Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,56 @@ export const ReceivedLikesApi = {
list: (): Promise<ReceivedLikesResponse> =>
request<ReceivedLikesResponse>('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<CreateReportResponse> =>
request<CreateReportResponse>('POST', '/matching/reports', body),
};
2 changes: 1 addition & 1 deletion src/components/chat/AttachmentBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function AttachmentBubble({
<button
type="button"
onClick={onReportPeerMedia}
{...trackProps(UiElement.CHAT_REPORT_BLOCK)}
{...trackProps(UiElement.CHAT_REPORT_OPEN)}
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] font-black opacity-60 hover:opacity-100 transition-opacity"
>
<Flag className="w-3 h-3" aria-hidden="true" />
Expand Down
10 changes: 10 additions & 0 deletions src/config/deployment.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
158 changes: 146 additions & 12 deletions src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -138,6 +140,11 @@ export function ChatView({
const [profileOpen, setProfileOpen] = useState(false);
const [reportReason, setReportReason] = useState<BlockReason | null>(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<HTMLElement | null>(() => {
if (typeof document !== 'undefined') {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)}
{...trackProps(UiElement.CHAT_REPORT_OPEN)}
>
Report spam
Report
</button>
<button
type="button"
Expand All @@ -463,9 +490,9 @@ 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)}
>
Report &amp; block
Block
</button>
</>
)}
Expand Down Expand Up @@ -616,12 +643,12 @@ export function ChatView({
disabled={reporting}
onClick={() => {
setShieldOpen(false);
void runReportAndBlock('SPAM');
setChatReportOpen(true);
}}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-surface-hover transition-colors text-red-500 disabled:opacity-40 cursor-pointer"
{...trackProps(UiElement.CHAT_REPORT_SPAM)}
{...trackProps(UiElement.CHAT_REPORT_OPEN)}
>
Report spam
Report
</button>
<button
type="button"
Expand All @@ -632,9 +659,9 @@ 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)}
>
Report &amp; block
Block
</button>
</>
)}
Expand Down Expand Up @@ -713,6 +740,9 @@ export function ChatView({
currentUserId={currentUser.id}
wallpaper={wallpaper}
onReportPeer={() => setReportReason('OTHER')}
reportOpen={chatReportOpen}
onCloseReport={() => setChatReportOpen(false)}
onRequestUnmatch={() => void runUnmatch()}
/>
) : (
<EmptyState onNavigateToDiscovery={onNavigateToDiscovery} onOpenPicker={() => setPickerOpen(true)} />
Expand Down Expand Up @@ -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 && (
<ReportFlowDialog
open={profileReportOpen}
onClose={() => setProfileReportOpen(false)}
source="profile"
reportedUserId={activeMatch.peerId}
reportedName={peerName}
onRequestBlock={() => setReportReason('OTHER')}
onRequestUnmatch={() => void runUnmatch()}
/>
)}

{/* ── REPORT & BLOCK (reason picker) ────────────────────────────────── */}
<ReportBlockModal
open={reportReason !== null}
Expand Down Expand Up @@ -974,6 +1025,9 @@ function MatchConversation({
currentUserId,
wallpaper,
onReportPeer,
reportOpen,
onCloseReport,
onRequestUnmatch,
}: {
key?: string;
matchId: string;
Expand All @@ -982,6 +1036,9 @@ function MatchConversation({
currentUserId: string;
wallpaper: ChatWallpaper;
onReportPeer?: () => void;
reportOpen?: boolean;
onCloseReport?: () => void;
onRequestUnmatch?: () => void;
}) {
const {
messages,
Expand All @@ -995,15 +1052,66 @@ function MatchConversation({
markAsDelivered,
markAsRead,
refetch,
loadEvidenceWindow,
hasUndecryptable,
historyHorizon,
} = useChatMessages(matchId, peerId);
const [input, setInput] = useState('');

// Snapshot of decrypted messages for the optional evidence share. Text comes from the SAME local
// decrypt the chat UI uses (the E2EE hook); attachments/undecryptable/system messages map to a
// null/undecryptable entry so the window selector skips them. Never leaves the device unless the
// reporter ticks the (never pre-ticked) share box and submits.
const evidenceMessages: SourceMessage[] = useMemo(
() =>
messages.map((m) => {
let text: string | null = null;
if (!m.undecryptable) {
try {
const decoded = decodeMessagePlaintext(m.ciphertext);
text = decoded.type === 'attachment' ? null : decoded.text;
} catch {
text = null;
}
}
return {
messageId: m.messageId,
sentAtMs: m.timestamp,
senderSide: m.senderId === 'me' ? 'reporter' : 'reported',
text,
undecryptable: m.undecryptable,
};
}),
[messages]
);
const { showToast } = useToast();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [recoveryOpen, setRecoveryOpen] = useState(false);
const [dragActive, setDragActive] = useState(false);

// When the report dialog opens, page + decrypt the COMPLETE evidence window (the live `messages`
// snapshot is a single oldest-first page and can omit the most recent messages in a long chat).
// Until it resolves, the snapshot is used as a non-blocking fallback. Reset on close so a decrypted
// copy never lingers.
const [fullEvidence, setFullEvidence] = useState<SourceMessage[] | null>(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.
Expand Down Expand Up @@ -1296,6 +1404,20 @@ function MatchConversation({
onClose={() => setRecoveryOpen(false)}
onRecovered={() => void refetch()}
/>

{peerId && (
<ReportFlowDialog
open={!!reportOpen}
onClose={() => onCloseReport?.()}
source="chat"
reportedUserId={peerId}
reportedName={peerName}
conversationId={matchId}
sourceMessages={fullEvidence ?? evidenceMessages}
onRequestBlock={onReportPeer}
onRequestUnmatch={onRequestUnmatch}
/>
)}
</>
);
}
Expand Down Expand Up @@ -2047,6 +2169,7 @@ function ProfileInspectorModal({
peerName,
peerPhoto,
distanceKm,
onReport,
}: {
open: boolean;
onClose: () => void;
Expand All @@ -2055,6 +2178,7 @@ function ProfileInspectorModal({
peerName: string;
peerPhoto: string;
distanceKm: number | null;
onReport?: () => void;
}) {
return (
<AnimatePresence>
Expand Down Expand Up @@ -2132,6 +2256,16 @@ function ProfileInspectorModal({
&ldquo;{peerProfile.bio}&rdquo;
</p>
)}
{onReport && (
<button
type="button"
onClick={onReport}
className="w-full mt-2 py-3 border border-border text-sm font-semibold rounded-xl hover:bg-surface-hover transition-colors text-red-500 cursor-pointer"
data-track-exempt="duplicate: opens the member report flow from the profile; reported via the report_flow signal (report.open)."
>
Report
</button>
)}
</>
)}
</div>
Expand Down Expand Up @@ -2174,7 +2308,7 @@ function ReportBlockModal({
<div className="max-w-md w-full bg-surface-card text-text border border-border p-8 space-y-6 shadow-2xl rounded-2xl">
<div className="flex items-center gap-3 text-red-500">
<AlertTriangle className="w-6 h-6" />
<h3 className="text-xl font-bold text-text">Report &amp; block</h3>
<h3 className="text-xl font-bold text-text">Block</h3>
</div>
<p className="text-xs text-text-secondary leading-relaxed">
Blocking {peerName} ends this connection and prevents further contact. Your chat history
Expand Down Expand Up @@ -2214,7 +2348,7 @@ function ReportBlockModal({
{...trackProps(UiElement.CHAT_BLOCK_CONFIRM)}
className="flex-1 py-3 bg-red-600 text-white text-xs font-semibold rounded-xl hover:bg-red-700 transition-colors flex items-center justify-center gap-2 cursor-pointer"
>
{submitting ? <LoadingSpinner size="sm" /> : 'Block & report'}
{submitting ? <LoadingSpinner size="sm" /> : 'Block'}
</button>
</div>
</div>
Expand Down
Loading
Loading