~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
306 lines · 12.1 KB Raw History
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<Person, ActorRow>(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)
    }
}