Files
FTStore/admin/FTStoreAdmin/App/AdminReviewDetailView.swift
Felitendo cff4de60a8 feat: ask a tweak both questions
A tweak listing asked how good the tweak is and nothing else. The other
thing worth knowing about one is whether the build being served actually
launches, and that is not something stars can carry: a tweak worth five
of them can still ship a version that does not start, and that is the
half somebody about to install reads first.

So a review of a tweak now carries a thumb beside its stars. The two
answer different questions and are kept apart everywhere: the stars are
about the tweak, the thumb is about the build the reviewer had, the
listing publishes both counts separately, and the card and both consoles
draw them side by side. On a plain listing nothing changes, because
there is nothing there to rate.

The thumb is optional beside a rating and required without one. A review
of a tweak is about the tweak, and refusing to take one because somebody
had no opinion on today's build would cost the review to gain nothing.

Two things had to move for this. The rating is now what says which scale
a review is on, because the thumb no longer can. And asking for a build
to be looked at goes back to one rule for both scales, hanging off a
thumbs down: a tweak has a thumb of its own now, so the special case
added for it last time is gone. The revision history gained a column for
the thumb, which it needs and used to fake by storing a five or a one in
the rating slot.

The store row still prints one number. Three of them beside a version
and a description is a row nobody reads, and whether the build runs is
on the app's own page where there is room to say which of the two it is.
2026-09-02 14:32:56 +02:00

688 lines
20 KiB
Swift

//
// AdminReviewDetailView.swift
// FTStore Admin
//
// One review: read it, answer it as FTStore, fix it, or take it down. Mirrors
// /admin/reviews/<id>.
//
// 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<AdminEnvelope<AdminReviewDetail>>()
@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<AdminReviewDetail>.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/<id>` 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: " · ")
}
}