Files
FTStore/admin/FTStoreAdmin/App/AdminBoardMaintenanceView.swift
Felitendo 66a1f52d41 feat: give the people who use ftstore a screen of their own
There were two lists of people and both were called Users. One is whoever may
sign in and run the server; the other is everybody who posts, reviews and asks
for things in the app, and it was filed away under Feedback as a detail of it.
So somebody looking for a person who had written something landed on the
operator list, which is the one where deleting a row revokes access to the whole
server.

The second list is now Users, sits in the sidebar and on the console's main
screen, and the first is Operators. They stay two screens: the buttons on them
mean very different things, and a single screen would put those next to each
other. The operator list carries a link across, because being on the wrong one
is the failure that actually happened.

The user detail now shows all three places somebody writes. Reviews were missing
from it for exactly as long as reviews have existed, which made it quietly wrong
rather than incomplete: an operator deciding whether to mute somebody was being
shown two thirds of what they had been doing. Every row links through to where
it can be answered, edited or taken down, including the tweak request replies,
which had no way through at all.

Muting is joined by renaming, signing every device out, and deleting the
account. Deleting closes it and leaves what was written where it is: the name is
released, the password, picture, backup and sessions go, and the row itself
stays. That last part is load-bearing. Without it every post they wrote would
resolve to no author at all and render under the one shared nameless byline, so
several closed accounts in a thread would become indistinguishable. Keeping the
row keeps the number, so their posts stay attributable to somebody. The mute
survives a deletion too, and registering again reopens the row rather than
minting a second identity for the same phone.

Removing the posts stays where it was: on each post, where whoever is doing it
can see what goes with it.
2026-09-01 23:02:52 +02:00

210 lines
6.7 KiB
Swift

//
// AdminBoardMaintenanceView.swift
// FTStore Admin
//
// The lever for when the boards have gone badly wrong. Mirrors the panel on
// /admin/users.
//
// Two controls that belong together and nowhere else. The lock stops the
// flood; emptying a board clears up behind it. They sit in that order because
// emptying a board that is still accepting posts is a way to be back where you
// started in ten minutes.
//
// The lock is a pause and not a shutdown: reading is untouched, and so is the
// operator side, so answering and moderating still work while it is on. A lock
// that also shut out the people doing the clearing would be a worse version of
// turning the server off.
//
import SwiftUI
struct AdminBoardMaintenanceView: View {
@EnvironmentObject private var session: AdminSession
@StateObject private var _settings = AdminLoader<AdminSettingsResponse>()
@State private var _locked = false
@State private var _note = ""
@State private var _savedNote = ""
@State private var _busy = false
@State private var _banner: String?
/// Which board the confirmation is about, if any.
@State private var _purging: Target?
/// The message writes when editing ends rather than on a button, matching
/// the switch above it and Server Settings. Saving per keystroke would put a
/// half-typed sentence in front of every user of the app.
@FocusState private var _noteFocused: Bool
private enum Target: Identifiable {
case feedback, requests, both
var id: String { title }
var title: String {
switch self {
case .feedback: .localized("Empty feedback")
case .requests: .localized("Empty tweak requests")
case .both: .localized("Empty both")
}
}
/// What actually goes, said plainly. The number is not knowable here, so
/// the sentence names the thing rather than pretending to count it.
var detail: String {
switch self {
case .feedback: .localized("Every report on the feedback board goes, with every reply, vote and mention on it.")
case .requests: .localized("Every tweak request goes, with every reply, vote and mention on it.")
case .both: .localized("Every report and every tweak request goes, with every reply, vote and mention on them.")
}
}
var body: AdminRequest.BoardPurge {
switch self {
case .feedback: .init(feedback: true, tweakRequests: false)
case .requests: .init(feedback: false, tweakRequests: true)
case .both: .init(feedback: true, tweakRequests: true)
}
}
}
var body: some View {
List {
if let banner = _banner {
Section {
Text(banner).font(.footnote).foregroundStyle(.secondary)
}
}
Section {
Toggle(isOn: Binding(get: { _locked }, set: { _setLocked($0) })) {
Label("Pause new posts", systemImage: _locked ? "pause.circle.fill" : "pause.circle")
}
.disabled(_busy || !_settings.hasLoaded)
} header: {
Text("The lock")
} footer: {
Text("Stops reports, requests, replies and votes coming from the app. Reading still works, and so do you: answering and moderating are unaffected, which is what makes it possible to catch up.")
}
Section {
TextField("Optional message", text: $_note, axis: .vertical)
.lineLimit(1...4)
.disabled(_busy)
.focused($_noteFocused)
} header: {
Text("What the app says")
} footer: {
Text("Saved when you finish typing. Left empty, the app says posting is paused and nothing more.")
}
Section {
Button(role: .destructive) { _purging = .feedback } label: {
Label(Target.feedback.title, systemImage: "trash")
}
Button(role: .destructive) { _purging = .requests } label: {
Label(Target.requests.title, systemImage: "trash")
}
Button(role: .destructive) { _purging = .both } label: {
Label(Target.both.title, systemImage: "trash.fill")
}
} header: {
Text("Emptying a board")
} footer: {
Text("There is no undo, and a settings backup does not carry the boards. Names and mutes are kept, so nobody gets their handle back and nobody quietly stops being muted. Pause posting first, or it fills up again while you work.")
}
.disabled(_busy)
}
.navigationTitle("Board Maintenance")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await _reload() }
.task { await _reload() }
// Single-argument onChange: the two-argument form is iOS 17.
.onChange(of: _noteFocused) { focused in
if !focused { _saveNote() }
}
// Leaving the screen ends editing without focus moving anywhere, so the
// last thing typed would otherwise be dropped.
.onDisappear { _saveNote() }
.alert(item: $_purging) { target in
Alert(
title: Text("Delete it all?"),
message: Text(verbatim: target.detail),
primaryButton: .destructive(Text("Delete everything")) { _purge(target) },
secondaryButton: .cancel()
)
}
}
// MARK: Actions
private func _reload() async {
await _settings.load {
try await session.get("/api/settings", as: AdminSettingsResponse.self)
}
if let data = _settings.value?.data {
_locked = data.bool("boards_locked") ?? false
_savedNote = data.string("boards_locked_note") ?? ""
// Not while it is being typed in: flipping the lock reloads, and
// that must not replace a half-written message under the cursor.
if !_noteFocused { _note = _savedNote }
}
}
private func _setLocked(_ locked: Bool) {
// Moved first so the switch does not sit under a thumb waiting for a
// round trip; the reload afterwards is what makes it honest again.
_locked = locked
_run(
AdminRequest.BoardLock(boards_locked: locked),
locked ? "New posts paused." : "Boards open again."
)
}
private func _saveNote() {
let trimmed = _note.trimmingCharacters(in: .whitespacesAndNewlines)
// Nothing to say and nothing to send. Without this every dismissal of
// the keyboard would be a write.
guard trimmed != _savedNote else { return }
// Recorded before the round trip so two triggers landing together
// (focus leaving as the screen closes) do not send it twice.
_savedNote = trimmed
_run(
AdminRequest.BoardLock(boards_locked_note: trimmed),
.localized("Message saved.")
)
}
private func _run(_ body: AdminRequest.BoardLock, _ fallback: String) {
_busy = true
Task { @MainActor in
do {
let ack = try await session.act(.put, "/api/settings", body: body)
_banner = ack.message ?? fallback
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busy = false
await _reload()
}
}
private func _purge(_ target: Target) {
_busy = true
Task { @MainActor in
do {
let ack = try await session.act(
.post,
"/api/actions/purge-boards",
body: target.body
)
_banner = ack.summary
} catch {
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
_busy = false
}
}
}