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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
444 lines · 16.9 KB Raw History
import SwiftUI

/// A repository kept to hand on the home screen. They stay on this phone.
struct Favorite: Codable, Hashable {
    static let key = "favorites"

    let did: String, handle: String, name: String
    var avatar: String?

    init(_ repo: Repo) {
        did = repo.owner.did
        handle = repo.owner.handle
        name = repo.name
        avatar = repo.owner.avatar
    }

    var ref: RepoRef { RepoRef(did: did, handle: handle, name: name) }

    static func decode(_ data: Data) -> [Favorite] { (try? JSONDecoder().decode([Favorite].self, from: data)) ?? [] }
    static func encode(_ all: [Favorite]) -> Data { (try? JSONEncoder().encode(all)) ?? Data() }
}

struct HomeView: View {
    @Environment(Account.self) private var account

    var body: some View {
        Group {
            if let person = account.person {
                HomeScreen(person: person).id(person.did)
            } else if account.checking {
                ProgressView()
            } else {
                SignInForm(why: "Sign in to see your work.")
            }
        }
    }
}

private struct HomeScreen: View {
    let person: Person

    @AppStorage(Favorite.key) private var stored = Data()
    @State private var recent: [FeedEvent] = []
    @State private var editing = false
    @State private var writing = false

    private var favorites: [Favorite] { Favorite.decode(stored) }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                Heading("My Work")
                Card {
                    row(.mine(kind: "issue"), "Issues", "issue-opened", .success)
                    Separator()
                    row(.mine(kind: "pull"), "Pull Requests", "git-pull-request", .accent)
                    Separator()
                    row(.repositories(person.handle), "Repositories", "repo", .neutral)
                    Separator()
                    row(.organizations(person.handle), "Organizations", "organization", .orange)
                    Separator()
                    row(.starred(person.handle), "Starred", "star", .attention)
                }
                Heading("Favorites") {
                    Button { editing = true } label: { Oct(name: "kebab-horizontal", size: Icon.xl).foregroundStyle(.secondary) }
                        .buttonStyle(.plain)
                        .accessibilityLabel("Edit favorites")
                }
                Card {
                    if favorites.isEmpty {
                        VStack(spacing: Space.s2_5) {
                            Text("Keep repositories you visit a lot here.").foregroundStyle(.secondary).multilineTextAlignment(.center)
                            Button("Add favorites") { editing = true }.fontWeight(.medium)
                        }
                        .frame(maxWidth: .infinity)
                        .padding(Space.s5)
                    }
                    ForEach(Array(favorites.enumerated()), id: \.element) { at, favorite in
                        if at > 0 { Separator() }
                        NavigationLink(value: Route.repo(favorite.ref)) { FavoriteRow(favorite: favorite) }.buttonStyle(.plain)
                    }
                }
                if !recent.isEmpty {
                    Heading("Recent")
                    Card {
                        ForEach(Array(recent.enumerated()), id: \.element.id) { at, event in
                            if at > 0 { Separator() }
                            if let subject = event.action.subject {
                                NavigationLink(value: Route.thread(event.repo, uri: subject)) { RecentRow(event: event) }
                                    .buttonStyle(.plain)
                            }
                        }
                    }
                }
            }
            .padding(.horizontal, Space.s4)
            .padding(.bottom, Space.s6)
        }
        .background(Color.groupedPage)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .topBarLeading) {
                NavigationLink(value: Route.profile(person.handle)) {
                    Avatar(url: person.avatar, name: person.handle, did: person.did, size: Size.Avatar.toolbar)
                        // A pattern is faint, so the button's shape needs this behind it.
                        .background(Color.card, in: Circle())
                }
                .accessibilityLabel("Your profile")
            }
            .withoutGlass()
            ToolbarItemGroup(placement: .topBarTrailing) {
                Button { writing = true } label: { Oct(name: "plus", size: Icon.xxl) }.tint(.primary).accessibilityLabel("New issue")
                NavigationLink(value: Route.search) { Oct(name: "search", size: Icon.xxl) }.tint(.primary).accessibilityLabel("Search")
            }
        }
        .sheet(isPresented: $editing) { EditFavorites(person: person, stored: $stored) }
        .sheet(isPresented: $writing) { NewIssue(person: person, favorites: favorites.map(\.ref)) }
        .task { await load() }
        .refreshable { await load() }
    }

    private func row(_ route: Route, _ title: String, _ icon: String, _ color: Color) -> some View {
        NavigationLink(value: route) { PageRowLabel(title, icon, color) { EmptyView() } }.buttonStyle(.plain)
    }

    private func load() async {
        let feed: Feed? = try? await Client.shared.query("getFeed", ["actor": person.did, "limit": "40"])
        guard let events = feed?.events else { return }
        recent = FeedEvent.recent(events)
    }
}

private extension ToolbarContent {
    /// Takes away the glass iOS 26 puts round a toolbar item, for one that fills its place.
    @ToolbarContentBuilder func withoutGlass() -> some ToolbarContent {
        if #available(iOS 26, *) {
            sharedBackgroundVisibility(.hidden)
        } else {
            self
        }
    }
}

private struct Heading<Trailing: View>: View {
    let title: String
    @ViewBuilder let trailing: Trailing

    init(_ title: String, @ViewBuilder trailing: () -> Trailing = { EmptyView() }) {
        self.title = title
        self.trailing = trailing()
    }

    var body: some View {
        HStack {
            Text(title).font(.title2.bold())
            Spacer()
            trailing
        }
        .padding(.top, Space.s6)
        .padding(.bottom, Space.s2_5)
    }
}

private struct Card<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        VStack(spacing: 0) { content }
            .background(Color.card, in: RoundedRectangle(cornerRadius: Radius.card))
            .clipShape(RoundedRectangle(cornerRadius: Radius.card))
    }
}

private struct Separator: View {
    var body: some View { Divider().padding(.leading, Size.rowInset) }
}

private struct FavoriteRow: View {
    let favorite: Favorite

    var body: some View {
        HStack(spacing: Space.s3) {
            Avatar(url: favorite.avatar, name: favorite.handle, did: favorite.did, size: Size.Avatar.regular)
            VStack(alignment: .leading, spacing: 0) {
                Text(favorite.handle).font(.footnote).foregroundStyle(.secondary).lineLimit(1)
                Text(favorite.name).font(.body.weight(.medium)).lineLimit(1)
            }
            Spacer()
            RowChevron(name: "chevron-right")
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s3)
        .contentShape(Rectangle())
    }
}

private struct RecentRow: View {
    let event: FeedEvent

    var body: some View {
        let style = StateStyle(kind: kind(ofURI: event.action.subject), state: event.action.standing)
        HStack(alignment: .top, spacing: Space.s3) {
            Oct(name: String(style.icon.dropFirst(4)), size: Icon.xl)
                .foregroundStyle(event.action.standing == nil ? Color.neutral : style.color)
                .frame(width: 30)
                .padding(.top, Space.s0_5)
            VStack(alignment: .leading, spacing: Space.s1) {
                HStack(alignment: .firstTextBaseline) {
                    Text(event.repo.label).font(.subheadline).foregroundStyle(.secondary).lineLimit(1)
                    Spacer()
                    Text(When.short(event.createdAt)).font(.subheadline).foregroundStyle(.secondary)
                }
                if let title = event.action.title { Text(title).font(.body.weight(.medium)).lineLimit(3) }
                Text(event.action.yours).font(.subheadline).foregroundStyle(.secondary)
            }
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s2_5)
        .contentShape(Rectangle())
    }
}

/// Choose the favorites from your repositories and those you starred.
private struct EditFavorites: View {
    @Environment(\.dismiss) private var dismiss
    let person: Person
    @Binding var stored: Data

    private struct Answer: Sendable { let repos: [Repo]; let stars: [Repo] }

    var body: some View {
        NavigationStack {
            Loading(load: { [handle = person.handle] in
                async let repos: RepoList = Client.shared.query("getActorRepos", ["actor": handle])
                async let stars: RepoList = Client.shared.query("getActorStars", ["actor": handle])
                return try await Answer(repos: repos.repos, stars: stars.repos)
            }) { answer in
                List {
                    Group {
                        if !answer.repos.isEmpty { Section("Your repositories") { ForEach(answer.repos) { pick($0) } } }
                        if !answer.stars.isEmpty { Section("Starred") { ForEach(answer.stars) { pick($0) } } }
                    }
                    .listRowBackground(Color.card)
                }
            }
            .navigationTitle("Favorites")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
        }
    }

    private func pick(_ repo: Repo) -> some View {
        let chosen = Favorite.decode(stored)
        let on = chosen.contains { $0.ref == repo.ref }
        return Button {
            stored = Favorite.encode(on ? chosen.filter { $0.ref != repo.ref } : chosen + [Favorite(repo)])
        } label: {
            HStack {
                Text(repo.ref.label).foregroundStyle(.primary).lineLimit(1)
                Spacer()
                if on { Oct(name: "check").foregroundStyle(Color.accent).accessibilityLabel("Chosen") }
            }
        }
    }
}

private struct NewIssue: View {
    @Environment(\.dismiss) private var dismiss
    let person: Person
    let favorites: [RepoRef]

    @State private var repos: [RepoRef] = []
    @State private var repo: RepoRef?
    @State private var title = ""
    @State private var text = ""
    @State private var sending = false
    @State private var failure: String?

    var body: some View {
        NavigationStack {
            Form {
                Group {
                    Picker("Repository", selection: $repo) {
                        Text("Choose").tag(RepoRef?.none)
                        ForEach(repos, id: \.self) { Text($0.label).tag(RepoRef?.some($0)) }
                    }
                    Section {
                        TextField("Title", text: $title)
                        TextEditor(text: $text).frame(minHeight: 160)
                    }
                    if let failure { Text(failure).foregroundStyle(Color.danger) }
                }
                .listRowBackground(Color.card)
            }
            .navigationTitle("New issue")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Create") { Task { await send() } }
                        .disabled(sending || repo == nil || title.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
            .task {
                let list: RepoList? = try? await Client.shared.query("getActorRepos", ["actor": person.handle])
                let own = (list?.repos ?? []).map(\.ref)
                repos = favorites + own.filter { !favorites.contains($0) }
                repo = repo ?? favorites.first
            }
        }
    }

    private func send() async {
        guard let repo else { return }
        sending = true
        defer { sending = false }
        do {
            try await Client.shared.send(Writes.issue(repo: repo, title: title, body: text))
            dismiss()
        } catch {
            failure = error.localizedDescription
        }
    }
}

/// Your pulls or issues across repositories.
struct MyItemsView: View {
    @Environment(Account.self) private var account
    let kind: String

    @State private var state = "open"
    @State private var relation = "created"

    private var relations: [(String, String)] {
        var all = [("created", "Created by you"), ("assigned", "Assigned to you")]
        if kind == "pull" { all.append(("review", "Review requested")) }
        return all
    }

    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: Space.s2) {
                Menu {
                    Picker("State", selection: $state) {
                        Text("Open").tag("open")
                        Text("Closed").tag("closed")
                    }
                } label: { chip(state == "open" ? "Open" : "Closed") }
                .tint(.primary)
                Menu {
                    Picker("Which", selection: $relation) {
                        ForEach(relations, id: \.0) { Text($0.1).tag($0.0) }
                    }
                } label: { chip(relations.first { $0.0 == relation }?.1 ?? "") }
                .tint(.primary)
                Spacer()
            }
            .padding(.horizontal, Space.s4)
            .padding(.vertical, Space.s2)
            if let did = account.person?.did {
                PagedList<ActorItem, MineRow>(empty: kind == "pull" ? "No pull requests here." : "No issues here.", fetch: { [kind, state, relation] cursor in
                    let answer: ActorItems = try await Client.shared.query("listActorItems", [
                        "actor": did, "kind": kind, "relation": relation, "state": state, "cursor": cursor,
                    ])
                    return .init(items: answer.items, cursor: answer.cursor)
                }) { MineRow(found: $0) }
                .id("\(state)-\(relation)")
            }
        }
        .navigationTitle(kind == "pull" ? "Pull Requests" : "Issues")
        .navigationBarTitleDisplayMode(.inline)
    }

    private func chip(_ title: String) -> some View {
        HStack(spacing: Space.s1) {
            Text(title)
            Oct(name: "chevron-down", size: Icon.xs)
        }
        .font(.subheadline)
        .foregroundStyle(.primary)
        .padding(.horizontal, Space.s3)
        .padding(.vertical, Space.s1_5)
        .background(Color.fill, in: Capsule())
    }
}

private struct MineRow: View {
    let found: ActorItem

    var body: some View {
        NavigationLink(value: Route.thread(found.repo, uri: found.item.uri)) {
            VStack(alignment: .leading, spacing: Space.s1) {
                Text(found.repo.label).font(.footnote).foregroundStyle(.secondary).lineLimit(1)
                ItemRow(item: found.item)
            }
        }
    }
}

struct StarredView: View {
    let actor: String

    var body: some View {
        Loading(load: { [actor] in
            let list: RepoList = try await Client.shared.query("getActorStars", ["actor": actor])
            return list.repos
        }) { repos in
            if repos.isEmpty {
                ContentUnavailableView("Nothing starred yet", image: "oct-star")
            } else {
                List { ForEach(repos) { RepoHitRow(repo: $0) }.listRowBackground(Color.page) }.listStyle(.plain)
            }
        }
        .navigationTitle("Starred")
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct OrganizationsView: View {
    let actor: String

    var body: some View {
        Loading(load: { [actor] in
            let profile: Profile = try await Client.shared.query("getProfile", ["actor": actor])
            return profile.orgs ?? []
        }) { orgs in
            if orgs.isEmpty {
                ContentUnavailableView("No organizations yet", image: "oct-organization")
            } else {
                List { ForEach(orgs, id: \.actor.did) { org in
                    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.medium)
                            Text(org.actor.handle).lineLimit(1)
                        }
                    }
                }.listRowBackground(Color.page) }
                .listStyle(.plain)
            }
        }
        .navigationTitle("Organizations")
        .navigationBarTitleDisplayMode(.inline)
    }
}