import SwiftUI struct InboxView: View { private static let filters: [(name: String, kind: String?)] = [ ("All", nil), ("Reviews", "reviews"), ("Mentions", "mentions"), ("Comments", "comments"), ("Checks", "checks"), ("Pulls and issues", "pulls-and-issues"), ] @Environment(Account.self) private var account @State private var filter: String? @State private var items: [InboxItem] = [] @State private var cursor: String? @State private var phase = Phase.loading /// What `items` are of. @State private var read: ReloadKey? enum Phase { case loading, ready, signedOut, failed(String) } private struct ReloadKey: Hashable { let filter: String?; let did: String? } var body: some View { VStack(spacing: 0) { if !signedOut { chips Divider() } content.frame(maxHeight: .infinity) } .navigationTitle(signedOut ? "" : "Inbox") .task(id: ReloadKey(filter: filter, did: account.person?.did)) { // Shown again, it keeps the pages read of it. Another filter or account starts anew. let key = ReloadKey(filter: filter, did: account.person?.did) // Only once it's read: with none, `items` are still of what came before. if await reload(keepingPages: key == read) { read = key } } } private var signedOut: Bool { if case .signedOut = phase { true } else { false } } private var chips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Space.s2) { ForEach(Self.filters, id: \.name) { option in Button(option.name) { filter = option.kind } .font(.subheadline.weight(.medium)) .foregroundStyle(filter == option.kind ? Color.accent : .primary) .padding(.horizontal, Space.s3) .padding(.vertical, Space.s2) .background(filter == option.kind ? Color.accentSoft : Color.raised, in: Capsule()) } } .padding(.horizontal) .padding(.vertical, Space.s2) } } @ViewBuilder private var content: some View { switch phase { case .loading: ProgressView() case .signedOut: SignInForm(why: "Sign in to see your inbox.") case .failed(let message): ContentUnavailableView { SwiftUI.Label("This didn't load", systemImage: "wifi.exclamationmark") } description: { Text(message) } actions: { Button("Try again") { Task { await reload() } } } case .ready: if items.isEmpty { ContentUnavailableView("Nothing here", systemImage: "tray", description: Text("You're all caught up.")) } else { List { Group { ForEach(items) { item in row(item).onAppear { if item == items.last { Task { await more() } } } } } .listRowBackground(Color.page) } .listStyle(.plain) .refreshable { await reload() } } } } @ViewBuilder private func row(_ item: InboxItem) -> some View { if let repo = item.repo, let subject = item.reasonSubject, kind(ofURI: subject) != nil { NavigationLink(value: Route.thread(repo, uri: subject)) { InboxRow(item: item) } } else if let repo = item.repo { NavigationLink(value: Route.repo(repo)) { InboxRow(item: item) } } else { NavigationLink(value: Route.profile(item.author.did)) { InboxRow(item: item) } } } private func fetch(cursor: String?) async throws -> NotificationList { try await Client.shared.query("listNotifications", ["limit": "30", "kind": filter, "cursor": cursor]) } /// Whether it was read. @discardableResult private func reload(keepingPages: Bool = false) async -> Bool { do { let answer = try await fetch(cursor: nil) let old = keepingPages ? items : [] cursor = old.count > answer.notifications.count ? cursor : answer.cursor items = rereading(answer.notifications, over: old) phase = .ready // Seen, once all of them were on offer. The dots stay until the next // look, so someone can see what was new this time. if filter == nil, SessionStore.token != nil, let newest = items.first?.createdAt { try? await Client.shared.send(Writes.seen(at: newest)) Pushes.shared.clearBadge() } return true } catch let error as APIError where error.needsSignIn { phase = .signedOut } catch is CancellationError { } catch let error as URLError where error.code == .cancelled { } catch { phase = .failed(error.localizedDescription) } return false } private func more() async { let asked = filter guard let cursor, let answer = try? await fetch(cursor: cursor), filter == asked else { return } self.cursor = answer.cursor items += answer.notifications.filter { !items.contains($0) } } } struct InboxRow: View { let item: InboxItem private var what: String { switch item.reason { case "star": "starred your repository" case "follow": "followed you" case "invite": "invited you to join" case "checks-failed": "Checks failed" case "ejected": "Left the merge queue" case "review-requested": "asked for your review" default: switch item.verdict { case "approve": "approved" case "changes": "asked for changes" default: "commented" } } } private var style: StateStyle { StateStyle(kind: kind(ofURI: item.reasonSubject), state: item.subjectState) } var body: some View { HStack(alignment: .top, spacing: Space.s3) { VStack(spacing: Space.s2) { if kind(ofURI: item.reasonSubject) == nil { Image(systemName: other).font(.title3).foregroundStyle(Color.neutral) } else { StateIcon(style: style, size: Icon.xxl) } if !item.isRead { Circle().fill(Color.accent).frame(width: 9, height: 9).accessibilityLabel("Unread") } } .frame(width: 28) VStack(alignment: .leading, spacing: Space.s1) { HStack { Text(item.repo?.label ?? item.author.handle).lineLimit(1).foregroundStyle(.secondary) Spacer() Text(When.short(item.createdAt)).foregroundStyle(.secondary) } .font(.subheadline) Text(item.title ?? what).font(.body.weight(.medium)).lineLimit(2) HStack(spacing: Space.s1_5) { Avatar(url: item.author.avatar, name: item.author.handle, did: item.author.did, size: Size.Avatar.xsmall) Text(MarkdownView.plain(preview)).font(.subheadline).foregroundStyle(.secondary).lineLimit(1) } } } .padding(.vertical, Space.s1) } private var preview: String { if let text = item.text?.split(whereSeparator: \.isNewline).first { return String(text) } return "\(item.author.handle) \(what)" } private var other: String { switch item.reason { case "star": "star.fill" case "follow": "person.badge.plus" case "invite": "building.2" default: "bell" } } }