// // AdminFeedbackAuthorView.swift // FTStore Admin // // Everything one person has written in FTStore. Mirrors /admin/users/. // // The list this is pushed from answers "who is here?". This answers "what did // they do?", which is the question actually being asked when an operator opens // somebody's row: muting is a judgement about a pattern of posting, and making // it without being able to see the posting was the gap. // // Everything here is on the public board already, so this is a rearrangement of // what anyone can read rather than a new disclosure. What it adds is the join — // these posts are all one install, which the board itself only says through a // handle that can change. // // It shows what the app cannot: a report taken off the board, a review taken // out of the store, a reply its author took back. That is what an operator view // is for. // // The actions are ordered by how much each takes back. Muting stops them // writing and touches nothing they wrote; signing out cuts off whoever is // holding a session, which is the half of a stolen account a mute does not // solve; renaming is for when the name is itself the problem; deleting closes // the account and leaves everything written under it exactly where it is. // Removing the posts too is done from each post, where whoever is doing it can // see what they are removing. // import SwiftUI import UIKit struct AdminFeedbackAuthorView: View { @EnvironmentObject private var session: AdminSession let installId: String /// Shown while the detail loads, so the screen has a title straight away. let handle: String @StateObject private var _detail = AdminLoader() @State private var _busy = false @State private var _banner: String? @State private var _confirmingMute = false @State private var _confirmingSignOut = false @State private var _confirmingDelete = false @State private var _renaming = false @State private var _nextName = "" /// The reset link, held only until this screen is left. /// /// The server shows it once and stores a digest, so there is nowhere to fetch /// it back from. Kept on screen rather than announced in a banner, so there /// is time to copy it. @State private var _resetURL: String? private var _payload: AdminFeedbackAuthorDetail.Payload? { _detail.value?.data } private var _author: AdminFeedbackAuthorRow? { _payload?.author } private var _tickets: [AdminFeedbackAuthorDetail.Ticket] { _payload?.tickets ?? [] } private var _replies: [AdminFeedbackAuthorDetail.Reply] { _payload?.comments ?? [] } private var _requestReplies: [AdminFeedbackAuthorDetail.RequestReply] { _payload?.requestComments ?? [] } private var _reviews: [AdminFeedbackAuthorDetail.Review] { _payload?.reviews ?? [] } var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } if _detail.isLoading && !_detail.hasLoaded { AdminLoadingRow() } else if let error = _detail.errorMessage, !_detail.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else if let author = _author { _identitySection(author) _moderationSection(author) // Reviews first: they are what an operator most often opens this // screen about now that the store has them, and they are the one // list that carries a number as well as words. _reviewsSection _ticketsSection _repliesSection _requestRepliesSection } } .navigationTitle(_author?.handle ?? handle) .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .alert("Stop this account from posting?", isPresented: $_confirmingMute) { Button("Mute", role: .destructive) { _setBlocked(true) } Button("Cancel", role: .cancel) {} } message: { Text("Everything already written stays up. They can still read the store and the boards, they just can't write anything new.") } .alert("Sign every device out?", isPresented: $_confirmingSignOut) { Button("Sign out everywhere", role: .destructive) { _signOutEverywhere() } Button("Cancel", role: .cancel) {} } 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.") } .alert("Delete this account?", isPresented: $_confirmingDelete) { Button("Delete the account", role: .destructive) { _deleteAccount() } Button("Cancel", role: .cancel) {} } message: { Text("The name is released, the password, picture and backup go, and every device is signed out. What they wrote stays up, posted from now on as their anonymous handle. Muting them is untouched. Deleting the posts too is done from each post.") } .alert("Rename", isPresented: $_renaming) { TextField("New handle", text: $_nextName) Button("Save") { _rename() } Button("Cancel", role: .cancel) {} } message: { Text("A rename follows them everywhere, including the @handle in posts other people already wrote about them. Leaving it empty releases the name for somebody else to take.") } } // MARK: Who @ViewBuilder private func _identitySection(_ author: AdminFeedbackAuthorRow) -> some View { Section { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text(verbatim: author.handle) .font(.headline) if author.registered { Image(systemName: "lock.fill") .font(.caption2) .foregroundStyle(.tertiary) } if author.isDeleted { Label("Deleted", systemImage: "person.crop.circle.badge.xmark") .font(.caption2) .foregroundStyle(.orange) } if author.blocked { Label("Muted", systemImage: "hand.raised.fill") .font(.caption2) .foregroundStyle(.red) } } // The id is the only stable identity and it is what the logs // record, so it is on screen rather than behind the handle. Text(verbatim: author.installId) .font(.caption2.monospaced()) .foregroundStyle(.tertiary) if let note = author.blockedNote, !note.isEmpty { Text(verbatim: note) .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 2) } footer: { Text("Everything below includes what the app cannot see: the posts taken down, the reviews taken out of the store, and the ones their author withdrew.") } } // MARK: Moderation @ViewBuilder private func _moderationSection(_ author: AdminFeedbackAuthorRow) -> some View { Section { // Spelled out rather than inlined: Swift will not infer `ButtonRole?` // from a ternary with `nil` on one side. let role: ButtonRole? = author.blocked ? nil : .destructive Button(role: role) { if author.blocked { _setBlocked(false) } else { _confirmingMute = true } } label: { Label( author.blocked ? "Let this account post again" : "Mute this account", systemImage: author.blocked ? "checkmark.shield" : "hand.raised" ) } .disabled(_busy) Button { _nextName = author.displayName ?? "" _renaming = true } label: { Label("Rename", systemImage: "pencil") } .disabled(_busy) Button { _makeResetLink() } label: { Label("Password reset link", systemImage: "key") } .disabled(_busy) if let url = _resetURL { VStack(alignment: .leading, spacing: 6) { Text("Send them this link") .font(.caption.weight(.semibold)) Text(verbatim: url) .font(.caption2.monospaced()) .textSelection(.enabled) Button { UIPasteboard.general.string = url _banner = .localized("Copied.") } label: { Label("Copy", systemImage: "doc.on.doc") .font(.caption) } .buttonStyle(.bordered) .controlSize(.small) Text("It works once, lasts a day, and signs every device out when it is used. Nothing here can show it again, so copy it now. Making another one kills this one.") .font(.caption2) .foregroundStyle(.secondary) } .padding(.vertical, 2) } Button { _confirmingSignOut = true } label: { Label("Sign out everywhere", systemImage: "rectangle.portrait.and.arrow.right") } .disabled(_busy || (author.sessions ?? 0) == 0) // Admin-only server-side (`withAdmin`), and last for the same reason // it is last everywhere else: the reversible answers come first. Button(role: .destructive) { _confirmingDelete = true } label: { Label("Delete account", systemImage: "trash") } .disabled(_busy || author.isDeleted) } header: { Text("Moderation") } footer: { if author.isDeleted { Text("This account was deleted. Its handle and everything written under it are still here, which is what keeps those posts attributable to somebody rather than to nobody.") } else { Text("Deleting closes the account and leaves everything written under it where it is. Removing a post is done from that post, where you can see what goes with it.") } } } // MARK: What @ViewBuilder private var _reviewsSection: some View { if !_reviews.isEmpty { Section("Reviews") { ForEach(_reviews) { review in NavigationLink { AdminReviewDetailView(reviewId: review.id, title: review.headline) { await _reload() } .environmentObject(session) } label: { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { if review.isThumb { Image(systemName: review.works == true ? "hand.thumbsup.fill" : "hand.thumbsdown.fill") .font(.caption2) .foregroundStyle(review.works == true ? Color.green : Color.red) } else { AdminStars(value: Double(review.rating ?? 0), size: 11) } Text(verbatim: review.headline) .font(.subheadline.weight(.medium)) .strikethrough(review.isDeleted) .lineLimit(1) } // A thumb can carry nothing beyond itself, and the // headline above has already stood in for whatever // there was. if let body = review.body, !body.isEmpty, !review.isThumb { Text(body) .font(.caption) .foregroundStyle(.secondary) .strikethrough(review.isDeleted) .lineLimit(2) } _meta( [ review.appName, review.reportedBroken ? String.localized("Asked for a check") : nil, review.answered ? String.localized("Answered") : nil, review.hidden ? String.localized("Out of the store") : nil, review.isDeleted ? String.localized("Withdrawn") : nil, ], at: review.createdAt, isRed: review.hidden ) } .padding(.vertical, 2) .opacity(review.hidden || review.isDeleted ? 0.5 : 1) } } } } } @ViewBuilder private var _ticketsSection: some View { if !_tickets.isEmpty { Section("Reports") { ForEach(_tickets) { ticket in NavigationLink { AdminFeedbackDetailView(ticketId: ticket.id, title: ticket.title) { await _reload() } .environmentObject(session) } label: { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Image(systemName: ticket.symbol) .font(.caption2) .foregroundStyle(.tertiary) Text(ticket.title) .font(.subheadline) .lineLimit(2) } _meta( [ ticket.statusLabel, "#\(ticket.id)", ticket.hidden ? String.localized("Off the board") : nil, ], at: ticket.createdAt, isRed: ticket.hidden ) } .padding(.vertical, 2) } } } } } @ViewBuilder private var _repliesSection: some View { if !_replies.isEmpty { Section("Replies") { ForEach(_replies) { reply in NavigationLink { AdminFeedbackDetailView( ticketId: reply.ticketId, title: reply.ticketTitle ?? "#\(reply.ticketId)" ) { await _reload() } .environmentObject(session) } label: { _replyRow( body: reply.body, on: reply.ticketTitle ?? "#\(reply.ticketId)", hidden: reply.hidden, deleted: reply.isDeleted, at: reply.createdAt ) } } } } } @ViewBuilder private var _requestRepliesSection: some View { if !_requestReplies.isEmpty { Section("On tweak requests") { ForEach(_requestReplies) { reply in _replyRow( body: reply.body, on: reply.tweakName ?? "#\(reply.requestId)", hidden: reply.hidden, deleted: reply.isDeleted, at: reply.createdAt ) } } } } @ViewBuilder private func _replyRow( body: String?, on subject: String, hidden: Bool, deleted: Bool, at date: Date? ) -> some View { VStack(alignment: .leading, spacing: 3) { Text(verbatim: body ?? "") .font(.subheadline) .lineLimit(3) .strikethrough(deleted) _meta( [ subject, hidden ? String.localized("Off the board") : nil, deleted ? String.localized("Withdrawn") : nil, ], at: date, isRed: hidden ) } .padding(.vertical, 2) .opacity(hidden || deleted ? 0.5 : 1) } /// The grey line under a row: the facts that are true about it, then when. @ViewBuilder private func _meta(_ parts: [String?], at date: Date?, isRed: Bool) -> some View { let text = (parts.compactMap { $0 } + [date.map(AdminFormat.relative)].compactMap { $0 }) .joined(separator: " · ") Text(verbatim: text) .font(.caption2) .foregroundStyle(isRed ? Color.red : Color.secondary) .lineLimit(2) } // MARK: Actions private func _reload() async { await _detail.load { try await session.get( "/api/feedback/authors/\(installId)", as: AdminFeedbackAuthorDetail.self ) } } private func _setBlocked(_ blocked: Bool) { _run { try await session.act( .put, "/api/feedback/authors", body: AdminRequest.FeedbackAuthorUpdate( installId: installId, blocked: blocked ) ) } } /// Mint a one-time link that lets them set a new password. /// /// Not through `_run`: that reloads and shows an acknowledgement, and this one /// has a payload that has to survive on screen long enough to be copied. private func _makeResetLink() { _busy = true _resetURL = nil Task { @MainActor in do { let response = try await session.send( .post, "/api/feedback/authors/\(installId)/reset", as: AdminEnvelope.self ) _resetURL = response.data?.url _banner = response.message ?? .localized("Link made.") } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false } } private func _signOutEverywhere() { _run { try await session.act( .put, "/api/feedback/authors", body: AdminRequest.FeedbackAuthorUpdate( installId: installId, signOutEverywhere: true ) ) } } /// Rename them, with no PIN and no cooldown. /// /// An empty field releases the name rather than being ignored, which is why /// the server takes `displayName` as "" and not as an absent key: taking a /// name away is a thing an operator reaches for, and it has to be sayable. private func _rename() { let name = _nextName.trimmingCharacters(in: .whitespacesAndNewlines) _run { try await session.act( .put, "/api/feedback/authors", body: AdminRequest.FeedbackAuthorUpdate( installId: installId, displayName: name ) ) } onSuccess: { _nextName = "" } } /// Close the account and leave everything written under it. /// /// Reloads rather than dismissing: the row is still here afterwards, now /// showing an anonymous handle, and an operator should see that rather than /// be dropped back onto a list as if the person had evaporated. private func _deleteAccount() { _run { try await session.act(.delete, "/api/feedback/authors/\(installId)") } } private func _run( _ operation: @escaping @MainActor () async throws -> AdminAck, onSuccess: (@MainActor () -> Void)? = nil ) { _busy = true Task { @MainActor in do { let ack = try await operation() _banner = ack.summary onSuccess?() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } } }