// // AdminTweakRequestThreadView.swift // FTStore Admin // // The public conversation under one tweak request. Mirrors the thread panel on // /admin/tweak-requests. // // The queue behind this screen is about deciding: approve the source or turn it // down. This is the part a decision cannot answer — somebody asking what the // hold-up is, somebody naming a second channel with the same tweak, or the // operators saying "we looked, it's dead" without having to reject anything. // // One thread per **source**, not per request row. The queue has always grouped // by source and settled a whole group with one decision, and the board follows // the same rule: the server resolves whatever id it is handed to the group's // canonical row. So this is the thread the app is showing, not a copy of it. // // What is published is narrower than what the queue holds. A request predates // the board and was typed privately, so its note and who asked stay in the // queue and never appear here. Only the replies below are public, which is why // they carry a handle and the wish above them does not. // import SwiftUI struct AdminTweakRequestThreadView: View { @EnvironmentObject private var session: AdminSession /// The group this thread belongs to, so the decision can be made from here. let group: AdminTweakRequest /// Any row of the group. The server resolves it to the canonical one. let requestId: Int /// Called after a decision, so the queue behind this screen re-reads itself /// rather than keeping a row that has just been answered. let onDecided: () async -> Void @Environment(\.dismiss) private var dismiss @StateObject private var _board = AdminLoader() @State private var _reply = "" @State private var _busy = false @State private var _banner: String? @State private var _hiding: AdminTweakRequestComment? /// Set while the reason prompt is up, and what it will do. @State private var _deciding = false @State private var _approving = false @State private var _reason = "" private var _comments: [AdminTweakRequestComment] { _board.value?.comments ?? [] } private var _tally: AdminTweakRequestTally { _board.value?.votes ?? .none } private var _teamVote: Int { _tally.teamVote ?? 0 } var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } _standingSection if _board.isLoading && !_board.hasLoaded { AdminLoadingRow() } else if let error = _board.errorMessage, !_board.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else { _threadSection } _answerSection _decisionSection } .navigationTitle(group.tweakName) .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .alert( "Hide this reply?", isPresented: Binding( get: { _hiding != nil }, set: { if !$0 { _hiding = nil } } ) ) { Button("Hide", role: .destructive) { if let comment = _hiding { _setCommentHidden(comment, hidden: true) } } Button("Cancel", role: .cancel) { _hiding = nil } } message: { Text("It leaves the board for everyone except its author, who still sees it. Reversible at any time.") } .alert(_approving ? "Approve request" : "Reject request", isPresented: $_deciding) { TextField("Reason (optional)", text: $_reason) Button(_approving ? "Approve" : "Reject", role: _approving ? nil : .destructive) { _decide() } Button("Cancel", role: .cancel) {} } message: { Text(_decisionMessage) } } // MARK: Deciding /// Approve or turn the source down, from the screen where you just read what /// people said about it. /// /// Here as well as on the queue, because the two are different moments. The /// queue is triage — clear the obvious ones without opening them. This is /// the decision you make *because* of the thread, and having to go back to /// the list to act on what you just read is how a reply gets forgotten. @ViewBuilder private var _decisionSection: some View { if group.isPending { Section { Button { _approving = true _reason = "" _deciding = true } label: { Label("Approve", systemImage: "checkmark.circle") } .disabled(_busy) Button(role: .destructive) { _approving = false _reason = "" _deciding = true } label: { Label("Reject", systemImage: "xmark.circle") } .disabled(_busy) } header: { Text("Decide") } footer: { Text(_decisionMessage) } } } private var _decisionMessage: String { var lines = [group.sourceLabelOrNone] if group.pendingIds.count > 1 { lines.append( String.localized( "This answers all %lld open requests for that source.", group.pendingIds.count ) ) } lines.append(.localized("The reason is shown to everyone who asked.")) return lines.joined(separator: " ") } // MARK: Where it stands @ViewBuilder private var _standingSection: some View { Section { VStack(alignment: .leading, spacing: 8) { Text(verbatim: group.sourceLabelOrNone) .font(.footnote.monospaced()) .foregroundStyle(.secondary) HStack(spacing: 8) { // The tally, read-only: the buttons under it are what casts // FTStore's own vote, one for the whole team however many // operators there are. Text(verbatim: _tally.votes.scoreLabel) .font(.caption.weight(.semibold).monospacedDigit()) Label("\(_tally.upvotes)", systemImage: "arrowtriangle.up") Label("\(_tally.downvotes)", systemImage: "arrowtriangle.down") if _teamVote > 0 { Label("Backed by FTStore", systemImage: "checkmark.seal.fill") .foregroundStyle(.green) } } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } .padding(.vertical, 2) HStack { Button { _vote(1) } label: { Label("Back it", systemImage: "arrowshape.up.fill") } .buttonStyle(.borderless) .tint(_teamVote > 0 ? AdminTheme.brand : .gray) Spacer() Button { _vote(-1) } label: { Label("Vote down", systemImage: "arrowshape.down.fill") } .buttonStyle(.borderless) .tint(_teamVote < 0 ? .red : .gray) } .disabled(_busy) Button { _togglePinned(!group.isPinned) } label: { Label( group.isPinned ? "Pinned to the top" : "Pin to the top", systemImage: group.isPinned ? "pin.fill" : "pin" ) } .disabled(_busy) } footer: { Text("This is the public wish for the source, not one person's request. The note somebody sent with theirs stays in the queue.") } } // MARK: The thread @ViewBuilder private var _threadSection: some View { Section { if _comments.isEmpty { Text("Nobody has replied yet.") .font(.footnote) .foregroundStyle(.secondary) } else { ForEach(_comments) { comment in _entry(comment) } } } header: { Text("Replies") } } @ViewBuilder private func _entry(_ comment: AdminTweakRequestComment) -> some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 6) { Text(verbatim: comment.authorName) .font(.subheadline.weight(.medium)) if comment.isTeam { Text("Team") .font(.caption2.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(AdminTheme.brand.opacity(0.15), in: Capsule()) .foregroundStyle(AdminTheme.brand) } if comment.asksForTeam { Label("Asked for FTStore", systemImage: "at") .font(.caption2) .foregroundStyle(.orange) } Spacer(minLength: 0) } Text(AdminMentions.attributed(comment.body, tint: AdminTheme.brand)) .font(.subheadline) .strikethrough(comment.isDeleted) .textSelection(.enabled) // A reply the team took down is only on screen for its author, and // the server refuses a vote on one, so it gets no control here. if !comment.isDeleted && !comment.hidden { HStack(spacing: 8) { Text(verbatim: comment.votes.scoreLabel) .font(.caption.weight(.semibold).monospacedDigit()) Label("\(comment.votes.upvotes)", systemImage: "arrowtriangle.up") Label("\(comment.votes.downvotes)", systemImage: "arrowtriangle.down") Spacer(minLength: 0) // Backing a reply, not just the wish: sometimes the useful // thing on a thread is somebody else's answer. Button { _voteReply(comment, 1) } label: { Image(systemName: "arrowshape.up.fill") } .buttonStyle(.borderless) .tint((comment.teamVote ?? 0) > 0 ? AdminTheme.brand : .gray) Button { _voteReply(comment, -1) } label: { Image(systemName: "arrowshape.down.fill") } .buttonStyle(.borderless) .tint((comment.teamVote ?? 0) < 0 ? .red : .gray) } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .disabled(_busy) } Text(verbatim: _byline(comment)) .font(.caption2) .foregroundStyle(.tertiary) if comment.isDeleted { Text("Deleted by its author. Nobody in the app can see it.") .font(.caption2) .foregroundStyle(.secondary) } if comment.hidden, let reason = comment.hiddenReason, !reason.isEmpty { Text(verbatim: .localized("Hidden: %@", reason)) .font(.caption2) .foregroundStyle(.red) } } .padding(.vertical, 2) .opacity(comment.hidden || comment.isDeleted ? 0.5 : 1) .swipeActions(edge: .trailing) { // Hiding something its author already withdrew would change nothing // anyone can see, so the action goes away with the reply. if !comment.isDeleted { Button(comment.hidden ? "Restore" : "Hide") { if comment.hidden { _setCommentHidden(comment, hidden: false) } else { _hiding = comment } } .tint(comment.hidden ? .green : .orange) } } } private func _byline(_ comment: AdminTweakRequestComment) -> String { var parts: [String] = [] if let created = comment.createdAt { parts.append(AdminFormat.relative(created)) } if comment.editedAt != nil { parts.append(.localized(comment.wasEditedByTeam ? "edited by FTStore" : "edited")) } if let operatorName = comment.operatorName { parts.append(operatorName) } return parts.joined(separator: " · ") } // MARK: Answering @ViewBuilder private var _answerSection: some View { Section { TextField("Write a reply...", text: $_reply, axis: .vertical) .lineLimit(3...8) _mentionSuggestions Button { _send() } label: { HStack { Label("Post reply", systemImage: "paperplane") if _busy { Spacer() ProgressView() } } } .disabled(_busy || _reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } header: { Text("Answer as FTStore") } footer: { Text("Everyone who opens this wish in FTStore sees what you write, under the name FTStore.") } } /// The handles worth offering for the `@` being typed. /// /// The people already in this thread, plus FTStore. The requester is not /// among them and cannot be: the board does not record who asked, which is /// the point of publishing a wish without a name on it. @ViewBuilder private var _mentionSuggestions: some View { if let draft = AdminMentions.draftHandle(in: _reply) { let handles = AdminMentions.suggestions( for: draft, among: _comments.filter { !$0.isTeam }.map(\.authorName) + ["FTStore"] ) if !handles.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(handles.prefix(6), id: \.self) { handle in Button { _reply = AdminMentions.complete(_reply, with: handle) } label: { Text(verbatim: "@\(handle)") .font(.caption) .padding(.horizontal, 9) .padding(.vertical, 4) .background(AdminTheme.brand.opacity(0.15), in: Capsule()) .foregroundStyle(AdminTheme.brand) } .buttonStyle(.plain) } } .padding(.vertical, 2) } } } } // MARK: Actions private func _reload() async { await _board.load { try await session.get( "/api/tweak-requests/\(requestId)/board", as: AdminTweakRequestBoard.self ) } } private func _send() { let body = _reply.trimmingCharacters(in: .whitespacesAndNewlines) _run({ try await session.act( .post, "/api/tweak-requests/\(requestId)/board", body: AdminRequest.TweakRequestReply(body: body) ) }, onSuccess: { _reply = "" }) } /// Put FTStore's weight behind the wish, or take it back. /// /// Pressing the direction a vote already holds clears it, which is the same /// gesture the app's own control uses, so the two behave alike. private func _vote(_ value: Int) { let next = _teamVote == value ? 0 : value _run { try await session.act( .put, "/api/tweak-requests/\(requestId)/board", body: AdminRequest.TweakRequestBoardUpdate(value: next) ) } } private func _setCommentHidden(_ comment: AdminTweakRequestComment, hidden: Bool) { _hiding = nil _run { try await session.act( .put, "/api/tweak-requests/\(requestId)/board", body: AdminRequest.TweakRequestBoardUpdate( commentId: comment.id, hidden: hidden ) ) } } /// FTStore's vote on one reply. Pressing the direction it already holds /// clears it, the same gesture the app's control uses. private func _voteReply(_ comment: AdminTweakRequestComment, _ value: Int) { let next = (comment.teamVote ?? 0) == value ? 0 : value _run { try await session.act( .put, "/api/tweak-requests/\(requestId)/board", body: AdminRequest.TweakRequestReplyVote(value: next, commentId: comment.id) ) } } /// Hold the wish at the top of the app's board, or let it go. private func _togglePinned(_ pinned: Bool) { _run { try await session.act( .put, "/api/tweak-requests/\(requestId)/board", body: AdminRequest.TweakRequestPin(pinned: pinned) ) } } private func _decide() { let approve = _approving let reason = _reason.trimmingCharacters(in: .whitespacesAndNewlines) _busy = true Task { @MainActor in do { let ack = try await session.act( .put, "/api/tweak-requests", body: AdminRequest.TweakRequestDecision( ids: group.pendingIds, status: approve ? "approved" : "rejected", reason: reason.isEmpty ? nil : reason ) ) _banner = ack.summary await onDecided() // Back to the queue: this screen still holds the group as it was // before the decision, and a stale Approve button under a wish // that has just been approved is worse than no button. dismiss() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false } } private func _run( _ operation: @escaping @MainActor () async throws -> AdminAck, onSuccess: (@MainActor () -> Void)? = nil ) { _busy = true Task { @MainActor in do { let ack = try await operation() _banner = ack.summary onSuccess?() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } } }