// // 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() @State private var _search = "" @State private var _onlyBlocked = false @State private var _banner: String? @State private var _busyIds: Set = [] /// 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 { Binding(get: { _renaming != nil }, set: { if !$0 { _renaming = nil } }) } private var _isMuting: Binding { Binding(get: { _muting != nil }, set: { if !$0 { _muting = nil } }) } private var _isSigningOut: Binding { 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() } } }