import SwiftUI struct ThreadView: View { /// How much of a long description shows before Read more. private static let foldedHeight: CGFloat = 360 /// How much taller than that it must be to fold at all. private static let foldSlack: CGFloat = 80 /// As much as is drawn folded, far more than `foldedHeight` holds. private static let foldedLength = 2000 let repo: RepoRef let uri: String @Environment(Account.self) private var account @State private var screen: CGFloat = 0 @State private var expanded = false @State private var bodyHeight: CGFloat = 0 /// Moved by a write, so the thread is read again as it now stands. @State private var edits = 0 @State private var composing = false @State private var reviewing = false @State private var isPull = false @State private var failure: String? @State private var canSetState: Bool? @State private var state: String? @State private var title: String? @State private var author: String? @State private var reporting: Reporting? /// What's reported of it: nothing of one's own, or by no one. private var reportable: Bool { account.signedIn && author != nil && account.person?.did != author } private var canClose: Bool { canSetState == true && state != "merged" } var body: some View { Loading(load: { [repo, uri] in let thread: PullThread = try await Client.shared.query( "getThread", ["repo": repo.uri, "uri": uri, "filesOnly": "true"]) return thread }, refresh: edits) { thread in content(thread) .onChange(of: thread.item.state, initial: true) { canSetState = thread.viewer?.setState state = thread.item.state title = thread.item.title isPull = thread.item.kind == "pull" author = thread.item.author.did } .sheet(isPresented: $composing) { Composer(repo: repo, uri: uri) { edits += 1 } } .sheet(isPresented: $reviewing) { ReviewSheet(repo: repo, uri: uri, author: thread.item.author.did) { edits += 1 } } } .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { if account.signedIn { HStack { Spacer() Button { composing = true } label: { SwiftUI.Label { Text("Comment") } icon: { Oct(name: "comment", size: Icon.md) } .font(.callout.weight(.medium)) } .floating() if isPull, state == "open" { ReviewButton { reviewing = true } } } .padding(.horizontal) .padding(.bottom, Space.s2) } } .onGeometryChange(for: CGFloat.self, of: \.size.width) { screen = $0 } .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } .toolbar { if let title { ToolbarItem(placement: .principal) { VStack(alignment: .leading, spacing: 0) { Text(title).font(.subheadline.weight(.semibold)).lineLimit(1) Text(repo.label).font(.caption).foregroundStyle(.secondary).lineLimit(1) } // A bar doesn't keep it off the buttons beside it when it's long. .frame(width: titleWidth, alignment: .leading) } } if let url = pageURL { ToolbarItem(placement: .topBarTrailing) { ShareLink(item: url) } } if canClose || reportable { ToolbarItem(placement: .topBarTrailing) { Menu { if canClose { if state == "open" { Button("Close", role: .destructive) { setState("closed") } } else { Button("Reopen") { setState("open") } } } if reportable { let what = isPull ? "this pull" : "this issue" Button("Report \(what)") { reporting = Reporting(subject: uri, what: what) } } } label: { SwiftUI.Label("More", systemImage: "ellipsis") } } } } .sheet(item: $reporting) { ReportSheet(reporting: $0) } } private func setState(_ next: String) { Task { do { try await Client.shared.send(Writes.setState(repo: repo, subject: uri, state: next)) edits += 1 } catch { failure = error.localizedDescription } } } private var pageURL: URL? { // Its page is named by who wrote it and its key, the two either side // of the collection, in a private repository's longer URI too. guard let kind = kind(ofURI: uri) else { return nil } let parts = uri.split(separator: "/").map(String.init) guard let at = parts.firstIndex(of: "dev.knotless.\(kind)"), at > 0, at + 1 < parts.count else { return nil } return URL(string: "https://knotless.dev/\(repo.handle)/\(repo.name)/\(kind)/\(parts[at - 1])/\(parts[at + 1])") } /// The screen, less the back button on one side and the bar's buttons on the other. private var titleWidth: CGFloat { let buttons: CGFloat = (pageURL == nil ? 0 : 1) + (canClose || reportable ? 1 : 0) return max(0, screen - 76 - 16 - buttons * 52) } private func content(_ thread: PullThread) -> some View { ScrollView { VStack(spacing: 0) { header(thread) if let changes = thread.changes { changesSection(changes) } statusSection(thread) let comments = thread.comments ?? [] if !comments.isEmpty { ThreadSection("Conversation") { ForEach(Array(Comment.threaded(comments).enumerated()), id: \.element.id) { at, comment in if at > 0 { Divider().padding(.leading, Space.s4) } CommentRow(repo: repo, comment: comment) { edits += 1 } } } } } } .background(Color.groupedPage) } /// The links here are only their own words, as a page's are: nothing marks /// them as rows, and the text around them doesn't respond to a touch. private func header(_ thread: PullThread) -> some View { let item = thread.item return VStack(alignment: .leading, spacing: Space.s3) { NavigationLink(value: Route.repo(repo)) { HStack(spacing: Space.s1_5) { OwnerAvatar(repo: repo, known: item.author.did == repo.did ? item.author.avatar : nil) Text(repo.handle).lineLimit(1) Text("/").foregroundStyle(.secondary) Text(repo.name).lineLimit(1) } .font(.subheadline.weight(.medium)) } .buttonStyle(.plain) Text(item.title).font(.title2.bold()) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Space.s2) { StatePill(style: StateStyle(kind: item.kind, state: item.state, draft: item.draft == true)) if let base = thread.base, let head = thread.head?.branch { branches(base, head) } } .padding(.horizontal, Space.s4) } .padding(.horizontal, -16) NavigationLink(value: Route.profile(item.author.handle)) { HStack(spacing: Space.s3) { Avatar(url: item.author.avatar, name: item.author.handle, did: item.author.did, size: Size.Avatar.large) Text(item.author.handle).font(.body.weight(.medium)).lineLimit(1) Text("· \(When.short(item.createdAt))").foregroundStyle(.secondary) } } .buttonStyle(.plain) .accessibilityIdentifier("thread-author") .padding(.top, Space.s1) body(thread.body, html: thread.html, author: thread.item.author.did) ReactionBar(repo: repo, subject: uri, reactions: thread.reactions ?? []) { edits += 1 } } .padding(Space.s4) .frame(maxWidth: .infinity, alignment: .leading) // Runs on up behind the navigation bar, and past a pull down. .background(Color.card.padding(.top, -1000)) } private func changesSection(_ changes: Changes) -> some View { let files = changes.files ?? [] let commits = changes.commits ?? [] return ThreadSection("Changes") { NavigationLink(value: Route.files(repo, uri: uri)) { ThreadRow { RowIcon(name: "oct-file-diff") } content: { Text(files.count == 1 ? "1 file changed" : "\(files.count) files changed") Spacer() Text("+\(files.reduce(0) { $0 + $1.added })").foregroundStyle(Color.success) Text("-\(files.reduce(0) { $0 + $1.removed })").foregroundStyle(Color.danger) RowChevron(name: "chevron-right") } } .buttonStyle(RowPress()) RowDivider() NavigationLink(value: Route.commits(repo, uri: uri)) { ThreadRow { RowIcon(name: "oct-git-commit") } content: { Text(commits.count == 1 ? "1 commit" : "\(commits.count) commits") Spacer() if let date = commits.last?.date { Text(When.short(date)).foregroundStyle(.secondary) } RowChevron(name: "chevron-right") } } .buttonStyle(RowPress()) } } @ViewBuilder private func statusSection(_ thread: PullThread) -> some View { let item = thread.item let reviews = thread.reviews ?? [] let checks = thread.checks ?? [] let merges = item.kind == "pull" && item.state == "open" && account.signedIn if item.kind == "pull", merges || !reviews.isEmpty || !checks.isEmpty { ThreadSection("Status") { reviewsRow(reviews) if !checks.isEmpty { RowDivider() checksRow(checks) } if merges { RowDivider() MergeSection(repo: repo, pull: uri, base: thread.base ?? "its base", queue: thread.queue, refresh: edits) { edits += 1 } } } } } @ViewBuilder private func reviewsRow(_ reviews: [Review]) -> some View { let standing = reviews.filter { $0.outdated != true } let asked = standing.filter { $0.verdict == "changes" }.count let approved = standing.filter { $0.verdict == "approve" }.count let said = reviews.isEmpty ? "None" : asked > 0 ? "Changes asked" : approved > 0 ? "\(approved) approved" : "Waiting" let label = ThreadRow { RowIcon(name: "oct-person") } content: { Text("Reviews") Text("· \(said)").foregroundStyle(.secondary) Spacer() } if reviews.isEmpty { label } else { FoldRow(label: label) { ForEach(reviews, id: \.reviewer.did) { review in ThreadRow { Avatar(url: review.reviewer.avatar, name: review.reviewer.handle, did: review.reviewer.did, size: Size.Avatar.small) } content: { Text(review.reviewer.handle).lineLimit(1) Spacer() verdict(review.verdict, outdated: review.outdated == true) } } } } } private func checksRow(_ checks: [Check]) -> some View { let summary = CheckSummary(checks) return FoldRow(label: ThreadRow { Oct(name: summary.symbol, size: Icon.xl).foregroundStyle(summary.color) } content: { Text(summary.text) Spacer() }) { ForEach(checks) { check in NavigationLink(value: Route.run(repo, uri: check.uri)) { ThreadRow { StatusMark(check: check) } content: { Text(check.workflow).lineLimit(1) Spacer() Text(RunWords.took(check).map { check.skipped == nil ? $0 : "Skipped" } ?? RunWords.status(check)) .font(.subheadline) .foregroundStyle(.secondary) RowChevron(name: "chevron-right") } } .buttonStyle(RowPress()) } } } private func branches(_ base: String, _ head: String) -> some View { HStack(spacing: Space.s1_5) { BranchChip(name: base) Image(systemName: "arrow.left").font(.caption).foregroundStyle(.secondary) .accessibilityLabel("from") BranchChip(name: head) } } @ViewBuilder private func body(_ text: String?, html: String?, author: String) -> some View { if let text, !text.isEmpty { let folded = !expanded && folds(text) TickableMarkdown(source: text, html: html, place: MarkdownPlace(repo: repo, subject: uri), repo: repo, subject: uri, author: author, limit: expanded ? nil : Self.foldedLength) { edits += 1 } // Its whole height and then cut, or its text is squeezed to // fit and truncates, rather than reading as it does opened. .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: CGFloat.self, of: \.size.height) { bodyHeight = $0 } .frame(maxHeight: folded ? Self.foldedHeight : nil, alignment: .top) .clipped() .mask(LinearGradient(stops: [ .init(color: .black, location: folded ? 0.7 : 1), .init(color: folded ? .clear : .black, location: 1), ], startPoint: .top, endPoint: .bottom)) .overlay(alignment: .bottom) { if folded { Button { expanded = true } label: { SwiftUI.Label { Text("Read more") } icon: { Oct(name: "arrow-down", size: Icon.md) }.font(.callout.weight(.medium)) } .floating() } } } else { Text("No description.").foregroundStyle(.secondary) } } /// Whether folding hides enough to be worth a button. Until it's measured, /// a guess from its length, so what's going to fold doesn't first draw whole. private func folds(_ text: String) -> Bool { if text.count > Self.foldedLength { return true } return bodyHeight == 0 ? text.count > 800 : bodyHeight > Self.foldedHeight + Self.foldSlack } @ViewBuilder private func verdict(_ verdict: String?, outdated: Bool) -> some View { switch verdict { case "approve": SwiftUI.Label { Text(outdated ? "Approved earlier" : "Approved") } icon: { Oct(name: "check-circle-fill") } .foregroundStyle(outdated ? Color.neutral : Color.success).font(.subheadline) case "changes": SwiftUI.Label { Text("Changes asked") } icon: { Oct(name: "alert-fill") } .foregroundStyle(outdated ? Color.neutral : Color.danger).font(.subheadline) default: Text("Waiting").foregroundStyle(.secondary).font(.subheadline) } } } /// A titled part of a thread, its rows white from edge to edge on the grey behind. struct ThreadSection: View { let title: String @ViewBuilder let content: Content init(_ title: String, @ViewBuilder content: () -> Content) { self.title = title self.content = content() } var body: some View { VStack(alignment: .leading, spacing: 0) { Text(title) .font(.title3.bold()) .padding(.horizontal, Space.s4) .padding(.top, Space.s7) .padding(.bottom, Space.s3) VStack(spacing: 0) { content } .background(Color.card) .overlay(alignment: .top) { Divider() } .overlay(alignment: .bottom) { Divider() } } } } /// A row with its icon in a column, so the words of a section's rows line up. struct ThreadRow: View { @ViewBuilder let icon: Icon @ViewBuilder let content: Content var body: some View { HStack(spacing: Space.s3) { icon.frame(width: 24) HStack(spacing: Space.s1_5) { content } } .padding(.horizontal, Space.s4) .padding(.vertical, Space.s3_5) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } } struct RowDivider: View { var body: some View { Divider().padding(.leading, Size.smallRowInset) } } struct RowIcon: View { let name: String var body: some View { Image(name).renderingMode(.template).resizable().scaledToFit() .frame(width: 20, height: 20) .foregroundStyle(.secondary) } } struct RowChevron: View { let name: String var body: some View { Oct(name: name, size: Icon.sm).foregroundStyle(.tertiary) } } /// A row that opens to show more under it. struct FoldRow: View { let label: Label @ViewBuilder let detail: Detail @State private var open = false var body: some View { VStack(spacing: 0) { Button { withAnimation(.snappy(duration: 0.2)) { open.toggle() } } label: { HStack(spacing: 0) { label RowChevron(name: open ? "chevron-up" : "chevron-down").padding(.trailing, Space.s4) } .contentShape(Rectangle()) } .buttonStyle(RowPress()) if open { detail } } } } /// A whole row as a link or button, shaded only while it's held. struct RowPress: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(.primary) .background(configuration.isPressed ? Color.fill : .clear) } } extension View { /// A button that floats over the page: glass where the system has it. @ViewBuilder func floating() -> some View { if #available(iOS 26, *) { buttonStyle(.glass).buttonBorderShape(.capsule).controlSize(.large).tint(.primary) } else { buttonStyle(.bordered).buttonBorderShape(.capsule).controlSize(.large).tint(.primary) } } } struct CommentRow: View { let repo: RepoRef let comment: Comment let changed: () -> Void @Environment(Account.self) private var account @State private var reporting: Reporting? @State private var editing = false @State private var deleting = false @State private var failure: String? private var mine: Bool { account.signedIn && account.person?.did == comment.author.did } var body: some View { VStack(alignment: .leading, spacing: Space.s2) { HStack(spacing: Space.s2) { NavigationLink(value: Route.profile(comment.author.handle)) { HStack(spacing: Space.s2) { Avatar(url: comment.author.avatar, name: comment.author.handle, did: comment.author.did, size: Size.Avatar.small) Text(comment.author.handle).font(.subheadline.weight(.medium)).lineLimit(1) } } .buttonStyle(.plain) Text(When.short(comment.createdAt)).font(.subheadline).foregroundStyle(.secondary) if comment.verdict == "approve" { Oct(name: "check-circle-fill").foregroundStyle(Color.success).accessibilityLabel("Approved") } else if comment.verdict == "changes" { Oct(name: "alert-fill").foregroundStyle(Color.danger).accessibilityLabel("Asked for changes") } if account.signedIn, let uri = comment.uri { Spacer(minLength: 0) Menu { if mine { Button("Edit") { editing = true } Button("Delete", role: .destructive) { deleting = true } } else { Button("Report this comment") { reporting = Reporting(subject: uri, what: "this comment") } } } label: { Oct(name: "kebab-horizontal") .foregroundStyle(.secondary) .frame(width: Size.Avatar.small, height: Size.Avatar.small) .contentShape(Rectangle()) } .accessibilityLabel("More about this comment") } } // An answer sits under the lines its thread starts with. if let lines = comment.line, comment.reply == nil { LineQuote(at: lines, resolved: comment.resolved == true) } if let body = comment.body, !body.isEmpty { if let uri = comment.uri { TickableMarkdown(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: uri), repo: repo, subject: uri, author: comment.author.did, changed: changed) } else { MarkdownView(source: body, html: comment.html, place: MarkdownPlace(repo: repo, subject: comment.uri)) } } if let uri = comment.uri { ReactionBar(repo: repo, subject: uri, reactions: comment.reactions ?? [], changed: changed) } } .padding(.vertical, Space.s4) .padding(.trailing, Space.s4) .padding(.leading, comment.reply == nil ? 16 : 40) .frame(maxWidth: .infinity, alignment: .leading) .sheet(item: $reporting) { ReportSheet(reporting: $0) } .sheet(isPresented: $editing) { if let uri = comment.uri { Composer(repo: repo, uri: uri, editing: comment.body ?? "", sent: changed) } } .confirmationDialog("Delete this comment for good?", isPresented: $deleting, titleVisibility: .visible) { Button("Delete", role: .destructive) { Task { await delete() } } } .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } } private func delete() async { guard let uri = comment.uri else { return } do { try await Client.shared.send(Writes.deleteComment(repo: repo, subject: uri)) changed() } catch { failure = error.localizedDescription } } } /// A repository's owner's picture. A thread's answer doesn't name it, so unless /// its author is the owner it's asked for, once while the app runs. A plain /// ground until the answer, as the identicon would flash first. private struct OwnerAvatar: View { let repo: RepoRef let known: String? @State private var answered = false @State private var asked: String? private var url: String? { known ?? asked ?? Faces.address(of: repo.did) } var body: some View { Group { if url != nil || answered { Avatar(url: url, name: repo.handle, did: repo.did, size: Size.Avatar.small) } else { Circle().fill(Color.fill).frame(width: 24, height: 24) } } .task(id: repo.did) { guard url == nil else { return } let profile: Profile? = try? await Client.shared.query("getProfile", ["actor": repo.did]) guard !Task.isCancelled else { return } if let avatar = profile?.avatar { Faces.keep(address: avatar, of: repo.did) } asked = profile?.avatar answered = true } } } /// The lines of a pull's diff a comment is on, as they were when it was /// written: those it names marked, and for one alone a few before it. private struct LineQuote: View { let at: Comment.Lines let resolved: Bool private var first: Int { at.startLine ?? at.line } private var place: String { at.startLine.map { "\(at.path):\($0)-\(at.line)" } ?? "\(at.path):\(at.line)" } private var mark: Color { at.side == "old" ? .danger : .success } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: Space.s2) { Text(place).font(.footnote.monospaced()).lineLimit(1).truncationMode(.head) Spacer(minLength: 4) if resolved { Text("Resolved").font(.caption).foregroundStyle(.secondary).fixedSize() } if at.outdated { Text("Outdated").font(.caption).foregroundStyle(.secondary).fixedSize() } } .padding(.horizontal, Space.s2_5) .padding(.vertical, Space.s2) if let lines = at.lines, !lines.isEmpty { Divider() ScrollView(.horizontal, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { ForEach(lines, id: \.number) { line in HStack(spacing: Space.s2_5) { Text(String(line.number)).foregroundStyle(.secondary) .frame(minWidth: 28, alignment: .trailing) Text(line.text).fixedSize() Spacer(minLength: 0) } .font(.caption.monospaced()) .padding(.vertical, Space.s0_5) .padding(.trailing, Space.s2_5) .background(line.number >= first ? mark.opacity(0.18) : .clear) } } .frame(minWidth: 0) } } else { Divider() Text("These lines can no longer be read.").font(.footnote).foregroundStyle(.secondary) .padding(.horizontal, Space.s2_5).padding(.vertical, Space.s2) } } .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.medium)) .clipShape(RoundedRectangle(cornerRadius: Radius.medium)) } } struct CommitsView: View { let repo: RepoRef let uri: String private struct Answer: Sendable { let home: RepoRef; let commits: [Commit] } var body: some View { Loading(load: { [repo, uri] in let thread: PullThread = try await Client.shared.query( "getThread", ["repo": repo.uri, "uri": uri, "filesOnly": "true"]) return Answer(home: thread.changes?.repo?.ref ?? repo, commits: thread.changes?.commits ?? []) }) { answer in List { ForEach(answer.commits) { CommitRow(repo: answer.home, commit: $0) }.listRowBackground(Color.page) } .listStyle(.plain) } .navigationTitle("Commits") .navigationBarTitleDisplayMode(.inline) } }