Files
FTStore/admin/FTStoreAdmin/App/AdminTweakRequestThreadView.swift
Felitendo a59454d863 fix: finish the optional source on the operator side
The app was taught that a request may have no source; neither operator front
end was, and I reported the work as done. Both still typed `sourceId` as
required and used it as the identity of a grouped wish — so every wish filed
without one would have shared the same empty identity.

Concretely: two React keys and the whole busy-state map were keyed on it, which
collapses every sourceless group into one row's worth of state, and deleting
one sent that empty name to a route that would have matched nothing or
everything else with it.

A group now carries `groupKey`, which is what it is actually grouped by, and
`allIds`, so a deletion names its rows rather than a source it may not have.
Where the source is printed there is a sentence instead of a blank, matching
what the app already says.

Found by going back through the change looking for what still assumed the old
shape, rather than by anything failing — nothing would have failed until a
request without a source reached the queue.
2026-08-15 13:37:34 +02:00

530 lines
15 KiB
Swift

//
// 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<AdminTweakRequestBoard>()
@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()
}
}
}