diff --git a/src/api/rest.ts b/src/api/rest.ts index c85ce8c..1d952c2 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -319,3 +319,56 @@ export const ReceivedLikesApi = { list: (): Promise => request('GET', '/matching/likes'), }; + +// ── Member reports (POST /matching/reports) ─────────────────────────────────── + +/** + * Report category superset (OneHookBackend packages/matching `ReportCategory`). A new client sends + * these codes; the server maps them onto the legacy block `reason` allowlist for old-client compat. + */ +export type ReportCategoryCode = + | 'HARASSMENT' + | 'THREATS_VIOLENCE' + | 'SEXUAL_CONTENT_NONCONSENT' + | 'MINOR_SAFETY' + | 'SCAM_FRAUD' + | 'FAKE_IMPERSONATION' + | 'SPAM_PROMOTION' + | 'HATE' + | 'SELF_HARM_CONCERN' + | 'OFF_PLATFORM_HARM' + | 'OTHER'; + +/** Body of `POST /matching/reports` (OneHookBackend matching `CreateReportRequest`). */ +export interface CreateReportRequest { + reportedUserId: string; + category: ReportCategoryCode; + /** <= 2000 chars; REQUIRED for OTHER. */ + details?: string; + /** Defaults server-side to true, except false for SELF_HARM_CONCERN. */ + alsoBlock?: boolean; + conversationId?: string; + /** Whether the reporter opted to share a decrypted copy of the chat. */ + evidenceOffered: boolean; +} + +/** `201` response of `POST /matching/reports` (OneHookBackend matching `CreateReportResponse`). */ +export interface CreateReportResponse { + /** Backend report UUID — the id the evidence presign/complete calls reference. */ + reportId: string; + severity: string; + /** + * Whether the server will accept a decrypted-chat evidence upload for this report. False for + * MINOR_SAFETY (escalated immediately) and any `evidenceEligible:false` category. + */ + evidenceAllowed: boolean; +} + +export const ReportsApi = { + /** + * File a member report. Throws {@link ApiError} on failure; a `429` (rate limit) surfaces as + * `ApiError` with `status === 429` so the UI can show a friendly message. + */ + create: (body: CreateReportRequest): Promise => + request('POST', '/matching/reports', body), +}; diff --git a/src/components/chat/AttachmentBubble.tsx b/src/components/chat/AttachmentBubble.tsx index 4b5b300..01b56a6 100644 --- a/src/components/chat/AttachmentBubble.tsx +++ b/src/components/chat/AttachmentBubble.tsx @@ -203,7 +203,7 @@ export function AttachmentBubble({ )} @@ -616,12 +643,12 @@ export function ChatView({ disabled={reporting} onClick={() => { setShieldOpen(false); - void runReportAndBlock('SPAM'); + setChatReportOpen(true); }} className="w-full text-left px-4 py-2.5 text-sm hover:bg-surface-hover transition-colors text-red-500 disabled:opacity-40 cursor-pointer" - {...trackProps(UiElement.CHAT_REPORT_SPAM)} + {...trackProps(UiElement.CHAT_REPORT_OPEN)} > - Report spam + Report )} @@ -713,6 +740,9 @@ export function ChatView({ currentUserId={currentUser.id} wallpaper={wallpaper} onReportPeer={() => setReportReason('OTHER')} + reportOpen={chatReportOpen} + onCloseReport={() => setChatReportOpen(false)} + onRequestUnmatch={() => void runUnmatch()} /> ) : ( setPickerOpen(true)} /> @@ -755,8 +785,29 @@ export function ChatView({ peerName={peerName} peerPhoto={peerPhoto} distanceKm={activeDistance ?? null} + onReport={ + !isMrOneHook && activeMatch?.peerId + ? () => { + setProfileOpen(false); + setProfileReportOpen(true); + } + : undefined + } /> + {/* ── REPORT FLOW (profile entry; no evidence — evidence is chat-only) ── */} + {activeMatch?.peerId && ( + setProfileReportOpen(false)} + source="profile" + reportedUserId={activeMatch.peerId} + reportedName={peerName} + onRequestBlock={() => setReportReason('OTHER')} + onRequestUnmatch={() => void runUnmatch()} + /> + )} + {/* ── REPORT & BLOCK (reason picker) ────────────────────────────────── */} void; + reportOpen?: boolean; + onCloseReport?: () => void; + onRequestUnmatch?: () => void; }) { const { messages, @@ -995,15 +1052,66 @@ function MatchConversation({ markAsDelivered, markAsRead, refetch, + loadEvidenceWindow, hasUndecryptable, historyHorizon, } = useChatMessages(matchId, peerId); const [input, setInput] = useState(''); + + // Snapshot of decrypted messages for the optional evidence share. Text comes from the SAME local + // decrypt the chat UI uses (the E2EE hook); attachments/undecryptable/system messages map to a + // null/undecryptable entry so the window selector skips them. Never leaves the device unless the + // reporter ticks the (never pre-ticked) share box and submits. + const evidenceMessages: SourceMessage[] = useMemo( + () => + messages.map((m) => { + let text: string | null = null; + if (!m.undecryptable) { + try { + const decoded = decodeMessagePlaintext(m.ciphertext); + text = decoded.type === 'attachment' ? null : decoded.text; + } catch { + text = null; + } + } + return { + messageId: m.messageId, + sentAtMs: m.timestamp, + senderSide: m.senderId === 'me' ? 'reporter' : 'reported', + text, + undecryptable: m.undecryptable, + }; + }), + [messages] + ); const { showToast } = useToast(); const messagesEndRef = useRef(null); const [recoveryOpen, setRecoveryOpen] = useState(false); const [dragActive, setDragActive] = useState(false); + // When the report dialog opens, page + decrypt the COMPLETE evidence window (the live `messages` + // snapshot is a single oldest-first page and can omit the most recent messages in a long chat). + // Until it resolves, the snapshot is used as a non-blocking fallback. Reset on close so a decrypted + // copy never lingers. + const [fullEvidence, setFullEvidence] = useState(null); + useEffect(() => { + if (!reportOpen) { + setFullEvidence(null); + return; + } + let cancelled = false; + void loadEvidenceWindow() + .then((w) => { + if (!cancelled) setFullEvidence(w); + }) + .catch(() => { + /* non-fatal: fall back to the single-page snapshot */ + }); + return () => { + cancelled = true; + }; + }, [reportOpen, loadEvidenceWindow]); + // Voice / video calling goes through the ONE app-level controller (OneHookBackend/docs/ // voice-video-calls.md §10): the ring modal and in-call surface are owned by CallProvider at the // app root, so a call rings on any screen. This conversation only STARTS calls and shows history. @@ -1296,6 +1404,20 @@ function MatchConversation({ onClose={() => setRecoveryOpen(false)} onRecovered={() => void refetch()} /> + + {peerId && ( + onCloseReport?.()} + source="chat" + reportedUserId={peerId} + reportedName={peerName} + conversationId={matchId} + sourceMessages={fullEvidence ?? evidenceMessages} + onRequestBlock={onReportPeer} + onRequestUnmatch={onRequestUnmatch} + /> + )} ); } @@ -2047,6 +2169,7 @@ function ProfileInspectorModal({ peerName, peerPhoto, distanceKm, + onReport, }: { open: boolean; onClose: () => void; @@ -2055,6 +2178,7 @@ function ProfileInspectorModal({ peerName: string; peerPhoto: string; distanceKm: number | null; + onReport?: () => void; }) { return ( @@ -2132,6 +2256,16 @@ function ProfileInspectorModal({ “{peerProfile.bio}”

)} + {onReport && ( + + )} )} @@ -2174,7 +2308,7 @@ function ReportBlockModal({
-

Report & block

+

Block

Blocking {peerName} ends this connection and prevents further contact. Your chat history @@ -2214,7 +2348,7 @@ function ReportBlockModal({ {...trackProps(UiElement.CHAT_BLOCK_CONFIRM)} className="flex-1 py-3 bg-red-600 text-white text-xs font-semibold rounded-xl hover:bg-red-700 transition-colors flex items-center justify-center gap-2 cursor-pointer" > - {submitting ? : 'Block & report'} + {submitting ? : 'Block'}

diff --git a/src/features/moderation/report/ReportFlowDialog.tsx b/src/features/moderation/report/ReportFlowDialog.tsx new file mode 100644 index 0000000..5967cd6 --- /dev/null +++ b/src/features/moderation/report/ReportFlowDialog.tsx @@ -0,0 +1,773 @@ +/** + * Member report flow dialog (OneHook moderation Phase 2, web). + * + * Steps: category picker (11 categories + "This isn't a safety issue") -> optional self-harm support + * -> details + also-block + optional decrypted-chat evidence -> submit -> confirmation. Evidence is + * offered ONLY from a chat and only when the category is not MINOR_SAFETY, is never pre-ticked, and + * declining is treated identically to sharing (no dark patterns). Evidence failure never fails the + * report. + * + * Telemetry is the backend `report_flow` SIGNAL (see telemetry.ts / the catalog ReportEvent doc), not + * ui_interaction — so the flow's controls carry `data-track-exempt` (the no-untracked-control guard's + * sanctioned escape) with the meaningful presses emitting a report_flow signal in-handler. + * + * Accessibility: focus trap + Escape to close, aria-live status region, and prefers-reduced-motion + * renders the nudge shield as a static icon. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { Shield, X, AlertTriangle, Phone, ChevronLeft, Check, ChevronRight } from 'lucide-react'; +import { ApiError } from '../../../lib/api-client'; +import { + REPORT_CATEGORIES, + getCategoryMeta, + NOT_REPORTABLE_CASES, + DETAILS_MAX_CHARS, + ReportCategory, + type CategoryMeta, +} from './categories'; +import { REPORT_STRINGS as S, SELF_HARM_RESOURCES } from './strings'; +import { trackReport, ReportEvent, type ReportSource } from './telemetry'; +import { + submitReport, + uploadEvidence, + type SubmitDeps, + type SubmitResult, +} from './submit'; +import { + canSubmit as canSubmitDecision, + confirmationView, + detailsRequired as detailsRequiredFor, + detailsTooLong as detailsTooLongFor, + initialDialogState, + isEvidenceEligible, + nudgeMode, + selfHarmTelHref, + stepForCategory, + type ConfirmationView, +} from './dialog-logic'; +import { selectEvidenceWindow, type SourceMessage } from './window-selection'; + +type Step = 'category' | 'not_reportable' | 'self_harm' | 'form' | 'preview' | 'confirmation'; + +export interface ReportFlowDialogProps { + open: boolean; + onClose: () => void; + source: ReportSource; + /** Opaque id of the reported member (never shown in telemetry/logs). */ + reportedUserId: string; + /** Display name, UI only. */ + reportedName: string; + /** Match/conversation id — present only when started from a chat. */ + conversationId?: string; + /** Decrypted source messages for evidence (chat only). */ + sourceMessages?: SourceMessage[]; + /** Existing block action (for the not-reportable redirect). */ + onRequestBlock?: () => void; + /** Existing unmatch action (for the not-reportable redirect). */ + onRequestUnmatch?: () => void; + /** Test seam: inject submit dependencies. */ + deps?: SubmitDeps; + /** Test seam: force reduced motion. */ + forceReducedMotion?: boolean; +} + +function usePrefersReducedMotion(force?: boolean): boolean { + const [reduced, setReduced] = useState(force ?? false); + useEffect(() => { + if (force !== undefined) return; + if (typeof window === 'undefined' || !window.matchMedia) return; + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReduced(mq.matches); + const on = () => setReduced(mq.matches); + mq.addEventListener?.('change', on); + return () => mq.removeEventListener?.('change', on); + }, [force]); + return reduced; +} + +const FOCUSABLE = + 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +export function ReportFlowDialog({ + open, + onClose, + source, + reportedUserId, + reportedName, + conversationId, + sourceMessages, + onRequestBlock, + onRequestUnmatch, + deps, + forceReducedMotion, +}: ReportFlowDialogProps) { + const reducedMotion = usePrefersReducedMotion(forceReducedMotion); + const panelRef = useRef(null); + + const [step, setStep] = useState('category'); + const [category, setCategory] = useState(null); + const [details, setDetails] = useState(''); + const [alsoBlock, setAlsoBlock] = useState(true); + const [share, setShare] = useState(false); // NEVER pre-ticked + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [retrying, setRetrying] = useState(false); + const nudgeShownRef = useRef(false); + + const meta: CategoryMeta | null = category ? getCategoryMeta(category) : null; + + // Evidence is only offered from a chat, with a non-MINOR_SAFETY category, when there is chat to share. + const evidenceEligible = isEvidenceEligible({ source, conversationId, meta }); + + const previewWindow = useMemo( + () => (evidenceEligible ? selectEvidenceWindow(sourceMessages ?? []) : []), + [evidenceEligible, sourceMessages] + ); + + // Reset to a clean state whenever the dialog opens, and emit report.open. + useEffect(() => { + if (!open) return; + const init = initialDialogState(); + setStep(init.step); + setCategory(init.category); + setDetails(init.details); + setAlsoBlock(init.alsoBlock); + setShare(init.share); // NEVER pre-ticked + setSubmitting(false); + setError(null); + setResult(null); + nudgeShownRef.current = false; + trackReport(ReportEvent.REPORT_OPEN, { source }); + }, [open, source]); + + // The nudge is shown once, when the evidence section first appears. + useEffect(() => { + if (open && step === 'form' && evidenceEligible && !nudgeShownRef.current) { + nudgeShownRef.current = true; + trackReport(ReportEvent.REPORT_NUDGE_SHOWN, { category: category ?? undefined, source }); + } + }, [open, step, evidenceEligible, category, source]); + + // Focus trap + Escape. + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + const first = panel.querySelector(FOCUSABLE); + first?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + return; + } + if (e.key !== 'Tab') return; + const nodes = Array.from( + panel.querySelectorAll(FOCUSABLE) + ) as HTMLElement[]; + const visible = nodes.filter((n) => n.offsetParent !== null || n === document.activeElement); + if (visible.length === 0) return; + const firstN = visible[0]; + const lastN = visible[visible.length - 1]; + if (e.shiftKey && document.activeElement === firstN) { + e.preventDefault(); + lastN.focus(); + } else if (!e.shiftKey && document.activeElement === lastN) { + e.preventDefault(); + firstN.focus(); + } + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [open, step, onClose]); + + const chooseCategory = useCallback( + (code: ReportCategory) => { + setCategory(code); + const m = getCategoryMeta(code); + setAlsoBlock(m.alsoBlockDefault); + trackReport(ReportEvent.REPORT_CATEGORY_SELECTED, { category: code, source }); + const next = stepForCategory(m); + if (next === 'self_harm') { + trackReport(ReportEvent.REPORT_SELF_HARM_RESOURCES_SHOWN, { category: code, source }); + } + setStep(next); + }, + [source] + ); + + const openNotReportable = useCallback(() => { + trackReport(ReportEvent.REPORT_NOT_REPORTABLE_SHOWN, { source }); + setStep('not_reportable'); + }, [source]); + + const toggleShare = useCallback( + (next: boolean) => { + setShare(next); + trackReport(ReportEvent.REPORT_EVIDENCE_TOGGLE, { + category: category ?? undefined, + source, + evidence: next, + }); + }, + [category, source] + ); + + const openPreview = useCallback(() => { + trackReport(ReportEvent.REPORT_EVIDENCE_PREVIEW, { category: category ?? undefined, source }); + setStep('preview'); + }, [category, source]); + + const detailsRequired = detailsRequiredFor(meta); + const detailsTooLong = detailsTooLongFor(details); + const canSubmit = canSubmitDecision({ meta, submitting, details }); + + const handleSubmit = useCallback(async () => { + if (!meta || !canSubmit) return; + setSubmitting(true); + setError(null); + try { + const r = await submitReport( + { + reportedUserId, + category: meta.code, + details, + alsoBlock, + conversationId: source === 'chat' ? conversationId : undefined, + source, + shareEvidence: evidenceEligible && share, + messages: evidenceEligible && share ? sourceMessages : undefined, + }, + deps + ); + setResult(r); + setStep('confirmation'); + } catch (err) { + const status = err instanceof ApiError ? err.status : (err as { status?: number })?.status; + setError(status === 429 ? S.rateLimited : S.submitError); + } finally { + setSubmitting(false); + } + }, [meta, canSubmit, reportedUserId, details, alsoBlock, conversationId, source, evidenceEligible, share, sourceMessages, deps]); + + const handleRetryEvidence = useCallback(async () => { + if (!result) return; + setRetrying(true); + try { + const outcome = await uploadEvidence(result.reportId, conversationId ?? '', sourceMessages ?? [], deps); + if (outcome === 'attached') { + trackReport(ReportEvent.REPORT_EVIDENCE_UPLOAD_SUCCEEDED, { + category: category ?? undefined, + source, + }); + } + setResult({ ...result, evidenceOutcome: outcome }); + } catch { + trackReport(ReportEvent.REPORT_EVIDENCE_UPLOAD_FAILED, { category: category ?? undefined, source }); + setResult({ ...result, evidenceOutcome: 'failed' }); + } finally { + setRetrying(false); + } + }, [result, conversationId, sourceMessages, deps, category, source]); + + if (!open) return null; + + return ( + + +
e.stopPropagation()} + data-track-exempt="backdrop: modal container; stops backdrop dismissal, not an interactive control." + > + {/* Header */} +
+
+ {step !== 'category' && step !== 'confirmation' && ( + + )} +

{S.dialogTitle}

+
+ +
+ + {/* aria-live status for non-blocking outcomes / errors */} +
+ {error ?? (result ? S.confirmationHeading : '')} +
+ +
+ {step === 'category' && ( + + )} + + {step === 'not_reportable' && ( + { + trackReport(ReportEvent.REPORT_NOT_REPORTABLE_BLOCK, { source }); + onClose(); + onRequestBlock?.(); + }} + onUnmatch={ + onRequestUnmatch + ? () => { + onClose(); + onRequestUnmatch(); + } + : undefined + } + /> + )} + + {step === 'self_harm' && meta && ( + setStep('form')} /> + )} + + {step === 'form' && meta && ( + + )} + + {step === 'preview' && ( + + )} + + {step === 'confirmation' && result && ( + + )} +
+ + {/* Footer actions */} + {step === 'form' && meta && ( +
+ {error && ( +

+ {error} +

+ )} + +
+ )} + + {step === 'preview' && ( +
+ +
+ )} + + {step === 'confirmation' && ( +
+ +
+ )} +
+
+
+ ); +} + +// ── Steps ──────────────────────────────────────────────────────────────────── + +function CategoryStep({ + onChoose, + onNotReportable, +}: { + onChoose: (c: ReportCategory) => void; + onNotReportable: () => void; +}) { + return ( +
+
+

{S.pickerHeading}

+

{S.pickerSubheading}

+
+
    + {REPORT_CATEGORIES.map((c) => ( +
  • + +
  • + ))} +
  • + +
  • +
+
+ ); +} + +function NotReportableStep({ onBlock, onUnmatch }: { onBlock: () => void; onUnmatch?: () => void }) { + return ( +
+

{S.notReportableHeading}

+

{S.notReportableBody}

+
    + {NOT_REPORTABLE_CASES.map((c) => ( +
  • + {c} +
  • + ))} +
+
+ + {onUnmatch && ( + + )} +
+
+ ); +} + +function SelfHarmStep({ onContinue }: { onContinue: () => void }) { + return ( +
+
+
+

{S.selfHarmBody}

+ + +
+ ); +} + +function FormStep({ + meta, + reportedName, + details, + onDetails, + detailsRequired, + detailsTooLong, + alsoBlock, + onAlsoBlock, + evidenceEligible, + share, + onShare, + onPreview, + reducedMotion, + previewCount, +}: { + meta: CategoryMeta; + reportedName: string; + details: string; + onDetails: (v: string) => void; + detailsRequired: boolean; + detailsTooLong: boolean; + alsoBlock: boolean; + onAlsoBlock: (v: boolean) => void; + evidenceEligible: boolean; + share: boolean; + onShare: (v: boolean) => void; + onPreview: () => void; + reducedMotion: boolean; + previewCount: number; +}) { + return ( +
+
+

{meta.title}

+

{meta.description}

+
+ + {/* Details */} +
+ +