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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
204 lines · 7.8 KB Raw History
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"
        }
    }
}