Files
FTStore/admin/FTStoreAdmin/App/AdminFeedbackDetailView.swift
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

816 lines
24 KiB
Swift

//
// AdminFeedbackDetailView.swift
// FTStore Admin
//
// One feedback thread: read it, answer it as the FTStore Team, and move its
// status. Mirrors /admin/feedback/<id>.
//
// 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<AdminEnvelope<AdminFeedbackThread>>()
@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<AdminFeedbackThread>.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<AdminEnvelope<[AdminFeedbackRevision]>>()
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
)
}
}
}