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