import SwiftUI struct OwnProfileView: View { @Environment(Account.self) private var account var body: some View { if let handle = account.person?.handle { ProfileScreen(actor: handle) .id(handle) } else if account.checking { ProgressView() } else { SignInForm(why: "Sign in to see your profile.") } } } struct ProfileScreen: View { @Environment(Account.self) private var account let actor: String private struct Answer: Sendable { let profile: Profile; let repos: [Repo] } var body: some View { Loading(load: { [actor] in async let profile: Profile = Client.shared.query("getProfile", ["actor": actor]) async let repos: RepoList = Client.shared.query("getActorRepos", ["actor": actor]) return try await Answer(profile: profile, repos: repos.repos) }, refresh: account.follows) { answer in ProfilePage(profile: answer.profile, repos: answer.repos) } .navigationTitle(actor.hasPrefix("did:") ? "Profile" : actor) .navigationBarTitleDisplayMode(.inline) // Out here, so that an account whose profile won't load can still sign out. .toolbar { if actor == account.person?.handle || actor == account.person?.did { ToolbarItem(placement: .topBarTrailing) { NavigationLink(value: Route.settings) { Oct(name: "gear", size: Icon.xl).foregroundStyle(.primary) } .accessibilityLabel("Settings") } } } } } private struct ProfilePage: View { @Environment(Account.self) private var account let profile: Profile let repos: [Repo] @State private var reporting: Reporting? @State private var failure: String? private var other: Bool { account.signedIn && account.person?.did != profile.did } var body: some View { let p = profile ScrollView { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: Space.s3_5) { HStack(spacing: Space.s3_5) { Avatar(url: p.avatar, name: p.handle, did: p.did, size: Size.Avatar.xlarge) VStack(alignment: .leading, spacing: Space.s0_5) { Text(p.displayName ?? p.handle).font(.title2.bold()) Text(p.handle).foregroundStyle(.secondary) if let pronouns = p.pronouns { Text(pronouns).font(.footnote).foregroundStyle(.secondary) } } } if let about = p.description, !about.isEmpty { Text(about) } if let link = p.links?.first { LinkLine(link: link) } HStack(spacing: Space.s1_5) { Oct(name: "person").foregroundStyle(.secondary) count(.followers(p.handle), p.followersCount ?? 0, (p.followersCount ?? 0) == 1 ? "follower" : "followers") Text("ยท").foregroundStyle(.secondary) count(.following(p.handle), p.followsCount ?? 0, "following") } .font(.subheadline) if account.signedIn, account.person?.did != p.did { FollowButton(did: p.did, following: p.viewer?.following != nil) } } .padding(.horizontal, Space.s4) .padding(.top, Space.s2) .padding(.bottom, Space.s5) .frame(maxWidth: .infinity, alignment: .leading) Sections { PageRow(.repositories(p.handle), "Repositories", "repo", .neutral, repos.count) } if let orgs = p.orgs, !orgs.isEmpty { SectionTitle(icon: "organization", title: "Organizations") { EmptyView() } Sections { ForEach(Array(orgs.enumerated()), id: \.element.actor.did) { index, org in if index > 0 { RowDivider() } NavigationLink(value: Route.profile(org.actor.handle)) { HStack(spacing: Space.s3) { Avatar(url: org.actor.avatar, name: org.actor.handle, did: org.actor.did, size: Size.Avatar.regular) Text(org.actor.handle) Spacer() RowChevron(name: "chevron-right") } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s2) .frame(minHeight: Size.row) .contentShape(Rectangle()) } .buttonStyle(RowPress()) } } } if !repos.isEmpty { SectionTitle(icon: "clock", title: "Recent") { EmptyView() } ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: Space.s3) { ForEach(repos.prefix(6)) { RepoCard(repo: $0) } } .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, Space.s4) } } } .padding(.bottom, Space.s6) } .background(Color.groupedPage) .toolbar { if other { ToolbarItem(placement: .topBarTrailing) { Menu { let blocking = profile.viewer?.blocking != nil Button("\(blocking ? "Unblock" : "Block") \(profile.handle)") { toggleBlock(undo: blocking) } Button("Report \(profile.handle)") { reporting = Reporting(subject: profile.did, what: profile.handle) } NavigationLink("Blocked accounts", value: Route.blocks) } label: { SwiftUI.Label("More about \(profile.handle)", systemImage: "ellipsis") } } } } .sheet(item: $reporting) { ReportSheet(reporting: $0) } .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } } private func toggleBlock(undo: Bool) { Task { do { try await Client.shared.send(Writes.block(subject: profile.did, undo: undo)) account.follows += 1 } catch { failure = error.localizedDescription } } } private func count(_ route: Route, _ number: Int, _ word: String) -> some View { NavigationLink(value: route) { Text("\(Text("\(number)").fontWeight(.semibold)) \(Text(word).foregroundStyle(.secondary))") } .buttonStyle(.plain) } } /// Who follows an account, or whom it follows. struct FollowsView: View { let actor: String let followers: Bool var body: some View { PagedList(empty: followers ? "No followers yet." : "Not following anyone yet.", fetch: { [actor, followers] cursor in let answer: ActorPage = try await Client.shared.query(followers ? "getFollowers" : "getFollows", ["actor": actor, "cursor": cursor]) return .init(items: answer.actors, cursor: answer.cursor) }) { ActorRow(person: $0) } .navigationTitle(followers ? "Followers" : "Following") .navigationBarTitleDisplayMode(.inline) } } private struct ActorRow: View { let person: Person var body: some View { NavigationLink(value: Route.profile(person.handle)) { HStack(spacing: Space.s3) { Avatar(url: person.avatar, name: person.handle, did: person.did, size: Size.Avatar.medium) Text(person.handle).lineLimit(1) } } } } private struct FollowButton: View { @Environment(Account.self) private var account let did: String @State var following: Bool @State private var busy = false @State private var failure: String? var body: some View { Button { Task { await toggle() } } label: { HStack(spacing: Space.s1_5) { if !following { Image(systemName: "plus") } Text(following ? "Following" : "Follow").fontWeight(.semibold) } .frame(maxWidth: .infinity) } .buttonStyle(HeaderButton()) .disabled(busy) .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } } private func toggle() async { busy = true defer { busy = false } do { try await Client.shared.send(Writes.follow(did: did, undo: following)) following.toggle() account.follows += 1 } catch { failure = error.localizedDescription } } } private struct RepoStats: View { let repo: Repo var body: some View { HStack(spacing: Space.s3) { if let stars = repo.starCount { HStack(spacing: Space.s1) { Oct(name: "star") Text("\(stars)") } } if let language = repo.language { HStack(spacing: Space.s1_5) { Circle().fill(Color.hex(language.color) ?? .neutral).frame(width: 10, height: 10) Text(language.name) } } } .font(.subheadline) .foregroundStyle(.secondary) } } private struct RepoCard: View { let repo: Repo var body: some View { NavigationLink(value: Route.repo(repo.ref)) { VStack(alignment: .leading, spacing: Space.s1_5) { HStack(spacing: Space.s1_5) { Avatar(url: repo.owner.avatar, name: repo.owner.handle, did: repo.owner.did, size: Size.Avatar.xsmall) Text(repo.owner.handle).font(.subheadline).foregroundStyle(.secondary).lineLimit(1) } Text(repo.name).font(.headline).lineLimit(1) if let about = repo.description { Text(about).font(.subheadline).lineLimit(3) } Spacer(minLength: 4) RepoStats(repo: repo) } .padding(Space.s3_5) .frame(width: 280, alignment: .leading) .frame(maxHeight: .infinity, alignment: .topLeading) .background(Color.card, in: RoundedRectangle(cornerRadius: Radius.large)) } .buttonStyle(.plain) } } struct RepositoriesView: View { let actor: String var body: some View { Loading(load: { [actor] in let list: RepoList = try await Client.shared.query("getActorRepos", ["actor": actor]) return list.repos }) { repos in List { ForEach(repos) { repo in NavigationLink(value: Route.repo(repo.ref)) { VStack(alignment: .leading, spacing: Space.s1_5) { HStack(spacing: Space.s1_5) { Text(repo.name).font(.body.weight(.semibold)).lineLimit(1) if repo.private == true { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary).accessibilityLabel("Private") } } if let about = repo.description, !about.isEmpty { Text(about).lineLimit(3) } RepoStats(repo: repo) } } }.listRowBackground(Color.page) } .listStyle(.plain) } .navigationTitle("Repositories") .navigationBarTitleDisplayMode(.inline) } }