From 658b81737b419adc16828f7ab1e4ac0d4830a73f Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 02:37:18 +0530
Subject: [PATCH 01/10] chore(telemetry): vendor catalog 1.6.0 (report_flow
signal + report taxonomy)
---
.../analytics/generated/telemetryCatalog.ts | 51 +++++++++++++++++--
1 file changed, 47 insertions(+), 4 deletions(-)
diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts
index 2b36fa9..506d7a4 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 df6df589e003512e5d8bc9de08bf989cc28fe3bac66904d9ca22f6150de47642
// 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.6.0, hash 435bb38a1852d74c.
//
// 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.6.0";
+export const TELEMETRY_CATALOG_HASH = "435bb38a1852d74c";
/** 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",
From 8e6ae58044fd357d9bcc9b1d3849a6539f809627 Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 02:47:06 +0530
Subject: [PATCH 02/10] feat(report): canonical evidence encoder, window
selection, categories, report+evidence API, submit orchestration (+tests)
---
src/api/rest.ts | 53 ++++
src/config/deployment.config.ts | 10 +
src/features/moderation/report/categories.ts | 192 +++++++++++
.../moderation/report/evidence-api.ts | 201 ++++++++++++
.../moderation/report/evidence-bundle.ts | 205 ++++++++++++
src/features/moderation/report/member-auth.ts | 18 ++
src/features/moderation/report/strings.ts | 103 ++++++
src/features/moderation/report/submit.ts | 206 ++++++++++++
src/features/moderation/report/telemetry.ts | 47 +++
.../moderation/report/window-selection.ts | 82 +++++
.../fixtures/moderation/bundle-basic.json | 50 +++
.../fixtures/moderation/bundle-empty.json | 24 ++
.../fixtures/moderation/bundle-escaping.json | 38 +++
.../moderation/bundle-tiebreak-media.json | 57 ++++
src/tests/report-evidence-bundle.test.ts | 90 ++++++
src/tests/report-submit.test.ts | 298 ++++++++++++++++++
src/tests/report-window-categories.test.ts | 153 +++++++++
src/utils/env.config.ts | 7 +
18 files changed, 1834 insertions(+)
create mode 100644 src/features/moderation/report/categories.ts
create mode 100644 src/features/moderation/report/evidence-api.ts
create mode 100644 src/features/moderation/report/evidence-bundle.ts
create mode 100644 src/features/moderation/report/member-auth.ts
create mode 100644 src/features/moderation/report/strings.ts
create mode 100644 src/features/moderation/report/submit.ts
create mode 100644 src/features/moderation/report/telemetry.ts
create mode 100644 src/features/moderation/report/window-selection.ts
create mode 100644 src/tests/fixtures/moderation/bundle-basic.json
create mode 100644 src/tests/fixtures/moderation/bundle-empty.json
create mode 100644 src/tests/fixtures/moderation/bundle-escaping.json
create mode 100644 src/tests/fixtures/moderation/bundle-tiebreak-media.json
create mode 100644 src/tests/report-evidence-bundle.test.ts
create mode 100644 src/tests/report-submit.test.ts
create mode 100644 src/tests/report-window-categories.test.ts
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/config/deployment.config.ts b/src/config/deployment.config.ts
index 951d3cd..66b98b5 100644
--- a/src/config/deployment.config.ts
+++ b/src/config/deployment.config.ts
@@ -27,6 +27,13 @@ export interface BackendDeploymentConfig {
* pins the token's `aud` to that value, so a mismatch is rejected.
*/
readonly googleClientId: string;
+ /**
+ * Base URL of the admin portal's PUBLIC member-facing moderation surfaces (evidence presign /
+ * complete). Member-JWT authenticated via the portal gateway; see OneHookAdminPortal
+ * features/moderation README. The `{stage}` segment is baked per-stage here (same convention as
+ * `apiBaseUrl`) so it is never hardcoded in the report feature.
+ */
+ readonly portalModerationBaseUrl: string;
readonly enableAnalytics: boolean;
readonly enableDebug: boolean;
readonly requestTimeoutMs: number;
@@ -50,6 +57,9 @@ export const BACKEND_DEPLOYMENTS = {
cognitoDomain: '',
// Mirrors OneHookBackend infra/config/auth.ts (gamma).
googleClientId: '187849624492-jmmc973assosfgv3aaevh8fts2h0mtlr.apps.googleusercontent.com',
+ // Admin portal public moderation gateway for this stage (member-JWT): evidence presign/complete
+ // route under /api/p/{service}/{stage}/{feature}. {service}=onehook, {feature}=moderation.
+ portalModerationBaseUrl: 'https://admin.onehook.club/api/p/onehook/gamma/moderation',
enableAnalytics: false,
enableDebug: false,
requestTimeoutMs: 30_000,
diff --git a/src/features/moderation/report/categories.ts b/src/features/moderation/report/categories.ts
new file mode 100644
index 0000000..6154a21
--- /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 ReportSeverityTier = '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: ReportSeverityTier;
+ /** 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/evidence-api.ts b/src/features/moderation/report/evidence-api.ts
new file mode 100644
index 0000000..6a0cdbf
--- /dev/null
+++ b/src/features/moderation/report/evidence-api.ts
@@ -0,0 +1,201 @@
+/**
+ * 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';
+
+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..928e7bd
--- /dev/null
+++ b/src/features/moderation/report/strings.ts
@@ -0,0 +1,103 @@
+/**
+ * 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…',
+
+ // ── 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..fb6ef98
--- /dev/null
+++ b/src/features/moderation/report/submit.ts
@@ -0,0 +1,206 @@
+/**
+ * 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,
+ type EvidenceTransport,
+ type RetryOptions,
+} from './evidence-api';
+import { getMemberIdToken } from './member-auth';
+import { trackReport, ReportEvent, type ReportCategory, type ReportSource } from './telemetry';
+
+export type EvidenceOutcome = 'skipped' | 'not_allowed' | 'empty' | 'attached' | 'failed';
+
+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). Throws on
+ * failure (the caller decides whether that fails the report — it must NOT). Returns the outcome
+ * `'attached'`, or `'empty'` when there is nothing to share.
+ */
+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';
+
+ 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);
+
+ await completeEvidence(
+ transport,
+ { reportId, evidenceId: presign.evidenceId, messageIds, firstSentAt, lastSentAt },
+ deps.evidenceRetry
+ );
+ return 'attached';
+}
+
+/**
+ * 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;
+ emit(evidenceOffered ? ReportEvent.REPORT_SUBMIT_WITH_EVIDENCE : ReportEvent.REPORT_SUBMIT_WITHOUT_EVIDENCE, {
+ category: sub.category,
+ source: sub.source,
+ evidence: evidenceOffered,
+ });
+
+ let response: CreateReportResponse;
+ try {
+ response = await createReport(buildReportBody(sub));
+ } catch (err) {
+ emit(ReportEvent.REPORT_SUBMIT_FAILED, { category: sub.category, source: sub.source });
+ throw err;
+ }
+
+ 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
+ );
+ 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-selection.ts b/src/features/moderation/report/window-selection.ts
new file mode 100644
index 0000000..e79d641
--- /dev/null
+++ b/src/features/moderation/report/window-selection.ts
@@ -0,0 +1,82 @@
+/**
+ * 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' && 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);
+}
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-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-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-window-categories.test.ts b/src/tests/report-window-categories.test.ts
new file mode 100644
index 0000000..e237daf
--- /dev/null
+++ b/src/tests/report-window-categories.test.ts
@@ -0,0 +1,153 @@
+/**
+ * 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('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/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;
From 516c98388b91af47ec8fc37ac79e222eadb42d56 Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 02:58:14 +0530
Subject: [PATCH 03/10] feat(report): report flow dialog + chat/profile entry
points, replace spam/report-block menu with Report + Block (+telemetry test)
---
src/features/chat/ChatView.tsx | 126 ++-
.../moderation/report/ReportFlowDialog.tsx | 754 ++++++++++++++++++
src/features/moderation/report/categories.ts | 4 +-
src/tests/report-telemetry.test.ts | 49 ++
4 files changed, 923 insertions(+), 10 deletions(-)
create mode 100644 src/features/moderation/report/ReportFlowDialog.tsx
create mode 100644 src/tests/report-telemetry.test.ts
diff --git a/src/features/chat/ChatView.tsx b/src/features/chat/ChatView.tsx
index 1f28292..baa26cf 100644
--- a/src/features/chat/ChatView.tsx
+++ b/src/features/chat/ChatView.tsx
@@ -81,6 +81,8 @@ import {
AttachmentDecodeError,
type AttachmentKind,
} from '../../lib/chat-attachments';
+import { ReportFlowDialog } from '../../features/moderation/report/ReportFlowDialog';
+import type { SourceMessage } from '../../features/moderation/report/window-selection';
import { probeImage, probeVideo } from '../../lib/attachment-media';
import type { AttachmentObjectUrlCache } from '../../lib/attachment-transport';
import type { OutgoingAttachment, AttachmentTransferState } from '../../hooks/use-api';
@@ -138,6 +140,11 @@ export function ChatView({
const [profileOpen, setProfileOpen] = useState(false);
const [reportReason, setReportReason] = useState(null);
const [reporting, setReporting] = useState(false);
+ // New member report flow (moderation Phase 2). Chat-sourced reports render inside the conversation
+ // (where the decrypted messages live, for the optional evidence share); profile-sourced reports
+ // render here without evidence.
+ const [chatReportOpen, setChatReportOpen] = useState(false);
+ const [profileReportOpen, setProfileReportOpen] = useState(false);
const [portalNode, setPortalNode] = useState(() => {
if (typeof document !== 'undefined') {
@@ -308,6 +315,26 @@ export function ChatView({
await loadMatches();
};
+ // Standalone unmatch (ends the connection without a block) — used by the report flow's
+ // "This isn't a safety issue" redirect. Uses the existing release-hook call.
+ const runUnmatch = async () => {
+ if (!activeMatch?.matchId) return;
+ try {
+ await StateApi.releaseHook(activeMatch.matchId, 'OTHER');
+ } catch {
+ showToast('We couldn’t unmatch just now. Please try again.', 'error');
+ return;
+ }
+ const remaining = matches.filter((m) => m.matchId !== activeMatch.matchId);
+ const next: Selection = remaining[0]?.matchId ?? MR_ONEHOOK;
+ setSelection(next);
+ onSelectMatch?.(next === MR_ONEHOOK ? null : next);
+ setReporting(false);
+ showToast('Unmatched.', 'success');
+ if (onRefetchState) await onRefetchState();
+ await loadMatches();
+ };
+
// Rows for the conversation picker: Mr.OneHook pinned first, then matches.
const pickerRows: (SortableConversation & {
photo?: string;
@@ -447,12 +474,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)}
>
- Report spam
+ Report
>
)}
@@ -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)}
>
- 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,
@@ -999,6 +1056,33 @@ function MatchConversation({
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);
@@ -1296,6 +1380,20 @@ function MatchConversation({
onClose={() => setRecoveryOpen(false)}
onRecovered={() => void refetch()}
/>
+
+ {peerId && (
+ onCloseReport?.()}
+ source="chat"
+ reportedUserId={peerId}
+ reportedName={peerName}
+ conversationId={matchId}
+ sourceMessages={evidenceMessages}
+ onRequestBlock={onReportPeer}
+ onRequestUnmatch={onRequestUnmatch}
+ />
+ )}
>
);
}
@@ -2047,6 +2145,7 @@ function ProfileInspectorModal({
peerName,
peerPhoto,
distanceKm,
+ onReport,
}: {
open: boolean;
onClose: () => void;
@@ -2055,6 +2154,7 @@ function ProfileInspectorModal({
peerName: string;
peerPhoto: string;
distanceKm: number | null;
+ onReport?: () => void;
}) {
return (
@@ -2132,6 +2232,16 @@ function ProfileInspectorModal({
“{peerProfile.bio}”
)}
+ {onReport && (
+
+ )}
>
)}
@@ -2174,7 +2284,7 @@ function ReportBlockModal({
Blocking {peerName} ends this connection and prevents further contact. Your chat history
@@ -2214,7 +2324,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..aa0b5ab
--- /dev/null
+++ b/src/features/moderation/report/ReportFlowDialog.tsx
@@ -0,0 +1,754 @@
+/**
+ * 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 { 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 =
+ source === 'chat' && !!conversationId && !!meta && meta.evidenceEligible && !meta.minorSafety;
+
+ 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;
+ setStep('category');
+ setCategory(null);
+ setDetails('');
+ setAlsoBlock(true);
+ setShare(false);
+ 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 });
+ if (m.selfHarm) {
+ trackReport(ReportEvent.REPORT_SELF_HARM_RESOURCES_SHOWN, { category: code, source });
+ setStep('self_harm');
+ } else {
+ setStep('form');
+ }
+ },
+ [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 = !!meta?.requiresDetails;
+ const detailsTooLong = details.length > DETAILS_MAX_CHARS;
+ const canSubmit =
+ !!meta && !submitting && !detailsTooLong && (!detailsRequired || details.trim().length > 0);
+
+ 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);
+ 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 */}
+
+ {reducedMotion ? (
+
+ ) : (
+
+
+
+ )}
+
{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;
+}) {
+ return (
+
+
+
+
{S.confirmationHeading}
+
+
{S.confirmationBody}
+ {result.blocked && (
+
{S.confirmationBlocked}
+ )}
+ {result.evidenceOutcome === 'attached' && (
+
{S.evidenceAttached}
+ )}
+ {result.evidenceOutcome === 'failed' && (
+
+
{S.evidenceFailed}
+
+
+ )}
+
+ );
+}
diff --git a/src/features/moderation/report/categories.ts b/src/features/moderation/report/categories.ts
index 6154a21..d407bf4 100644
--- a/src/features/moderation/report/categories.ts
+++ b/src/features/moderation/report/categories.ts
@@ -19,7 +19,7 @@ import { ReportCategory } from '../../../lib/analytics/generated/telemetryCatalo
export { ReportCategory };
-export type ReportSeverityTier = 'critical' | 'high' | 'standard';
+export type ReportSeverity = 'critical' | 'high' | 'standard';
export interface CategoryMeta {
code: ReportCategory;
@@ -27,7 +27,7 @@ export interface CategoryMeta {
title: string;
/** One-line description shown under the title. */
description: string;
- tier: ReportSeverityTier;
+ tier: ReportSeverity;
/** Default for the "Also block this person" toggle. */
alsoBlockDefault: boolean;
/** Whether the decrypted-chat evidence option may be offered for this category. */
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');
+ });
+});
From 76401c31c335e8ccb367c823a70d975f52173407 Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 03:50:33 +0530
Subject: [PATCH 04/10] chore(telemetry): vendor catalog 1.7.0
(chat.report_open/block_open; legacy spam/block out of scope)
---
src/lib/analytics/generated/telemetryCatalog.ts | 16 ++++++++++------
1 file changed, 10 insertions(+), 6 deletions(-)
diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts
index 506d7a4..10e01c3 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 df6df589e003512e5d8bc9de08bf989cc28fe3bac66904d9ca22f6150de47642
+// telemetry-integrity: sha256 4a2f9396c7806c61497f25a09d85dc8a6132b5f77141a1eb79dedfc196c24a24
// Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts
// Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync
-// Catalog version 1.6.0, hash 435bb38a1852d74c.
+// Catalog version 1.7.0, hash 9bb71db335ecaebe.
//
// 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.6.0";
-export const TELEMETRY_CATALOG_HASH = "435bb38a1852d74c";
+export const TELEMETRY_CATALOG_VERSION = "1.7.0";
+export const TELEMETRY_CATALOG_HASH = "9bb71db335ecaebe";
/** Every signal type the ingest accepts. */
export const SignalType = {
@@ -361,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",
@@ -615,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"] },
From 6a4df6239fc91d8401b5b6f2aad48f3ac6440494 Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 03:50:47 +0530
Subject: [PATCH 05/10] fix(report): 409 already-completed=attached, terminal
codes hide retry; emit submit telemetry only after 201 (+tests)
---
.../moderation/report/evidence-api.ts | 36 +++
src/features/moderation/report/submit.ts | 123 +++++++----
src/tests/report-evidence-outcomes.test.ts | 205 ++++++++++++++++++
3 files changed, 319 insertions(+), 45 deletions(-)
create mode 100644 src/tests/report-evidence-outcomes.test.ts
diff --git a/src/features/moderation/report/evidence-api.ts b/src/features/moderation/report/evidence-api.ts
index 6a0cdbf..024b2bb 100644
--- a/src/features/moderation/report/evidence-api.ts
+++ b/src/features/moderation/report/evidence-api.ts
@@ -83,6 +83,42 @@ export interface RetryOptions {
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(/\/+$/, '');
}
diff --git a/src/features/moderation/report/submit.ts b/src/features/moderation/report/submit.ts
index fb6ef98..4a42a62 100644
--- a/src/features/moderation/report/submit.ts
+++ b/src/features/moderation/report/submit.ts
@@ -32,13 +32,24 @@ 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';
-export type EvidenceOutcome = 'skipped' | 'not_allowed' | 'empty' | 'attached' | 'failed';
+/**
+ * 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;
@@ -91,16 +102,23 @@ export function buildReportBody(sub: ReportSubmission): CreateReportRequest {
/**
* 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). Throws on
- * failure (the caller decides whether that fails the report — it must NOT). Returns the outcome
- * `'attached'`, or `'empty'` when there is nothing to share.
+ * 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> {
+): 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)
@@ -112,41 +130,51 @@ export async function uploadEvidence(
});
if (window.length === 0) return 'empty';
- 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);
-
- await completeEvidence(
- transport,
- { reportId, evidenceId: presign.evidenceId, messageIds, firstSentAt, lastSentAt },
- deps.evidenceRetry
- );
- return 'attached';
+ 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;
+ }
}
/**
@@ -159,12 +187,9 @@ export async function submitReport(sub: ReportSubmission, deps: SubmitDeps = {})
const emit = deps.telemetry ?? trackReport;
const evidenceOffered = sub.shareEvidence;
- emit(evidenceOffered ? ReportEvent.REPORT_SUBMIT_WITH_EVIDENCE : ReportEvent.REPORT_SUBMIT_WITHOUT_EVIDENCE, {
- category: sub.category,
- source: sub.source,
- evidence: evidenceOffered,
- });
+ // 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));
@@ -173,6 +198,12 @@ export async function submitReport(sub: ReportSubmission, deps: SubmitDeps = {})
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,
@@ -194,6 +225,8 @@ export async function submitReport(sub: ReportSubmission, deps: SubmitDeps = {})
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 });
}
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']);
+ });
+});
From 7ab71405a374ef63fd0e7a8c379be7b314f36e7d Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 03:50:59 +0530
Subject: [PATCH 06/10] fix(report): extract dialog decision logic into a pure
module the dialog uses; calm terminal note (+behaviour tests, no DOM env)
---
.../moderation/report/ReportFlowDialog.tsx | 67 +++++---
.../moderation/report/dialog-logic.ts | 156 ++++++++++++++++++
src/features/moderation/report/strings.ts | 2 +
src/tests/report-dialog-logic.test.ts | 137 +++++++++++++++
4 files changed, 338 insertions(+), 24 deletions(-)
create mode 100644 src/features/moderation/report/dialog-logic.ts
create mode 100644 src/tests/report-dialog-logic.test.ts
diff --git a/src/features/moderation/report/ReportFlowDialog.tsx b/src/features/moderation/report/ReportFlowDialog.tsx
index aa0b5ab..5967cd6 100644
--- a/src/features/moderation/report/ReportFlowDialog.tsx
+++ b/src/features/moderation/report/ReportFlowDialog.tsx
@@ -34,6 +34,18 @@ import {
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';
@@ -107,8 +119,7 @@ export function ReportFlowDialog({
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 =
- source === 'chat' && !!conversationId && !!meta && meta.evidenceEligible && !meta.minorSafety;
+ const evidenceEligible = isEvidenceEligible({ source, conversationId, meta });
const previewWindow = useMemo(
() => (evidenceEligible ? selectEvidenceWindow(sourceMessages ?? []) : []),
@@ -118,11 +129,12 @@ export function ReportFlowDialog({
// Reset to a clean state whenever the dialog opens, and emit report.open.
useEffect(() => {
if (!open) return;
- setStep('category');
- setCategory(null);
- setDetails('');
- setAlsoBlock(true);
- setShare(false);
+ 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);
@@ -177,12 +189,11 @@ export function ReportFlowDialog({
const m = getCategoryMeta(code);
setAlsoBlock(m.alsoBlockDefault);
trackReport(ReportEvent.REPORT_CATEGORY_SELECTED, { category: code, source });
- if (m.selfHarm) {
+ const next = stepForCategory(m);
+ if (next === 'self_harm') {
trackReport(ReportEvent.REPORT_SELF_HARM_RESOURCES_SHOWN, { category: code, source });
- setStep('self_harm');
- } else {
- setStep('form');
}
+ setStep(next);
},
[source]
);
@@ -209,10 +220,9 @@ export function ReportFlowDialog({
setStep('preview');
}, [category, source]);
- const detailsRequired = !!meta?.requiresDetails;
- const detailsTooLong = details.length > DETAILS_MAX_CHARS;
- const canSubmit =
- !!meta && !submitting && !detailsTooLong && (!detailsRequired || details.trim().length > 0);
+ const detailsRequired = detailsRequiredFor(meta);
+ const detailsTooLong = detailsTooLongFor(details);
+ const canSubmit = canSubmitDecision({ meta, submitting, details });
const handleSubmit = useCallback(async () => {
if (!meta || !canSubmit) return;
@@ -247,10 +257,12 @@ export function ReportFlowDialog({
setRetrying(true);
try {
const outcome = await uploadEvidence(result.reportId, conversationId ?? '', sourceMessages ?? [], deps);
- trackReport(ReportEvent.REPORT_EVIDENCE_UPLOAD_SUCCEEDED, {
- category: category ?? undefined,
- source,
- });
+ 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 });
@@ -526,7 +538,7 @@ function SelfHarmStep({ onContinue }: { onContinue: () => void }) {
@@ -638,7 +650,7 @@ function FormStep({
{/* Nudge: ~1s non-looping shield; static when reduced motion */}
- {reducedMotion ? (
+ {nudgeMode(reducedMotion) === 'static' ? (
) : (
void;
}) {
+ const view: ConfirmationView = confirmationView({
+ blocked: result.blocked,
+ evidenceOutcome: result.evidenceOutcome,
+ });
return (
@@ -729,13 +745,16 @@ function ConfirmationStep({
{S.confirmationHeading}
{S.confirmationBody}
- {result.blocked && (
+ {view.blocked && (
{S.confirmationBlocked}
)}
- {result.evidenceOutcome === 'attached' && (
+ {view.attached && (
{S.evidenceAttached}
)}
- {result.evidenceOutcome === 'failed' && (
+ {view.terminalNote && (
+
{S.evidenceTerminalNote}
+ )}
+ {view.retry && (
{S.evidenceFailed}
@@ -646,7 +646,7 @@ export function ChatView({
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
@@ -659,7 +659,7 @@ export function ChatView({
setReportReason('OTHER');
}}
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_BLOCK)}
+ {...trackProps(UiElement.CHAT_BLOCK_OPEN)}
>
Block
@@ -1052,6 +1052,7 @@ function MatchConversation({
markAsDelivered,
markAsRead,
refetch,
+ loadEvidenceWindow,
hasUndecryptable,
historyHorizon,
} = useChatMessages(matchId, peerId);
@@ -1088,6 +1089,29 @@ function MatchConversation({
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.
@@ -1389,7 +1413,7 @@ function MatchConversation({
reportedUserId={peerId}
reportedName={peerName}
conversationId={matchId}
- sourceMessages={evidenceMessages}
+ sourceMessages={fullEvidence ?? evidenceMessages}
onRequestBlock={onReportPeer}
onRequestUnmatch={onRequestUnmatch}
/>
From 7221a7fd007d15a3e0a03090bc095fb8cc2eb139 Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 03:53:16 +0530
Subject: [PATCH 09/10] telemetry: vendor catalog 1.7.0 (final)
---
src/lib/analytics/generated/telemetryCatalog.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/lib/analytics/generated/telemetryCatalog.ts b/src/lib/analytics/generated/telemetryCatalog.ts
index 10e01c3..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 4a2f9396c7806c61497f25a09d85dc8a6132b5f77141a1eb79dedfc196c24a24
+// 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.7.0, hash 9bb71db335ecaebe.
+// 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.7.0";
-export const TELEMETRY_CATALOG_HASH = "9bb71db335ecaebe";
+export const TELEMETRY_CATALOG_HASH = "4a3328962531236a";
/** Every signal type the ingest accepts. */
export const SignalType = {
From c0d9d7eb6c10085b3f7dfe060e8b0d5dae912ca8 Mon Sep 17 00:00:00 2001
From: Pushp
Date: Tue, 27 Oct 2026 04:12:25 +0530
Subject: [PATCH 10/10] report(web): drop C0-framed structured payloads
(attachment/call signal) from evidence
---
.../moderation/report/window-selection.ts | 19 ++++++++++++++++++-
src/tests/report-window-categories.test.ts | 13 +++++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/src/features/moderation/report/window-selection.ts b/src/features/moderation/report/window-selection.ts
index e79d641..8703adf 100644
--- a/src/features/moderation/report/window-selection.ts
+++ b/src/features/moderation/report/window-selection.ts
@@ -58,7 +58,11 @@ export function selectEvidenceWindow(
const cutoffMs = nowMs - maxDays * MS_PER_DAY;
const eligible = messages.filter(
- (m) => !m.undecryptable && typeof m.text === 'string' && m.sentAtMs >= cutoffMs
+ (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.
@@ -80,3 +84,16 @@ export function selectEvidenceWindow(
// 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/tests/report-window-categories.test.ts b/src/tests/report-window-categories.test.ts
index e237daf..6015f36 100644
--- a/src/tests/report-window-categories.test.ts
+++ b/src/tests/report-window-categories.test.ts
@@ -62,6 +62,19 @@ describe('selectEvidenceWindow', () => {
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(
[