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.
210 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|