Files
FTStore/admin/FTStoreAdmin/App/AdminTweakRequestsView.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

375 lines
11 KiB
Swift

//
// AdminTweakRequestsView.swift
// FTStore Admin
//
// Mirrors /admin/tweak-requests: the sources people asked FTStore to follow,
// folded together and ranked by how many asked.
//
// A row is a *source*, not a single request, because that is what a decision
// acts on. Answering one clears every open request for it, with the same reason
// going to everyone who asked.
//
// A decision is approve or reject and nothing else. Approving used to be able
// to create the scraper source in the same step; adding a source is now its own
// deliberate action in Channels & Sources.
//
// Whatever is typed into the reason field is shown to the requesters in the
// FTStore app, so it is written for them. It is optional either way.
//
import SwiftUI
struct AdminTweakRequestsView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _requests = AdminLoader<AdminEnvelope<[AdminTweakRequest]>>()
/// Busy state is keyed by `groupKey` — the row's identity. Not `sourceId`,
/// which is null for a wish nobody named a source for.
@State private var _busyIds: Set<String> = []
@State private var _banner: String?
/// The group waiting on a "really?", if any.
@State private var _removing: AdminTweakRequest?
/// The group the reason prompt is about, and what it will do.
@State private var _deciding: AdminTweakRequest?
@State private var _decidingApproval = false
@State private var _reason = ""
/// Filters the loaded queue in place. The server takes a `search` parameter
/// too, but the grouped response is already the whole ranked queue, so
/// matching here keeps typing instant and the demand ranking intact.
@State private var _search = ""
private var _all: [AdminTweakRequest] {
let rows = _requests.value?.data ?? []
let query = _search.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return rows }
return rows.filter { $0.matches(query) }
}
private var _pending: [AdminTweakRequest] { _all.filter(\.isPending) }
private var _decided: [AdminTweakRequest] { _all.filter { !$0.isPending } }
var body: some View {
List {
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
if _requests.isLoading {
AdminLoadingRow()
} else if let error = _requests.errorMessage, !_requests.hasLoaded {
AdminErrorRow(message: error) { Task { await _reload() } }
} else if _all.isEmpty {
if _search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
AdminEmptyRow(
icon: "tray",
title: "No requests",
detail: "Sources people ask for from the FTStore app land here, most wanted first."
)
} else {
AdminEmptyRow(
icon: "magnifyingglass",
title: "No matches",
detail: "Nothing in the queue matches that tweak name or source."
)
}
} else {
if !_pending.isEmpty {
Section {
ForEach(_pending) { group in
_link(group)
}
} header: {
Text("Waiting for review")
} footer: {
Text("Most wanted first. Deciding a source answers everyone who asked for it.")
}
}
if !_decided.isEmpty {
Section("Decided") {
ForEach(_decided) { group in
_link(group)
}
}
}
}
}
.navigationTitle("Tweak Requests")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $_search, prompt: "Search tweak or source")
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.refreshable { await _reload() }
.task { await _reload() }
.alert("Delete this request for good?", isPresented: _isRemoving) {
Button("Delete", role: .destructive) { _commitDelete() }
Button("Cancel", role: .cancel) { _removing = nil }
} message: {
if let group = _removing {
Text(verbatim: _deleteMessage(for: group))
}
}
.alert(_decidingApproval ? "Approve request" : "Reject request", isPresented: _isDeciding) {
TextField("Reason (optional)", text: $_reason)
let role: ButtonRole? = _decidingApproval ? nil : .destructive
Button(_decidingApproval ? "Approve" : "Reject", role: role) {
_commitDecision()
}
Button("Cancel", role: .cancel) { _deciding = nil }
} message: {
if let group = _deciding {
Text(_promptMessage(for: group))
}
}
}
/// The alert is bound to "is a group selected", so dismissing it any way
/// clears the selection rather than leaving a stale target behind.
private var _isDeciding: Binding<Bool> {
Binding(
get: { _deciding != nil },
set: { if !$0 { _deciding = nil } }
)
}
private var _isRemoving: Binding<Bool> {
Binding(
get: { _removing != nil },
set: { if !$0 { _removing = nil } }
)
}
/// The part an operator is not thinking about: the replies belong to
/// whoever wrote them, and this takes those too.
private func _deleteMessage(for group: AdminTweakRequest) -> String {
var lines = ["\(group.tweakName) · \(group.sourceLabelOrNone)"]
let replies = group.commentCount ?? 0
if replies > 0 {
// Two strings rather than one with a plural variation, which is the
// idiom the rest of this app already uses for a count.
lines.append(
replies == 1
? String.localized("The one reply written under it goes too.")
: String.localized("The %lld replies written under it go too.", replies)
)
}
lines.append(.localized("Rejecting it is the reversible option."))
return lines.joined(separator: " ")
}
private func _promptMessage(for group: AdminTweakRequest) -> String {
var lines = ["\(group.tweakName) · \(group.sourceLabelOrNone)"]
if group.pendingIds.count > 1 {
lines.append("This answers all \(group.pendingIds.count) open requests for that source.")
}
lines.append("The reason is shown to everyone who asked.")
return lines.joined(separator: " ")
}
// MARK: Row
/// The row, and the way into the conversation under it.
///
/// A plain `NavigationLink` rather than an entry in a menu. Reading what
/// people said is the common thing to do with a request and deciding it is
/// the rare one, so the common thing is the tap and the rare one is a swipe.
/// It also matches the feedback queue in this app, where tapping a row has
/// always opened the thread.
@ViewBuilder
private func _link(_ group: AdminTweakRequest) -> some View {
Group {
if let threadId = group.threadId {
NavigationLink {
AdminTweakRequestThreadView(group: group, requestId: threadId) {
await _reload()
}
.environmentObject(session)
} label: {
_row(group)
}
} else {
// A settled group on a server that predates `boardId`. Nothing
// to open, so the row does not pretend there is.
_row(group)
}
}
// On the row the List lays out rather than inside the link's label,
// which is where a swipe would never reach them.
.swipeActions(edge: .trailing) {
if group.isPending {
Button("Reject", role: .destructive) { _prompt(group, approve: false) }
}
}
.swipeActions(edge: .leading) {
if group.isPending {
Button("Approve") { _prompt(group, approve: true) }
.tint(.green)
}
}
.contextMenu {
// Behind a long press rather than on the trailing edge with Reject:
// rejecting is the reversible answer and should be the one under a
// thumb. This destroys other people's replies too.
Button(role: .destructive) {
_removing = group
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private func _row(_ group: AdminTweakRequest) -> some View {
HStack(alignment: .top, spacing: 12) {
// The demand count is the point of the grouped list, so it leads.
VStack(spacing: 1) {
Text(verbatim: "\(group.requestCount)")
.font(.headline.monospacedDigit())
.foregroundStyle(AdminTheme.brand)
Group {
if group.sourceType == "github" {
Image(systemName: "chevron.left.forwardslash.chevron.right")
} else {
TelegramMark(size: 10)
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
}
.frame(width: 30)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 5) {
if group.isPinned {
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(AdminTheme.brand)
}
Text(group.tweakName)
.font(.subheadline.weight(.medium))
}
Text(group.sourceLabelOrNone)
.font(.caption)
.foregroundStyle(.secondary)
if !group.otherNames.isEmpty {
Text(String.localized(
"also: %@",
group.otherNames.map { "\($0.name) (\($0.count))" }.joined(separator: ", ")
))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(2)
}
if let note = group.note, !note.isEmpty {
Text(note)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(2)
}
if let asked = group.lastRequestedAt {
Text("last asked \(AdminFormat.relative(asked))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
// What the app's users did with this wish, which is a different
// number from the demand count on the left: that counts the
// people who asked before there was anywhere to say "me too",
// this counts the ones who said it.
HStack(spacing: 8) {
Label(group.votes.scoreLabel, systemImage: "arrowtriangle.up")
Label("\(group.commentCount ?? 0)", systemImage: "bubble.left")
}
.font(.caption2.monospacedDigit())
.foregroundStyle(.tertiary)
if let reason = group.decisionReason, !reason.isEmpty {
Text(reason)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
if _busyIds.contains(group.groupKey) {
ProgressView()
} else if !group.isPending {
AdminStatusPill(status: group.status)
}
}
.padding(.vertical, 2)
}
// MARK: Actions
private func _reload() async {
await _requests.load {
try await session.get("/api/tweak-requests", as: AdminEnvelope<[AdminTweakRequest]>.self)
}
}
private func _prompt(_ group: AdminTweakRequest, approve: Bool) {
_reason = ""
_decidingApproval = approve
_deciding = group
}
private func _commitDecision() {
guard let group = _deciding else { return }
let approve = _decidingApproval
let reason = _reason.trimmingCharacters(in: .whitespacesAndNewlines)
_deciding = nil
_run(group.groupKey) {
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
)
)
return ack.message ?? "\(group.tweakName) \(approve ? "approved" : "rejected")."
}
}
private func _commitDelete() {
guard let group = _removing else { return }
_removing = nil
_run(group.groupKey) {
let ack = try await session.act(
.delete,
"/api/tweak-requests",
// By row ids: a wish with no source cannot be named by one.
body: AdminRequest.TweakRequestDelete(ids: group.allIds)
)
return ack.message ?? .localized("Request deleted.")
}
}
private func _run(_ id: String, _ operation: @escaping @MainActor () async throws -> String) {
_busyIds.insert(id)
Task { @MainActor in
do {
_banner = try await operation()
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busyIds.remove(id)
await _reload()
}
}
}