// // 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>() /// 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 = [] @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 { Binding( get: { _deciding != nil }, set: { if !$0 { _deciding = nil } } ) } private var _isRemoving: Binding { 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() } } }