import SwiftUI /// Writes a comment, or with `editing`, changes one's body: `uri` is then the comment's. struct Composer: View { let repo: RepoRef let uri: String var editing: String? = nil let sent: () -> Void @Environment(\.dismiss) private var dismiss @State private var text = "" @State private var sending = false @State private var failure: String? @FocusState private var focused: Bool private var empty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { NavigationStack { VStack(spacing: Space.s3) { TextEditor(text: $text) .focused($focused) .accessibilityLabel("Your comment") .overlay(alignment: .topLeading) { if empty { Text("Write in Markdown") .foregroundStyle(.tertiary) .padding(.top, Space.s2) .padding(.leading, Space.s1_5) .allowsHitTesting(false) } } if let failure { Text(failure).font(.footnote).foregroundStyle(Color.danger) .frame(maxWidth: .infinity, alignment: .leading) } } .padding() .navigationTitle(editing == nil ? "Comment" : "Edit comment") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button(editing == nil ? "Send" : "Save") { Task { await send() } } .disabled(sending || empty) } } .onAppear { if let editing, text.isEmpty { text = editing } focused = true } .interactiveDismissDisabled(editing == nil ? !empty : text != editing) } } private func send() async { sending = true failure = nil defer { sending = false } do { if editing != nil { try await Client.shared.send(Writes.edit(repo: repo, subject: uri, body: text)) } else { try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: nil)) } sent() dismiss() } catch { failure = error.localizedDescription } } } /// The green button that opens a pull's review. struct ReviewButton: View { let action: () -> Void var body: some View { Button(action: action) { SwiftUI.Label { Text("Review") } icon: { Oct(name: "check", size: Icon.md) } .font(.callout.weight(.medium)) .foregroundStyle(Color.onAction) } .buttonStyle(.borderedProminent) .buttonBorderShape(.capsule) .controlSize(.large) .tint(Color.action) } } /// Writes a pull's review. Its author can only comment, as readers take no /// verdict from them. struct ReviewSheet: View { private enum Choice: CaseIterable, Identifiable { case comment, approve, changes var id: Self { self } var title: String { switch self { case .comment: "Comment" case .approve: "Approve" case .changes: "Request changes" } } var verdict: String? { switch self { case .comment: nil case .approve: "approve" case .changes: "changes" } } func about(own: Bool) -> String { switch self { case .comment: "Leave feedback without a verdict." case .approve: own ? "You can't approve your own pull." : "Say these changes are good to merge." case .changes: own ? "You can't request changes on your own pull." : "Say what must change before this merges." } } } let repo: RepoRef let uri: String /// The DID of the pull's author. let author: String let sent: () -> Void @Environment(Account.self) private var account @Environment(\.dismiss) private var dismiss @State private var text = "" @State private var choice = Choice.comment @State private var sending = false @State private var failure: String? private var own: Bool { account.person?.did == author } private var empty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } /// A review may say nothing past its verdict. A comment says something. private var ready: Bool { !sending && (choice != .comment || !empty) } private var action: String { switch choice { case .comment: "Submit review" default: choice.title } } var body: some View { NavigationStack { ScrollView { VStack(spacing: Space.s3) { TextField("Leave a comment", text: $text, axis: .vertical) .lineLimit(1...8) .accessibilityLabel("Your review") .padding(Space.s4) .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.card)) .padding(.bottom, Space.s2) ForEach(Choice.allCases) { option($0) } if let failure { Text(failure).font(.footnote).foregroundStyle(Color.danger) .frame(maxWidth: .infinity, alignment: .leading) } Button { Task { await send() } } label: { Text(action) .font(.body.weight(.semibold)) .foregroundStyle(ready ? AnyShapeStyle(Color.onAction) : AnyShapeStyle(.secondary)) .frame(maxWidth: .infinity) .padding(.vertical, Space.s4) .background(Color.action.opacity(ready ? 1 : 0.25), in: RoundedRectangle(cornerRadius: Radius.card)) } .buttonStyle(.plain) .disabled(!ready) .padding(.top, Space.s2) } .padding(Space.s4) } .navigationTitle("Review") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button { dismiss() } label: { Oct(name: "x", size: Icon.xl) } .tint(.primary) .accessibilityLabel("Close") } } .interactiveDismissDisabled(!empty) } } private func option(_ option: Choice) -> some View { let open = option == .comment || !own return Button { choice = option } label: { HStack(spacing: Space.s3) { VStack(alignment: .leading, spacing: Space.s1) { Text(option.title).font(.body.weight(.medium)).foregroundStyle(open ? .primary : .secondary) Text(option.about(own: own)).font(.subheadline).foregroundStyle(.secondary) } Spacer(minLength: 0) if choice == option { Oct(name: "check-circle-fill", size: Icon.xl).foregroundStyle(Color.accent) } } .padding(Space.s4) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.fill, in: RoundedRectangle(cornerRadius: Radius.card)) .contentShape(RoundedRectangle(cornerRadius: Radius.card)) } .buttonStyle(.plain) .disabled(!open) .accessibilityAddTraits(choice == option ? .isSelected : []) } private func send() async { sending = true failure = nil defer { sending = false } do { try await Client.shared.send(Writes.comment(repo: repo, subject: uri, body: text, verdict: choice.verdict)) sent() dismiss() } catch { failure = error.localizedDescription } } } /// How people reacted, and adding or taking back one's own. struct ReactionBar: View { private static let all = [ ("+1", "👍"), ("-1", "👎"), ("laugh", "😄"), ("hooray", "🎉"), ("confused", "😕"), ("heart", "❤️"), ("rocket", "🚀"), ("eyes", "👀"), ] @Environment(Account.self) private var account let repo: RepoRef let subject: String let reactions: [ReactionCount] let changed: () -> Void @State private var failure: String? /// What a touch made of them, until the server's answer replaces it. @State private var touched: [ReactionCount]? private var shown: [ReactionCount] { touched ?? reactions } var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Space.s1_5) { if account.signedIn { Menu { ForEach(Self.all, id: \.0) { content, emoji in Button(emoji) { toggle(content, mine: mine(content)) } } } label: { Oct(name: "smiley", size: Icon.lg) .padding(.horizontal, Space.s2) .padding(.vertical, Space.s1_5) .background(Color.fill, in: Capsule()) } .accessibilityLabel("Add a reaction") } ForEach(shown.filter { $0.count > 0 }, id: \.content) { reaction in Button { toggle(reaction.content, mine: reaction.reaction != nil) } label: { Text("\(emoji(reaction.content)) \(reaction.count)") .font(.subheadline) .foregroundStyle(.primary) .padding(.horizontal, Space.s2) .padding(.vertical, Space.s1) .background(reaction.reaction != nil ? Color.accentSoft : Color.fill, in: Capsule()) } .buttonStyle(.borderless) .disabled(!account.signedIn) } } } .onChange(of: reactions) { touched = nil } .alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { Button("OK", role: .cancel) {} } message: { Text(failure ?? "") } } private func emoji(_ content: String) -> String { Self.all.first { $0.0 == content }?.1 ?? content } private func mine(_ content: String) -> Bool { shown.first { $0.content == content }?.reaction != nil } private func toggle(_ content: String, mine: Bool) { var next = shown if let at = next.firstIndex(where: { $0.content == content }) { next[at].count += mine ? -1 : 1 next[at].reaction = mine ? nil : "mine" } else { next.append(ReactionCount(content: content, count: 1, reaction: "mine")) } touched = next Task { do { try await Client.shared.send(Writes.react(repo: repo, subject: subject, content: content, undo: mine)) changed() } catch { touched = nil failure = error.localizedDescription } } } } /// What can happen to an open pull now, asked of the server rather than worked /// out here, with the buttons for what the asker may do. struct MergeSection: View { let repo: RepoRef let pull: String let base: String let queue: QueueInfo? /// Moved when the thread is read again, as whether it can merge may be different. let refresh: Int let changed: () -> Void @State private var status: MergeStatus? @State private var loadFailed = false @State private var confirming = false /// The write under way, until the thread read after it shows the change. @State private var doing: String? @State private var failure: String? private var busy: Bool { doing != nil } private var queued: Bool { queue?.state == "queued" } var body: some View { VStack(alignment: .leading, spacing: Space.s3) { if let queue, queued { said(Standing(title: queue.said, icon: "clock", tone: .waiting)) BigButton(title: doing == "leaving" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaving", Writes.leaveQueue(repo: repo, pull: pull)) } } else if let status { // One the queue turned away can be queued again, once fixed. if let queue { said(.ejected(reason: queue.reason)) } said(.of(status, base: base)) let merges = Standing.canMerge(status) if merges { BigButton(title: doing == "merging" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true } .confirmationDialog("Merge this pull?", isPresented: $confirming, titleVisibility: .visible) { Button("Merge pull") { act("merging", Writes.merge(repo: repo, pull: pull)) } } } if Standing.canQueue(status) { BigButton(title: doing == "adding" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("adding", Writes.queue(repo: repo, pull: pull)) } } } else if loadFailed { Text("Can't say whether this can merge.").foregroundStyle(.secondary) } else { ProgressView().frame(maxWidth: .infinity) } if let failure { Text(failure).font(.footnote).foregroundStyle(Color.danger).padding(.leading, Space.s9) } } .padding(Space.s4) .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: queue) { doing = nil } .task(id: "\(queued) \(refresh)") { if queued { return } do { status = try await Client.shared.query("getMergeStatus", ["repo": repo.uri, "pull": pull]) } catch is CancellationError { } catch { loadFailed = true } } } private func said(_ it: Standing) -> some View { HStack(alignment: .top, spacing: Space.s3) { Oct(name: it.icon, size: Icon.xl).foregroundStyle(color(it.tone)).frame(width: 24) VStack(alignment: .leading, spacing: Space.s1) { Text(it.title) ForEach(it.lines, id: \.self) { line in Text(line).font(.subheadline).foregroundStyle(.secondary) } } } } private func color(_ tone: Standing.Tone) -> Color { switch tone { case .good: .success case .waiting: .attention case .bad: .danger case .neutral: .neutral } } private func act(_ what: String, _ write: Write) { doing = what failure = nil Task { do { try await Client.shared.send(write) changed() // The change usually shows sooner, and ends this: see onChange. try? await Task.sleep(for: .seconds(3)) } catch { failure = error.localizedDescription } doing = nil } } } /// A row's button, as wide as its words, indented under the icon beside them. /// The one marked `main` is what lands the pull next. private struct BigButton: View { let title: String var role: ButtonRole? var main = false let disabled: Bool let action: () -> Void var body: some View { Group { if main { button.buttonStyle(.borderedProminent).tint(Color.action).foregroundStyle(Color.onAction) .buttonBorderShape(.roundedRectangle(radius: 14)) } else { button.quietButton(large: true, radius: 14) } } .controlSize(.large) .disabled(disabled) .padding(.leading, Space.s9) } private var button: some View { Button(role: role, action: action) { Text(title).font(.body.weight(.medium)).frame(maxWidth: .infinity) } } }