johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
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)
}
}