From 898a8b8dd4ea01e140ba6dc48491cbace9ba9822 Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 22:19:19 -0700 Subject: [PATCH] feat(messages): report messages to community moderators Add a "Report message" action to the message overflow menu that signs and publishes a NIP-56 kind:1984 report through the session writer with a 10s deadline. No outbox and no plugin; failures keep the draft for manual retry. Rows holding an open report or its notice stay mounted in the virtualized timeline. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- dev/relay-broker-api.test.mjs | 48 ++++ dev/relay-broker.mjs | 41 ++++ dev/workflow-broker.test.mjs | 4 +- docs/relay-queries.md | 7 + .../messages/ChannelTimeline.report.test.tsx | 219 ++++++++++++++++++ src/features/messages/ChannelTimeline.tsx | 21 +- src/features/messages/MessageRow.tsx | 57 ++++- .../messages/ReportMessageDialog.test.tsx | 166 +++++++++++++ src/features/messages/ReportMessageDialog.tsx | 113 +++++++++ src/features/relay/messages.ts | 36 +++ src/features/relay/report.test.ts | 161 +++++++++++++ src/features/relay/session.ts | 21 ++ .../sessions/SessionMessageTarget.test.tsx | 1 + src/shared/design-system/icons/index.ts | 2 + 14 files changed, 891 insertions(+), 6 deletions(-) create mode 100644 src/features/messages/ChannelTimeline.report.test.tsx create mode 100644 src/features/messages/ReportMessageDialog.test.tsx create mode 100644 src/features/messages/ReportMessageDialog.tsx create mode 100644 src/features/relay/report.test.ts diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index 12aab7183..04107d398 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1321,6 +1321,54 @@ test("edit capability signs and publishes canonical replacements, rejecting malf } }); +test("report capability signs and publishes NIP-56 message reports, rejecting other shapes locally", async () => { + const h = await harness(success); + try { + await h.start(); + expect((await (await h.get("session")).json()).writeKinds).toContain(1984); + const template = { + kind: 1984, + content: "", + created_at: h.event.created_at, + tags: [ + ["p", h.event.pubkey], + ["e", h.event.id, "spam"], + ], + }; + const response = await h.post("sign", template); + expect(response.status).toBe(200); + const event = await response.json(); + expect(verifyEvent(event)).toBe(true); + expect(event).toMatchObject({ kind: 1984, tags: template.tags }); + expect((await h.post("publish", event)).status).toBe(200); + expect(h.publications).toEqual([JSON.parse(JSON.stringify(event))]); + for (const route of ["sign", "publish"]) { + for (const tags of [ + [["e", h.event.id, "spam"]], + [ + ["p", h.event.pubkey], + ["e", h.event.id, "rude"], + ], + [ + ["p", h.event.pubkey], + ["e", "bad", "spam"], + ], + [...template.tags, ["h", "c"]], + ]) + expect((await h.post(route, { ...event, tags })).status).toBe(400); + expect( + (await h.post(route, { ...event, content: " padded " })).status, + ).toBe(400); + expect( + (await h.post(route, { ...event, content: "x".repeat(32001) })).status, + ).toBe(400); + } + expect(h.publications).toHaveLength(1); + } finally { + await h.close(); + } +}); + test("message/reaction deletions pass real signing and publication without admitting workflow or arbitrary deletion shapes", async () => { const h = await harness((call) => Response.json({ accepted: true, event_id: call.body.id }), diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 9cf896a05..50722dd45 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -311,6 +311,40 @@ export function validMessageTemplate(event) { })() ); } +/** NIP-56 message report: exactly one author and one typed message target. */ +export function validReport(event) { + if ( + event?.kind !== 1984 || + typeof event.content !== "string" || + event.content !== event.content.trim() || + Buffer.byteLength(event.content) > 32000 || + !Number.isSafeInteger(event.created_at) || + event.created_at < 0 || + !Array.isArray(event.tags) || + event.tags.length !== 2 + ) + return false; + const [author, target] = event.tags; + return ( + Array.isArray(author) && + author.length === 2 && + author[0] === "p" && + /^[0-9a-f]{64}$/.test(author[1]) && + Array.isArray(target) && + target.length === 3 && + target[0] === "e" && + /^[0-9a-f]{64}$/.test(target[1]) && + [ + "spam", + "profanity", + "nudity", + "impersonation", + "malware", + "illegal", + "other", + ].includes(target[2]) + ); +} /** Channel-local NIP-09 removal; the relay enforces authorship of each target. */ export function validMessageDeletion(event) { if ( @@ -1062,6 +1096,7 @@ export function relayBrokerPlugin({ 9000, 30078, 40100, + 1984, ...WORKFLOW_KINDS, ...((await getAuthority(relay)).channelCreation ? [9007] : []), ], @@ -1846,6 +1881,12 @@ export function relayBrokerPlugin({ sent: false, }); } + } else if (filters?.kind === 1984) { + if (!validReport(filters)) + return json(res, 400, { + error: "Report rejected", + sent: false, + }); } else if ( ![7, 9, 40003].includes(filters?.kind) && !validMessageDeletion(filters) diff --git a/dev/workflow-broker.test.mjs b/dev/workflow-broker.test.mjs index 0d436350b..07492c84d 100644 --- a/dev/workflow-broker.test.mjs +++ b/dev/workflow-broker.test.mjs @@ -147,7 +147,7 @@ it("real broker scoped history signs exact GET path/cursor and captured principa const other = await connectBrokerTransport(h.base, undefined, "secondary"); expect(h.calls).toHaveLength(0); expect(first.writer.kinds).toEqual([ - 30315, 7, 9, 40003, 9000, 30078, 40100, 30620, 46020, 5, + 30315, 7, 9, 40003, 9000, 30078, 40100, 1984, 30620, 46020, 5, ]); await first.workflows.runs(id, cursor, signal()); await other.workflows.runs(id, undefined, signal()); @@ -314,7 +314,7 @@ it("existing backend signs only canonical workflow sign/publish with exact own e const t = await connectBrokerTransport(h.base); live = await openBrokerSocket(t); expect(t.writer.kinds).toEqual([ - 30315, 7, 9, 40003, 9000, 30078, 40100, 30620, 46020, 5, + 30315, 7, 9, 40003, 9000, 30078, 40100, 1984, 30620, 46020, 5, ]); for (const input of [ template(), diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 3ee641f7d..b60840512 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -377,6 +377,13 @@ owned by the viewer and a transport that supports kind 40003. The dev broker advertises edits and validates one canonical target reference before signing or publishing. Generic plugins can use `outbox.send` directly. +`session.messages.report(messageId, type, note?)` is the one exception: the relay +queues NIP-56 reports (kind 1984) for moderators and never stores or echoes them, +so the session signs and publishes directly and resolves on the relay's accepted +receipt, as Buzz desktop does. Nothing is persisted or restored; a rejection or +10-second timeout rejects the call and the dialog keeps its input for retry. The +method is undefined unless the writer supports kind 1984. + In an empty composer, unmodified Up arrow opens the latest eligible own message from that channel or thread in the same editor. Enter/the send arrow saves; Escape or × cancels. Edits retain raw attachment Markdown and leave original diff --git a/src/features/messages/ChannelTimeline.report.test.tsx b/src/features/messages/ChannelTimeline.report.test.tsx new file mode 100644 index 000000000..865801f72 --- /dev/null +++ b/src/features/messages/ChannelTimeline.report.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StrictMode, type ReactElement, type ReactNode } from "react"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { createRelaySession } from "../relay/session"; +import type { LiveCallbacks } from "../relay/live"; +import type { RelayEvent } from "../relay/events"; +import { + keypair, + message, + roster, + scriptedTransport, + signed, +} from "../relay/testing"; +import { ChannelTimeline } from "./ChannelTimeline"; + +// Virtualizer boundary: render only "visible" indices plus `keepMounted`, +// as Virtua does for rows scrolled out of its buffer. Layout is browser-tested. +const view = vi.hoisted(() => ({ + visible: undefined as ReadonlySet | undefined, +})); +vi.mock("virtua", async () => { + const { Children, forwardRef, useImperativeHandle } = await import("react"); + return { + Virtualizer: forwardRef(function Virtualizer( + { + children, + keepMounted = [], + }: { children: ReactNode; keepMounted?: readonly number[] }, + ref, + ) { + useImperativeHandle(ref, () => ({ + cache: undefined, + scrollOffset: 0, + scrollSize: 0, + viewportSize: 0, + scrollTo() {}, + scrollToIndex() {}, + })); + return ( +
    + {Children.toArray(children).map((child, index) => + !view.visible || + view.visible.has(index) || + keepMounted.includes(index) ? ( +
  1. {child}
  2. + ) : null, + )} +
+ ); + }), + }; +}); + +const viewer = keypair(), + other = keypair(), + relay = keypair(); +const target = message(other, "c", "Report me", 1); +const neighbor = message(other, "c", "Neighbor", 2); +const owners: { dispose(): void }[] = []; +beforeEach(() => { + view.visible = undefined; + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + for (const owner of owners.splice(0)) owner.dispose(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +function mount(publish: (event: RelayEvent) => Promise) { + let live: LiveCallbacks | undefined; + const owner = createRelaySession( + { + ...scriptedTransport(viewer.pubkey, relay.pubkey).transport, + writer: { sign: async (template) => signed(viewer, template), publish }, + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + owners.push(owner); + owner.session.channels.ensure("c"); + live?.receive([roster(relay, "c", [viewer.pubkey], 1), target, neighbor]); + const tree = () => ( + + + false} + /> + + + ); + const result = render(tree()); + const row = () => + document.querySelector(`[data-message-id="${target.id}"]`); + const neighborRow = () => + document.querySelector(`[data-message-id="${neighbor.id}"]`); + return { + row, + neighborRow, + // Scroll the target out of the virtualizer buffer. + evict() { + const index = owner.session.channels + .window("c") + .rows.findIndex((row) => row.id === neighbor.id); + view.visible = new Set([index]); + result.rerender(tree()); + }, + show() { + view.visible = undefined; + result.rerender(tree()); + }, + unmount: result.unmount, + }; +} + +async function openReport(row: HTMLElement) { + const user = userEvent.setup(); + const trigger = within(row).getByRole("button", { + name: "More message actions", + }); + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Report message" }), + ); + await screen.findByRole("dialog", { name: "Report message" }); + return { user, trigger }; +} + +it("keeps the reporting row mounted through a held submission and its notice, then releases it", async () => { + let settle!: () => void; + const publish = vi.fn( + () => new Promise((resolve) => (settle = resolve)), + ); + const h = mount(publish); + const row = h.row(); + if (!row) throw new Error("Missing target row"); + const { user } = await openReport(row); + h.evict(); + expect(h.neighborRow()).not.toBeNull(); + expect(h.row()).toBe(row); + await user.click(screen.getByRole("radio", { name: "Spam" })); + await user.type( + screen.getByRole("textbox", { name: "Additional context (optional)" }), + "repeated links", + ); + await user.click(screen.getByRole("button", { name: "Submit report" })); + await waitFor(() => expect(publish).toHaveBeenCalledOnce()); + h.evict(); + expect(h.row()).toBe(row); + act(() => settle()); + expect( + await screen.findByText("Report submitted to community moderators"), + ).toBeTruthy(); + h.evict(); + expect(h.row()).toBe(row); + await user.click(screen.getByRole("button", { name: /dismiss/i })); + // Once the notice and focus are gone, the virtualizer may evict the row. + await waitFor(() => expect(h.row()).toBeNull()); + expect(h.neighborRow()).not.toBeNull(); +}); + +it("releases on cancel, pins again on reopen, and unmounts cleanly while open", async () => { + const error = vi.spyOn(console, "error"); + const h = mount(async () => {}); + const row = h.row(); + if (!row) throw new Error("Missing target row"); + const { user, trigger } = await openReport(row); + await user.click(screen.getByRole("radio", { name: "Other" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + await openReport(row); + expect( + screen.getByRole("radio", { name: "Other" }).getAttribute("aria-checked"), + ).toBe("false"); + h.evict(); + expect(h.row()).toBe(row); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + // Restored focus keeps the row; once focus leaves, the pin must be gone. + act(() => trigger.blur()); + h.evict(); + await waitFor(() => expect(h.row()).toBeNull()); + + h.show(); + const remounted = h.row(); + if (!remounted) throw new Error("Missing target row"); + await openReport(remounted); + h.unmount(); + await act(() => new Promise((resolve) => setTimeout(resolve))); + expect(screen.queryByRole("dialog", { name: "Report message" })).toBeNull(); + expect(error).not.toHaveBeenCalled(); +}); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index cb8830019..b2f737ccf 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -128,7 +128,21 @@ function Timeline({ [window.rows, profiles, resolveName], ); const [focusedMessageId, setFocusedMessageId] = useState(); - const focusedIndex = rows.findIndex((row) => row.id === focusedMessageId); + const [pinnedIds, setPinnedIds] = useState>( + () => new Set(), + ); + const keepRowMounted = useCallback((id: string) => { + setPinnedIds((ids) => new Set(ids).add(id)); + return () => + setPinnedIds((ids) => { + const next = new Set(ids); + next.delete(id); + return next; + }); + }, []); + const keptIndices = rows.flatMap((row, index) => + row.id === focusedMessageId || pinnedIds.has(row.id) ? [index] : [], + ); const scroller = useRef(null); const handle = useRef(null); const [size, setSize] = useState({ width: 0, height: 0 }); @@ -475,8 +489,8 @@ function Timeline({ scrollRef={scroller} shift={prepend} bufferSize={1600} - // Reflow must not evict the focused control and drop keyboard focus. - keepMounted={focusedIndex < 0 ? [] : [focusedIndex]} + // Reflow must not evict the focused control or a row's open report. + keepMounted={keptIndices} as="ol" item="li" startMargin={EDGE_HEIGHT} @@ -520,6 +534,7 @@ function Timeline({ onOpenThread={onOpenThread} {...(onOpenMediaReview ? { onOpenMediaReview } : {})} retry={queries.outbox?.retry} + keepMounted={keepRowMounted} day={day} /> ); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index ad6ccb3df..eea60ef41 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -6,6 +6,8 @@ import { IconButton } from "../../shared/design-system/ui/IconButton"; import { memo, useRef, + useState, + useEffect, useCallback, useSyncExternalStore, type ReactNode, @@ -32,6 +34,10 @@ import { usesLargeEmojiPresentation } from "./emoji-size"; import { MessageReactionControls, MessageReactions } from "./MessageReactions"; import { MessageActionBar } from "./MessageActionBar"; +import { FlagIcon } from "../../shared/design-system/icons"; +import { MenuIcon, MenuItem } from "../../shared/design-system/ui/Menu"; +import { ToastNotice } from "../../shared/design-system/ui/Toast"; +import { ReportMessageDialog } from "./ReportMessageDialog"; import { messageCopyLink, messageCopyText } from "./message-copy"; const emptySubscribe = () => () => {}; @@ -55,6 +61,8 @@ export type MessageRowProps = { onOpenLink(url: string): boolean; day: boolean; retry: ((id: string) => void) | undefined; + /** Pins this row in a virtualized list; returns the release. */ + keepMounted?: ((messageId: string) => () => void) | undefined; onOpenThread?: | ((messageId: string, threadRootId: string, intent?: "reply") => void) | undefined; @@ -85,6 +93,7 @@ export const MessageRow = memo(function MessageRow({ canOpenLink, day, retry, + keepMounted, onOpenThread, onReply, quickControls, @@ -149,6 +158,28 @@ export const MessageRow = memo(function MessageRow({ ?.readOnly ); const menuTrigger = useRef(null); + const [reporting, setReporting] = useState<"open" | "sent">(); + const reportActive = reporting !== undefined; + // The dialog, pending submit and notice live in this row; eviction loses them. + useEffect(() => { + const release = reportActive ? keepMounted?.(row.id) : undefined; + // Dialog focus restoration runs in a microtask after unmount; releasing a + // task later lets restored focus keep the row mounted instead. + return release && (() => void setTimeout(release)); + }, [reportActive, keepMounted, row.id]); + const report = + !row.membership && + (!row.delivery || ["accepted", "seen"].includes(row.delivery)) + ? session?.messages.report + : undefined; + const reportItem = report && ( + setReporting("open")}> + + + + Report message + + ); const body = row.diff ? (

{row.diff.filePath || "Diff"}

@@ -276,7 +307,31 @@ export const MessageRow = memo(function MessageRow({ /> ) : undefined) } - overflowItems={overflowItems} + overflowItems={ + overflowItems || reportItem ? ( + <> + {overflowItems} + {reportItem} + + ) : undefined + } + /> + )} + {report && reporting === "open" && ( + report(row.id, type, note)} + close={(submitted) => + setReporting(submitted ? "sent" : undefined) + } + finalFocus={menuTrigger} + /> + )} + {reporting === "sent" && ( + setReporting(undefined)} /> )}
diff --git a/src/features/messages/ReportMessageDialog.test.tsx b/src/features/messages/ReportMessageDialog.test.tsx new file mode 100644 index 000000000..24c2040ed --- /dev/null +++ b/src/features/messages/ReportMessageDialog.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { createRelaySession } from "../relay/session"; +import { PublishRejected } from "../relay/outbox"; +import type { LiveCallbacks } from "../relay/live"; +import type { RelayEvent } from "../relay/events"; +import { + keypair, + message, + roster, + scriptedTransport, + signed, +} from "../relay/testing"; +import { MessageRow } from "./MessageRow"; + +const viewer = keypair(), + other = keypair(), + relay = keypair(); +const root = message(other, "c", "Report me", 1); +const owners: { dispose(): void }[] = []; +afterEach(() => { + cleanup(); + for (const owner of owners.splice(0)) owner.dispose(); +}); + +let focusedAtRelease: Element | null = null; +const release = vi.fn(() => { + focusedAtRelease = document.activeElement; +}); +const keepMounted = vi.fn((_id: string) => release); +afterEach(() => { + focusedAtRelease = null; + release.mockClear(); + keepMounted.mockClear(); +}); + +function mount(publish: (event: RelayEvent) => Promise) { + let live: LiveCallbacks | undefined; + const owner = createRelaySession( + { + ...scriptedTransport(viewer.pubkey, relay.pubkey).transport, + writer: { sign: async (template) => signed(viewer, template), publish }, + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + owners.push(owner); + owner.session.channels.ensure("c"); + live?.receive([roster(relay, "c", [viewer.pubkey], 1), root]); + const row = owner.session.channels.window("c").rows[0]; + if (!row) throw new Error("Missing row"); + render( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + keepMounted={keepMounted} + />, + { wrapper: ToastProvider }, + ); +} + +it("reports from the message menu, keeps input after failure and confirms success", async () => { + const user = userEvent.setup(); + let settle!: (error?: Error) => void; + const publish = vi.fn( + (_event: RelayEvent) => + new Promise((resolve, reject) => { + settle = (error) => (error ? reject(error) : resolve()); + }), + ); + mount(publish); + const trigger = screen.getByRole("button", { name: "More message actions" }); + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Report message" }), + ); + const dialog = await screen.findByRole("dialog", { name: "Report message" }); + // A virtualized list must not evict the row owning the draft and outcome. + expect(keepMounted.mock.calls).toEqual([[root.id]]); + const submit = screen.getByRole("button", { name: "Submit report" }); + expect(submit.hasAttribute("disabled")).toBe(true); + await user.click(screen.getByRole("radio", { name: "Spam" })); + await user.type( + screen.getByRole("textbox", { name: "Additional context (optional)" }), + "repeated links", + ); + await user.click(submit); + await waitFor(() => expect(publish).toHaveBeenCalledOnce()); + expect(submit.getAttribute("aria-busy")).toBe("true"); + expect( + screen.getByRole("button", { name: "Cancel" }).hasAttribute("disabled"), + ).toBe(true); + settle(new PublishRejected("blocked")); + expect((await screen.findByRole("alert")).textContent).toContain( + "Failed to submit report", + ); + expect(dialog.isConnected).toBe(true); + expect( + ( + screen.getByRole("textbox", { + name: "Additional context (optional)", + }) as HTMLTextAreaElement + ).value, + ).toBe("repeated links"); + await user.click(submit); + await waitFor(() => expect(publish).toHaveBeenCalledTimes(2)); + expect(publish.mock.calls[1]?.[0]).toMatchObject({ + kind: 1984, + content: "repeated links", + tags: [ + ["p", other.pubkey], + ["e", root.id, "spam"], + ], + }); + settle(); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Report message" })).toBeNull(), + ); + expect( + await screen.findByText("Report submitted to community moderators"), + ).toBeTruthy(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(release).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: /dismiss/i })); + await waitFor(() => expect(release).toHaveBeenCalledOnce()); + expect(keepMounted).toHaveBeenCalledOnce(); +}); + +it("starts each report with an empty form", async () => { + const user = userEvent.setup(); + mount(async () => {}); + const trigger = screen.getByRole("button", { name: "More message actions" }); + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Report message" }), + ); + await user.click(await screen.findByRole("radio", { name: "Other" })); + expect(keepMounted.mock.calls).toEqual([[root.id]]); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: "Report message" })).toBeNull(), + ); + // Restored focus must reach the row before its pin is released. + await waitFor(() => expect(release).toHaveBeenCalledOnce()); + expect(focusedAtRelease).toBe(trigger); + await user.click(trigger); + await user.click( + await screen.findByRole("menuitem", { name: "Report message" }), + ); + expect( + ( + (await screen.findByRole("radio", { name: "Other" })) as HTMLElement + ).getAttribute("aria-checked"), + ).toBe("false"); +}); diff --git a/src/features/messages/ReportMessageDialog.tsx b/src/features/messages/ReportMessageDialog.tsx new file mode 100644 index 000000000..e6bd3656e --- /dev/null +++ b/src/features/messages/ReportMessageDialog.tsx @@ -0,0 +1,113 @@ +import { useId, useState, type RefObject } from "react"; +import { FlagIcon } from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; +import { Textarea } from "../../shared/design-system/ui/Textarea"; +import type { ReportType } from "../relay/messages"; + +/** Same order and copy as Buzz desktop; `other` reads as the fallback. */ +const CATEGORIES: readonly (readonly [ReportType, string])[] = [ + ["spam", "Spam"], + ["profanity", "Profanity or hate speech"], + ["nudity", "Nudity or sexual content"], + ["impersonation", "Impersonation"], + ["malware", "Malware or scam"], + ["illegal", "Illegal content"], + ["other", "Other"], +]; + +/** Mount only while open so every report starts with an empty form. */ +export function ReportMessageDialog({ + report, + close, + finalFocus, +}: { + report(type: ReportType, note: string): Promise; + close(submitted: boolean): void; + finalFocus?: RefObject; +}) { + const formId = useId(); + const [category, setCategory] = useState(null); + const [note, setNote] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + const submit = async () => { + if (!category || pending) return; + setPending(true); + setError(""); + try { + await report(category, note); + close(true); + } catch { + setError("Failed to submit report. Try again."); + setPending(false); + } + }; + return ( + { + if (!open) close(false); + }} + preventClose={pending} + finalFocus={finalFocus} + title={ + + + } + description="Reports go to this community's moderators for review. The author is not notified of who reported them." + actions={ + <> + + + + } + > +
{ + event.preventDefault(); + event.stopPropagation(); + void submit(); + }} + > + + + name="report-reason" + value={category} + disabled={pending} + onValueChange={setCategory} + > + {CATEGORIES.map(([value, label]) => ( + + ))} + + + +