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({
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.selfHarmHeading}
+
+
{S.selfHarmBody}
+
+ {SELF_HARM_RESOURCES.map((r) => (
+ -
+
+
+ ))}
+
+
+
+ );
+}
+
+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 */}
+
+
+
+
+ {/* Also block — plain checkbox (no press handler; value travels in the report) */}
+
+
+ {/* Minor safety: evidence hidden, escalation note shown instead */}
+ {meta.minorSafety && (
+
+
+
{S.minorSafetyEscalated}
+
+ )}
+
+ {/* Evidence section (chat + non-MINOR_SAFETY) */}
+ {evidenceEligible && (
+
+ {/* Nudge: ~1s non-looping shield; static when reduced motion */}
+
+ {nudgeMode(reducedMotion) === 'static' ? (
+
+ ) : (
+
+
+
+ )}
+
{S.evidenceNudgeLine}
+
+
+
+
+
+
+ {S.evidencePanelHeading}
+
+
{S.evidencePanelWhat}
+
{S.evidencePanelNoMedia}
+
{S.evidencePanelNotNotified}
+
+
+
+
+ )}
+
+ {/* A reportedName reference keeps the UI personable; never logged / never in telemetry. */}
+
Reporting {reportedName}
+
+ );
+}
+
+function PreviewStep({ window }: { window: { messageId: string; sentAt: string; senderSide: string; text: string }[] }) {
+ return (
+
+
{S.evidencePreviewHeading}
+ {window.length === 0 ? (
+
{S.evidencePreviewEmpty}
+ ) : (
+
+ )}
+
+ );
+}
+
+function ConfirmationStep({
+ result,
+ retrying,
+ onRetryEvidence,
+}: {
+ result: SubmitResult;
+ retrying: boolean;
+ onRetryEvidence: () => void;
+}) {
+ const view: ConfirmationView = confirmationView({
+ blocked: result.blocked,
+ evidenceOutcome: result.evidenceOutcome,
+ });
+ return (
+
+
+
+
{S.confirmationHeading}
+
+
{S.confirmationBody}
+ {view.blocked && (
+
{S.confirmationBlocked}
+ )}
+ {view.attached && (
+
{S.evidenceAttached}
+ )}
+ {view.terminalNote && (
+
{S.evidenceTerminalNote}
+ )}
+ {view.retry && (
+
+
{S.evidenceFailed}
+
+
+ )}
+
+ );
+}
diff --git a/src/features/moderation/report/categories.ts b/src/features/moderation/report/categories.ts
new file mode 100644
index 0000000..d407bf4
--- /dev/null
+++ b/src/features/moderation/report/categories.ts
@@ -0,0 +1,192 @@
+/**
+ * Report categories and their rules (OneHook moderation, docs/MODERATION.md §1 + §9).
+ *
+ * The 11 category codes mirror the backend `ReportCategory` taxonomy (also vendored into the
+ * telemetry catalog as `ReportCategory`). Titles and one-line descriptions are short, plain-language
+ * versions of the §1 definitions. Per-category rules captured here:
+ *
+ * - `alsoBlockDefault`: the default for the "Also block this person" toggle. True for every
+ * category EXCEPT SELF_HARM_CONCERN (a support route, not punishment — default OFF).
+ * - `evidenceEligible`: whether the decrypted-chat evidence option may be offered. False for
+ * MINOR_SAFETY, which is escalated immediately and never carries an evidence upload (§9.1). The
+ * server independently gates this via `CreateReportResponse.evidenceAllowed`; the client hides
+ * the option entirely for MINOR_SAFETY.
+ * - `requiresDetails`: whether the free-text details field is mandatory (OTHER only).
+ * - `selfHarm`: SELF_HARM_CONCERN shows support resources first and routes to a support team.
+ * - `minorSafety`: MINOR_SAFETY shows the "escalated immediately" note instead of evidence.
+ */
+import { ReportCategory } from '../../../lib/analytics/generated/telemetryCatalog';
+
+export { ReportCategory };
+
+export type ReportSeverity = 'critical' | 'high' | 'standard';
+
+export interface CategoryMeta {
+ code: ReportCategory;
+ /** Short, plain-language title shown in the picker. */
+ title: string;
+ /** One-line description shown under the title. */
+ description: string;
+ tier: ReportSeverity;
+ /** Default for the "Also block this person" toggle. */
+ alsoBlockDefault: boolean;
+ /** Whether the decrypted-chat evidence option may be offered for this category. */
+ evidenceEligible: boolean;
+ /** OTHER requires a free-text description. */
+ requiresDetails: boolean;
+ /** SELF_HARM_CONCERN: support route, resources shown first. */
+ selfHarm: boolean;
+ /** MINOR_SAFETY: escalated immediately, evidence hidden entirely. */
+ minorSafety: boolean;
+}
+
+/** Ordered list of the 11 categories as shown in the picker. */
+export const REPORT_CATEGORIES: CategoryMeta[] = [
+ {
+ code: ReportCategory.HARASSMENT,
+ title: 'Harassment',
+ description: 'Repeated unwanted contact, insults, or degradation.',
+ tier: 'high',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.THREATS_VIOLENCE,
+ title: 'Threats or violence',
+ description: 'Threats of physical harm, intimidation, or extortion.',
+ tier: 'critical',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.SEXUAL_CONTENT_NONCONSENT,
+ title: 'Unwanted sexual content',
+ description: 'Unsolicited sexual content, coercion, or sharing intimate images without consent.',
+ tier: 'critical',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.MINOR_SAFETY,
+ title: 'Minor safety',
+ description: 'A suspected underage user, or any sexualization of a minor.',
+ tier: 'critical',
+ alsoBlockDefault: true,
+ evidenceEligible: false,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: true,
+ },
+ {
+ code: ReportCategory.SCAM_FRAUD,
+ title: 'Scam or fraud',
+ description: 'Money requests, investment or crypto pitches, phishing, or off-platform payment.',
+ tier: 'high',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.FAKE_IMPERSONATION,
+ title: 'Fake or impersonation',
+ description: 'A fake profile, stolen photos, or impersonating a real person or brand.',
+ tier: 'high',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.SPAM_PROMOTION,
+ title: 'Spam or promotion',
+ description: 'Bulk advertising, solicitation, link spam, or driving you to other platforms.',
+ tier: 'standard',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.HATE,
+ title: 'Hate',
+ description:
+ 'Attacks on protected characteristics such as race, religion, caste, gender, orientation, or disability.',
+ tier: 'high',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.SELF_HARM_CONCERN,
+ title: 'Self-harm concern',
+ description: 'Someone expressing suicidal thoughts or self-harm. This goes to a support team, not punishment.',
+ tier: 'critical',
+ alsoBlockDefault: false,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: true,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.OFF_PLATFORM_HARM,
+ title: 'Off-platform harm',
+ description: 'Harm that started here but continues elsewhere, like doxxing, stalking, or spreading private images.',
+ tier: 'high',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: false,
+ selfHarm: false,
+ minorSafety: false,
+ },
+ {
+ code: ReportCategory.OTHER,
+ title: 'Something else',
+ description: 'Anything not covered above. Please add a short description.',
+ tier: 'standard',
+ alsoBlockDefault: true,
+ evidenceEligible: true,
+ requiresDetails: true,
+ selfHarm: false,
+ minorSafety: false,
+ },
+];
+
+const BY_CODE: Record = Object.fromEntries(
+ REPORT_CATEGORIES.map((c) => [c.code, c])
+);
+
+export function getCategoryMeta(code: ReportCategory): CategoryMeta {
+ const meta = BY_CODE[code];
+ if (!meta) throw new Error(`Unknown report category: ${code}`);
+ return meta;
+}
+
+/** Max length of the details free-text field (backend CreateReportRequest cap). */
+export const DETAILS_MAX_CHARS = 2000;
+
+/**
+ * The cases that are explicitly NOT reportable (docs/MODERATION.md §1). Choosing "This isn't a
+ * safety issue" shows these and offers Block / Unmatch instead of creating a report.
+ */
+export const NOT_REPORTABLE_CASES: string[] = [
+ 'Disagreeing with you or having different opinions.',
+ 'Not replying, replying slowly, or unmatching.',
+ "Someone's protected attributes themselves — race, religion, caste, gender, orientation, disability, body, or accent.",
+ "Their dating preferences, or who they are or aren't interested in.",
+ 'Being boring, unattractive, or a bad conversationalist.',
+];
diff --git a/src/features/moderation/report/dialog-logic.ts b/src/features/moderation/report/dialog-logic.ts
new file mode 100644
index 0000000..3efe84b
--- /dev/null
+++ b/src/features/moderation/report/dialog-logic.ts
@@ -0,0 +1,156 @@
+/**
+ * Report dialog decision logic (OneHook moderation Phase 2, web) — PURE, no DOM.
+ *
+ * The behavioural rules of {@link ReportFlowDialog} (docs/MODERATION.md §2 + §9) live here as pure
+ * functions so they can be unit-tested WITHOUT a browser DOM test environment (this repo has none
+ * installed; see the moderation task notes). The component imports and uses these — it owns only the
+ * React state plumbing and markup, never the rules — so a test of this module is a test of the real
+ * behaviour the component ships.
+ *
+ * Hard rules encoded here:
+ * - the evidence share checkbox is NEVER pre-ticked (`initialDialogState().share === false`);
+ * - Submit is enabled whether or not evidence is shared (`canSubmit` never reads `share`);
+ * - MINOR_SAFETY hides the evidence option entirely and shows the "escalated immediately" note;
+ * - the "this isn't a safety issue" path redirects to block / unmatch, it never files a report;
+ * - SELF_HARM_CONCERN shows support resources (Tele-MANAS 14416) first;
+ * - prefers-reduced-motion renders the nudge shield as a STATIC icon (no animation).
+ */
+import { DETAILS_MAX_CHARS, type CategoryMeta } from './categories';
+import type { SupportResource } from './strings';
+import type { EvidenceOutcome } from './submit';
+import type { ReportSource } from './telemetry';
+
+export type Step = 'category' | 'not_reportable' | 'self_harm' | 'form' | 'preview' | 'confirmation';
+
+export interface DialogState {
+ step: Step;
+ category: null;
+ details: string;
+ alsoBlock: boolean;
+ /** The decrypted-chat share checkbox. NEVER pre-ticked. */
+ share: boolean;
+}
+
+/** The clean state the dialog resets to every time it opens. `share` is always OFF. */
+export function initialDialogState(): DialogState {
+ return { step: 'category', category: null, details: '', alsoBlock: true, share: false };
+}
+
+/**
+ * Whether the decrypted-chat evidence option may be offered. Only from a chat (we need a
+ * conversation to share), only for a category the server will accept evidence for, and NEVER for
+ * MINOR_SAFETY (escalated immediately; evidence hidden entirely — §9.1).
+ */
+export function isEvidenceEligible(args: {
+ source: ReportSource;
+ conversationId?: string;
+ meta: CategoryMeta | null;
+}): boolean {
+ const { source, conversationId, meta } = args;
+ return source === 'chat' && !!conversationId && !!meta && meta.evidenceEligible && !meta.minorSafety;
+}
+
+/** Whether the free-text details field is mandatory for the chosen category (OTHER only). */
+export function detailsRequired(meta: CategoryMeta | null): boolean {
+ return !!meta?.requiresDetails;
+}
+
+/** Whether the details field exceeds the backend cap. */
+export function detailsTooLong(details: string): boolean {
+ return details.length > DETAILS_MAX_CHARS;
+}
+
+/**
+ * Whether Submit is enabled. Deliberately INDEPENDENT of `share`: the report is accepted identically
+ * whether or not evidence is attached (no dark patterns). Blocked only by a missing category, an
+ * in-flight submit, an over-long details field, or missing required details.
+ */
+export function canSubmit(args: {
+ meta: CategoryMeta | null;
+ submitting: boolean;
+ details: string;
+}): boolean {
+ const { meta, submitting, details } = args;
+ if (!meta || submitting) return false;
+ if (detailsTooLong(details)) return false;
+ if (detailsRequired(meta) && details.trim().length === 0) return false;
+ return true;
+}
+
+export interface SectionVisibility {
+ /** The evidence share section (nudge + checkbox + preview). */
+ evidence: boolean;
+ /** The "escalated immediately, nothing to attach" note (MINOR_SAFETY). */
+ minorSafetyNote: boolean;
+}
+
+/** Which conditional sections of the form step are visible for the chosen category / source. */
+export function sectionVisibility(args: {
+ source: ReportSource;
+ conversationId?: string;
+ meta: CategoryMeta | null;
+}): SectionVisibility {
+ const { meta } = args;
+ return {
+ evidence: isEvidenceEligible(args),
+ minorSafetyNote: !!meta?.minorSafety,
+ };
+}
+
+/** The step a chosen category routes to: self-harm support first, otherwise straight to the form. */
+export function stepForCategory(meta: CategoryMeta): Extract {
+ return meta.selfHarm ? 'self_harm' : 'form';
+}
+
+/** The default for the "Also block this person" toggle for a category. */
+export function alsoBlockDefaultFor(meta: CategoryMeta): boolean {
+ return meta.alsoBlockDefault;
+}
+
+export type NudgeMode = 'static' | 'animated';
+
+/** prefers-reduced-motion renders the shield as a static icon; otherwise it animates once (~1s). */
+export function nudgeMode(reducedMotion: boolean): NudgeMode {
+ return reducedMotion ? 'static' : 'animated';
+}
+
+/** `tel:` href for a self-harm support resource (e.g. Tele-MANAS 14416 -> `tel:14416`). */
+export function selfHarmTelHref(resource: SupportResource): string {
+ return `tel:${resource.phone}`;
+}
+
+/** The actions offered on the "this isn't a safety issue" redirect (never files a report). */
+export function notReportableActions(args: { hasUnmatch: boolean }): {
+ block: boolean;
+ unmatch: boolean;
+} {
+ return { block: true, unmatch: args.hasUnmatch };
+}
+
+export interface ConfirmationView {
+ /** "The chat was attached" line. */
+ attached: boolean;
+ /** "You've also blocked this person" line. */
+ blocked: boolean;
+ /** Non-blocking failure box WITH a Retry affordance. */
+ retry: boolean;
+ /** Calm, FINAL note with NO Retry (terminal evidence outcome). */
+ terminalNote: boolean;
+}
+
+/**
+ * How the confirmation screen presents an evidence outcome. `failed` is the only state that offers
+ * Retry; `terminal` shows a calm final note with no Retry; `attached` confirms the attach.
+ */
+export function confirmationView(args: {
+ blocked: boolean;
+ evidenceOutcome: EvidenceOutcome;
+}): ConfirmationView {
+ const { blocked, evidenceOutcome } = args;
+ return {
+ attached: evidenceOutcome === 'attached',
+ blocked,
+ retry: evidenceOutcome === 'failed',
+ terminalNote: evidenceOutcome === 'terminal',
+ };
+}
diff --git a/src/features/moderation/report/evidence-api.ts b/src/features/moderation/report/evidence-api.ts
new file mode 100644
index 0000000..024b2bb
--- /dev/null
+++ b/src/features/moderation/report/evidence-api.ts
@@ -0,0 +1,237 @@
+/**
+ * Evidence upload transport (OneHook moderation Phase 2 client contract).
+ *
+ * Implements the three HTTP steps against the admin portal's PUBLIC member gateway
+ * (features/moderation README "Evidence (decrypted-chat share) - client contract"):
+ *
+ * 1. POST {base}/evidence/presign (Bearer member ID token) -> { evidenceId, upload, expiresAt }
+ * 2. PUT upload.url (EXACTLY upload.headers, no auth) with the canonical bytes
+ * 3. POST {base}/evidence/complete (Bearer member ID token) -> { status }
+ *
+ * `complete` is retried on `503 EVIDENCE_FINALIZE_RETRY` with bounded backoff. Everything is
+ * dependency-injected (fetch, token provider, sleep) so the orchestration is unit-testable without a
+ * browser or the network. No message text or user ids are ever logged here.
+ */
+import { portalModerationBaseUrl } from '../../../utils/env.config';
+
+export class EvidenceError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ public readonly code?: string
+ ) {
+ super(message);
+ this.name = 'EvidenceError';
+ }
+}
+
+export interface EvidenceTransport {
+ /** Portal moderation base URL (…/moderation). Defaults to the app's env config. */
+ baseUrl?: string;
+ fetchImpl?: typeof fetch;
+ /** Returns the member's Cognito ID token, or null when unauthenticated. */
+ getToken: () => Promise;
+ /** Injected for tests; defaults to a real timed delay. */
+ sleep?: (ms: number) => Promise;
+}
+
+export interface PresignRequest {
+ reportId: string;
+ sizeBytes: number;
+ /** 64 lowercase hex. */
+ sha256: string;
+ messageCount: number;
+ includesMedia: boolean;
+}
+
+export interface PresignUpload {
+ url: string;
+ method: string;
+ headers: Record;
+}
+
+export interface PresignResponse {
+ evidenceId: string;
+ upload: PresignUpload;
+ expiresAt: number;
+}
+
+export interface CompleteRequest {
+ reportId: string;
+ evidenceId: string;
+ messageIds: string[];
+ /** Epoch ms. */
+ firstSentAt: number;
+ /** Epoch ms. */
+ lastSentAt: number;
+}
+
+export interface CompleteResponse {
+ evidenceId: string;
+ status: string;
+ idempotent: boolean;
+}
+
+export interface RetryOptions {
+ /** Max additional attempts after the first on a retryable 503. Default 4. */
+ maxRetries?: number;
+ /** Base backoff ms (doubled each attempt, capped). Default 400. */
+ baseDelayMs?: number;
+ /** Cap on a single backoff delay. Default 4000. */
+ maxDelayMs?: number;
+}
+
+const FINALIZE_RETRY_CODE = 'EVIDENCE_FINALIZE_RETRY';
+
+/**
+ * Evidence for this report is already recorded server-side (presign returns `409` with this code
+ * once evidence exists, and `complete` is idempotent on `evidenceId`). Either way the evidence IS
+ * attached, so this is a success, not a failure.
+ */
+export const EVIDENCE_ALREADY_COMPLETED_CODE = 'EVIDENCE_ALREADY_COMPLETED';
+
+/**
+ * Codes that mean the server will NEVER accept evidence for this report, so retrying cannot help.
+ * The UI closes the evidence affordance with a calm, final note (no Retry):
+ * - EVIDENCE_DISABLED : evidence not allowed for this category (e.g. MINOR_SAFETY).
+ * - EVIDENCE_NOT_OFFERED : the report was not filed with `evidenceOffered`.
+ * - REPORT_TOO_OLD : past the server's evidence window (`reportMaxAgeHours`).
+ * - NOT_REPORTER : the caller is not the report's reporter.
+ */
+export const EVIDENCE_TERMINAL_CODES: readonly string[] = [
+ 'EVIDENCE_DISABLED',
+ 'EVIDENCE_NOT_OFFERED',
+ 'REPORT_TOO_OLD',
+ 'NOT_REPORTER',
+];
+
+/**
+ * Map an evidence transport error to an evidence outcome, or `null` when it is a transient/retryable
+ * failure the caller should surface with a Retry. Pure (no I/O) so it is unit-testable.
+ * - `'attached'` : evidence already recorded (409 EVIDENCE_ALREADY_COMPLETED).
+ * - `'terminal'` : a code where retry can never succeed (hide Retry, show a calm final note).
+ * - `null` : anything else (network, 5xx, S3 PUT rejection, exhausted finalize retry).
+ */
+export function classifyEvidenceError(err: unknown): 'attached' | 'terminal' | null {
+ if (!(err instanceof EvidenceError) || !err.code) return null;
+ if (err.code === EVIDENCE_ALREADY_COMPLETED_CODE) return 'attached';
+ if (EVIDENCE_TERMINAL_CODES.includes(err.code)) return 'terminal';
+ return null;
+}
+
+function resolveBaseUrl(t: EvidenceTransport): string {
+ return (t.baseUrl ?? portalModerationBaseUrl).replace(/\/+$/, '');
+}
+
+async function parseBody(res: Response): Promise<{ code?: string; message?: string }> {
+ const text = await res.text().catch(() => '');
+ let parsed: unknown = undefined;
+ if (text) {
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ parsed = text;
+ }
+ }
+ const obj = typeof parsed === 'object' && parsed !== null ? (parsed as Record) : undefined;
+ return {
+ code: typeof obj?.code === 'string' ? (obj.code as string) : undefined,
+ message: typeof obj?.message === 'string' ? (obj.message as string) : undefined,
+ };
+}
+
+async function postJson(t: EvidenceTransport, path: string, body: unknown): Promise {
+ const doFetch = t.fetchImpl ?? fetch;
+ const token = await t.getToken();
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ };
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+
+ let res: Response;
+ try {
+ res = await doFetch(`${resolveBaseUrl(t)}${path}`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ });
+ } catch (err) {
+ throw new EvidenceError(
+ err instanceof Error ? err.message : 'Network request failed',
+ 0,
+ 'NETWORK_ERROR'
+ );
+ }
+ if (!res.ok) {
+ const { code, message } = await parseBody(res);
+ throw new EvidenceError(message || `Evidence request failed (${res.status})`, res.status, code);
+ }
+ return (await res.json()) as T;
+}
+
+/** Step 1: get a presigned PUT for the evidence object. */
+export function presignEvidence(t: EvidenceTransport, req: PresignRequest): Promise {
+ return postJson(t, '/evidence/presign', req);
+}
+
+/**
+ * Step 2: PUT the exact canonical bytes to S3 using EXACTLY the returned headers (and no auth
+ * header — the presigned URL carries the signature). S3 enforces the SHA-256 checksum header.
+ */
+export async function putEvidenceBytes(
+ fetchImpl: typeof fetch | undefined,
+ upload: PresignUpload,
+ bytes: Uint8Array
+): Promise {
+ const doFetch = fetchImpl ?? fetch;
+ let res: Response;
+ try {
+ res = await doFetch(upload.url, {
+ method: upload.method || 'PUT',
+ headers: { ...upload.headers },
+ body: bytes as unknown as BodyInit,
+ });
+ } catch (err) {
+ throw new EvidenceError(
+ err instanceof Error ? err.message : 'Evidence upload failed',
+ 0,
+ 'UPLOAD_NETWORK_ERROR'
+ );
+ }
+ if (!res.ok) {
+ throw new EvidenceError(`Evidence upload rejected (${res.status})`, res.status, 'UPLOAD_FAILED');
+ }
+}
+
+const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * Step 3: record the upload. Retries on `503 EVIDENCE_FINALIZE_RETRY` with bounded exponential
+ * backoff; any other error propagates immediately.
+ */
+export async function completeEvidence(
+ t: EvidenceTransport,
+ req: CompleteRequest,
+ opts: RetryOptions = {}
+): Promise {
+ const maxRetries = opts.maxRetries ?? 4;
+ const baseDelayMs = opts.baseDelayMs ?? 400;
+ const maxDelayMs = opts.maxDelayMs ?? 4000;
+ const sleep = t.sleep ?? defaultSleep;
+
+ let attempt = 0;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ try {
+ return await postJson(t, '/evidence/complete', req);
+ } catch (err) {
+ const retryable =
+ err instanceof EvidenceError && err.status === 503 && err.code === FINALIZE_RETRY_CODE;
+ if (!retryable || attempt >= maxRetries) throw err;
+ const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
+ attempt += 1;
+ await sleep(delay);
+ }
+ }
+}
diff --git a/src/features/moderation/report/evidence-bundle.ts b/src/features/moderation/report/evidence-bundle.ts
new file mode 100644
index 0000000..c906367
--- /dev/null
+++ b/src/features/moderation/report/evidence-bundle.ts
@@ -0,0 +1,205 @@
+/**
+ * Canonical evidence-bundle format for the web client (OneHook moderation Phase 2).
+ *
+ * The reporter's device decrypts the last-N chat locally and uploads a copy (docs/MODERATION.md §2).
+ * iOS, Android and web MUST all produce BYTE-IDENTICAL canonical JSON so the SHA-256 the client
+ * declares at presign matches the hash the server recomputes on complete and the admin viewer
+ * recomputes on read.
+ *
+ * This is the web port of the portal reference implementation
+ * (OneHookAdminPortal/features/moderation/api/evidence-bundle.ts). The ONLY difference is hashing:
+ * the browser has no `node:crypto`, so SHA-256 is computed with the Web Crypto API (SubtleCrypto)
+ * and is therefore asynchronous. The canonical string / escaping / sort logic is identical, and the
+ * golden fixtures (features/moderation/contract-fixtures/*.json) are reused verbatim as test vectors.
+ *
+ * CANONICAL RULES (identical across platforms):
+ * - UTF-8 JSON, NO whitespace anywhere.
+ * - Object keys appear in a FIXED order (never alphabetical, never insertion order):
+ * top level : v, reportId, conversationId, window, messages, attachments
+ * window : maxMessages, maxDays
+ * message : messageId, sentAt, senderSide, text
+ * - `v` is the number 1; window.maxMessages / window.maxDays are JSON numbers (no quotes).
+ * - messages are sorted by sentAt (ISO-8601 UTC ms; lexicographic == chronological) then messageId
+ * (lexicographic by UTF-16 code unit). Sorting is applied canonically regardless of input order.
+ * - String escaping (STANDARD JSON, specified exactly so every platform agrees):
+ * escape only " and \
+ * control chars < 0x20: use the short escapes \b \f \n \r \t; any OTHER control char is
+ * \u00XX with LOWERCASE hex (e.g. \u0000, \u001f).
+ * do NOT escape '/'; do NOT escape non-ASCII (emoji, accents travel as raw UTF-8); do NOT
+ * escape DEL (0x7f) or anything >= 0x20.
+ */
+
+/** The evidence window (docs/MODERATION.md §9.3): last 200 messages OR 30 days, whichever is smaller. */
+export const EVIDENCE_WINDOW = { maxMessages: 200, maxDays: 30 } as const;
+
+/** Bundle format version. Bump only with a documented migration; clients pin on it. */
+export const BUNDLE_VERSION = 1 as const;
+
+export type SenderSide = 'reporter' | 'reported';
+
+export interface BundleMessage {
+ messageId: string;
+ /** ISO-8601 UTC with millisecond precision, e.g. "2026-10-26T12:34:56.789Z". */
+ sentAt: string;
+ senderSide: SenderSide;
+ text: string;
+}
+
+export interface EvidenceWindow {
+ maxMessages: number;
+ maxDays: number;
+}
+
+export interface EvidenceBundle {
+ v: typeof BUNDLE_VERSION;
+ reportId: string;
+ conversationId: string;
+ window: EvidenceWindow;
+ messages: BundleMessage[];
+ attachments: string[];
+}
+
+export interface BundleInput {
+ reportId: string;
+ conversationId: string;
+ messages: BundleMessage[];
+ /** Defaults to EVIDENCE_WINDOW. */
+ window?: EvidenceWindow;
+ /** Opaque attachment refs (empty unless the reporter opted into media). Default []. */
+ attachments?: string[];
+}
+
+const HEX = '0123456789abcdef';
+
+/**
+ * Escape a string per the canonical rules and wrap it in double quotes. Deliberately hand-written
+ * (not JSON.stringify) so the contract is explicit and does not depend on any engine's escaping
+ * choices; iOS / Android / the portal implement the identical routine.
+ */
+export function escapeCanonicalString(s: string): string {
+ let out = '"';
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i];
+ const u = s.charCodeAt(i);
+ if (c === '"') out += '\\"';
+ else if (c === '\\') out += '\\\\';
+ else if (u === 0x08) out += '\\b';
+ else if (u === 0x09) out += '\\t';
+ else if (u === 0x0a) out += '\\n';
+ else if (u === 0x0c) out += '\\f';
+ else if (u === 0x0d) out += '\\r';
+ else if (u < 0x20) out += '\\u00' + HEX[(u >> 4) & 0xf] + HEX[u & 0xf];
+ else out += c;
+ }
+ return out + '"';
+}
+
+/** Canonical message order: sentAt then messageId, both lexicographic (UTF-16 code unit). */
+export function sortMessages(messages: readonly BundleMessage[]): BundleMessage[] {
+ return [...messages].sort((a, b) => {
+ if (a.sentAt < b.sentAt) return -1;
+ if (a.sentAt > b.sentAt) return 1;
+ if (a.messageId < b.messageId) return -1;
+ if (a.messageId > b.messageId) return 1;
+ return 0;
+ });
+}
+
+/** Build the fully-formed, canonically-ordered bundle object from loose input. */
+export function buildBundle(input: BundleInput): EvidenceBundle {
+ return {
+ v: BUNDLE_VERSION,
+ reportId: input.reportId,
+ conversationId: input.conversationId,
+ window: input.window
+ ? { maxMessages: input.window.maxMessages, maxDays: input.window.maxDays }
+ : { ...EVIDENCE_WINDOW },
+ messages: sortMessages(input.messages),
+ attachments: input.attachments ? [...input.attachments] : [],
+ };
+}
+
+function isBundle(x: BundleInput | EvidenceBundle): x is EvidenceBundle {
+ return (x as EvidenceBundle).v === BUNDLE_VERSION && 'window' in x && !!(x as EvidenceBundle).window;
+}
+
+/** The canonical JSON string (what gets hashed and uploaded). */
+export function canonicalString(input: BundleInput | EvidenceBundle): string {
+ const b = isBundle(input) ? input : buildBundle(input);
+ const esc = escapeCanonicalString;
+ const window = `{"maxMessages":${b.window.maxMessages},"maxDays":${b.window.maxDays}}`;
+ const messages = sortMessages(b.messages)
+ .map(
+ (m) =>
+ `{"messageId":${esc(m.messageId)},"sentAt":${esc(m.sentAt)},"senderSide":${esc(
+ m.senderSide
+ )},"text":${esc(m.text)}}`
+ )
+ .join(',');
+ const attachments = (b.attachments ?? []).map((a) => esc(a)).join(',');
+ return (
+ `{"v":${BUNDLE_VERSION}` +
+ `,"reportId":${esc(b.reportId)}` +
+ `,"conversationId":${esc(b.conversationId)}` +
+ `,"window":${window}` +
+ `,"messages":[${messages}]` +
+ `,"attachments":[${attachments}]}`
+ );
+}
+
+/** Canonical bytes (UTF-8). This is exactly what the client PUTs to S3. */
+export function canonicalBytes(input: BundleInput | EvidenceBundle): Uint8Array {
+ return new TextEncoder().encode(canonicalString(input));
+}
+
+/** Byte length of the canonical bundle (the sizeBytes a client declares at presign). */
+export function canonicalByteLength(input: BundleInput | EvidenceBundle): number {
+ return canonicalBytes(input).length;
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ let out = '';
+ for (const b of bytes) out += HEX[(b >> 4) & 0xf] + HEX[b & 0xf];
+ return out;
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary);
+}
+
+/** Convert a 64-char lowercase hex SHA-256 to the base64 S3 checksum header value. */
+export function hexToBase64(hex: string): string {
+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error('sha256 must be 64 lowercase hex chars');
+ const bytes = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
+ return bytesToBase64(bytes);
+}
+
+function subtle(): SubtleCrypto {
+ const api = globalThis.crypto?.subtle;
+ if (!api) {
+ throw new Error(
+ 'Evidence hashing is unavailable: this environment has no Web Crypto API (a secure HTTPS context is required).'
+ );
+ }
+ return api;
+}
+
+/** Raw SHA-256 digest of the canonical bytes. */
+export async function bundleSha256(input: BundleInput | EvidenceBundle): Promise {
+ const bytes = canonicalBytes(input);
+ const digest = await subtle().digest('SHA-256', bytes as unknown as ArrayBuffer);
+ return new Uint8Array(digest);
+}
+
+/** SHA-256 of the canonical bytes, lowercase hex (the `sha256` field at presign). */
+export async function bundleSha256Hex(input: BundleInput | EvidenceBundle): Promise {
+ return bytesToHex(await bundleSha256(input));
+}
+
+/** SHA-256 of the canonical bytes, base64 (the x-amz-checksum-sha256 header value S3 enforces). */
+export async function bundleSha256Base64(input: BundleInput | EvidenceBundle): Promise {
+ return bytesToBase64(await bundleSha256(input));
+}
diff --git a/src/features/moderation/report/member-auth.ts b/src/features/moderation/report/member-auth.ts
new file mode 100644
index 0000000..3e79b6a
--- /dev/null
+++ b/src/features/moderation/report/member-auth.ts
@@ -0,0 +1,18 @@
+/**
+ * Member auth token for portal evidence calls.
+ *
+ * The portal gateway verifies the member's Cognito USER POOL ID token (per the features/moderation
+ * client contract: the ID token — not the access token — is the correct bearer). Read the same way
+ * sdk-client.ts / rest.ts do: ID token first, access token as a fallback. Returns null when
+ * unauthenticated so the request is still attempted (and rejected) rather than throwing here.
+ */
+export async function getMemberIdToken(): Promise {
+ if (typeof window === 'undefined') return null;
+ try {
+ const { fetchAuthSession } = await import('aws-amplify/auth');
+ const session = await fetchAuthSession();
+ return session.tokens?.idToken?.toString() || session.tokens?.accessToken?.toString() || null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/features/moderation/report/strings.ts b/src/features/moderation/report/strings.ts
new file mode 100644
index 0000000..712c51d
--- /dev/null
+++ b/src/features/moderation/report/strings.ts
@@ -0,0 +1,105 @@
+/**
+ * Member report flow copy (OneHook moderation Phase 2).
+ *
+ * The web app has no i18n framework; its existing mechanism is inline string constants co-located
+ * with the feature (see e.g. ChatView's BLOCK_REASONS, ADD_BY_USERNAME_CONFIRMATION). This module
+ * centralises the report flow copy the same way so the strings live in one reviewable place and the
+ * UI never hard-codes them inline. No message text, user ids or free text ever appears in telemetry
+ * or logs; these are static UI strings only.
+ */
+
+/** A self-harm support resource (config so it can change per region — docs/MODERATION.md §9.5). */
+export interface SupportResource {
+ name: string;
+ /** Phone number as dialled (tel: link target). */
+ phone: string;
+ region: string;
+ availability: string;
+}
+
+/** India, 24x7 — the resource surfaced for SELF_HARM_CONCERN (docs/MODERATION.md §9.5). */
+export const SELF_HARM_RESOURCES: SupportResource[] = [
+ { name: 'Tele-MANAS', phone: '14416', region: 'India', availability: '24x7' },
+];
+
+export const REPORT_STRINGS = {
+ entryLabel: 'Report',
+ blockLabel: 'Block',
+
+ dialogTitle: 'Report',
+ close: 'Close',
+ back: 'Back',
+ cancel: 'Cancel',
+ next: 'Continue',
+
+ // ── Category picker ──
+ pickerHeading: 'What happened?',
+ pickerSubheading: 'Pick the closest reason. Your report goes to our safety team.',
+ notReportableRow: "This isn't a safety issue",
+ notReportableRowHint: 'Different opinions, slow replies, who they like, and similar',
+
+ // ── Not-reportable explainer ──
+ notReportableHeading: "This isn't something we act on",
+ notReportableBody:
+ "These aren't safety issues, so reporting them won't create a case. If you'd rather not see or hear from this person, you can block or unmatch them instead.",
+ notReportableBlock: 'Block',
+ notReportableUnmatch: 'Unmatch',
+
+ // ── Self-harm support ──
+ selfHarmHeading: "If someone's in crisis, help is available",
+ selfHarmBody:
+ 'Thank you for looking out for them. This report goes to a support team, not to punishment. If you or they are in immediate danger, please contact local emergency services.',
+ selfHarmCallPrefix: 'Call',
+ selfHarmContinue: 'Continue report',
+
+ // ── Details ──
+ detailsLabel: 'Add details',
+ detailsLabelRequired: 'Add details (required)',
+ detailsPlaceholder: 'Share anything that helps our team understand what happened.',
+ detailsRequiredHint: 'Please add a short description so we can review this.',
+
+ // ── Also block ──
+ alsoBlockLabel: 'Also block this person',
+ alsoBlockHint: 'They won’t be able to contact you or see you.',
+
+ // ── Evidence ──
+ evidenceCheckboxLabel: 'Share a decrypted copy of this chat with OneHook Safety',
+ evidenceNudgeLine: 'Reports with the chat attached are resolved faster and more accurately.',
+ evidencePanelHeading: 'What gets shared',
+ evidencePanelWhat:
+ 'The last 200 messages or the last 30 days of this chat — whichever is smaller. Each message includes its time, who sent it (you or them), and the text.',
+ evidencePanelNoMedia: 'No photos or media are shared in this version.',
+ evidencePanelNotNotified: 'The other person is not told that you shared the chat.',
+ evidencePreviewButton: 'Preview what will be shared',
+ evidencePreviewHeading: 'Exactly what will be shared',
+ evidencePreviewEmpty: 'There are no messages in this chat to share.',
+ evidencePreviewYou: 'You',
+ evidencePreviewThem: 'Them',
+
+ // ── Minor safety ──
+ minorSafetyEscalated:
+ 'This report is escalated to our safety team immediately. You don’t need to attach anything.',
+
+ // ── Submit / confirmation ──
+ submit: 'Submit report',
+ submitting: 'Submitting…',
+ confirmationHeading: 'Your report was sent',
+ confirmationBody:
+ "Thanks for telling us. Our safety team reviews every report. We won't tell this person who reported them.",
+ confirmationBlocked: "You've also blocked this person, so they can no longer contact you or see you.",
+ confirmationDone: 'Done',
+
+ // ── Evidence outcome (non-blocking) ──
+ evidenceAttached: 'The chat was attached to your report.',
+ evidenceFailed: "Your report was sent. We couldn't attach the chat.",
+ evidenceRetry: 'Retry attaching the chat',
+ evidenceRetrying: 'Attaching…',
+ evidenceTerminalNote:
+ "Your report was sent. This chat can't be attached to this report, and you don't need to do anything else.",
+
+ // ── Errors ──
+ rateLimited: "You've sent a lot of reports recently. Please try again in a little while.",
+ submitError: "We couldn't send your report just now. Please try again.",
+} as const;
+
+export type ReportStrings = typeof REPORT_STRINGS;
diff --git a/src/features/moderation/report/submit.ts b/src/features/moderation/report/submit.ts
new file mode 100644
index 0000000..4a42a62
--- /dev/null
+++ b/src/features/moderation/report/submit.ts
@@ -0,0 +1,239 @@
+/**
+ * Report submit orchestration (OneHook moderation Phase 2).
+ *
+ * Files the report, then — only if the reporter opted in AND the server allows it — decrypts the
+ * chat window locally, builds the canonical bundle, and uploads it. The two hard rules:
+ *
+ * 1. The report fields are IDENTICAL whether or not evidence is shared, except `evidenceOffered`
+ * (no dark patterns: declining is treated exactly like sharing).
+ * 2. Evidence failure must NEVER fail the report: the report is already filed, so an upload error
+ * is swallowed into a non-blocking outcome the UI surfaces with a Retry action.
+ *
+ * Fully dependency-injected (report API, evidence transport, telemetry, clock) so it is unit-testable
+ * without a browser or the network.
+ */
+import {
+ ReportsApi,
+ type CreateReportRequest,
+ type CreateReportResponse,
+ type ReportCategoryCode,
+} from '../../../api/rest';
+import {
+ buildBundle,
+ canonicalByteLength,
+ canonicalBytes,
+ bundleSha256Hex,
+ hexToBase64,
+ EVIDENCE_WINDOW,
+ type BundleMessage,
+} from './evidence-bundle';
+import { selectEvidenceWindow, type SourceMessage } from './window-selection';
+import {
+ presignEvidence,
+ putEvidenceBytes,
+ completeEvidence,
+ classifyEvidenceError,
+ type EvidenceTransport,
+ type RetryOptions,
+} from './evidence-api';
+import { getMemberIdToken } from './member-auth';
+import { trackReport, ReportEvent, type ReportCategory, type ReportSource } from './telemetry';
+
+/**
+ * Outcome of the optional decrypted-chat evidence attach:
+ * - `skipped` : the reporter did not opt in.
+ * - `not_allowed` : the server said evidence is not accepted for this report (evidenceAllowed:false).
+ * - `empty` : opted in, but there was nothing in the window to share.
+ * - `attached` : evidence recorded (including a 409 EVIDENCE_ALREADY_COMPLETED / idempotent complete).
+ * - `terminal` : the server will never accept evidence here (disabled / not-offered / too-old /
+ * not-reporter) — a calm, final state with NO Retry.
+ * - `failed` : a transient failure — the report is filed, Retry is offered.
+ */
+export type EvidenceOutcome = 'skipped' | 'not_allowed' | 'empty' | 'attached' | 'failed' | 'terminal';
+
+export interface ReportSubmission {
+ reportedUserId: string;
+ category: ReportCategory;
+ details?: string;
+ alsoBlock: boolean;
+ /** Present only when the flow started from a chat (used as the bundle conversationId too). */
+ conversationId?: string;
+ source: ReportSource;
+ /** The evidence checkbox value. Never pre-ticked; only possible from a chat on an eligible category. */
+ shareEvidence: boolean;
+ /** Decrypted source messages for the evidence window (only used when shareEvidence). */
+ messages?: SourceMessage[];
+ includesMedia?: boolean;
+}
+
+export interface SubmitDeps {
+ createReport?: (body: CreateReportRequest) => Promise;
+ evidence?: EvidenceTransport;
+ evidenceRetry?: RetryOptions;
+ telemetry?: typeof trackReport;
+ nowMs?: () => number;
+}
+
+export interface SubmitResult {
+ reportId: string;
+ severity: string;
+ evidenceAllowed: boolean;
+ blocked: boolean;
+ evidenceOutcome: EvidenceOutcome;
+}
+
+function defaultEvidenceTransport(): EvidenceTransport {
+ return { getToken: getMemberIdToken };
+}
+
+/** Build the exact report body. Only `evidenceOffered` ever varies with the checkbox. */
+export function buildReportBody(sub: ReportSubmission): CreateReportRequest {
+ const details = sub.details?.trim();
+ const body: CreateReportRequest = {
+ reportedUserId: sub.reportedUserId,
+ category: sub.category as ReportCategoryCode,
+ alsoBlock: sub.alsoBlock,
+ evidenceOffered: sub.shareEvidence,
+ };
+ if (details) body.details = details;
+ if (sub.conversationId) body.conversationId = sub.conversationId;
+ return body;
+}
+
+/**
+ * Upload the decrypted-chat evidence for an already-filed report. Builds the window + canonical
+ * bundle, hashes it, presigns, PUTs the exact bytes, and completes (with 503 retry).
+ *
+ * Returns the outcome:
+ * - `'attached'` : recorded (also when presign returns 409 EVIDENCE_ALREADY_COMPLETED, or complete
+ * is idempotent — the evidence already exists server-side).
+ * - `'empty'` : there was nothing in the window to share.
+ * - `'terminal'` : the server will never accept evidence here — the caller hides Retry.
+ *
+ * Throws only on a transient/retryable failure (network, 5xx, S3 PUT rejection, exhausted finalize
+ * retry); the caller must NOT let that fail the already-filed report, and may offer Retry.
+ */
+export async function uploadEvidence(
+ reportId: string,
+ conversationId: string,
+ messages: SourceMessage[],
+ deps: SubmitDeps = {}
+): Promise> {
+ const nowMs = deps.nowMs ?? (() => Date.now());
+ const transport = deps.evidence ?? defaultEvidenceTransport();
+ const includesMedia = false; // no media in this release (docs/MODERATION.md §9.3)
+
+ const window: BundleMessage[] = selectEvidenceWindow(messages, {
+ maxMessages: EVIDENCE_WINDOW.maxMessages,
+ maxDays: EVIDENCE_WINDOW.maxDays,
+ nowMs: nowMs(),
+ });
+ if (window.length === 0) return 'empty';
+
+ try {
+ const bundle = buildBundle({
+ reportId,
+ conversationId,
+ messages: window,
+ attachments: [],
+ });
+ const sha256 = await bundleSha256Hex(bundle);
+ const sizeBytes = canonicalByteLength(bundle);
+ const bytes = canonicalBytes(bundle);
+
+ const presign = await presignEvidence(transport, {
+ reportId,
+ sizeBytes,
+ sha256,
+ messageCount: window.length,
+ includesMedia,
+ });
+
+ // The presigned PUT requires the exact checksum header; if the server didn't echo it, compute it
+ // from our hash so S3's enforcement still matches our bytes.
+ const headers = { ...presign.upload.headers };
+ if (!headers['x-amz-checksum-sha256']) headers['x-amz-checksum-sha256'] = hexToBase64(sha256);
+
+ await putEvidenceBytes(transport.fetchImpl, { ...presign.upload, headers }, bytes);
+
+ const messageIds = window.map((m) => m.messageId);
+ const firstSentAt = Date.parse(window[0].sentAt);
+ const lastSentAt = Date.parse(window[window.length - 1].sentAt);
+
+ // `complete` is idempotent on evidenceId: a success (idempotent:true or false) means the evidence
+ // is recorded, i.e. attached.
+ await completeEvidence(
+ transport,
+ { reportId, evidenceId: presign.evidenceId, messageIds, firstSentAt, lastSentAt },
+ deps.evidenceRetry
+ );
+ return 'attached';
+ } catch (err) {
+ // 409 EVIDENCE_ALREADY_COMPLETED => attached; terminal codes => terminal (no Retry). Anything
+ // else is transient and rethrows so the caller can offer Retry.
+ const classified = classifyEvidenceError(err);
+ if (classified) return classified;
+ throw err;
+ }
+}
+
+/**
+ * File the report and, when applicable, attach the decrypted chat. Report failures throw (the UI
+ * shows a friendly message, handling 429 specially). Evidence failures never throw — they are
+ * reported through `evidenceOutcome: 'failed'`.
+ */
+export async function submitReport(sub: ReportSubmission, deps: SubmitDeps = {}): Promise {
+ const createReport = deps.createReport ?? ((body) => ReportsApi.create(body));
+ const emit = deps.telemetry ?? trackReport;
+
+ const evidenceOffered = sub.shareEvidence;
+
+ // File the report FIRST. Telemetry for the submit outcome is emitted only once the result is
+ // known: submit_with/without_evidence after the report is created (201), submit_failed otherwise.
+ let response: CreateReportResponse;
+ try {
+ response = await createReport(buildReportBody(sub));
+ } catch (err) {
+ emit(ReportEvent.REPORT_SUBMIT_FAILED, { category: sub.category, source: sub.source });
+ throw err;
+ }
+
+ emit(evidenceOffered ? ReportEvent.REPORT_SUBMIT_WITH_EVIDENCE : ReportEvent.REPORT_SUBMIT_WITHOUT_EVIDENCE, {
+ category: sub.category,
+ source: sub.source,
+ evidence: evidenceOffered,
+ });
+
+ const result: SubmitResult = {
+ reportId: response.reportId,
+ severity: response.severity,
+ evidenceAllowed: response.evidenceAllowed,
+ blocked: sub.alsoBlock,
+ evidenceOutcome: 'skipped',
+ };
+
+ if (!evidenceOffered) return result;
+ if (!response.evidenceAllowed) {
+ result.evidenceOutcome = 'not_allowed';
+ return result;
+ }
+
+ try {
+ result.evidenceOutcome = await uploadEvidence(
+ response.reportId,
+ sub.conversationId ?? '',
+ sub.messages ?? [],
+ deps
+ );
+ // Only a genuine attach is a telemetry success; 'empty'/'terminal' uploaded nothing and are
+ // not failures either, so they emit no upload event.
+ if (result.evidenceOutcome === 'attached') {
+ emit(ReportEvent.REPORT_EVIDENCE_UPLOAD_SUCCEEDED, { category: sub.category, source: sub.source });
+ }
+ } catch {
+ // Evidence failure must NOT fail the report — it is already filed.
+ result.evidenceOutcome = 'failed';
+ emit(ReportEvent.REPORT_EVIDENCE_UPLOAD_FAILED, { category: sub.category, source: sub.source });
+ }
+ return result;
+}
diff --git a/src/features/moderation/report/telemetry.ts b/src/features/moderation/report/telemetry.ts
new file mode 100644
index 0000000..d4c85c2
--- /dev/null
+++ b/src/features/moderation/report/telemetry.ts
@@ -0,0 +1,47 @@
+/**
+ * Report flow telemetry (OneHook moderation Phase 2).
+ *
+ * The report flow mixes button presses, "shown" impressions and upload outcomes, so the backend
+ * catalog models it as a dedicated `report_flow` SIGNAL (not `ui_interaction`) — see the ReportEvent
+ * doc in the generated catalog. This helper is the ONLY place the flow emits telemetry.
+ *
+ * HARD PRIVACY RULE: a report_flow signal may carry ONLY these scalar dimensions:
+ * - event : the ReportEvent id (which step)
+ * - category : the ReportCategory code (never free text)
+ * - source : chat | profile (where the flow started)
+ * - evidence : boolean (whether the decrypted chat was offered), on submit
+ * It MUST NEVER carry message text, user ids, the reported person, or the report's free-text
+ * details. `buildReportSignalAttributes` is pure and whitelists exactly those keys so a mistake is
+ * caught by a unit test, and the analytics client additionally strips any denylisted key.
+ */
+import { track, ReportEvent, type ReportCategory, type ReportSource } from '../../../lib/analytics/analytics';
+
+export { ReportEvent };
+export type { ReportCategory, ReportSource };
+
+export interface ReportTelemetryAttrs {
+ category?: ReportCategory;
+ source?: ReportSource;
+ evidence?: boolean;
+}
+
+/**
+ * Build the scalar attribute bag for a report_flow signal. Only the whitelisted dimensions survive;
+ * `undefined` values are omitted. Returned values are always enum strings or a boolean — never free
+ * text, user ids or message content.
+ */
+export function buildReportSignalAttributes(
+ event: ReportEvent,
+ attrs: ReportTelemetryAttrs = {}
+): Record {
+ const out: Record = { event };
+ if (attrs.category !== undefined) out.category = attrs.category;
+ if (attrs.source !== undefined) out.source = attrs.source;
+ if (attrs.evidence !== undefined) out.evidence = attrs.evidence;
+ return out;
+}
+
+/** Emit one report_flow signal. No-op unless analytics is enabled + consented (handled downstream). */
+export function trackReport(event: ReportEvent, attrs: ReportTelemetryAttrs = {}): void {
+ track('report_flow', buildReportSignalAttributes(event, attrs));
+}
diff --git a/src/features/moderation/report/window-loader.ts b/src/features/moderation/report/window-loader.ts
new file mode 100644
index 0000000..0ca5329
--- /dev/null
+++ b/src/features/moderation/report/window-loader.ts
@@ -0,0 +1,86 @@
+/**
+ * Evidence-window paging (OneHook moderation Phase 2, docs/MODERATION.md §2, §9.3).
+ *
+ * The AppSync `getMessages(matchId: ID!, after: AWSTimestamp): [Message]` resolver (verified
+ * read-only in OneHookBackend `packages/chat/infra/resolvers/get-messages.js`) is a DynamoDB
+ * `Query` keyed `matchId = :matchId AND timestamp > :after`, `scanIndexForward: true`. It returns
+ * `ctx.result.items` — a SINGLE DynamoDB page (the ~1 MB service limit), oldest-first — and exposes
+ * no `limit` and no `nextToken`. So:
+ *
+ * - a single call is NOT guaranteed complete for a long conversation, and
+ * - because it returns OLDEST-first, a single call can omit the MOST RECENT messages — exactly the
+ * ones the evidence window (last 200 / last 30 days) needs.
+ *
+ * The resolver DOES page: passing `after = ` returns the next slice. This helper
+ * therefore seeds the cursor at the 30-day cutoff (so we only read the window, not all history) and
+ * pages FORWARD until a page is empty, the cursor stops advancing, or a hard page cap is hit. The
+ * 200-message cap is applied downstream by `selectEvidenceWindow`, which keeps the most-recent tail
+ * of whatever this returns.
+ *
+ * Pure and fully injectable (no network, no clock) so it is unit-testable.
+ */
+import { EVIDENCE_WINDOW } from './evidence-bundle';
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+export interface CollectWindowDeps {
+ /** Fetch one page of messages with `timestamp > after` (oldest-first). `after` undefined/0 = from start. */
+ fetchPage: (after: number | undefined) => Promise;
+ /** Epoch-ms timestamp of an item (the chat stores `Date.now()` ms; the cursor unit matches). */
+ getTimestamp: (item: T) => number;
+ /** Stable id of an item, used to de-duplicate across page boundaries. */
+ getId: (item: T) => string;
+}
+
+export interface CollectWindowOptions {
+ nowMs?: number;
+ /** Window length in days (default 30). */
+ maxDays?: number;
+ /** Hard safety cap on page fetches so the loop is always bounded (default 50). */
+ maxPages?: number;
+}
+
+/**
+ * Page the message history forward until the whole evidence window (last `maxDays`) is covered or a
+ * cap bites. Returns the collected items oldest-first, de-duplicated by id. The caller still applies
+ * the 200-message / 30-day selection.
+ */
+export async function collectWindowPages(
+ deps: CollectWindowDeps,
+ opts: CollectWindowOptions = {}
+): Promise {
+ const nowMs = opts.nowMs ?? Date.now();
+ const maxDays = opts.maxDays ?? EVIDENCE_WINDOW.maxDays;
+ const maxPages = opts.maxPages ?? 50;
+
+ // Seed strictly after the window cutoff so we read only the last `maxDays`, not all history. The
+ // resolver treats any `after <= 0` as "from the start", so a non-positive cutoff reads everything.
+ const cutoff = Math.max(0, nowMs - maxDays * MS_PER_DAY);
+
+ const collected: T[] = [];
+ const seen = new Set();
+ let after: number | undefined = cutoff > 0 ? cutoff : undefined;
+
+ for (let page = 0; page < maxPages; page++) {
+ const items = await deps.fetchPage(after);
+ if (!items || items.length === 0) break;
+
+ let maxTs = after ?? Number.NEGATIVE_INFINITY;
+ for (const item of items) {
+ const id = deps.getId(item);
+ if (!seen.has(id)) {
+ seen.add(id);
+ collected.push(item);
+ }
+ const ts = deps.getTimestamp(item);
+ if (ts > maxTs) maxTs = ts;
+ }
+
+ // Advance the cursor to the newest timestamp this page returned. If it did not advance past the
+ // current cursor, there is nothing further to page (and we must not loop forever).
+ if (after !== undefined && maxTs <= after) break;
+ after = maxTs;
+ }
+
+ return collected;
+}
diff --git a/src/features/moderation/report/window-selection.ts b/src/features/moderation/report/window-selection.ts
new file mode 100644
index 0000000..8703adf
--- /dev/null
+++ b/src/features/moderation/report/window-selection.ts
@@ -0,0 +1,99 @@
+/**
+ * Evidence window selection (OneHook moderation Phase 2, docs/MODERATION.md §2, §9.3).
+ *
+ * Builds the exact set of messages that will be shared as decrypted evidence: the most recent 200
+ * messages within the last 30 days (whichever cap bites first), oldest first in the bundle (per the
+ * canonical sort rule). System and undecryptable messages are skipped; messages from other
+ * conversations are never seen here because the caller only ever passes ONE conversation's history.
+ *
+ * Pure and deterministic so it can be unit-tested without a browser or the network. `nowMs` is
+ * injected (defaults to Date.now) so the 30-day cutoff is testable.
+ */
+import type { BundleMessage, SenderSide } from './evidence-bundle';
+import { EVIDENCE_WINDOW, sortMessages } from './evidence-bundle';
+
+/** A single decrypted source message as the chat UI already holds it (via the E2EE hook). */
+export interface SourceMessage {
+ messageId: string;
+ /** Epoch milliseconds the message was sent. */
+ sentAtMs: number;
+ senderSide: SenderSide;
+ /**
+ * Decrypted plaintext, or null for a message that must be skipped: a system message, an
+ * attachment/media-only message (no text in this release), or anything without a text body.
+ */
+ text: string | null;
+ /** True when the envelope could not be decrypted on this device; such messages are skipped. */
+ undecryptable?: boolean;
+}
+
+export interface WindowOptions {
+ maxMessages?: number;
+ maxDays?: number;
+ nowMs?: number;
+}
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+/** ISO-8601 UTC with millisecond precision, e.g. "2026-10-26T12:34:56.789Z". */
+export function toIsoUtcMillis(epochMs: number): string {
+ return new Date(epochMs).toISOString();
+}
+
+/**
+ * Select and map the evidence window.
+ *
+ * 1. Drop undecryptable messages and messages with no text body (system / media-only).
+ * 2. Drop anything older than `maxDays` before `nowMs` (30-day cap).
+ * 3. Keep only the most recent `maxMessages` (200-message cap).
+ * 4. Return them oldest-first, canonically sorted (sentAt then messageId), mapped to BundleMessage.
+ */
+export function selectEvidenceWindow(
+ messages: readonly SourceMessage[],
+ opts: WindowOptions = {}
+): BundleMessage[] {
+ const maxMessages = opts.maxMessages ?? EVIDENCE_WINDOW.maxMessages;
+ const maxDays = opts.maxDays ?? EVIDENCE_WINDOW.maxDays;
+ const nowMs = opts.nowMs ?? Date.now();
+ const cutoffMs = nowMs - maxDays * MS_PER_DAY;
+
+ const eligible = messages.filter(
+ (m) =>
+ !m.undecryptable &&
+ typeof m.text === 'string' &&
+ !isFramedNonText(m.text) &&
+ m.sentAtMs >= cutoffMs
+ );
+
+ // Keep the most recent `maxMessages`: order by (sentAt, messageId) and take the tail.
+ const ordered = [...eligible].sort((a, b) => {
+ if (a.sentAtMs !== b.sentAtMs) return a.sentAtMs - b.sentAtMs;
+ if (a.messageId < b.messageId) return -1;
+ if (a.messageId > b.messageId) return 1;
+ return 0;
+ });
+ const windowed = ordered.length > maxMessages ? ordered.slice(ordered.length - maxMessages) : ordered;
+
+ const mapped: BundleMessage[] = windowed.map((m) => ({
+ messageId: m.messageId,
+ sentAt: toIsoUtcMillis(m.sentAtMs),
+ senderSide: m.senderSide,
+ text: m.text as string,
+ }));
+
+ // Canonical oldest-first order (sentAt then messageId by UTF-16 code unit).
+ return sortMessages(mapped);
+}
+
+/**
+ * Defence in depth: chat plaintext uses C0-control framing for structured payloads (U+0001
+ * attachment envelope with the media key, U+0002 call signal with SDP/ICE). Such a payload must
+ * never enter an evidence bundle even if an upstream mapper let it through. Tab, newline and
+ * carriage return are ordinary text.
+ */
+export function isFramedNonText(text: string): boolean {
+ if (text.length === 0) return false;
+ if (text.includes('\u0001') || text.includes('\u0002')) return true;
+ const c = text.charCodeAt(0);
+ return c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d;
+}
diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts
index fdf8c1b..fadb5a4 100644
--- a/src/hooks/use-api.ts
+++ b/src/hooks/use-api.ts
@@ -29,10 +29,14 @@ import {
encodeKeyMaterial,
encryptAttachment,
frameAttachment,
+ decodeMessagePlaintext,
type AttachmentEnvelope,
type AttachmentKind,
} from '../lib/chat-attachments';
import { ChatApi } from '../api/chat';
+import type { ChatMessageExtended } from '../api/chat';
+import { collectWindowPages } from '../features/moderation/report/window-loader';
+import type { SourceMessage } from '../features/moderation/report/window-selection';
import {
Coordinates,
isValidCoordinate,
@@ -574,6 +578,44 @@ export function useChatMessages(matchId: string, recipientId?: string) {
}
}, [matchId, myId, decryptInbound, encryptionManager, recipientId]);
+ // Page the FULL evidence window (last 200 messages / 30 days — docs/MODERATION.md §2) for the
+ // optional decrypted-chat share. `getMessages` returns one oldest-first DynamoDB page per call, so
+ // the live `messages` snapshot (a single page) can omit the most recent messages in a long chat.
+ // This pages forward to completeness (capped) and decrypts locally with the SAME path the UI uses,
+ // so the window the reporter previews/uploads is correct. Nothing leaves the device here.
+ const loadEvidenceWindow = useCallback(async (): Promise => {
+ if (!matchId) return [];
+ const raw = await collectWindowPages(
+ {
+ fetchPage: (after) => ChatMessagingApi.getMessages(matchId, after),
+ getTimestamp: (m) => Number(m.timestamp),
+ getId: (m) => String(m.messageId),
+ },
+ {}
+ );
+ const out: SourceMessage[] = [];
+ for (const m of raw) {
+ const decrypted = await decryptInbound(m.ciphertext);
+ let text: string | null = null;
+ if (!decrypted.undecryptable) {
+ try {
+ const decoded = decodeMessagePlaintext(decrypted.text);
+ text = decoded.type === 'attachment' ? null : decoded.text;
+ } catch {
+ text = null;
+ }
+ }
+ out.push({
+ messageId: m.messageId,
+ sentAtMs: Number(m.timestamp),
+ senderSide: m.senderId === myId ? 'reporter' : 'reported',
+ text,
+ undecryptable: decrypted.undecryptable,
+ });
+ }
+ return out;
+ }, [matchId, myId, decryptInbound]);
+
useEffect(() => {
void fetchMessages();
}, [fetchMessages]);
@@ -862,6 +904,8 @@ export function useChatMessages(matchId: string, recipientId?: string) {
markAsDelivered,
markAsRead,
refetch: fetchMessages,
+ /** Page + decrypt the FULL evidence window (last 200 / 30 days) for the optional chat share. */
+ loadEvidenceWindow,
/** True when at least one message needs a history key this device does not have yet. */
hasUndecryptable: messages.some((m) => m.undecryptable),
/** Epoch start after a reset; messages older than this are permanently unreadable here. */
diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts
index 2b36fa9..b582c2f 100644
--- a/src/lib/analytics/generated/telemetryCatalog.ts
+++ b/src/lib/analytics/generated/telemetryCatalog.ts
@@ -1,15 +1,15 @@
// GENERATED FILE - DO NOT EDIT.
-// telemetry-integrity: sha256 d056181449f8f1338ba1f67fcca18da6873ef5d1ddecc63a861f39cdd2a8f286
+// telemetry-integrity: sha256 ba978d01fe927413a36fd8f2b3d8d595ed4c72a84bc4ad1c5a9e880a72aa4c97
// Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts
// Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync
-// Catalog version 1.5.0, hash 4ab81c4d5f20cdb7.
+// Catalog version 1.7.0, hash 4a3328962531236a.
//
// HARD RULE: every user interaction MUST emit ui_interaction with an id from THIS file, and
// ids are controlled by the backend. Read TELEMETRY_CONTRACT.txt (printed by every build).
/* eslint-disable */
-export const TELEMETRY_CATALOG_VERSION = "1.5.0";
-export const TELEMETRY_CATALOG_HASH = "4ab81c4d5f20cdb7";
+export const TELEMETRY_CATALOG_VERSION = "1.7.0";
+export const TELEMETRY_CATALOG_HASH = "4a3328962531236a";
/** Every signal type the ingest accepts. */
export const SignalType = {
@@ -31,6 +31,7 @@ export const SignalType = {
HOOKED_STATE_VIEW: "hooked_state_view",
LIKES_YOU_VIEW: "likes_you_view",
SAFETY_UI_OPEN: "safety_ui_open",
+ REPORT_FLOW: "report_flow",
} as const;
export type SignalType = (typeof SignalType)[keyof typeof SignalType];
@@ -212,6 +213,48 @@ export const HookedStateAction = {
} as const;
export type HookedStateAction = (typeof HookedStateAction)[keyof typeof HookedStateAction];
+/** Member-report reason category the user chose. Mirrors the backend ReportCategory taxonomy (packages/matching ReportCategory / MODERATION.md section 1) so telemetry segments by the same codes the report API records. Carries no free text. */
+export const ReportCategory = {
+ HARASSMENT: "HARASSMENT",
+ THREATS_VIOLENCE: "THREATS_VIOLENCE",
+ SEXUAL_CONTENT_NONCONSENT: "SEXUAL_CONTENT_NONCONSENT",
+ MINOR_SAFETY: "MINOR_SAFETY",
+ SCAM_FRAUD: "SCAM_FRAUD",
+ FAKE_IMPERSONATION: "FAKE_IMPERSONATION",
+ SPAM_PROMOTION: "SPAM_PROMOTION",
+ HATE: "HATE",
+ SELF_HARM_CONCERN: "SELF_HARM_CONCERN",
+ OFF_PLATFORM_HARM: "OFF_PLATFORM_HARM",
+ OTHER: "OTHER",
+} as const;
+export type ReportCategory = (typeof ReportCategory)[keyof typeof ReportCategory];
+
+/** Where in the app the member started the report flow from (not the reported content). */
+export const ReportSource = {
+ CHAT: "chat",
+ PROFILE: "profile",
+} as const;
+export type ReportSource = (typeof ReportSource)[keyof typeof ReportSource];
+
+/** Step in the member-report flow a report_flow signal records. The flow mixes presses, "shown" impressions and upload outcomes, so it is a signal (not a ui_interaction). Carries only the ReportCategory / ReportSource / evidence dimensions — never message text, user ids or report free text. */
+export const ReportEvent = {
+ REPORT_OPEN: "report.open",
+ REPORT_CATEGORY_SELECTED: "report.category_selected",
+ REPORT_NOT_REPORTABLE_SHOWN: "report.not_reportable_shown",
+ REPORT_NOT_REPORTABLE_BLOCK: "report.not_reportable_block",
+ REPORT_SELF_HARM_RESOURCES_SHOWN: "report.self_harm_resources_shown",
+ REPORT_EVIDENCE_TOGGLE: "report.evidence_toggle",
+ REPORT_ATTACHMENTS_TOGGLE: "report.attachments_toggle",
+ REPORT_EVIDENCE_PREVIEW: "report.evidence_preview",
+ REPORT_NUDGE_SHOWN: "report.nudge_shown",
+ REPORT_SUBMIT_WITH_EVIDENCE: "report.submit_with_evidence",
+ REPORT_SUBMIT_WITHOUT_EVIDENCE: "report.submit_without_evidence",
+ REPORT_SUBMIT_FAILED: "report.submit_failed",
+ REPORT_EVIDENCE_UPLOAD_SUCCEEDED: "report.evidence_upload_succeeded",
+ REPORT_EVIDENCE_UPLOAD_FAILED: "report.evidence_upload_failed",
+} as const;
+export type ReportEvent = (typeof ReportEvent)[keyof typeof ReportEvent];
+
/** Registered UI element ids (`.`). Use with trackUi / data-track-id. */
export const UiElement = {
NAV_TAB_SELECT: "nav.tab_select",
@@ -318,6 +361,8 @@ export const UiElement = {
CHAT_PROFILE_OPEN: "chat.profile_open",
CHAT_SAFETY_MENU_OPEN: "chat.safety_menu_open",
CHAT_UNMATCH: "chat.unmatch",
+ CHAT_REPORT_OPEN: "chat.report_open",
+ CHAT_BLOCK_OPEN: "chat.block_open",
CHAT_REPORT_SPAM: "chat.report_spam",
CHAT_REPORT_BLOCK: "chat.report_block",
CHAT_BLOCK_CONFIRM: "chat.block_confirm",
@@ -572,8 +617,10 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = {
"chat.profile_open": { screen: "CHAT", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] },
"chat.safety_menu_open": { screen: "CHAT", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] },
"chat.unmatch": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: ["ios", "android"] },
- "chat.report_spam": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
- "chat.report_block": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
+ "chat.report_open": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
+ "chat.block_open": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
+ "chat.report_spam": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: [] },
+ "chat.report_block": { screen: "CHAT", kind: "menu_item", actions: ["tap"], values: null, platforms: [] },
"chat.block_confirm": { screen: "CHAT", kind: "dialog_button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] },
"chat.settings_open": { screen: "CHAT", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios"] },
"chat.wallpaper_open": { screen: "CHAT", kind: "menu_item", actions: ["tap", "dismiss"], values: null, platforms: ["web"] },
diff --git a/src/tests/fixtures/moderation/bundle-basic.json b/src/tests/fixtures/moderation/bundle-basic.json
new file mode 100644
index 0000000..95aa21b
--- /dev/null
+++ b/src/tests/fixtures/moderation/bundle-basic.json
@@ -0,0 +1,50 @@
+{
+ "note": "Two ASCII messages; input order reversed, canonical sort puts m1 (earlier sentAt) first.",
+ "input": {
+ "reportId": "rep_01HZ",
+ "conversationId": "conv_42",
+ "messages": [
+ {
+ "messageId": "m2",
+ "sentAt": "2026-10-26T12:00:01.000Z",
+ "senderSide": "reported",
+ "text": "stop messaging me"
+ },
+ {
+ "messageId": "m1",
+ "sentAt": "2026-10-26T12:00:00.000Z",
+ "senderSide": "reporter",
+ "text": "hello"
+ }
+ ]
+ },
+ "bundle": {
+ "v": 1,
+ "reportId": "rep_01HZ",
+ "conversationId": "conv_42",
+ "window": {
+ "maxMessages": 200,
+ "maxDays": 30
+ },
+ "messages": [
+ {
+ "messageId": "m1",
+ "sentAt": "2026-10-26T12:00:00.000Z",
+ "senderSide": "reporter",
+ "text": "hello"
+ },
+ {
+ "messageId": "m2",
+ "sentAt": "2026-10-26T12:00:01.000Z",
+ "senderSide": "reported",
+ "text": "stop messaging me"
+ }
+ ],
+ "attachments": []
+ },
+ "canonical": "{\"v\":1,\"reportId\":\"rep_01HZ\",\"conversationId\":\"conv_42\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[{\"messageId\":\"m1\",\"sentAt\":\"2026-10-26T12:00:00.000Z\",\"senderSide\":\"reporter\",\"text\":\"hello\"},{\"messageId\":\"m2\",\"sentAt\":\"2026-10-26T12:00:01.000Z\",\"senderSide\":\"reported\",\"text\":\"stop messaging me\"}],\"attachments\":[]}",
+ "canonicalByteLength": 328,
+ "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f3031485a222c22636f6e766572736174696f6e4964223a22636f6e765f3432222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b7b226d6573736167654964223a226d31222c2273656e744174223a22323032362d31302d32365431323a30303a30302e3030305a222c2273656e64657253696465223a227265706f72746572222c2274657874223a2268656c6c6f227d2c7b226d6573736167654964223a226d32222c2273656e744174223a22323032362d31302d32365431323a30303a30312e3030305a222c2273656e64657253696465223a227265706f72746564222c2274657874223a2273746f70206d6573736167696e67206d65227d5d2c226174746163686d656e7473223a5b5d7d",
+ "sha256Hex": "d0cd2fdab27cee978781a8b341d0fa5f4fffb4da7e483c00c9848d10dd662998",
+ "sha256Base64": "0M0v2rJ87peHgaizQdD6X0//tNp+SDwAyYSNEN1mKZg="
+}
diff --git a/src/tests/fixtures/moderation/bundle-empty.json b/src/tests/fixtures/moderation/bundle-empty.json
new file mode 100644
index 0000000..6723e8d
--- /dev/null
+++ b/src/tests/fixtures/moderation/bundle-empty.json
@@ -0,0 +1,24 @@
+{
+ "note": "No messages: empty messages[] and attachments[], default window.",
+ "input": {
+ "reportId": "rep_empty",
+ "conversationId": "conv_empty",
+ "messages": []
+ },
+ "bundle": {
+ "v": 1,
+ "reportId": "rep_empty",
+ "conversationId": "conv_empty",
+ "window": {
+ "maxMessages": 200,
+ "maxDays": 30
+ },
+ "messages": [],
+ "attachments": []
+ },
+ "canonical": "{\"v\":1,\"reportId\":\"rep_empty\",\"conversationId\":\"conv_empty\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[],\"attachments\":[]}",
+ "canonicalByteLength": 133,
+ "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f656d707479222c22636f6e766572736174696f6e4964223a22636f6e765f656d707479222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b5d2c226174746163686d656e7473223a5b5d7d",
+ "sha256Hex": "256838f9f46c1e37e0e1c2936d70c5956f1ae40ac34a8cb4515c9d744660a1e5",
+ "sha256Base64": "JWg4+fRsHjfg4cKTbXDFlW8a5ArDSoy0UVyddEZgoeU="
+}
diff --git a/src/tests/fixtures/moderation/bundle-escaping.json b/src/tests/fixtures/moderation/bundle-escaping.json
new file mode 100644
index 0000000..bd3e569
--- /dev/null
+++ b/src/tests/fixtures/moderation/bundle-escaping.json
@@ -0,0 +1,38 @@
+{
+ "note": "Locks escaping: escape only \" \\ and control<0x20 (\\b\\f\\n\\r\\t else \\u00XX lowercase); never escape / or non-ASCII or DEL.",
+ "input": {
+ "reportId": "rep_esc",
+ "conversationId": "conv_esc",
+ "messages": [
+ {
+ "messageId": "m1",
+ "sentAt": "2026-10-26T00:00:00.000Z",
+ "senderSide": "reporter",
+ "text": "quote:\" backslash:\\ slash:/ tab:\t newline:\n ctrl:\u0001 del: emoji:😀 accent:café"
+ }
+ ]
+ },
+ "bundle": {
+ "v": 1,
+ "reportId": "rep_esc",
+ "conversationId": "conv_esc",
+ "window": {
+ "maxMessages": 200,
+ "maxDays": 30
+ },
+ "messages": [
+ {
+ "messageId": "m1",
+ "sentAt": "2026-10-26T00:00:00.000Z",
+ "senderSide": "reporter",
+ "text": "quote:\" backslash:\\ slash:/ tab:\t newline:\n ctrl:\u0001 del: emoji:😀 accent:café"
+ }
+ ],
+ "attachments": []
+ },
+ "canonical": "{\"v\":1,\"reportId\":\"rep_esc\",\"conversationId\":\"conv_esc\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[{\"messageId\":\"m1\",\"sentAt\":\"2026-10-26T00:00:00.000Z\",\"senderSide\":\"reporter\",\"text\":\"quote:\\\" backslash:\\\\ slash:/ tab:\\t newline:\\n ctrl:\\u0001 del: emoji:😀 accent:café\"}],\"attachments\":[]}",
+ "canonicalByteLength": 306,
+ "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f657363222c22636f6e766572736174696f6e4964223a22636f6e765f657363222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b7b226d6573736167654964223a226d31222c2273656e744174223a22323032362d31302d32365430303a30303a30302e3030305a222c2273656e64657253696465223a227265706f72746572222c2274657874223a2271756f74653a5c22206261636b736c6173683a5c5c20736c6173683a2f207461623a5c74206e65776c696e653a5c6e206374726c3a5c75303030312064656c3a7f20656d6f6a693af09f988020616363656e743a636166c3a9227d5d2c226174746163686d656e7473223a5b5d7d",
+ "sha256Hex": "89206a07737afb492a63295099ced43b3b84baa6893f293377e99aaedc2d64bf",
+ "sha256Base64": "iSBqB3N6+0kqYylQmc7UOzuEuqaJPykzd+martwtZL8="
+}
diff --git a/src/tests/fixtures/moderation/bundle-tiebreak-media.json b/src/tests/fixtures/moderation/bundle-tiebreak-media.json
new file mode 100644
index 0000000..da0a63a
--- /dev/null
+++ b/src/tests/fixtures/moderation/bundle-tiebreak-media.json
@@ -0,0 +1,57 @@
+{
+ "note": "Equal sentAt tie-broken by messageId (a before b); attachments present (media opt-in).",
+ "input": {
+ "reportId": "rep_media",
+ "conversationId": "conv_media",
+ "messages": [
+ {
+ "messageId": "b",
+ "sentAt": "2026-10-26T09:30:00.500Z",
+ "senderSide": "reported",
+ "text": "two"
+ },
+ {
+ "messageId": "a",
+ "sentAt": "2026-10-26T09:30:00.500Z",
+ "senderSide": "reporter",
+ "text": "one"
+ }
+ ],
+ "attachments": [
+ "att_0001",
+ "att_0002"
+ ]
+ },
+ "bundle": {
+ "v": 1,
+ "reportId": "rep_media",
+ "conversationId": "conv_media",
+ "window": {
+ "maxMessages": 200,
+ "maxDays": 30
+ },
+ "messages": [
+ {
+ "messageId": "a",
+ "sentAt": "2026-10-26T09:30:00.500Z",
+ "senderSide": "reporter",
+ "text": "one"
+ },
+ {
+ "messageId": "b",
+ "sentAt": "2026-10-26T09:30:00.500Z",
+ "senderSide": "reported",
+ "text": "two"
+ }
+ ],
+ "attachments": [
+ "att_0001",
+ "att_0002"
+ ]
+ },
+ "canonical": "{\"v\":1,\"reportId\":\"rep_media\",\"conversationId\":\"conv_media\",\"window\":{\"maxMessages\":200,\"maxDays\":30},\"messages\":[{\"messageId\":\"a\",\"sentAt\":\"2026-10-26T09:30:00.500Z\",\"senderSide\":\"reporter\",\"text\":\"one\"},{\"messageId\":\"b\",\"sentAt\":\"2026-10-26T09:30:00.500Z\",\"senderSide\":\"reported\",\"text\":\"two\"}],\"attachments\":[\"att_0001\",\"att_0002\"]}",
+ "canonicalByteLength": 335,
+ "canonicalBytesHex": "7b2276223a312c227265706f72744964223a227265705f6d65646961222c22636f6e766572736174696f6e4964223a22636f6e765f6d65646961222c2277696e646f77223a7b226d61784d65737361676573223a3230302c226d617844617973223a33307d2c226d65737361676573223a5b7b226d6573736167654964223a2261222c2273656e744174223a22323032362d31302d32365430393a33303a30302e3530305a222c2273656e64657253696465223a227265706f72746572222c2274657874223a226f6e65227d2c7b226d6573736167654964223a2262222c2273656e744174223a22323032362d31302d32365430393a33303a30302e3530305a222c2273656e64657253696465223a227265706f72746564222c2274657874223a2274776f227d5d2c226174746163686d656e7473223a5b226174745f30303031222c226174745f30303032225d7d",
+ "sha256Hex": "975a5b39f428c09269cee0198040a6cc8a5c4387d5b4f86ebbd4dc541f29a870",
+ "sha256Base64": "l1pbOfQowJJpzuAZgECmzIpcQ4fVtPhuu9TcVB8pqHA="
+}
diff --git a/src/tests/report-dialog-logic.test.ts b/src/tests/report-dialog-logic.test.ts
new file mode 100644
index 0000000..6452954
--- /dev/null
+++ b/src/tests/report-dialog-logic.test.ts
@@ -0,0 +1,137 @@
+/**
+ * Report dialog behaviour (OneHook moderation Phase 2, web).
+ *
+ * This repo has NO DOM test environment installed (no jsdom / happy-dom, transitively or otherwise),
+ * so the dialog's behaviour is verified through the PURE decision module the component uses
+ * (dialog-logic.ts) rather than by rendering React. Each case below maps to a required behaviour:
+ * - the evidence checkbox is NEVER pre-ticked;
+ * - Submit is enabled whether or not evidence is shared;
+ * - MINOR_SAFETY hides the evidence option;
+ * - "this isn't a safety issue" redirects to block / unmatch (never files a report);
+ * - the self-harm step links tel:14416 (Tele-MANAS);
+ * - prefers-reduced-motion renders the nudge shield static.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ canSubmit,
+ confirmationView,
+ initialDialogState,
+ isEvidenceEligible,
+ notReportableActions,
+ nudgeMode,
+ sectionVisibility,
+ selfHarmTelHref,
+ stepForCategory,
+} from '../features/moderation/report/dialog-logic';
+import { getCategoryMeta, ReportCategory, NOT_REPORTABLE_CASES } from '../features/moderation/report/categories';
+import { SELF_HARM_RESOURCES } from '../features/moderation/report/strings';
+
+const harassment = getCategoryMeta(ReportCategory.HARASSMENT);
+const minorSafety = getCategoryMeta(ReportCategory.MINOR_SAFETY);
+const selfHarm = getCategoryMeta(ReportCategory.SELF_HARM_CONCERN);
+const other = getCategoryMeta(ReportCategory.OTHER);
+
+describe('evidence checkbox is never pre-ticked', () => {
+ it('initial state has share OFF', () => {
+ expect(initialDialogState().share).toBe(false);
+ });
+});
+
+describe('Submit is enabled both ways (share has no bearing)', () => {
+ it('enabled for a normal category with no details', () => {
+ expect(canSubmit({ meta: harassment, submitting: false, details: '' })).toBe(true);
+ });
+
+ it('canSubmit does not take `share` as an input at all', () => {
+ // The signature only accepts meta/submitting/details — there is no way for the share state to
+ // gate Submit, which is the no-dark-pattern guarantee.
+ expect(Object.keys({ meta: harassment, submitting: false, details: '' }).sort()).toEqual([
+ 'details',
+ 'meta',
+ 'submitting',
+ ]);
+ });
+
+ it('is disabled while submitting, over-long, or missing required OTHER details', () => {
+ expect(canSubmit({ meta: harassment, submitting: true, details: '' })).toBe(false);
+ expect(canSubmit({ meta: harassment, submitting: false, details: 'x'.repeat(2001) })).toBe(false);
+ expect(canSubmit({ meta: other, submitting: false, details: ' ' })).toBe(false);
+ expect(canSubmit({ meta: other, submitting: false, details: 'spam link' })).toBe(true);
+ expect(canSubmit({ meta: null, submitting: false, details: 'x' })).toBe(false);
+ });
+});
+
+describe('MINOR_SAFETY hides evidence', () => {
+ it('is never evidence-eligible even from a chat with a conversation', () => {
+ expect(
+ isEvidenceEligible({ source: 'chat', conversationId: 'c1', meta: minorSafety })
+ ).toBe(false);
+ });
+
+ it('shows the escalation note and no evidence section', () => {
+ const v = sectionVisibility({ source: 'chat', conversationId: 'c1', meta: minorSafety });
+ expect(v.evidence).toBe(false);
+ expect(v.minorSafetyNote).toBe(true);
+ });
+
+ it('a normal chat category DOES show the evidence section', () => {
+ const v = sectionVisibility({ source: 'chat', conversationId: 'c1', meta: harassment });
+ expect(v.evidence).toBe(true);
+ expect(v.minorSafetyNote).toBe(false);
+ });
+
+ it('evidence is never offered from a profile (no chat to share)', () => {
+ expect(isEvidenceEligible({ source: 'profile', conversationId: undefined, meta: harassment })).toBe(false);
+ });
+});
+
+describe('not-reportable redirect', () => {
+ it('offers block always and unmatch only when available; never files a report', () => {
+ expect(notReportableActions({ hasUnmatch: true })).toEqual({ block: true, unmatch: true });
+ expect(notReportableActions({ hasUnmatch: false })).toEqual({ block: true, unmatch: false });
+ });
+
+ it('has a non-empty list of explicitly not-reportable cases', () => {
+ expect(NOT_REPORTABLE_CASES.length).toBeGreaterThan(0);
+ });
+});
+
+describe('self-harm support', () => {
+ it('SELF_HARM_CONCERN routes to the self-harm support step first', () => {
+ expect(stepForCategory(selfHarm)).toBe('self_harm');
+ expect(stepForCategory(harassment)).toBe('form');
+ });
+
+ it('links Tele-MANAS tel:14416', () => {
+ const r = SELF_HARM_RESOURCES[0];
+ expect(r.phone).toBe('14416');
+ expect(selfHarmTelHref(r)).toBe('tel:14416');
+ });
+});
+
+describe('reduced motion renders the shield static', () => {
+ it('maps prefers-reduced-motion to a static (non-animated) shield', () => {
+ expect(nudgeMode(true)).toBe('static');
+ expect(nudgeMode(false)).toBe('animated');
+ });
+});
+
+describe('confirmation presentation per evidence outcome', () => {
+ it('failed shows Retry only; terminal shows a calm note with NO retry; attached confirms attach', () => {
+ expect(confirmationView({ blocked: false, evidenceOutcome: 'failed' })).toMatchObject({
+ retry: true,
+ terminalNote: false,
+ attached: false,
+ });
+ expect(confirmationView({ blocked: true, evidenceOutcome: 'terminal' })).toMatchObject({
+ retry: false,
+ terminalNote: true,
+ blocked: true,
+ });
+ expect(confirmationView({ blocked: false, evidenceOutcome: 'attached' })).toMatchObject({
+ attached: true,
+ retry: false,
+ terminalNote: false,
+ });
+ });
+});
diff --git a/src/tests/report-evidence-bundle.test.ts b/src/tests/report-evidence-bundle.test.ts
new file mode 100644
index 0000000..bfd08a0
--- /dev/null
+++ b/src/tests/report-evidence-bundle.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Canonical evidence-bundle encoder — golden-vector parity (OneHook moderation Phase 2).
+ *
+ * The web encoder MUST reproduce the portal reference's `canonical`, `canonicalBytesHex`,
+ * `sha256Hex` and `sha256Base64` byte-for-byte for every contract fixture, or the SHA-256 the
+ * client declares at presign won't match what the server and admin viewer recompute.
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import {
+ canonicalString,
+ canonicalByteLength,
+ canonicalBytes,
+ bundleSha256Hex,
+ bundleSha256Base64,
+ hexToBase64,
+ escapeCanonicalString,
+ type BundleInput,
+} from '../features/moderation/report/evidence-bundle';
+
+const FIXTURE_DIR = new URL('./fixtures/moderation/', import.meta.url).pathname;
+
+interface Fixture {
+ input: BundleInput;
+ canonical: string;
+ canonicalByteLength: number;
+ canonicalBytesHex: string;
+ sha256Hex: string;
+ sha256Base64: string;
+}
+
+function loadFixtures(): { name: string; fx: Fixture }[] {
+ return readdirSync(FIXTURE_DIR)
+ .filter((f) => f.startsWith('bundle-') && f.endsWith('.json'))
+ .sort()
+ .map((name) => ({ name, fx: JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf8')) as Fixture }));
+}
+
+function toHex(bytes: Uint8Array): string {
+ let out = '';
+ for (const b of bytes) out += b.toString(16).padStart(2, '0');
+ return out;
+}
+
+describe('canonical evidence bundle — golden fixtures', () => {
+ const fixtures = loadFixtures();
+
+ it('finds all four contract fixtures', () => {
+ expect(fixtures.map((f) => f.name)).toEqual([
+ 'bundle-basic.json',
+ 'bundle-empty.json',
+ 'bundle-escaping.json',
+ 'bundle-tiebreak-media.json',
+ ]);
+ });
+
+ for (const { name, fx } of fixtures) {
+ describe(name, () => {
+ it('reproduces the canonical string', () => {
+ expect(canonicalString(fx.input)).toBe(fx.canonical);
+ });
+ it('reproduces the canonical byte length', () => {
+ expect(canonicalByteLength(fx.input)).toBe(fx.canonicalByteLength);
+ });
+ it('reproduces the canonical bytes (hex)', () => {
+ expect(toHex(canonicalBytes(fx.input))).toBe(fx.canonicalBytesHex);
+ });
+ it('reproduces sha256Hex', async () => {
+ expect(await bundleSha256Hex(fx.input)).toBe(fx.sha256Hex);
+ });
+ it('reproduces sha256Base64', async () => {
+ expect(await bundleSha256Base64(fx.input)).toBe(fx.sha256Base64);
+ });
+ it('hexToBase64(sha256Hex) === sha256Base64', () => {
+ expect(hexToBase64(fx.sha256Hex)).toBe(fx.sha256Base64);
+ });
+ });
+ }
+});
+
+describe('canonical string escaping', () => {
+ it('escapes only " and \\\\ and control chars; leaves /, DEL and non-ASCII raw', () => {
+ expect(escapeCanonicalString('a"b\\c')).toBe('"a\\"b\\\\c"');
+ expect(escapeCanonicalString('tab\tnl\ncr\rbs\bff\f')).toBe('"tab\\tnl\\ncr\\rbs\\bff\\f"');
+ expect(escapeCanonicalString('\u0000\u001f')).toBe('"\\u0000\\u001f"');
+ // slash, DEL (0x7f) and non-ASCII are NOT escaped
+ expect(escapeCanonicalString('/\u007f😀café')).toBe('"/\u007f😀café"');
+ });
+});
diff --git a/src/tests/report-evidence-outcomes.test.ts b/src/tests/report-evidence-outcomes.test.ts
new file mode 100644
index 0000000..e5a0600
--- /dev/null
+++ b/src/tests/report-evidence-outcomes.test.ts
@@ -0,0 +1,205 @@
+/**
+ * Report evidence outcomes + submit telemetry ordering (OneHook moderation Phase 2).
+ *
+ * Fix (1): a 409 EVIDENCE_ALREADY_COMPLETED on presign (or an idempotent complete) means the
+ * evidence is ATTACHED, not failed; the terminal codes (EVIDENCE_DISABLED / EVIDENCE_NOT_OFFERED /
+ * REPORT_TOO_OLD / NOT_REPORTER) yield a 'terminal' outcome (the UI hides Retry and shows a calm,
+ * final note). Any other error stays a retryable 'failed'.
+ *
+ * Fix (3): submit_with_evidence / submit_without_evidence are emitted ONLY after the report is
+ * created (201); on a create failure only submit_failed is emitted (never a submit_* success).
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ submitReport,
+ type ReportSubmission,
+ type SubmitDeps,
+} from '../features/moderation/report/submit';
+import {
+ classifyEvidenceError,
+ EvidenceError,
+ type EvidenceTransport,
+} from '../features/moderation/report/evidence-api';
+import { ReportCategory } from '../features/moderation/report/categories';
+import type { CreateReportResponse } from '../api/rest';
+import type { SourceMessage } from '../features/moderation/report/window-selection';
+
+const NOW = Date.parse('2026-10-26T12:00:00.000Z');
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+const okReport = (over: Partial = {}): CreateReportResponse => ({
+ reportId: 'b3f1c2de-4a5b-4c6d-8e9f-0a1b2c3d4e5f',
+ severity: 'high',
+ evidenceAllowed: true,
+ ...over,
+});
+
+const messages = (): SourceMessage[] => [
+ { messageId: 'm1', sentAtMs: NOW - 2000, senderSide: 'reporter', text: 'hello' },
+ { messageId: 'm2', sentAtMs: NOW - 1000, senderSide: 'reported', text: 'stop messaging me' },
+];
+
+const withEvidence = (over: Partial = {}): ReportSubmission => ({
+ reportedUserId: 'user-reported-123',
+ category: ReportCategory.HARASSMENT,
+ details: 'they kept messaging',
+ alsoBlock: true,
+ conversationId: 'match-abc',
+ source: 'chat',
+ shareEvidence: true,
+ messages: messages(),
+ ...over,
+});
+
+/** A transport whose presign returns a given status+code; PUT/complete succeed if reached. */
+function presignFailingTransport(status: number, code: string): EvidenceTransport {
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = String(input);
+ if (url.endsWith('/evidence/presign')) return jsonResponse(status, { code });
+ if (url.endsWith('/evidence/complete')) return jsonResponse(200, { evidenceId: 'ev', status: 'recorded', idempotent: false });
+ if ((init?.method ?? 'GET') === 'PUT') return new Response(null, { status: 200 });
+ throw new Error(`unexpected ${url}`);
+ }) as unknown as typeof fetch;
+ return { baseUrl: 'https://x/moderation', fetchImpl, getToken: async () => 't', sleep: async () => {} };
+}
+
+/** A transport that presigns + PUTs fine but whose complete returns a given status+code. */
+function completeResultTransport(status: number, body: unknown): EvidenceTransport {
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = String(input);
+ if (url.endsWith('/evidence/presign'))
+ return jsonResponse(200, {
+ evidenceId: 'ev',
+ upload: { url: 'https://s3/pending/ev.json?sig', method: 'PUT', headers: { 'x-amz-checksum-sha256': 'h' } },
+ expiresAt: NOW + 900_000,
+ });
+ if (url.endsWith('/evidence/complete')) return jsonResponse(status, body);
+ if ((init?.method ?? 'GET') === 'PUT') return new Response(null, { status: 200 });
+ throw new Error(`unexpected ${url}`);
+ }) as unknown as typeof fetch;
+ return { baseUrl: 'https://x/moderation', fetchImpl, getToken: async () => 't', sleep: async () => {} };
+}
+
+describe('classifyEvidenceError', () => {
+ it('maps 409 EVIDENCE_ALREADY_COMPLETED to attached', () => {
+ expect(classifyEvidenceError(new EvidenceError('x', 409, 'EVIDENCE_ALREADY_COMPLETED'))).toBe('attached');
+ });
+
+ it('maps the terminal codes to terminal', () => {
+ for (const code of ['EVIDENCE_DISABLED', 'EVIDENCE_NOT_OFFERED', 'REPORT_TOO_OLD', 'NOT_REPORTER']) {
+ expect(classifyEvidenceError(new EvidenceError('x', 409, code))).toBe('terminal');
+ }
+ });
+
+ it('returns null for transient / unknown errors (stay retryable)', () => {
+ expect(classifyEvidenceError(new EvidenceError('net', 0, 'NETWORK_ERROR'))).toBeNull();
+ expect(classifyEvidenceError(new EvidenceError('finalize', 503, 'EVIDENCE_FINALIZE_RETRY'))).toBeNull();
+ expect(classifyEvidenceError(new Error('plain'))).toBeNull();
+ });
+});
+
+describe('submitReport — evidence outcomes (fix 1)', () => {
+ it('treats 409 EVIDENCE_ALREADY_COMPLETED on presign as attached', async () => {
+ const events: string[] = [];
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: presignFailingTransport(409, 'EVIDENCE_ALREADY_COMPLETED'),
+ telemetry: ((e: string) => events.push(e)) as SubmitDeps['telemetry'],
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(withEvidence(), deps);
+ expect(res.evidenceOutcome).toBe('attached');
+ expect(events).toContain('report.evidence_upload_succeeded');
+ expect(events).not.toContain('report.evidence_upload_failed');
+ });
+
+ it('treats an idempotent complete as attached', async () => {
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: completeResultTransport(200, { evidenceId: 'ev', status: 'recorded', idempotent: true }),
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(withEvidence(), deps);
+ expect(res.evidenceOutcome).toBe('attached');
+ });
+
+ it.each([
+ ['EVIDENCE_DISABLED', 409],
+ ['EVIDENCE_NOT_OFFERED', 409],
+ ['REPORT_TOO_OLD', 409],
+ ['NOT_REPORTER', 403],
+ ])('maps terminal code %s to a terminal outcome with no upload telemetry', async (code, status) => {
+ const events: string[] = [];
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: presignFailingTransport(status as number, code as string),
+ telemetry: ((e: string) => events.push(e)) as SubmitDeps['telemetry'],
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(withEvidence(), deps);
+ expect(res.evidenceOutcome).toBe('terminal');
+ expect(events).not.toContain('report.evidence_upload_succeeded');
+ expect(events).not.toContain('report.evidence_upload_failed');
+ });
+
+ it('keeps a transient failure retryable (failed) and never fails the report', async () => {
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: presignFailingTransport(500, 'INTERNAL'),
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(withEvidence(), deps);
+ expect(res.reportId).toBeTruthy();
+ expect(res.evidenceOutcome).toBe('failed');
+ });
+});
+
+describe('submit telemetry ordering (fix 3)', () => {
+ it('emits submit_with_evidence only AFTER the report is created', async () => {
+ const seq: string[] = [];
+ const deps: SubmitDeps = {
+ createReport: async () => {
+ seq.push('created');
+ return okReport();
+ },
+ evidence: completeResultTransport(200, { evidenceId: 'ev', status: 'recorded', idempotent: false }),
+ telemetry: ((e: string) => seq.push(e)) as SubmitDeps['telemetry'],
+ nowMs: () => NOW,
+ };
+ await submitReport(withEvidence(), deps);
+ expect(seq.indexOf('created')).toBeLessThan(seq.indexOf('report.submit_with_evidence'));
+ // submit_with_evidence must not be emitted before creation.
+ expect(seq[0]).toBe('created');
+ });
+
+ it('emits submit_without_evidence after creation when not sharing', async () => {
+ const seq: string[] = [];
+ const deps: SubmitDeps = {
+ createReport: async () => {
+ seq.push('created');
+ return okReport();
+ },
+ telemetry: ((e: string) => seq.push(e)) as SubmitDeps['telemetry'],
+ nowMs: () => NOW,
+ };
+ await submitReport(withEvidence({ shareEvidence: false, messages: undefined }), deps);
+ expect(seq).toEqual(['created', 'report.submit_without_evidence']);
+ });
+
+ it('on a create failure emits ONLY submit_failed (no submit_with/without)', async () => {
+ const seq: string[] = [];
+ const err = Object.assign(new Error('boom'), { status: 500 });
+ const deps: SubmitDeps = {
+ createReport: async () => {
+ throw err;
+ },
+ telemetry: ((e: string) => seq.push(e)) as SubmitDeps['telemetry'],
+ nowMs: () => NOW,
+ };
+ await expect(submitReport(withEvidence(), deps)).rejects.toBe(err);
+ expect(seq).toEqual(['report.submit_failed']);
+ });
+});
diff --git a/src/tests/report-submit.test.ts b/src/tests/report-submit.test.ts
new file mode 100644
index 0000000..5e962fb
--- /dev/null
+++ b/src/tests/report-submit.test.ts
@@ -0,0 +1,298 @@
+/**
+ * Report submit orchestration + evidence transport (OneHook moderation Phase 2).
+ *
+ * Covers: submit with and without evidence send IDENTICAL report fields except `evidenceOffered`;
+ * evidence failure leaves the report filed; the 503 EVIDENCE_FINALIZE_RETRY backoff; MINOR_SAFETY
+ * (evidenceAllowed:false) never uploads; and telemetry carries no text / user ids.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import {
+ submitReport,
+ buildReportBody,
+ type ReportSubmission,
+ type SubmitDeps,
+} from '../features/moderation/report/submit';
+import {
+ completeEvidence,
+ EvidenceError,
+ type EvidenceTransport,
+} from '../features/moderation/report/evidence-api';
+import { ReportCategory } from '../features/moderation/report/categories';
+import type { CreateReportRequest, CreateReportResponse } from '../api/rest';
+import type { SourceMessage } from '../features/moderation/report/window-selection';
+
+const NOW = Date.parse('2026-10-26T12:00:00.000Z');
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+const PRESIGN_BODY = {
+ evidenceId: 'ev_test',
+ upload: {
+ url: 'https://evidence-bucket.s3.ap-south-1.amazonaws.com/pending/rep/ev_test.json?X-Amz-Signature=abc',
+ method: 'PUT',
+ headers: {
+ 'x-amz-checksum-sha256': 'PLACEHOLDER',
+ 'x-amz-server-side-encryption': 'aws:kms',
+ 'x-amz-server-side-encryption-aws-kms-key-id': 'arn:aws:kms:ap-south-1:1:key/evidence',
+ 'x-amz-tagging': 'state=pending',
+ },
+ },
+ expiresAt: NOW + 900_000,
+};
+
+interface FetchLog {
+ presign: number;
+ put: number;
+ complete: number;
+ putHeaders: Record[];
+}
+
+function makeTransport(
+ completeResponder: () => Response,
+ log: FetchLog,
+ putResponder: () => Response = () => new Response(null, { status: 200 })
+): EvidenceTransport {
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = String(input);
+ const method = init?.method ?? 'GET';
+ if (url.endsWith('/evidence/presign')) {
+ log.presign++;
+ return jsonResponse(200, PRESIGN_BODY);
+ }
+ if (url.endsWith('/evidence/complete')) {
+ log.complete++;
+ return completeResponder();
+ }
+ if (method === 'PUT') {
+ log.put++;
+ log.putHeaders.push({ ...(init?.headers as Record) });
+ return putResponder();
+ }
+ throw new Error(`unexpected fetch ${method} ${url}`);
+ }) as unknown as typeof fetch;
+
+ return {
+ baseUrl: 'https://admin.onehook.club/api/p/onehook/gamma/moderation',
+ fetchImpl,
+ getToken: async () => 'member-id-token',
+ sleep: async () => {},
+ };
+}
+
+function newLog(): FetchLog {
+ return { presign: 0, put: 0, complete: 0, putHeaders: [] };
+}
+
+const okReport = (over: Partial = {}): CreateReportResponse => ({
+ reportId: 'b3f1c2de-4a5b-4c6d-8e9f-0a1b2c3d4e5f',
+ severity: 'high',
+ evidenceAllowed: true,
+ ...over,
+});
+
+const baseSubmission = (over: Partial = {}): ReportSubmission => ({
+ reportedUserId: 'user-reported-123',
+ category: ReportCategory.HARASSMENT,
+ details: 'they kept messaging after I asked them to stop',
+ alsoBlock: true,
+ conversationId: 'match-abc',
+ source: 'chat',
+ shareEvidence: false,
+ ...over,
+});
+
+const sampleMessages = (): SourceMessage[] => [
+ { messageId: 'm2', sentAtMs: NOW - 1000, senderSide: 'reported', text: 'stop messaging me' },
+ { messageId: 'm1', sentAtMs: NOW - 2000, senderSide: 'reporter', text: 'hello' },
+];
+
+describe('buildReportBody', () => {
+ it('sends identical fields except evidenceOffered', () => {
+ const noShare = buildReportBody(baseSubmission({ shareEvidence: false }));
+ const share = buildReportBody(baseSubmission({ shareEvidence: true, messages: sampleMessages() }));
+ expect(noShare.evidenceOffered).toBe(false);
+ expect(share.evidenceOffered).toBe(true);
+ const stripEvidence = (b: CreateReportRequest) => ({ ...b, evidenceOffered: undefined });
+ expect(stripEvidence(noShare)).toEqual(stripEvidence(share));
+ });
+
+ it('omits empty details and includes conversationId', () => {
+ const b = buildReportBody(baseSubmission({ details: ' ', conversationId: 'match-xyz' }));
+ expect(b.details).toBeUndefined();
+ expect(b.conversationId).toBe('match-xyz');
+ });
+});
+
+describe('submitReport — no evidence', () => {
+ it('files the report and never touches the evidence transport', async () => {
+ const bodies: CreateReportRequest[] = [];
+ const log = newLog();
+ const deps: SubmitDeps = {
+ createReport: async (b) => {
+ bodies.push(b);
+ return okReport();
+ },
+ evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log),
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(baseSubmission({ shareEvidence: false }), deps);
+ expect(res.evidenceOutcome).toBe('skipped');
+ expect(res.blocked).toBe(true);
+ expect(res.reportId).toBe('b3f1c2de-4a5b-4c6d-8e9f-0a1b2c3d4e5f');
+ expect(bodies[0].evidenceOffered).toBe(false);
+ expect(log.presign + log.put + log.complete).toBe(0);
+ });
+});
+
+describe('submitReport — with evidence', () => {
+ it('files the report then presigns, PUTs the exact bytes and completes', async () => {
+ const log = newLog();
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log),
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(baseSubmission({ shareEvidence: true, messages: sampleMessages() }), deps);
+ expect(res.evidenceOutcome).toBe('attached');
+ expect(log.presign).toBe(1);
+ expect(log.put).toBe(1);
+ expect(log.complete).toBe(1);
+ // PUT carries the SSE-KMS + checksum headers the presign returned.
+ expect(log.putHeaders[0]['x-amz-server-side-encryption']).toBe('aws:kms');
+ expect(log.putHeaders[0]['x-amz-checksum-sha256']).toBeTruthy();
+ expect(log.putHeaders[0]['Authorization']).toBeUndefined();
+ });
+
+ it('does not upload when the server disallows evidence (e.g. MINOR_SAFETY)', async () => {
+ const log = newLog();
+ const deps: SubmitDeps = {
+ createReport: async () => okReport({ evidenceAllowed: false }),
+ evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log),
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(
+ baseSubmission({ category: ReportCategory.MINOR_SAFETY, shareEvidence: true, messages: sampleMessages() }),
+ deps
+ );
+ expect(res.evidenceOutcome).toBe('not_allowed');
+ expect(log.presign).toBe(0);
+ });
+
+ it('leaves the report FILED when the evidence upload fails', async () => {
+ const log = newLog();
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: makeTransport(
+ () => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }),
+ log,
+ () => new Response('denied', { status: 403 }) // PUT fails
+ ),
+ nowMs: () => NOW,
+ };
+ const res = await submitReport(baseSubmission({ shareEvidence: true, messages: sampleMessages() }), deps);
+ // Report succeeded; evidence failed, non-blocking.
+ expect(res.reportId).toBeTruthy();
+ expect(res.evidenceOutcome).toBe('failed');
+ });
+});
+
+describe('completeEvidence — 503 EVIDENCE_FINALIZE_RETRY backoff', () => {
+ it('retries the finalize on 503 then succeeds', async () => {
+ const log = newLog();
+ let n = 0;
+ const transport = makeTransport(() => {
+ n++;
+ if (n <= 2) return jsonResponse(503, { code: 'EVIDENCE_FINALIZE_RETRY', message: 'finalizing' });
+ return jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false });
+ }, log);
+ const sleeps: number[] = [];
+ const res = await completeEvidence(
+ { ...transport, sleep: async (ms) => void sleeps.push(ms) },
+ { reportId: 'r', evidenceId: 'ev_test', messageIds: ['m1'], firstSentAt: NOW - 2000, lastSentAt: NOW },
+ { baseDelayMs: 10, maxDelayMs: 100 }
+ );
+ expect(res.status).toBe('recorded');
+ expect(log.complete).toBe(3);
+ expect(sleeps).toEqual([10, 20]); // exponential backoff, two retries
+ });
+
+ it('gives up after maxRetries and throws', async () => {
+ const log = newLog();
+ const transport = makeTransport(
+ () => jsonResponse(503, { code: 'EVIDENCE_FINALIZE_RETRY' }),
+ log
+ );
+ await expect(
+ completeEvidence(
+ { ...transport, sleep: async () => {} },
+ { reportId: 'r', evidenceId: 'ev_test', messageIds: ['m1'], firstSentAt: NOW, lastSentAt: NOW },
+ { maxRetries: 2, baseDelayMs: 1 }
+ )
+ ).rejects.toBeInstanceOf(EvidenceError);
+ expect(log.complete).toBe(3); // first + 2 retries
+ });
+
+ it('does NOT retry a non-retryable error', async () => {
+ const log = newLog();
+ const transport = makeTransport(() => jsonResponse(409, { code: 'EVIDENCE_ALREADY_COMPLETED' }), log);
+ await expect(
+ completeEvidence(
+ { ...transport, sleep: async () => {} },
+ { reportId: 'r', evidenceId: 'ev_test', messageIds: ['m1'], firstSentAt: NOW, lastSentAt: NOW }
+ )
+ ).rejects.toMatchObject({ status: 409, code: 'EVIDENCE_ALREADY_COMPLETED' });
+ expect(log.complete).toBe(1);
+ });
+});
+
+describe('submitReport — report failure propagates', () => {
+ it('rethrows the report error and emits submit_failed (429 preserved)', async () => {
+ const events: { event: string; attrs: Record }[] = [];
+ const err = Object.assign(new Error('rate limited'), { status: 429 });
+ const deps: SubmitDeps = {
+ createReport: async () => {
+ throw err;
+ },
+ telemetry: ((event: string, attrs: Record) => events.push({ event, attrs })) as SubmitDeps['telemetry'],
+ };
+ await expect(submitReport(baseSubmission({ shareEvidence: false }), deps)).rejects.toBe(err);
+ expect(events.some((e) => e.event === 'report.submit_failed')).toBe(true);
+ });
+});
+
+describe('report telemetry carries no text / user ids', () => {
+ it('only ever emits the whitelisted scalar dimensions', async () => {
+ const events: { event: string; attrs: Record }[] = [];
+ const log = newLog();
+ const deps: SubmitDeps = {
+ createReport: async () => okReport(),
+ evidence: makeTransport(() => jsonResponse(200, { evidenceId: 'ev_test', status: 'recorded', idempotent: false }), log),
+ telemetry: ((event: string, attrs: Record) => events.push({ event, attrs })) as SubmitDeps['telemetry'],
+ nowMs: () => NOW,
+ };
+ const sub = baseSubmission({
+ shareEvidence: true,
+ messages: sampleMessages(),
+ details: 'SECRET free text that must never leave the device',
+ reportedUserId: 'user-reported-123',
+ });
+ await submitReport(sub, deps);
+
+ expect(events.length).toBeGreaterThan(0);
+ const allowed = new Set(['event', 'category', 'source', 'evidence']);
+ for (const { attrs } of events) {
+ for (const [k, v] of Object.entries(attrs)) {
+ expect(allowed.has(k)).toBe(true);
+ // no value is free text, the reported user id, or message content
+ expect(v).not.toBe(sub.details);
+ expect(v).not.toBe(sub.reportedUserId);
+ if (typeof v === 'string') expect(v).not.toContain('hello');
+ }
+ }
+ // the submit + upload success were both reported
+ expect(events.map((e) => e.event)).toContain('report.submit_with_evidence');
+ expect(events.map((e) => e.event)).toContain('report.evidence_upload_succeeded');
+ });
+});
diff --git a/src/tests/report-telemetry.test.ts b/src/tests/report-telemetry.test.ts
new file mode 100644
index 0000000..c07c372
--- /dev/null
+++ b/src/tests/report-telemetry.test.ts
@@ -0,0 +1,49 @@
+/**
+ * Report flow telemetry — whitelist + no-PII (OneHook moderation Phase 2).
+ *
+ * The report_flow signal must carry ONLY the scalar dimensions event / category / source / evidence,
+ * and never free text, user ids or message content.
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ buildReportSignalAttributes,
+ ReportEvent,
+} from '../features/moderation/report/telemetry';
+import { ReportCategory } from '../features/moderation/report/categories';
+
+describe('buildReportSignalAttributes', () => {
+ it('always includes the event id', () => {
+ expect(buildReportSignalAttributes(ReportEvent.REPORT_OPEN)).toEqual({ event: 'report.open' });
+ });
+
+ it('includes only the whitelisted scalar dimensions', () => {
+ const attrs = buildReportSignalAttributes(ReportEvent.REPORT_SUBMIT_WITH_EVIDENCE, {
+ category: ReportCategory.HARASSMENT,
+ source: 'chat',
+ evidence: true,
+ });
+ expect(attrs).toEqual({
+ event: 'report.submit_with_evidence',
+ category: 'HARASSMENT',
+ source: 'chat',
+ evidence: true,
+ });
+ for (const [k, v] of Object.entries(attrs)) {
+ expect(['event', 'category', 'source', 'evidence']).toContain(k);
+ expect(['string', 'boolean']).toContain(typeof v);
+ }
+ });
+
+ it('omits undefined dimensions and never carries arbitrary keys', () => {
+ const attrs = buildReportSignalAttributes(ReportEvent.REPORT_NOT_REPORTABLE_SHOWN, { source: 'profile' });
+ expect(attrs).toEqual({ event: 'report.not_reportable_shown', source: 'profile' });
+ // no way to smuggle a free-text / id key through the builder
+ expect(Object.keys(attrs).sort()).toEqual(['event', 'source']);
+ });
+
+ it('maps every report_flow event id to the catalog values', () => {
+ expect(Object.values(ReportEvent)).toContain('report.open');
+ expect(Object.values(ReportEvent)).toContain('report.evidence_upload_failed');
+ expect(Object.values(ReportEvent)).toContain('report.nudge_shown');
+ });
+});
diff --git a/src/tests/report-window-categories.test.ts b/src/tests/report-window-categories.test.ts
new file mode 100644
index 0000000..6015f36
--- /dev/null
+++ b/src/tests/report-window-categories.test.ts
@@ -0,0 +1,166 @@
+/**
+ * Evidence window selection + category rules (OneHook moderation Phase 2).
+ */
+import { describe, it, expect } from 'vitest';
+import {
+ selectEvidenceWindow,
+ toIsoUtcMillis,
+ type SourceMessage,
+} from '../features/moderation/report/window-selection';
+import {
+ REPORT_CATEGORIES,
+ getCategoryMeta,
+ ReportCategory,
+ NOT_REPORTABLE_CASES,
+ DETAILS_MAX_CHARS,
+} from '../features/moderation/report/categories';
+
+const DAY = 24 * 60 * 60 * 1000;
+const NOW = Date.parse('2026-10-26T12:00:00.000Z');
+
+function msg(id: string, offsetMs: number, side: 'reporter' | 'reported', text: string | null): SourceMessage {
+ return { messageId: id, sentAtMs: NOW + offsetMs, senderSide: side, text };
+}
+
+describe('selectEvidenceWindow', () => {
+ it('sorts oldest-first by sentAt then messageId and maps to ISO-8601 UTC ms', () => {
+ const out = selectEvidenceWindow(
+ [
+ msg('b', -1000, 'reported', 'second'),
+ msg('a', -2000, 'reporter', 'first'),
+ ],
+ { nowMs: NOW }
+ );
+ expect(out.map((m) => m.messageId)).toEqual(['a', 'b']);
+ expect(out[0]).toEqual({
+ messageId: 'a',
+ sentAt: toIsoUtcMillis(NOW - 2000),
+ senderSide: 'reporter',
+ text: 'first',
+ });
+ // ISO format has millisecond precision and a Z suffix
+ expect(out[0].sentAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
+ });
+
+ it('breaks sentAt ties by messageId (lexicographic)', () => {
+ const out = selectEvidenceWindow(
+ [msg('b', 0, 'reported', 'two'), msg('a', 0, 'reporter', 'one')],
+ { nowMs: NOW }
+ );
+ expect(out.map((m) => m.messageId)).toEqual(['a', 'b']);
+ });
+
+ it('skips undecryptable and system/media (null text) messages', () => {
+ const out = selectEvidenceWindow(
+ [
+ msg('ok', -1000, 'reporter', 'keep me'),
+ { ...msg('undec', -900, 'reported', 'x'), undecryptable: true },
+ msg('system', -800, 'reported', null),
+ ],
+ { nowMs: NOW }
+ );
+ expect(out.map((m) => m.messageId)).toEqual(['ok']);
+ });
+
+ it('skips framed structured payloads (attachment / call signal) but keeps ordinary text', () => {
+ const out = selectEvidenceWindow(
+ [
+ msg('ok', -1000, 'reporter', 'line one\nline two \u{1F600}'),
+ msg('att', -900, 'reported', '\u0001{"contentKey":"SECRET"}'),
+ msg('call', -800, 'reported', '\u0002{"sdp":"v=0"}'),
+ msg('ctl', -700, 'reported', '\u0003other'),
+ ],
+ { nowMs: NOW }
+ );
+ expect(out.map((m) => m.messageId)).toEqual(['ok']);
+ });
+
+ it('applies the 30-day cap', () => {
+ const out = selectEvidenceWindow(
+ [
+ msg('old', -31 * DAY, 'reported', 'too old'),
+ msg('edge', -29 * DAY, 'reporter', 'inside'),
+ msg('fresh', -1000, 'reported', 'inside'),
+ ],
+ { nowMs: NOW, maxDays: 30 }
+ );
+ expect(out.map((m) => m.messageId)).toEqual(['edge', 'fresh']);
+ });
+
+ it('keeps only the most recent 200 messages', () => {
+ const many: SourceMessage[] = [];
+ for (let i = 0; i < 250; i++) many.push(msg(`m${String(i).padStart(3, '0')}`, -(250 - i) * 1000, 'reporter', `t${i}`));
+ const out = selectEvidenceWindow(many, { nowMs: NOW });
+ expect(out).toHaveLength(200);
+ // The oldest 50 (m000..m049) are dropped; m050 is the first kept.
+ expect(out[0].messageId).toBe('m050');
+ expect(out[out.length - 1].messageId).toBe('m249');
+ });
+
+ it('returns an empty window when nothing is eligible', () => {
+ expect(selectEvidenceWindow([], { nowMs: NOW })).toEqual([]);
+ expect(
+ selectEvidenceWindow([{ ...msg('u', -1, 'reporter', 'x'), undecryptable: true }], { nowMs: NOW })
+ ).toEqual([]);
+ });
+});
+
+describe('report categories', () => {
+ it('has exactly the 11 backend categories', () => {
+ expect(REPORT_CATEGORIES.map((c) => c.code).sort()).toEqual(
+ [
+ 'FAKE_IMPERSONATION',
+ 'HARASSMENT',
+ 'HATE',
+ 'MINOR_SAFETY',
+ 'OFF_PLATFORM_HARM',
+ 'OTHER',
+ 'SCAM_FRAUD',
+ 'SELF_HARM_CONCERN',
+ 'SEXUAL_CONTENT_NONCONSENT',
+ 'SPAM_PROMOTION',
+ 'THREATS_VIOLENCE',
+ ].sort()
+ );
+ });
+
+ it('every category has a short title and a one-line description', () => {
+ for (const c of REPORT_CATEGORIES) {
+ expect(c.title.length).toBeGreaterThan(0);
+ expect(c.description.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('OTHER requires details; no other category does', () => {
+ for (const c of REPORT_CATEGORIES) {
+ expect(c.requiresDetails).toBe(c.code === ReportCategory.OTHER);
+ }
+ expect(DETAILS_MAX_CHARS).toBe(2000);
+ });
+
+ it('alsoBlock defaults ON except SELF_HARM_CONCERN (OFF)', () => {
+ expect(getCategoryMeta(ReportCategory.SELF_HARM_CONCERN).alsoBlockDefault).toBe(false);
+ for (const c of REPORT_CATEGORIES) {
+ if (c.code !== ReportCategory.SELF_HARM_CONCERN) expect(c.alsoBlockDefault).toBe(true);
+ }
+ });
+
+ it('MINOR_SAFETY is the only category that hides evidence; SELF_HARM allows it', () => {
+ expect(getCategoryMeta(ReportCategory.MINOR_SAFETY).evidenceEligible).toBe(false);
+ expect(getCategoryMeta(ReportCategory.MINOR_SAFETY).minorSafety).toBe(true);
+ expect(getCategoryMeta(ReportCategory.SELF_HARM_CONCERN).evidenceEligible).toBe(true);
+ for (const c of REPORT_CATEGORIES) {
+ if (c.code !== ReportCategory.MINOR_SAFETY) expect(c.evidenceEligible).toBe(true);
+ }
+ });
+
+ it('only SELF_HARM_CONCERN is flagged as the self-harm support route', () => {
+ for (const c of REPORT_CATEGORIES) {
+ expect(c.selfHarm).toBe(c.code === ReportCategory.SELF_HARM_CONCERN);
+ }
+ });
+
+ it('lists the not-reportable cases', () => {
+ expect(NOT_REPORTABLE_CASES.length).toBeGreaterThanOrEqual(5);
+ });
+});
diff --git a/src/tests/report-window-loader.test.ts b/src/tests/report-window-loader.test.ts
new file mode 100644
index 0000000..b7161b9
--- /dev/null
+++ b/src/tests/report-window-loader.test.ts
@@ -0,0 +1,102 @@
+/**
+ * Evidence-window paging (OneHook moderation Phase 2, docs/MODERATION.md §2).
+ *
+ * `getMessages(matchId, after)` returns ONE oldest-first DynamoDB page per call (no limit /
+ * nextToken), so `collectWindowPages` must page FORWARD with the `after` cursor to cover the whole
+ * window. These tests inject a fake `fetchPage` (no network) and assert: the cursor is seeded at the
+ * 30-day cutoff (older messages are never requested), the loop pages to completeness, it de-dupes
+ * across page boundaries, and it is always bounded (page cap + non-advancing-cursor guard).
+ */
+import { describe, it, expect } from 'vitest';
+import { collectWindowPages } from '../features/moderation/report/window-loader';
+
+const NOW = Date.parse('2026-10-26T12:00:00.000Z');
+const DAY = 24 * 60 * 60 * 1000;
+
+interface Msg {
+ messageId: string;
+ timestamp: number;
+}
+
+/** A fake AppSync `getMessages`: returns up to `pageSize` messages with timestamp > after, ascending. */
+function makeFetchPage(all: Msg[], pageSize: number, calls: (number | undefined)[]) {
+ const sorted = [...all].sort((a, b) => a.timestamp - b.timestamp);
+ return async (after: number | undefined): Promise => {
+ calls.push(after);
+ const start = after ?? 0;
+ return sorted.filter((m) => m.timestamp > start).slice(0, pageSize);
+ };
+}
+
+const deps = (fetchPage: (after: number | undefined) => Promise) => ({
+ fetchPage,
+ getTimestamp: (m: Msg) => m.timestamp,
+ getId: (m: Msg) => m.messageId,
+});
+
+describe('collectWindowPages', () => {
+ it('pages forward to completeness across multiple pages', async () => {
+ const all: Msg[] = Array.from({ length: 5 }, (_, i) => ({
+ messageId: `m${i}`,
+ timestamp: NOW - (5 - i) * 1000, // all within the last 30 days
+ }));
+ const calls: (number | undefined)[] = [];
+ const out = await collectWindowPages(deps(makeFetchPage(all, 2, calls)), { nowMs: NOW });
+ expect(out.map((m) => m.messageId)).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
+ // 3 content pages (2+2+1) + 1 trailing empty page that ends the loop.
+ expect(calls.length).toBe(4);
+ });
+
+ it('seeds the cursor at the 30-day cutoff so older messages are never requested', async () => {
+ const all: Msg[] = [
+ { messageId: 'old', timestamp: NOW - 40 * DAY }, // outside the window
+ { messageId: 'new1', timestamp: NOW - 2 * DAY },
+ { messageId: 'new2', timestamp: NOW - 1 * DAY },
+ ];
+ const calls: (number | undefined)[] = [];
+ const out = await collectWindowPages(deps(makeFetchPage(all, 10, calls)), { nowMs: NOW, maxDays: 30 });
+ expect(out.map((m) => m.messageId)).toEqual(['new1', 'new2']);
+ // The first cursor is the cutoff (~30 days ago), strictly after the 40-day-old message.
+ expect(calls[0]).toBe(NOW - 30 * DAY);
+ expect(out.find((m) => m.messageId === 'old')).toBeUndefined();
+ });
+
+ it('de-duplicates items that reappear across page boundaries', async () => {
+ const shared = { messageId: 'dup', timestamp: NOW - 1000 };
+ let n = 0;
+ const calls: (number | undefined)[] = [];
+ const fetchPage = async (after: number | undefined): Promise => {
+ calls.push(after);
+ n += 1;
+ if (n === 1) return [{ messageId: 'a', timestamp: NOW - 3000 }, shared];
+ if (n === 2) return [shared, { messageId: 'b', timestamp: NOW - 500 }];
+ return [];
+ };
+ const out = await collectWindowPages(deps(fetchPage), { nowMs: NOW });
+ expect(out.map((m) => m.messageId)).toEqual(['a', 'dup', 'b']);
+ });
+
+ it('is bounded by the page cap', async () => {
+ const all: Msg[] = Array.from({ length: 100 }, (_, i) => ({
+ messageId: `m${i}`,
+ timestamp: NOW - (100 - i) * 1000,
+ }));
+ const calls: (number | undefined)[] = [];
+ const out = await collectWindowPages(deps(makeFetchPage(all, 1, calls)), { nowMs: NOW, maxPages: 3 });
+ expect(out.length).toBe(3); // exactly maxPages fetches, 1 item each
+ expect(calls.length).toBe(3);
+ });
+
+ it('stops when the cursor cannot advance (no infinite loop)', async () => {
+ // A pathological page that always returns the same single item: the cursor never advances.
+ const stuck = { messageId: 'x', timestamp: NOW - 1000 };
+ let n = 0;
+ const fetchPage = async (): Promise => {
+ n += 1;
+ return [stuck];
+ };
+ const out = await collectWindowPages(deps(fetchPage), { nowMs: NOW, maxPages: 50 });
+ expect(out.map((m) => m.messageId)).toEqual(['x']);
+ expect(n).toBeLessThanOrEqual(2); // first fetch, then the non-advance guard breaks
+ });
+});
diff --git a/src/utils/env.config.ts b/src/utils/env.config.ts
index cd24a24..26ced38 100644
--- a/src/utils/env.config.ts
+++ b/src/utils/env.config.ts
@@ -19,6 +19,7 @@ interface RuntimeEnv extends ImportMetaEnv {
readonly VITE_APP_ENV?: string;
readonly VITE_BACKEND_STAGE?: string;
readonly VITE_API_BASE_URL?: string;
+ readonly VITE_PORTAL_MODERATION_BASE_URL?: string;
readonly VITE_GRAPHQL_URL?: string;
readonly VITE_COGNITO_REGION?: string;
readonly VITE_COGNITO_USER_POOL_ID?: string;
@@ -40,6 +41,7 @@ interface EnvConfig {
backendStage: BackendStage;
env: Environment;
apiBaseUrl: string;
+ portalModerationBaseUrl: string;
chatbotUrl: string;
graphqlUrl: string;
cognitoRegion: string;
@@ -141,6 +143,10 @@ export function createEnvConfig(envName: Environment = detectEnvironment()): Env
env.VITE_API_BASE_URL,
envName === 'development' ? '' : deployment.apiBaseUrl
),
+ portalModerationBaseUrl: normalizeUrl(
+ env.VITE_PORTAL_MODERATION_BASE_URL,
+ deployment.portalModerationBaseUrl
+ ),
chatbotUrl: normalizeUrl(env.VITE_CHATBOT_URL, deployment.chatbotUrl),
graphqlUrl: normalizeUrl(env.VITE_GRAPHQL_URL, deployment.graphqlUrl),
cognitoRegion,
@@ -179,6 +185,7 @@ export const config: EnvConfig = createEnvConfig();
export const backendStage = config.backendStage;
export const env = config.env;
export const apiBaseUrl = config.apiBaseUrl;
+export const portalModerationBaseUrl = config.portalModerationBaseUrl;
export const chatbotUrl = config.chatbotUrl;
export const graphqlUrl = config.graphqlUrl;
export const cognitoRegion = config.cognitoRegion;