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: 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: 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(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) } }