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.
530 lines
15 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|