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.
816 lines
24 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|