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.
325 lines
10 KiB
Swift
325 lines
10 KiB
Swift
//
|
|
// AdminFeedbackUsersView.swift
|
|
// FTStore Admin
|
|
//
|
|
// The people on the feedback board: who they post as, and whether they still
|
|
// may. Mirrors /admin/users.
|
|
//
|
|
// This screen exists because muting without it was a one-way door. A mute
|
|
// could only ever start from a report already in front of the operator, which
|
|
// is fine for starting one and useless for ending it: an install muted by
|
|
// mistake, or one whose only report was later deleted, had nothing anywhere
|
|
// that could reach it again.
|
|
//
|
|
// Not the same list as Users, and deliberately far from it. That one is
|
|
// operator accounts with passwords and an admin flag. This one is anonymous
|
|
// installs on a public board — no account, no password, and the only identity
|
|
// is a UUID the app minted for itself. Renaming somebody here changes what the
|
|
// board calls them, not who they are.
|
|
//
|
|
// A rename follows the `@handle` into the posts that used it, which the server
|
|
// does. That is what stops a cleanup leaving a board full of references to a
|
|
// name somebody else can now take.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct AdminFeedbackUsersView: View {
|
|
@EnvironmentObject private var session: AdminSession
|
|
|
|
@StateObject private var _authors = AdminLoader<AdminFeedbackAuthorsResponse>()
|
|
|
|
@State private var _search = ""
|
|
@State private var _onlyBlocked = false
|
|
@State private var _banner: String?
|
|
@State private var _busyIds: Set<String> = []
|
|
|
|
/// The person being renamed, and what to.
|
|
@State private var _renaming: AdminFeedbackAuthorRow?
|
|
@State private var _name = ""
|
|
/// The person being muted. Unmuting asks nothing: it only gives back.
|
|
@State private var _muting: AdminFeedbackAuthorRow?
|
|
@State private var _signingOut: AdminFeedbackAuthorRow?
|
|
|
|
private var _rows: [AdminFeedbackAuthorRow] { _authors.value?.authors ?? [] }
|
|
|
|
var body: some View {
|
|
List {
|
|
if let banner = _banner {
|
|
Section {
|
|
Text(banner).font(.footnote).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
// The lever for when moderating row by row has stopped being a
|
|
// plan is what somebody came to this screen for in that case.
|
|
NavigationLink {
|
|
AdminBoardMaintenanceView()
|
|
.environmentObject(session)
|
|
} label: {
|
|
Label("Board Maintenance", systemImage: "pause.circle")
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Toggle("Only muted", isOn: $_onlyBlocked)
|
|
} footer: {
|
|
let muted = _authors.value?.blockedCount ?? 0
|
|
if muted > 0 {
|
|
Text(
|
|
muted == 1
|
|
? String.localized("1 install is muted.")
|
|
: String.localized("%lld installs are muted.", muted)
|
|
)
|
|
}
|
|
}
|
|
|
|
if _authors.isLoading && !_authors.hasLoaded {
|
|
AdminLoadingRow()
|
|
} else if let error = _authors.errorMessage, !_authors.hasLoaded {
|
|
AdminErrorRow(message: error) { Task { await _reload() } }
|
|
} else if _rows.isEmpty {
|
|
AdminEmptyRow(
|
|
icon: _search.isEmpty ? "person.2" : "magnifyingglass",
|
|
title: _search.isEmpty ? "Nobody yet" : "No matches",
|
|
detail: _search.isEmpty
|
|
? "Anyone who opens the feedback board in FTStore turns up here."
|
|
: "No handle or install id matches that."
|
|
)
|
|
} else {
|
|
Section {
|
|
ForEach(_rows) { author in
|
|
NavigationLink {
|
|
AdminFeedbackAuthorView(
|
|
installId: author.installId,
|
|
handle: author.handle
|
|
)
|
|
.environmentObject(session)
|
|
} label: {
|
|
_row(author)
|
|
}
|
|
// 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 author.blocked {
|
|
// Unmuting asks nothing. It only gives something
|
|
// back, and being unreachable is the whole
|
|
// reason this screen exists.
|
|
Button("Unmute") { _setBlocked(author, false) }
|
|
.tint(.green)
|
|
} else {
|
|
Button("Mute", role: .destructive) { _muting = author }
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading) {
|
|
Button("Rename") {
|
|
_name = author.displayName ?? ""
|
|
_renaming = author
|
|
}
|
|
.tint(AdminTheme.brand)
|
|
|
|
// Only where there is something to sign out. An
|
|
// action that does nothing is worse than no action:
|
|
// it reads as having worked.
|
|
if (author.sessions ?? 0) > 0 {
|
|
Button("Sign Out") { _signingOut = author }
|
|
.tint(.orange)
|
|
}
|
|
}
|
|
}
|
|
} footer: {
|
|
Text("Tap somebody to see what they posted. Renaming them changes what the board calls them everywhere, including the @mentions already written about them.")
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Users")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.searchable(text: $_search, prompt: "Search handle or install id")
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.refreshable { await _reload() }
|
|
.task { await _reload() }
|
|
.onChange(of: _onlyBlocked) { _ in Task { await _reload() } }
|
|
.onChange(of: _search) { _ in Task { await _reload() } }
|
|
.alert("Rename", isPresented: _isRenaming) {
|
|
TextField("Name, or empty for anonymous", text: $_name)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
Button("Save") { _commitRename() }
|
|
Button("Cancel", role: .cancel) { _renaming = nil }
|
|
} message: {
|
|
Text("2 to 32 letters, numbers or underscores. A name belongs to one install, and clearing it puts them back to their anonymous handle.")
|
|
}
|
|
.alert("Stop this install from posting?", isPresented: _isMuting) {
|
|
Button("Mute", role: .destructive) {
|
|
if let author = _muting { _setBlocked(author, true) }
|
|
}
|
|
Button("Cancel", role: .cancel) { _muting = nil }
|
|
} message: {
|
|
Text("Everything already posted stays up. The install can still read the board and still be replied to, it just can't write anything new.")
|
|
}
|
|
.alert("Sign every device out?", isPresented: _isSigningOut) {
|
|
Button("Sign Out", role: .destructive) {
|
|
if let author = _signingOut { _signOutEverywhere(author) }
|
|
}
|
|
Button("Cancel", role: .cancel) { _signingOut = nil }
|
|
} message: {
|
|
Text("Every phone signed in to this account is signed out. Nothing posted is touched, and they can sign back in with their password.")
|
|
}
|
|
}
|
|
|
|
/// Every alert is bound to "is somebody selected", so dismissing one any way
|
|
/// clears the selection rather than leaving a stale target behind.
|
|
private var _isRenaming: Binding<Bool> {
|
|
Binding(get: { _renaming != nil }, set: { if !$0 { _renaming = nil } })
|
|
}
|
|
|
|
private var _isMuting: Binding<Bool> {
|
|
Binding(get: { _muting != nil }, set: { if !$0 { _muting = nil } })
|
|
}
|
|
|
|
private var _isSigningOut: Binding<Bool> {
|
|
Binding(get: { _signingOut != nil }, set: { if !$0 { _signingOut = nil } })
|
|
}
|
|
|
|
// MARK: Row
|
|
|
|
@ViewBuilder
|
|
private func _row(_ author: AdminFeedbackAuthorRow) -> some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack(spacing: 6) {
|
|
Text(verbatim: author.handle)
|
|
.font(.subheadline.weight(.medium))
|
|
if author.registered {
|
|
// An account: a name and a password, which is what makes it
|
|
// survive a reinstall on a new phone.
|
|
Image(systemName: "lock.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
if author.isLegacyName {
|
|
// Told apart from an account on purpose. A name held by the
|
|
// old PIN is somebody who has not signed up, and reading it
|
|
// as registered is the wrong thing to say to an operator.
|
|
Label("PIN only", systemImage: "key.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
if let devices = author.deviceLabel {
|
|
Text(verbatim: devices)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
if author.blocked {
|
|
Label("Muted", systemImage: "hand.raised.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
Spacer(minLength: 0)
|
|
|
|
if _busyIds.contains(author.installId) {
|
|
ProgressView()
|
|
}
|
|
}
|
|
|
|
Text(verbatim: author.activityLabel)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
|
|
if let note = author.blockedNote, !note.isEmpty {
|
|
Text(verbatim: note)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// The id is the only stable identity, and it is what the logs record,
|
|
// so it is on screen rather than hidden behind the handle it outlives.
|
|
Text(verbatim: author.installId)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
|
|
// MARK: Actions
|
|
|
|
private func _reload() async {
|
|
var query = ["pageSize": "100"]
|
|
let search = _search.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !search.isEmpty { query["search"] = search }
|
|
if _onlyBlocked { query["blocked"] = "1" }
|
|
|
|
await _authors.load {
|
|
try await session.get(
|
|
"/api/feedback/authors",
|
|
query: query,
|
|
as: AdminFeedbackAuthorsResponse.self
|
|
)
|
|
}
|
|
}
|
|
|
|
private func _commitRename() {
|
|
guard let author = _renaming else { return }
|
|
let name = _name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
_renaming = nil
|
|
|
|
_run(author) {
|
|
AdminRequest.FeedbackAuthorUpdate(
|
|
installId: author.installId,
|
|
// Empty rather than omitted: the route reads an absent field as
|
|
// "not asked about" and an empty one as "take the name away".
|
|
displayName: name
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The lever for an account somebody else has got into.
|
|
///
|
|
/// Not a mute and does not imply one: muting stops an account posting, this
|
|
/// stops whoever is holding its session from being signed in. They are
|
|
/// different judgements and either can be wanted without the other.
|
|
private func _signOutEverywhere(_ author: AdminFeedbackAuthorRow) {
|
|
_signingOut = nil
|
|
|
|
_run(author) {
|
|
AdminRequest.FeedbackAuthorUpdate(
|
|
installId: author.installId,
|
|
signOutEverywhere: true
|
|
)
|
|
}
|
|
}
|
|
|
|
private func _setBlocked(_ author: AdminFeedbackAuthorRow, _ blocked: Bool) {
|
|
_muting = nil
|
|
|
|
_run(author) {
|
|
AdminRequest.FeedbackAuthorUpdate(
|
|
installId: author.installId,
|
|
blocked: blocked
|
|
)
|
|
}
|
|
}
|
|
|
|
private func _run(
|
|
_ author: AdminFeedbackAuthorRow,
|
|
_ body: @escaping () -> AdminRequest.FeedbackAuthorUpdate
|
|
) {
|
|
_busyIds.insert(author.installId)
|
|
|
|
Task { @MainActor in
|
|
do {
|
|
let ack = try await session.act(.put, "/api/feedback/authors", body: body())
|
|
_banner = ack.summary
|
|
} catch {
|
|
_banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
}
|
|
_busyIds.remove(author.installId)
|
|
await _reload()
|
|
}
|
|
}
|
|
}
|