// // AdminReviewDetailView.swift // FTStore Admin // // One review: read it, answer it as FTStore, fix it, or take it down. Mirrors // /admin/reviews/. // // The nearest thing to it is `AdminFeedbackDetailView`, and the difference is // what shapes the screen. A ticket is a conversation, so that one is a timeline // with a composer at the end. A review is a verdict, so this one is the // verdict, the single answer allowed underneath it, and the levers for changing // either. // // Two things worth knowing before using the edit form: // // **Changing the stars moves the app's average.** It is allowed, because a // rating attached to a body that says the opposite is worth correcting, but it // is the one edit the console asks about first — and every front end draws // "edited by FTStore" beside a review afterwards, with the old rating kept in // its history. Editing somebody else's verdict is acceptable because it is // visible, not because it is small. // // **An answer is from FTStore and never from a person.** The operator behind it // is recorded server-side for the audit trail and stripped before the app sees // it, exactly like a team reply on the feedback board. // // Moderation is ordered by how much it takes back: taking a review out of the // store is reversible and is what to reach for first, muting the account stops // it writing without touching what it wrote, and deleting destroys the review's // history and the answer to it. The server keeps the last one behind // `withAdmin`, so a non-admin operator gets a 403 banner rather than a button // that is not there. // import SwiftUI struct AdminReviewDetailView: View { @EnvironmentObject private var session: AdminSession let reviewId: Int /// Shown while the review loads, so the screen has a title straight away. let title: String /// Called once the review is gone, so the queue behind this screen drops the /// row instead of keeping one that now 404s. let onDeleted: () async -> Void @Environment(\.dismiss) private var dismiss @StateObject private var _detail = AdminLoader>() @State private var _answer = "" @State private var _busy = false @State private var _banner: String? @State private var _editing = false @State private var _editTitle = "" @State private var _editBody = "" @State private var _editRating = 5 @State private var _confirmingTakedown = false @State private var _confirmingMute = false @State private var _confirmingDelete = false @State private var _confirmingRating = false @State private var _confirmingClearAnswer = false private var _data: AdminReviewDetail? { _detail.value?.data } private var _review: AdminReview? { _data?.review } 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 review = _review { if _editing { _editSection(review) } else { _reviewSection(review) } _answerSection(review) _appSection(review) _historySection _moderationSection(review) } } .navigationTitle(_review.map { "#\($0.id)" } ?? title) .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .alert("Take this review out of the store?", isPresented: $_confirmingTakedown) { Button("Take it down", role: .destructive) { _setHidden(true) } Button("Cancel", role: .cancel) {} } message: { Text("It disappears for everyone except its author, who sees it marked as removed, and it stops counting towards the app's rating. You can put it back at any time.") } .alert("Change what they rated it?", isPresented: $_confirmingRating) { Button("Change the rating", role: .destructive) { _saveEdit(includingRating: true) } Button("Cancel", role: .cancel) {} } message: { Text("This moves the app's average. The old rating is kept in the review's history, and the store marks the review as edited by FTStore.") } .alert("Take the answer down?", isPresented: $_confirmingClearAnswer) { Button("Withdraw it", role: .destructive) { _clearAnswer() } Button("Cancel", role: .cancel) {} } message: { Text("The review stays. What the answer said is kept in the history, because it was published under FTStore's name.") } .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("Delete this review for good?", isPresented: $_confirmingDelete) { Button("Delete", role: .destructive) { _delete() } Button("Cancel", role: .cancel) {} } message: { Text("Its history and the answer to it go with it, and nothing here can bring it back. Taking it out of the store is the reversible option.") } } // MARK: The review @ViewBuilder private func _reviewSection(_ review: AdminReview) -> some View { Section { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { if review.isThumb { Label( review.works == true ? "Works" : "Doesn't work", systemImage: review.works == true ? "hand.thumbsup.fill" : "hand.thumbsdown.fill" ) .font(.subheadline.weight(.medium)) .foregroundStyle(review.works == true ? Color.green : Color.red) } else { AdminStars(value: Double(review.rating ?? 0), size: 15) // Beside the stars rather than instead of them: on a // tweak the two answer different questions, and this is // the one a broken-build report hangs off. if let works = review.works { Label( works ? "Works" : "Doesn't work", systemImage: works ? "hand.thumbsup.fill" : "hand.thumbsdown.fill" ) .font(.caption.weight(.medium)) .foregroundStyle(works ? Color.green : Color.red) } } if review.reportedBroken { Label("Asked for a check", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } if review.hidden { Label("Out of the store", systemImage: "eye.slash") .font(.caption) .foregroundStyle(.red) } if review.isWithdrawn { Label("Withdrawn", systemImage: "trash") .font(.caption) .foregroundStyle(.secondary) } } if let title = review.title, !title.isEmpty { Text(title) .font(.headline) .strikethrough(review.isWithdrawn) } // A thumb can carry nothing at all, which is a complete answer // about a build that just works. if let body = review.body, !body.isEmpty { Text(body) .font(.subheadline) .strikethrough(review.isWithdrawn) .textSelection(.enabled) } if review.hidden, let reason = review.hiddenReason, !reason.isEmpty { Text(verbatim: .localized("Taken down: %@", reason)) .font(.caption) .foregroundStyle(.red) } Text(verbatim: _byline(review)) .font(.caption2) .foregroundStyle(.tertiary) Button { _startEditing(review) } label: { Label("Edit review", systemImage: "pencil") .font(.caption) } .buttonStyle(.bordered) .controlSize(.small) .disabled(_busy) } .padding(.vertical, 2) .opacity(review.hidden || review.isWithdrawn ? 0.6 : 1) } header: { Text("Review") } } private func _byline(_ review: AdminReview) -> String { var parts = [review.authorName] if let created = review.createdAt { parts.append(AdminFormat.relative(created)) } if let version = review.appVersion { parts.append(.localized("version %@", version)) } if review.editedAt != nil { parts.append(.localized(review.editedByTeam ? "edited by FTStore" : "edited")) } return parts.joined(separator: " · ") } // MARK: Editing @ViewBuilder private func _editSection(_ review: AdminReview) -> some View { Section { // Only where there is a scale to edit. A thumb is the whole verdict, // and the operator lever for one that is wrong is taking it down, not // turning it round. if !review.isThumb { // A picker rather than a row of tappable stars: this is a `Form` // list, where a segmented control is the shape every other choice // on the screen already has. Picker("Rating", selection: $_editRating) { ForEach(1...5, id: \.self) { star in Text(verbatim: "\(star)").tag(star) } } .pickerStyle(.segmented) AdminStars(value: Double(_editRating), size: 15) TextField("Headline", text: $_editTitle) } TextField("What they wrote", text: $_editBody, axis: .vertical) .lineLimit(4...12) Button { if !review.isThumb && _editRating != review.rating { _confirmingRating = true } else { _saveEdit(includingRating: false) } } label: { HStack { Label("Save", systemImage: "checkmark") if _busy { Spacer() ProgressView() } } } // A thumb has no headline to require. .disabled( _busy || (!review.isThumb && _editTitle.trimmingCharacters(in: .whitespaces).isEmpty) ) Button("Cancel", role: .cancel) { _editing = false } .disabled(_busy) } header: { Text("Edit review") } footer: { Text("The store marks a review FTStore edited, and keeps what it said before. That is what makes editing somebody else's words acceptable rather than quiet.") } } // MARK: The answer @ViewBuilder private func _answerSection(_ review: AdminReview) -> some View { Section { if let response = review.response { VStack(alignment: .leading, spacing: 6) { Text(response.body) .font(.subheadline) .textSelection(.enabled) Text(verbatim: _answerByline(review, response)) .font(.caption2) .foregroundStyle(.tertiary) } .padding(.vertical, 2) Button { _answer = response.body } label: { Label("Reword", systemImage: "pencil") } .disabled(_busy) Button(role: .destructive) { _confirmingClearAnswer = true } label: { Label("Withdraw the answer", systemImage: "trash") } .disabled(_busy) } TextField( review.isAnswered ? "Reword the answer..." : "Answer this review...", text: $_answer, axis: .vertical ) .lineLimit(3...8) Button { _sendAnswer() } label: { HStack { Label( review.isAnswered ? "Save answer" : "Publish answer", systemImage: "paperplane" ) if _busy { Spacer() ProgressView() } } } .disabled(_busy || _answer.trimmingCharacters(in: .whitespaces).isEmpty) } header: { Text("FTStore's answer") } footer: { Text("Everyone who opens this app in the store sees it, under the name FTStore. One answer per review, and rewording it keeps what it said before.") } } private func _answerByline(_ review: AdminReview, _ response: AdminReview.Response) -> String { var parts = [String.localized("Published as FTStore")] if let at = response.at { parts.append(AdminFormat.relative(at)) } if response.editedAt != nil { parts.append(.localized("reworded since")) } // Which operator wrote it, on this screen only. The app is told FTStore // answered and nothing else. if let name = review.responseOperatorName { parts.append(name) } return parts.joined(separator: " · ") } // MARK: The app @ViewBuilder private func _appSection(_ review: AdminReview) -> some View { Section { LabeledContent("App", value: review.appName) if let tweak = review.tweakName { LabeledContent("Tweak", value: tweak) } LabeledContent("Identifier") { // The key, not the bundle id: two tweaks of one app share a // bundle id and are two listings in the store, so this is the // only line that says which of them was reviewed. Text(review.appKey) .font(.caption.monospaced()) .textSelection(.enabled) .lineLimit(1) .truncationMode(.middle) } if let summary = _data?.summary, summary.count > 0 { if summary.isThumb { AdminWorksLine(ratio: summary.ratio, count: summary.count) } else { AdminRatingLine(average: summary.average, count: summary.count) AdminRatingHistogram( buckets: Dictionary( uniqueKeysWithValues: (1...5).map { ($0, summary.bucket($0)) } ), count: summary.count ) } } if let author = _data?.author { NavigationLink { AdminFeedbackAuthorView( installId: author.installId, handle: author.handle ?? review.authorName ) .environmentObject(session) } label: { Label( author.handle ?? review.authorName, systemImage: "person.crop.circle" ) } } } header: { Text("The app") } footer: { Text("The rating shown here is every review of this listing that is still in the store.") } } // MARK: History @ViewBuilder private var _historySection: some View { if let revisions = _data?.revisions, !revisions.isEmpty { Section { NavigationLink { AdminReviewHistoryView(revisions: revisions) } label: { Label( revisions.count == 1 ? String.localized("1 earlier version") : .localized("%lld earlier versions", revisions.count), systemImage: "clock.arrow.circlepath" ) } } header: { Text("History") } footer: { Text("Every edit is kept, whoever made it. A review carries a number, so an edit that left no trace would let one star become five with nothing to show for it.") } } } // MARK: Moderation @ViewBuilder private func _moderationSection(_ review: AdminReview) -> some View { Section { Button { if review.hidden { _setHidden(false) } else { _confirmingTakedown = true } } label: { Label( review.hidden ? "Put it back in the store" : "Take it out of the store", systemImage: review.hidden ? "eye" : "eye.slash" ) } .disabled(_busy) if let author = _data?.author { // 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(role: .destructive) { _confirmingDelete = true } label: { Label("Delete permanently", systemImage: "trash") } .disabled(_busy) } header: { Text("Moderation") } footer: { if _data?.author?.blocked == true { Text("This account is muted. What it already wrote is still in the store.") } else { Text("Taking a review down is reversible and stops it counting towards the rating. Deleting destroys its history too, so it is admin-only and there is no undo.") } } } // MARK: Actions private func _reload() async { await _detail.load { try await session.get( "/api/reviews/\(reviewId)", as: AdminEnvelope.self ) } } private func _startEditing(_ review: AdminReview) { _editTitle = review.title ?? "" _editBody = review.body ?? "" _editRating = review.rating ?? 0 _editing = true } private func _saveEdit(includingRating: Bool) { // A thumb review has no headline and no stars, and sending either would // create a field on a row whose scale has no room for one. let isThumb = _review?.isThumb ?? false let title = isThumb ? nil : _editTitle let body = _editBody let rating = _editRating _run { try await session.act( .put, "/api/reviews/\(reviewId)", body: AdminRequest.ReviewUpdate( title: title, editBody: body, rating: (includingRating && !isThumb) ? rating : nil ) ) } onSuccess: { _editing = false } } private func _setHidden(_ hidden: Bool) { _run { try await session.act( .put, "/api/reviews/\(reviewId)", body: AdminRequest.ReviewUpdate(hidden: hidden) ) } } private func _sendAnswer() { let text = _answer.trimmingCharacters(in: .whitespacesAndNewlines) _run { try await session.act( .put, "/api/reviews/\(reviewId)", body: AdminRequest.ReviewUpdate(response: text) ) } onSuccess: { _answer = "" } } private func _clearAnswer() { _run { try await session.act( .put, "/api/reviews/\(reviewId)", body: AdminRequest.ReviewUpdate(clearResponse: true) ) } onSuccess: { _answer = "" } } private func _setBlocked(_ blocked: Bool) { guard let installId = _data?.author?.installId else { return } _run { try await session.act( .put, "/api/feedback/authors", body: AdminRequest.FeedbackAuthorModeration( installId: installId, blocked: blocked ) ) } } /// Delete the review and leave the screen. /// /// The one action here that does not go through `_run`: there is nothing left /// to reload afterwards, and a 403 from a non-admin account has to stay on /// screen as a banner rather than dismiss as if it had worked. private func _delete() { _busy = true Task { @MainActor in do { _ = try await session.act(.delete, "/api/reviews/\(reviewId)") _busy = false await onDeleted() dismiss() } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription _busy = false } } } 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() } } } /// What a review, or the answer to it, said before somebody changed it. /// /// Pushed rather than inlined: most reviews were never edited, and the history /// is something an operator goes looking for. The diff arrives already computed /// and already condensed, so this only colours it — and prints the rating, which /// is the change a text diff cannot show and the one that moved a number. /// /// The rows arrive with the review itself rather than from a call of their own, /// which is the one place this differs from `AdminRevisionHistoryView`: a review /// has at most a handful of versions, so `/api/reviews/` sends them all and /// there is nothing here to load. struct AdminReviewHistoryView: View { let revisions: [AdminReviewRevision] var body: some View { List { ForEach(revisions) { revision in Section { if revision.ratingChanged, let rating = revision.rating { HStack(spacing: 6) { Text("was") .font(.caption) .foregroundStyle(.secondary) AdminStars(value: Double(rating)) } } if revision.titleChanged, let title = revision.title { Text(title) .font(.subheadline) .strikethrough() .foregroundStyle(.red) } if revision.diff.isEmpty { Text("The text was left alone.") .font(.caption) .foregroundStyle(.secondary) } else { ForEach(Array(revision.diff.enumerated()), id: \.offset) { _, line in Text(_prefix(line) + line.text) .font(.caption.monospaced()) .foregroundStyle(_tint(line)) } } } header: { Text(_header(revision)) } } } .navigationTitle("What changed") .navigationBarTitleDisplayMode(.inline) } private func _prefix(_ line: AdminDiffLine) -> String { switch line.kind { case "add": return "+ " case "remove": return "- " default: return " " } } private func _tint(_ line: AdminDiffLine) -> Color { switch line.kind { case "add": return .green case "remove": return .red default: return .secondary } } private func _header(_ revision: AdminReviewRevision) -> String { var parts: [String] = [] if revision.isResponse { parts.append(.localized("Answer")) } if let created = revision.createdAt { parts.append(AdminFormat.relative(created)) } if revision.byTeam { parts.append(.localized("edited by FTStore")) } return parts.joined(separator: " · ") } }