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.
375 lines
11 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|