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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
783 lines · 29.9 KB Raw History
import SwiftUI

struct RepoView: View {
    let repo: RepoRef

    @Environment(Account.self) private var account
    @State private var reporting: Reporting?

    private struct Answer: Sendable { let detail: RepoDetail; let tree: Tree?; let readme: FileAnswer? }

    var body: some View {
        Loading(load: { [repo] in
            let detail: RepoDetail = try await Client.shared.query("getRepo", ["repo": repo.uri])
            let tree: Tree? = try? await Client.shared.query("getTree", ["repo": repo.uri])
            var readme: FileAnswer?
            if let name = tree?.readme?.name {
                readme = try? await Client.shared.query("getFile", ["repo": repo.uri, "path": name])
            }
            return Answer(detail: detail, tree: tree, readme: readme)
        }) { answer in
            RepoPage(repo: repo, detail: answer.detail, checks: answer.tree?.checks ?? [], readmeName: answer.tree?.readme?.name, readme: answer.readme?.text, readmeHTML: answer.readme?.html)
        }
        .navigationTitle(repo.name)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            if let url = URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)") {
                ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) }
            }
            if account.signedIn, account.person?.did != repo.did {
                ToolbarItem(placement: .topBarTrailing) {
                    Menu {
                        Button("Report this repository") {
                            reporting = Reporting(subject: "knotless://\(repo.did)/\(repo.name)", what: "this repository")
                        }
                    } label: {
                        SwiftUI.Label("More", systemImage: "ellipsis")
                    }
                }
            }
        }
        .sheet(item: $reporting) { ReportSheet(reporting: $0) }
    }
}

private struct RepoPage: View {
    let repo: RepoRef
    let detail: RepoDetail
    let checks: [Check]
    let readmeName: String?
    let readme: String?
    let readmeHTML: String?

    /// The branch being looked at; the default until another is chosen.
    @State private var branch: String?
    @State private var choosing = false

    private var shown: String { branch ?? detail.defaultBranch }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                RepoHeader(repo: repo, detail: detail)
                Sections {
                    PageRow(.items(repo, kind: "issue"), "Issues", "issue-opened", .success, detail.openIssues)
                    RowDivider()
                    PageRow(.items(repo, kind: "pull"), "Pulls", "git-pull-request", .accent, detail.openPulls)
                    if detail.private != true {
                        RowDivider()
                        NavigationLink(value: Route.runs(repo)) {
                            PageRowLabel("Checks", "play", .attention) {
                                if !checks.isEmpty {
                                    let summary = CheckSummary(checks)
                                    Oct(name: summary.symbol).foregroundStyle(summary.color).accessibilityLabel(summary.text)
                                }
                            }
                        }
                        .buttonStyle(RowPress())
                    }
                    if let releases = detail.releases, releases.count > 0 {
                        RowDivider()
                        PageRow(.releases(repo), "Releases", "tag", .neutral, releases.count)
                        if let latest = releases.release { LatestRelease(release: latest, repo: repo) }
                    }
                }
                if !detail.defaultBranch.isEmpty {
                    SectionTitle(icon: "git-branch", title: shown, mono: true) {
                        Button("Change branch") { choosing = true }.font(.subheadline)
                    }
                    Sections {
                        PageRow(.tree(repo, ref: shown, path: ""), "Code", "code", .neutral, nil)
                        RowDivider()
                        PageRow(.history(repo, ref: shown), "Commits", "git-commit", .neutral, nil)
                    }
                    if let branch, branch != detail.defaultBranch {
                        BranchReadme(repo: repo, ref: branch).id(branch)
                    } else if let readme {
                        ReadmeSection(repo: repo, ref: detail.defaultBranch, name: readmeName ?? "Readme", text: readme, html: readmeHTML)
                    }
                }
            }
            .padding(.bottom, Space.s6)
        }
        .background(Color.groupedPage)
        .sheet(isPresented: $choosing) {
            BranchPicker(repo: repo, chosen: shown) { branch = $0 }
        }
    }
}

/// What a repository says of itself, and what someone can do with it.
private struct RepoHeader: View {
    @Environment(Account.self) private var account
    let repo: RepoRef
    let detail: RepoDetail

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s3) {
            NavigationLink(value: Route.profile(detail.owner.handle)) {
                HStack(spacing: Space.s2) {
                    Avatar(url: detail.owner.avatar, name: detail.owner.handle, did: detail.owner.did, size: Size.Avatar.small)
                    Text(detail.owner.handle).foregroundStyle(.secondary).lineLimit(1)
                }
            }
            .buttonStyle(.plain)
            .accessibilityIdentifier("repo-owner")
            HStack(spacing: Space.s2) {
                Text(detail.name).font(.largeTitle.bold())
                if detail.private == true {
                    Image(systemName: "lock.fill").foregroundStyle(.secondary).accessibilityLabel("Private")
                }
            }
            if let about = detail.description, !about.isEmpty { Text(about) }
            if let fork = detail.forkOf {
                NavigationLink(value: Route.repo(fork.ref)) {
                    HStack(spacing: Space.s1_5) {
                        Oct(name: "repo-forked").foregroundStyle(.secondary)
                        Text("Forked from \(fork.handle)/\(fork.name)").foregroundStyle(.secondary).lineLimit(1)
                    }
                }
                .buttonStyle(.plain)
                .font(.subheadline)
            }
            if let link = detail.links?.first { LinkLine(link: link) }
            if detail.private != true {
                RepoActions(repo: repo, detail: detail, mine: account.person?.did == detail.owner.did)
            }
        }
        .padding(.horizontal, Space.s4)
        .padding(.top, Space.s2)
        .padding(.bottom, Space.s5)
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

struct LinkLine: View {
    let link: WebLink

    var body: some View {
        if let url = URL(string: link.url) {
            Link(destination: url) {
                HStack(spacing: Space.s2) {
                    Oct(name: "link").foregroundStyle(.secondary)
                    Text(link.label).fontWeight(.semibold).foregroundStyle(.primary).lineLimit(1)
                }
            }
            .buttonStyle(.plain)
        }
    }
}

/// A repository's stars, and to someone signed in the buttons to star it, fork
/// it and choose what to hear of it.
private struct RepoActions: View {
    @Environment(Account.self) private var account
    let repo: RepoRef
    let mine: Bool
    @State private var starred: Bool
    @State private var count: Int
    @State private var level: String?
    @State private var failure: String?
    @State private var busy = false
    @State private var asking = false
    @State private var forking = false
    @State private var forked: RepoRef?

    init(repo: RepoRef, detail: RepoDetail, mine: Bool) {
        self.repo = repo
        self.mine = mine
        _starred = State(initialValue: detail.viewer?.star != nil)
        _count = State(initialValue: detail.starCount ?? 0)
        _level = State(initialValue: detail.viewer?.subscription)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s3_5) {
            HStack(spacing: Space.s1_5) {
                Oct(name: "star").foregroundStyle(.secondary)
                Text("\(Text("\(count)").fontWeight(.semibold)) \(Text(count == 1 ? "star" : "stars").foregroundStyle(.secondary))")
            }
            if account.signedIn {
                HStack(spacing: Space.s2) {
                    Button {
                        Task { await toggle() }
                    } label: {
                        HStack(spacing: Space.s1_5) {
                            Oct(name: starred ? "star-fill" : "star").foregroundStyle(starred ? Color.attention : .primary)
                            Text(starred ? "Starred" : "Star").fontWeight(.semibold)
                        }
                        .frame(maxWidth: .infinity)
                    }
                    .buttonStyle(HeaderButton())
                    .disabled(busy)
                    .accessibilityValue(starred ? "Starred" : "Not starred")
                    if !mine {
                        Button { asking = true } label: {
                            if forking { ProgressView() } else { Oct(name: "repo-forked") }
                        }
                        .buttonStyle(HeaderButton())
                        .disabled(forking)
                        .accessibilityLabel("Fork")
                        .confirmationDialog("Fork \(repo.name)?", isPresented: $asking, titleVisibility: .visible) {
                            Button("Fork") { Task { await fork() } }
                        } message: {
                            Text("This copies every branch and tag to your account.")
                        }
                    }
                    HearMenu(repo: repo, level: $level) { failure = $0 }
                }
            }
        }
        .navigationDestination(item: $forked) { RepoView(repo: $0) }
        .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) {
            Button("OK", role: .cancel) {}
        } message: {
            Text(failure ?? "")
        }
    }

    private func toggle() async {
        busy = true
        defer { busy = false }
        do {
            try await Client.shared.send(Writes.star(repo: repo, undo: starred))
            count += starred ? -1 : 1
            starred.toggle()
        } catch {
            failure = error.localizedDescription
        }
    }

    private func fork() async {
        guard let person = account.person else { return }
        forking = true
        defer { forking = false }
        do {
            let made: RepoMade = try await Client.shared.answer(to: Writes.fork(repo: repo))
            forked = RepoRef(did: person.did, handle: person.handle, name: made.name)
        } catch {
            failure = error.localizedDescription
        }
    }
}

private struct HearMenu: View {
    let repo: RepoRef
    @Binding var level: String?
    let failed: (String) -> Void

    private var listening: Bool { level == "all" || level == "releases" }

    var body: some View {
        Menu {
            choice(nil, "Only what I take part in")
            choice("all", "All activity")
            choice("releases", "Releases only")
            choice("ignore", "Ignore")
        } label: {
            Oct(name: level == "ignore" ? "bell-slash" : listening ? "bell-fill" : "bell")
                .foregroundStyle(listening ? Color.accent : .primary)
        }
        .buttonStyle(HeaderButton())
        .accessibilityLabel("Notifications")
    }

    private func choice(_ value: String?, _ title: String) -> some View {
        Button {
            let before = level
            level = value
            Task {
                do {
                    try await Client.shared.send(Writes.hear(repo: repo, level: value ?? "participating"))
                } catch {
                    level = before
                    failed(error.localizedDescription)
                }
            }
        } label: {
            if level == value { SwiftUI.Label(title, systemImage: "checkmark") } else { Text(title) }
        }
    }
}

/// A filled button in a repository's or profile's header, those beside each
/// other the same height.
struct HeaderButton: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .frame(minWidth: 26, minHeight: 24)
            .padding(.horizontal, Space.s3)
            .padding(.vertical, Space.s3)
            .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.large))
            .opacity(configuration.isPressed ? 0.6 : 1)
            .contentShape(RoundedRectangle(cornerRadius: Radius.large))
    }
}

/// A band of rows across the page.
struct Sections<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        VStack(spacing: 0) { content }
            .background(Color.card)
            .overlay(alignment: .top) { Divider() }
            .overlay(alignment: .bottom) { Divider() }
    }
}

/// The words over a band, with what can be done about it at the end.
struct SectionTitle<Trailing: View>: View {
    let icon: String
    let title: String
    var mono = false
    @ViewBuilder let trailing: Trailing

    var body: some View {
        HStack(spacing: Space.s2_5) {
            Oct(name: icon).foregroundStyle(.secondary)
            Text(title)
                .font(mono ? .subheadline.monospaced() : .subheadline)
                .foregroundStyle(.secondary)
                .lineLimit(1)
            Spacer()
            trailing
        }
        .padding(.horizontal, Space.s4)
        .padding(.top, Space.s7)
        .padding(.bottom, Space.s2_5)
    }
}

/// A row's coloured square, its words and whatever ends it, before its chevron.
struct PageRowLabel<Trailing: View>: View {
    let title: String
    let icon: String
    let color: Color
    @ViewBuilder let trailing: Trailing

    init(_ title: String, _ icon: String, _ color: Color, @ViewBuilder trailing: () -> Trailing) {
        self.title = title
        self.icon = icon
        self.color = color
        self.trailing = trailing()
    }

    var body: some View {
        HStack(spacing: Space.s3) {
            IconSquare(image: Image("oct-" + icon), color: color)
            Text(title)
            Spacer()
            trailing
            RowChevron(name: "chevron-right")
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s2)
        .frame(minHeight: Size.row)
        .frame(maxWidth: .infinity, alignment: .leading)
        .contentShape(Rectangle())
    }
}

/// A row that opens a screen, with how many it holds.
struct PageRow: View {
    let route: Route
    let title: String
    let icon: String
    let color: Color
    let count: Int?

    init(_ route: Route, _ title: String, _ icon: String, _ color: Color, _ count: Int?) {
        self.route = route
        self.title = title
        self.icon = icon
        self.color = color
        self.count = count
    }

    var body: some View {
        NavigationLink(value: route) {
            PageRowLabel(title, icon, color) {
                if let count { Text("\(count)").foregroundStyle(.secondary) }
            }
        }
        .buttonStyle(RowPress())
    }
}

private struct LatestRelease: View {
    let release: RepoDetail.Releases.Latest
    let repo: RepoRef

    var body: some View {
        NavigationLink(value: Route.releases(repo)) {
            VStack(alignment: .leading, spacing: Space.s0_5) {
                Text("\(release.package) \(release.version)").lineLimit(1)
                HStack(spacing: Space.s1) {
                    if let when = release.createdAt { Text(When.short(when)).foregroundStyle(.secondary) }
                    if release.latest == true {
                        if release.createdAt != nil { Text("·").foregroundStyle(.secondary) }
                        Text("Latest").foregroundStyle(Color.success)
                    }
                }
                .font(.subheadline)
            }
            .padding(Space.s3)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.inset, in: RoundedRectangle(cornerRadius: Radius.medium))
            .overlay(RoundedRectangle(cornerRadius: Radius.medium).stroke(Color.line))
        }
        .buttonStyle(.plain)
        .padding(.leading, Size.rowInset)
        .padding(.trailing, Space.s4)
        .padding(.bottom, Space.s3)
    }
}

private struct ReadmeSection: View {
    let repo: RepoRef
    let ref: String
    let name: String
    let text: String
    let html: String?

    var body: some View {
        SectionTitle(icon: "info", title: name, mono: true) { EmptyView() }
        Sections {
            MarkdownView(source: text, html: html, place: MarkdownPlace(repo: repo, ref: ref))
                .padding(Space.s4)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
}

/// The README of a branch other than the default.
private struct BranchReadme: View {
    let repo: RepoRef
    let ref: String

    private struct Answer: Sendable { let name: String; let text: String?; let html: String? }

    var body: some View {
        Loading(load: { [repo, ref] in
            let tree: Tree = try await Client.shared.query("getTree", ["repo": repo.uri, "ref": ref])
            guard let name = tree.readme?.name else { return Answer(name: "", text: nil, html: nil) }
            let file: FileAnswer = try await Client.shared.query("getFile", ["repo": repo.uri, "ref": ref, "path": name])
            return Answer(name: name, text: file.text, html: file.html)
        }) { answer in
            if let text = answer.text { ReadmeSection(repo: repo, ref: ref, name: answer.name, text: text, html: answer.html) }
        }
    }
}

private struct BranchPicker: View {
    @Environment(\.dismiss) private var dismiss
    let repo: RepoRef
    let chosen: String
    let choose: (String) -> Void

    var body: some View {
        NavigationStack {
            Loading(load: { [repo] in
                let list: BranchList = try await Client.shared.query("listBranches", ["repo": repo.uri, "limit": "1000"])
                return list.branches
            }) { branches in
                List { ForEach(branches) { branch in
                    Button {
                        choose(branch.name)
                        dismiss()
                    } label: {
                        HStack {
                            Text(branch.name).font(.body.monospaced()).lineLimit(1).foregroundStyle(.primary)
                            Spacer()
                            if branch.name == chosen { Image(systemName: "checkmark").foregroundStyle(Color.accent) }
                        }
                    }
                }.listRowBackground(Color.page) }
                .listStyle(.plain)
            }
            .navigationTitle("Branches")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
            }
        }
    }
}

/// A list a page at a time, for those screens that are only that.
/// A list's first page read again, over all that was read of it: the rest stay,
/// so a list scrolled far down stays where it was, but for any the new page moved up.
func rereading<Item: Identifiable>(_ first: [Item], over old: [Item]) -> [Item] {
    let new = Set(first.map(\.id))
    return first + old.dropFirst(first.count).filter { !new.contains($0.id) }
}

struct PagedList<Item: Identifiable & Equatable & Sendable, Row: View>: View {
    struct Page: Sendable {
        let items: [Item]
        let cursor: String?
    }

    let empty: String
    let fetch: @Sendable (String?) async throws -> Page
    /// Shown over the list, edge to edge.
    var header: AnyView?
    @ViewBuilder let row: (Item) -> Row

    @State private var items: [Item] = []
    @State private var cursor: String?
    @State private var loading = true
    @State private var failure: String?

    var body: some View {
        List {
            Group {
                if let header {
                    header.listRowInsets(EdgeInsets()).listRowSeparator(.hidden)
                }
                if let failure {
                    Text(failure).foregroundStyle(.secondary)
                } else if loading {
                    ProgressView().frame(maxWidth: .infinity)
                } else if items.isEmpty {
                    Text(empty).foregroundStyle(.secondary)
                }
                ForEach(items) { item in
                    row(item).onAppear { if item == items.last { Task { await more() } } }
                }
            }
            .listRowBackground(Color.page)
        }
        .listStyle(.plain)
        // Each time it's shown again, as a pull may have changed while it wasn't.
        .task { await read(keepingPages: true) }
        .refreshable { await read(keepingPages: false) }
    }

    private func read(keepingPages: Bool) async {
        do {
            let page = try await fetch(nil)
            let old = keepingPages ? items : []
            cursor = old.count > page.items.count ? cursor : page.cursor
            items = rereading(page.items, over: old)
            failure = nil
        } catch is CancellationError {
            return
        } catch let error as URLError where error.code == .cancelled {
            return
        } catch {
            // What's on screen stays, rather than a list gone for a lost connection.
            if items.isEmpty { failure = error.localizedDescription }
        }
        loading = false
    }

    private func more() async {
        guard let cursor, let next = try? await fetch(cursor) else { return }
        self.cursor = next.cursor
        items += next.items.filter { !items.contains($0) }
    }
}

struct HistoryView: View {
    let repo: RepoRef
    let ref: String?

    var body: some View {
        PagedList<Commit, CommitRow>(empty: "No commits yet.", fetch: { [repo, ref] cursor in
            let list: CommitList = try await Client.shared.query(
                "listCommits", ["repo": repo.uri, "ref": ref, "limit": "30", "cursor": cursor])
            return .init(items: list.commits, cursor: list.cursor)
        }) { CommitRow(repo: repo, commit: $0) }
        .navigationTitle("Commits")
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct ReleasesView: View {
    let repo: RepoRef

    var body: some View {
        PagedList<Release, AnyView>(empty: "Nothing published yet.", fetch: { [repo] cursor in
            let list: ReleaseList = try await Client.shared.query("listReleases", ["repo": repo.uri, "cursor": cursor])
            return .init(items: list.releases, cursor: list.cursor)
        }) { release in
            AnyView(NavigationLink(value: Route.release(release)) { ReleaseHead(release: release) })
        }
        .navigationTitle("Releases")
        .navigationBarTitleDisplayMode(.inline)
    }
}

private struct ReleaseHead: View {
    let release: Release

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s0_5) {
            Text("\(release.package) \(release.version)").font(.body.weight(.medium)).lineLimit(2)
            HStack(spacing: Space.s1) {
                if let when = release.createdAt { Text(When.short(when)).foregroundStyle(.secondary) }
                if release.latest == true {
                    if release.createdAt != nil { Text("·").foregroundStyle(.secondary) }
                    Text("Latest").foregroundStyle(Color.success)
                }
            }
            .font(.subheadline)
        }
    }
}

struct ReleaseView: View {
    let release: Release

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: Space.s4) {
                ReleaseHead(release: release)
                if let notes = release.notes, !notes.isEmpty { MarkdownView(source: notes, html: release.html) }
                if let files = release.files, !files.isEmpty {
                    VStack(alignment: .leading, spacing: Space.s2) {
                        Text("Files").font(.headline)
                        ForEach(files, id: \.name) { file in
                            HStack(alignment: .firstTextBaseline, spacing: Space.s2) {
                                Text(file.name).font(.footnote.monospaced())
                                Spacer()
                                if let size = file.size {
                                    Text(size.formatted(.byteCount(style: .file))).font(.footnote).foregroundStyle(.secondary)
                                }
                            }
                        }
                    }
                }
            }
            .padding(Space.s4)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .navigationTitle(release.version)
        .navigationBarTitleDisplayMode(.inline)
    }
}

struct ItemsView: View {
    let repo: RepoRef
    let kind: String

    @State private var state = "open"
    /// What's been read of each state, so going back to one shows it at once.
    @State private var answers: [String: ItemList] = [:]
    @State private var failure: String?

    private var shown: ItemList? { answers[state] }
    /// The counts, which every answer has, while this state's is on its way.
    private var counts: ItemList? { shown ?? answers.values.first }

    var body: some View {
        List {
            Group {
                Picker("State", selection: $state) {
                    Text(label("Open", counts?.openCount)).tag("open")
                    Text(label("Closed", counts?.closedCount)).tag("closed")
                }
                .pickerStyle(.segmented)
                .listRowSeparator(.hidden)

                if let shown {
                    if shown.items.isEmpty {
                        Text(state == "open" ? "Nothing open." : "Nothing closed.").foregroundStyle(.secondary)
                    }
                    ForEach(shown.items) { item in
                        NavigationLink(value: Route.thread(repo, uri: item.uri)) { ItemRow(item: item) }
                            .onAppear { if item == shown.items.last { Task { await more() } } }
                    }
                } else if let failure {
                    Text(failure).foregroundStyle(.secondary)
                } else {
                    ProgressView().frame(maxWidth: .infinity)
                }
            }
            .listRowBackground(Color.page)
        }
        .listStyle(.plain)
        .navigationTitle(kind == "pull" ? "Pulls" : "Issues")
        .navigationBarTitleDisplayMode(.inline)
        // Each time it's shown again too, as a pull may have changed while it wasn't.
        .task(id: state) { await read(keepingPages: true) }
        .refreshable { await read(keepingPages: false) }
    }

    private func label(_ name: String, _ count: Int?) -> String {
        count.map { "\(name) \($0)" } ?? name
    }

    private func fetch(_ state: String, _ cursor: String?) async throws -> ItemList {
        try await Client.shared.query("listItems", ["repo": repo.uri, "kind": kind, "state": state, "limit": "30", "cursor": cursor])
    }

    private func read(keepingPages: Bool) async {
        let asked = state
        failure = nil
        do {
            var page = try await fetch(asked, nil)
            if keepingPages, let old = answers[asked], old.items.count > page.items.count {
                page.cursor = old.cursor
                page.items = rereading(page.items, over: old.items)
            }
            answers[asked] = page
        } catch is CancellationError {
        } catch let error as URLError where error.code == .cancelled {
        } catch {
            // What's on screen stays, rather than a list gone for a lost connection.
            failure = error.localizedDescription
        }
    }

    private func more() async {
        let asked = state
        guard let cursor = answers[asked]?.cursor, let next = try? await fetch(asked, cursor) else { return }
        answers[asked]?.cursor = next.cursor
        let have = answers[asked]?.items ?? []
        answers[asked]?.items = have + next.items.filter { !have.contains($0) }
    }
}

struct ItemRow: View {
    let item: Item

    var body: some View {
        let style = StateStyle(kind: item.kind, state: item.state, draft: item.draft == true)
        HStack(alignment: .top, spacing: Space.s3) {
            StateIcon(style: style, size: Icon.xl).frame(width: 26).padding(.top, Space.s0_5)
            VStack(alignment: .leading, spacing: Space.s1) {
                Text(item.title).font(.body.weight(.medium)).lineLimit(3)
                HStack(spacing: Space.s1_5) {
                    Avatar(url: item.author.avatar, name: item.author.handle, did: item.author.did, size: Size.Avatar.tiny)
                    Text(item.author.handle).lineLimit(1)
                    Text(When.short(item.createdAt))
                    if item.comments > 0 {
                        SwiftUI.Label { Text("\(item.comments)") } icon: { Oct(name: "comment", size: Icon.xs) }.labelStyle(TightLabel())
                    }
                }
                .font(.footnote)
                .foregroundStyle(.secondary)
                if let labels = item.labels, !labels.isEmpty {
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: Space.s1) {
                            ForEach(labels, id: \.name) { label in
                                HStack(spacing: Space.s1) {
                                    Circle().fill(Color.hex(label.color) ?? .neutral).frame(width: 7, height: 7)
                                    Text(label.name)
                                }
                                .font(.caption)
                                .padding(.horizontal, Space.s2)
                                .padding(.vertical, Space.s0_5)
                                .overlay(Capsule().stroke(Color.line))
                            }
                        }
                    }
                }
            }
        }
        .padding(.vertical, Space.s0_5)
    }
}