// // AdminFeedbackDetailView.swift // FTStore Admin // // One feedback thread: read it, answer it as the FTStore Team, and move its // status. Mirrors /admin/feedback/. // // The reply box and the status picker are one form on purpose. Changing a // status without saying why is what makes a public board feel like a void, so // sending both together is a single call, and the server folds them into one // timeline entry the reporter reads as one thing. // // Everything typed here is published to every FTStore user under the name // "FTStore", badged "Team". The operator account behind it is recorded // server-side for // the audit trail and never leaves FTStore Admin. // // Moderation is ordered by how much it takes back: hiding a report is // reversible and is what an operator should reach for, muting an install stops // it writing without touching what it wrote, and deleting destroys the // conversation including other people's replies. The server keeps the last one // behind `withAdmin`, so a non-admin operator gets a 403 banner rather than a // button that isn't there. // import SwiftUI struct AdminFeedbackDetailView: View { @EnvironmentObject private var session: AdminSession let ticketId: Int /// Shown while the thread loads, so the screen has a title straight away. let title: String /// Called once the ticket is gone, so the queue behind this screen drops the /// row instead of keeping one that now 404s. let onDeleted: () async -> Void @Environment(\.dismiss) private var dismiss @StateObject private var _thread = AdminLoader>() @State private var _reply = "" @State private var _status: AdminFeedbackStatus? @State private var _busy = false @State private var _banner: String? @State private var _confirmingMute = false @State private var _confirmingTakedown = false @State private var _confirmingDelete = false private var _data: AdminFeedbackThread? { _thread.value?.data } private var _ticket: AdminFeedbackTicket? { _data?.ticket } /// The pending choice until it is sent, then whatever the server says. private var _selectedStatus: AdminFeedbackStatus { _status ?? _ticket.flatMap { AdminFeedbackStatus(rawValue: $0.status) } ?? .open } private var _statusChanged: Bool { guard let ticket = _ticket else { return false } return _selectedStatus.rawValue != ticket.status } private var _canSend: Bool { !_reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || _statusChanged } var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } if _thread.isLoading && !_thread.hasLoaded { AdminLoadingRow() } else if let error = _thread.errorMessage, !_thread.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else if let ticket = _ticket { _reportSection(ticket) _threadSection _answerSection(ticket) _triageSection(ticket) _moderationSection(ticket) } } .navigationTitle(_ticket.map { "#\($0.id)" } ?? title) .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .alert("Take this report off the board?", isPresented: $_confirmingTakedown) { Button("Take it down", role: .destructive) { _setHidden(true) } Button("Cancel", role: .cancel) {} } message: { Text("It disappears for everyone except its author, who sees it marked as removed. You can put it back at any time.") } .alert("Stop this install from posting?", isPresented: $_confirmingMute) { Button("Mute", role: .destructive) { _setBlocked(true) } Button("Cancel", role: .cancel) {} } message: { Text("Everything already posted stays up. The install can still read the board and still be replied to, it just can't write anything new.") } .alert("Delete this report for good?", isPresented: $_confirmingDelete) { Button("Delete", role: .destructive) { _delete() } Button("Cancel", role: .cancel) {} } message: { Text("The whole conversation goes with it, including other people's replies. Taking it off the board is the reversible option.") } } // MARK: The report @ViewBuilder private func _reportSection(_ ticket: AdminFeedbackTicket) -> some View { Section { VStack(alignment: .leading, spacing: 8) { Text(ticket.title) .font(.headline) HStack(spacing: 8) { Label(ticket.kindLabel, systemImage: ticket.symbol) // The ticket's own status, not the picker's pending choice. Label( ticket.statusLabel, systemImage: AdminFeedbackStatus(rawValue: ticket.status)?.symbol ?? "smallcircle.filled.circle" ) if ticket.isPinned { Label("Pinned", systemImage: "pin.fill") .foregroundStyle(AdminTheme.brand) } if ticket.asksForTeam { Label("Asked for FTStore", systemImage: "at") .foregroundStyle(.orange) } if ticket.hidden { Label("Off the board", systemImage: "eye.slash") .foregroundStyle(.red) } } .font(.caption) .foregroundStyle(.secondary) Text(AdminMentions.attributed(ticket.body, tint: AdminTheme.brand)) .font(.subheadline) .textSelection(.enabled) AdminFeedbackShotsView( shots: ticket.shots, onChanged: { await _reload() }, banner: $_banner ) if ticket.editedAt != nil { NavigationLink { AdminRevisionHistoryView(ticketId: ticketId, commentId: nil) .environmentObject(session) } label: { Label("Show what changed", systemImage: "clock.arrow.circlepath") .font(.caption) } } _voteTally(ticket.votes) HStack(spacing: 8) { Button { _vote(1, current: ticket.teamVote ?? 0) } label: { Label("Back it", systemImage: "arrowshape.up.fill") } .buttonStyle(.borderedProminent) .tint((ticket.teamVote ?? 0) > 0 ? AdminTheme.brand : .gray) Button { _vote(-1, current: ticket.teamVote ?? 0) } label: { Image(systemName: "arrowshape.down.fill") } .buttonStyle(.bordered) .tint((ticket.teamVote ?? 0) < 0 ? .red : .gray) Button { _togglePinned(!ticket.isPinned) } label: { Label(ticket.isPinned ? "Pinned" : "Pin", systemImage: "pin") } .buttonStyle(.bordered) .tint(ticket.isPinned ? AdminTheme.brand : .gray) } .font(.caption) .disabled(_busy) Text(verbatim: _byline(ticket)) .font(.caption2) .foregroundStyle(.tertiary) } .padding(.vertical, 2) } header: { Text("Report") } } private func _byline(_ ticket: AdminFeedbackTicket) -> String { var line = ticket.authorName if let created = ticket.createdAt { line += " · " + AdminFormat.relative(created) } if ticket.editedAt != nil { line += " · " + .localized(ticket.wasEditedByTeam ? "edited by FTStore" : "edited") } return line } // MARK: The thread @ViewBuilder private var _threadSection: some View { if let comments = _data?.comments, !comments.isEmpty { Section { ForEach(comments) { comment in _entry(comment) } } header: { Text("Thread") } } } @ViewBuilder private func _entry(_ comment: AdminFeedbackComment) -> some View { VStack(alignment: .leading, spacing: 5) { if comment.isStatusOnly, let to = comment.statusTo { // An event, not something someone said, so it reads as one line. Label( String.localized( "%@ marked this as %@", comment.authorName, AdminFeedbackStatus.label(for: to) ), systemImage: AdminFeedbackStatus(rawValue: to)?.symbol ?? "arrow.triangle.branch" ) .font(.caption) .foregroundStyle(.secondary) } else { HStack(spacing: 6) { Text(comment.authorName) .font(.subheadline.weight(.medium)) if comment.isTeam { // "Team", not "Admin": the byline beside it is just FTStore // now, so this badge is what says the entry came from the // people who run the store. Text("Team") .font(.caption2.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(AdminTheme.brand.opacity(0.15), in: Capsule()) .foregroundStyle(AdminTheme.brand) } Spacer(minLength: 0) if comment.isDeleted { Image(systemName: "trash").foregroundStyle(.red) } if comment.hidden { Image(systemName: "eye.slash").foregroundStyle(.red) } } .font(.caption) if let to = comment.statusTo { Text(verbatim: .localized("Marked as %@", AdminFeedbackStatus.label(for: to))) .font(.caption2) .foregroundStyle(.secondary) } if let body = comment.body, !body.isEmpty { // Struck through rather than dropped: the board no longer shows // this to anyone, and the replies underneath it were answering it. Text(AdminMentions.attributed(body, tint: AdminTheme.brand)) .font(.subheadline) .strikethrough(comment.isDeleted) .textSelection(.enabled) } AdminFeedbackShotsView( shots: comment.shots, onChanged: { await _reload() }, banner: $_banner ) if !comment.isDeleted { HStack(spacing: 8) { _voteTally(comment.votes) Button { _vote(1, current: comment.teamVote ?? 0, commentId: comment.id) } label: { Image(systemName: "arrowshape.up.fill") } .tint((comment.teamVote ?? 0) > 0 ? AdminTheme.brand : .gray) Button { _vote(-1, current: comment.teamVote ?? 0, commentId: comment.id) } label: { Image(systemName: "arrowshape.down.fill") } .tint((comment.teamVote ?? 0) < 0 ? .red : .gray) } .buttonStyle(.bordered) .controlSize(.mini) .disabled(_busy) } } Text(verbatim: _commentByline(comment)) .font(.caption2) .foregroundStyle(.tertiary) if comment.isDeleted { Text("Deleted by its author. Nobody in the app can see it.") .font(.caption2) .foregroundStyle(.secondary) } } .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") { _setCommentHidden(comment, hidden: !comment.hidden) } .tint(comment.hidden ? .green : .orange) } } } private func _commentByline(_ comment: AdminFeedbackComment) -> 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 deleted = comment.deletedAt { parts.append(.localized("deleted %@", AdminFormat.relative(deleted))) } if let operatorName = comment.operatorName { parts.append(operatorName) } return parts.joined(separator: " · ") } @ViewBuilder private func _voteTally(_ votes: AdminFeedbackVotes) -> some View { if !votes.isEmpty { // The tally, read-only: the buttons beside it are what casts // FTStore's own vote, one for the whole team however many operators // there are. The split is shown next to the score because 40 against // 38 is a different conversation from two people quietly agreeing, // and the score alone spells both as 2. HStack(spacing: 8) { Text(verbatim: votes.scoreLabel) .font(.caption.weight(.semibold).monospacedDigit()) Label("\(votes.upvotes)", systemImage: "arrowtriangle.up") Label("\(votes.downvotes)", systemImage: "arrowtriangle.down") } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color(uiColor: .tertiarySystemFill), in: Capsule()) } } // MARK: Answering @ViewBuilder private func _answerSection(_ ticket: AdminFeedbackTicket) -> some View { Section { Picker("Status", selection: Binding( get: { _selectedStatus }, set: { _status = $0 } )) { ForEach(AdminFeedbackStatus.allCases) { status in Label(status.label, systemImage: status.symbol).tag(status) } } TextField( _statusChanged ? "Why? (optional)" : "Write a reply...", text: $_reply, axis: .vertical ) .lineLimit(3...8) _mentionSuggestions Button { _send() } label: { HStack { Label( _statusChanged ? String.localized("Mark as %@", _selectedStatus.label) : .localized("Post reply"), systemImage: _statusChanged ? _selectedStatus.symbol : "paperplane" ) if _busy { Spacer() ProgressView() } } } .disabled(_busy || !_canSend) } header: { Text("Answer as FTStore") } footer: { if _statusChanged { Text("The status change and your note go into the thread as one entry, shown to everyone who opens this report.") } else if let handle = _data?.author?.handle { Text(verbatim: .localized( "Everyone who opens this report in FTStore sees what you write, under the name FTStore. Write @%@ to notify the reporter.", handle )) } else { Text("Everyone who opens this report in FTStore sees what you write, under the name FTStore.") } } } /// The handles worth offering for the `@` being typed. /// /// The reporter, whoever else replied, and FTStore. Only what is already on /// this screen, which is the rule the app and the dashboard follow: a lookup /// across the board would hand anybody a list of the people using FTStore. @ViewBuilder private var _mentionSuggestions: some View { if let draft = AdminMentions.draftHandle(in: _reply) { let handles = AdminMentions.suggestions(for: draft, among: _threadHandles) 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) } } } } /// Everyone in this thread, reporter first, plus FTStore itself. /// /// FTStore is one entry rather than one per operator who answered: an `@` at /// it reaches whoever is reading, and naming the individual would say which /// of them was on the board that day. private var _threadHandles: [String] { var names: [String] = [] if let handle = _data?.author?.handle { names.append(handle) } names += (_data?.comments ?? []).filter { !$0.isTeam }.map(\.authorName) names.append("FTStore") return names } // MARK: Triage @ViewBuilder private func _triageSection(_ ticket: AdminFeedbackTicket) -> some View { Section { LabeledContent("Reporter", value: ticket.authorName) // A registered name is one somebody holds with a PIN. An // unregistered one is the handle every install is given, and the // difference is worth having when a thread turns into an argument // about who said what. if let author = _data?.author { // Spelled out rather than written inline: `LabeledContent`'s // `value:` is generic over `StringProtocol`, so a bare // `.localized(…)` in there has nothing to resolve against. let identity: String = author.isRegistered ? .localized("Registered name") : .localized("Anonymous handle") LabeledContent("Identity", value: identity) } LabeledContent("Score", value: ticket.votes.scoreLabel) LabeledContent("Upvotes", value: AdminFormat.count(ticket.votes.upvotes)) LabeledContent("Downvotes", value: AdminFormat.count(ticket.votes.downvotes)) if let device = ticket.deviceSummary { LabeledContent("Sent from", value: device) } if let install = _data?.author?.installId { LabeledContent("Install") { Text(install) .font(.caption.monospaced()) .textSelection(.enabled) .lineLimit(1) .truncationMode(.middle) } } } header: { Text("Details") } footer: { Text("The board never publishes any of this. It is here so a bug can be reproduced without asking which version.") } } // MARK: Moderation @ViewBuilder private func _moderationSection(_ ticket: AdminFeedbackTicket) -> some View { Section { Picker("Kind", selection: Binding( get: { ticket.kind }, set: { _setKind($0) } )) { Text("Feature request").tag("feature") Text("Bug report").tag("bug") } Button { if ticket.hidden { _setHidden(false) } else { _confirmingTakedown = true } } label: { Label( ticket.hidden ? "Put back on the board" : "Take off the board", systemImage: ticket.hidden ? "eye" : "eye.slash" ) } .disabled(_busy) if let author = _data?.author { // Spelled out rather than inlined: Swift will not infer // `ButtonRole?` from a ternary with `nil` on one side. let role: ButtonRole? = author.blocked ? nil : .destructive Button(role: role) { if author.blocked { _setBlocked(false) } else { _confirmingMute = true } } label: { Label( author.blocked ? "Let this install post again" : "Mute this install", systemImage: author.blocked ? "checkmark.shield" : "hand.raised" ) } .disabled(_busy) } // Admin-only server-side (`withAdmin`), and the last thing in the // section for the same reason it is last on the dashboard: taking a // report down is the reversible answer and should be reached first. Button(role: .destructive) { _confirmingDelete = true } label: { Label("Delete permanently", systemImage: "trash") } .disabled(_busy) } header: { Text("Moderation") } footer: { if _data?.author?.blocked == true { Text("This install is muted. What it already posted is still on the board.") } else { Text("Taking a report down is reversible. Deleting one destroys other people's replies too, so it is admin-only and there is no undo.") } } } // MARK: Actions private func _reload() async { await _thread.load { try await session.get( "/api/feedback/\(ticketId)", as: AdminEnvelope.self ) } } /// Send the reply, the status change, or both. private func _send() { let text = _reply.trimmingCharacters(in: .whitespacesAndNewlines) let changingStatus = _statusChanged let status = _selectedStatus _run { if changingStatus { return try await session.act( .put, "/api/feedback/\(ticketId)", body: AdminRequest.FeedbackUpdate( status: status.rawValue, note: text.isEmpty ? nil : text ) ) } return try await session.act( .post, "/api/feedback/\(ticketId)", body: AdminRequest.FeedbackReply(body: text) ) } onSuccess: { _reply = "" _status = nil } } private func _setHidden(_ hidden: Bool) { _run { try await session.act( .put, "/api/feedback/\(ticketId)", body: AdminRequest.FeedbackUpdate(hidden: hidden) ) } } private func _setKind(_ kind: String) { _run { try await session.act( .put, "/api/feedback/\(ticketId)", body: AdminRequest.FeedbackUpdate(kind: kind) ) } } private func _setCommentHidden(_ comment: AdminFeedbackComment, hidden: Bool) { _run { try await session.act( .put, "/api/feedback/comments/\(comment.id)", body: AdminRequest.FeedbackCommentModeration(hidden: hidden, reason: nil, body: nil) ) } } private func _setBlocked(_ blocked: Bool) { guard let installId = _data?.author?.installId else { return } _run { try await session.act( .put, "/api/feedback/authors", body: AdminRequest.FeedbackAuthorModeration( installId: installId, blocked: blocked ) ) } } /// Delete the ticket and leave the screen. /// /// The one action here that does not go through `_run`: there is no thread /// left to reload afterwards, and a 403 from a non-admin account has to stay /// on screen as a banner rather than dismiss as if it had worked. private func _delete() { _busy = true Task { @MainActor in do { _ = try await session.act(.delete, "/api/feedback/\(ticketId)") _busy = false await onDeleted() dismiss() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription _busy = false } } } /// Hold the report at the top of the board, or let it go. private func _togglePinned(_ pinned: Bool) { _run { try await session.act( .put, "/api/feedback/\(ticketId)", body: AdminRequest.FeedbackUpdate(pinned: pinned) ) } } /// Put FTStore's weight behind something, 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, current: Int, commentId: Int? = nil) { let next = current == value ? 0 : value _run { try await session.act( .put, "/api/feedback/\(ticketId)/vote", body: AdminRequest.FeedbackVote(value: next, commentId: commentId) ) } } 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() } } } /// What a report or a reply said before it was edited. /// /// Pushed rather than inlined: most entries were never edited, and the history /// is something an operator goes looking for rather than something the thread /// should carry. The diff arrives already computed and already condensed, so /// this only colours it. struct AdminRevisionHistoryView: View { @EnvironmentObject private var session: AdminSession let ticketId: Int /// Nil when the history is the report's own. let commentId: Int? @StateObject private var _loader = AdminLoader>() var body: some View { List { if _loader.isLoading { AdminLoadingRow() } else if let error = _loader.errorMessage, !_loader.hasLoaded { AdminErrorRow(message: error) { Task { await _load() } } } else if (_loader.value?.data ?? []).isEmpty { AdminEmptyRow( icon: "clock.arrow.circlepath", title: "No earlier version", detail: "Nothing was recorded before this was last written." ) } else { ForEach(_loader.value?.data ?? []) { revision in Section { if revision.titleChanged, let title = revision.title { Text(title) .font(.subheadline) .strikethrough() .foregroundStyle(.red) } if revision.diff.isEmpty { Text("Only the title changed.") .font(.caption) .foregroundStyle(.secondary) } else { ForEach(Array(revision.diff.enumerated()), id: \.offset) { _, line in Text(_prefix(line) + line.text) .font(.caption.monospaced()) .foregroundStyle(_tint(line)) } } } header: { Text(_header(revision)) } } } } .navigationTitle("What changed") .navigationBarTitleDisplayMode(.inline) .task { await _load() } } private func _prefix(_ line: AdminDiffLine) -> String { switch line.kind { case "add": return "+ " case "remove": return "- " default: return " " } } private func _tint(_ line: AdminDiffLine) -> Color { switch line.kind { case "add": return .green case "remove": return .red default: return .secondary } } private func _header(_ revision: AdminFeedbackRevision) -> String { var parts: [String] = [] if let created = revision.createdAt { parts.append(AdminFormat.relative(created)) } if revision.byTeam { parts.append(.localized("edited by FTStore")) } return parts.joined(separator: " · ") } private func _load() async { let query = commentId.map { "?commentId=\($0)" } ?? "" await _loader.load { try await session.get( "/api/feedback/\(ticketId)/revisions\(query)", as: AdminEnvelope<[AdminFeedbackRevision]>.self ) } } }