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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"jsqr": "1.4.0",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"onehook-api-client": "^1.0.32",
"onehook-api-client": "^1.0.33",
"qrcode.react": "^4.2.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
Expand Down
11 changes: 11 additions & 0 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ export const BlocksApi = {
request('DELETE', `/matching/blocks/${encodeURIComponent(blockedUserId)}`),
};

// ── Mr. OneHook suggestion ────────────────────────────────────────────────────

/**
* The member's own OneHook suggestion (what they last asked Mr. OneHook for). One at a time: a new request
* replaces it, and only the member removes it. The current one comes with the preferences
* (`currentSuggestion`).
*/
export const SuggestionApi = {
remove: (): Promise<void> => request('DELETE', '/matching/suggestions'),
};

// ── Chat settings ─────────────────────────────────────────────────────────────

export type LastSeenVisibility = 'EVERYONE' | 'MATCHES' | 'NOBODY';
Expand Down
10 changes: 7 additions & 3 deletions src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1717,16 +1717,20 @@ function MrOneHookChatBody({
disabled={loading || pendingReview !== null || unavailable}
placeholder={
unavailable
? availability === 'NOT_ENTITLED'
? 'Mr.OneHook isn’t available on your account yet.'
? availability === 'LIMIT_REACHED'
? 'Today’s free messages are used up. Upgrade to Super or come back tomorrow.'
: availability === 'NOT_AUTHENTICATED'
? 'Sign in again to chat with Mr.OneHook.'
: 'Mr.OneHook is temporarily unavailable.'
: pendingReview
? 'Review the excerpts above first…'
: 'Say something to Mr.OneHook…'
}
footerRight="OneHook AI · Always Here"
footerRight={
typeof chat.remainingMessages === 'number'
? `${chat.remainingMessages} free message${chat.remainingMessages === 1 ? '' : 's'} left today`
: 'OneHook AI · Always Here'
}
/>
</>
);
Expand Down
67 changes: 67 additions & 0 deletions src/features/profile/CurrentSuggestionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useState } from 'react';
import { Sparkles } from 'lucide-react';
import { SuggestionApi } from '../../api/rest';
import { trackProps, UiElement } from '../../lib/analytics/analytics';

export interface CurrentSuggestion {
suggestionId: string;
reasoning: string;
createdAt: string;
}

/**
* The member's current Mr. OneHook request, shown in their match settings. One at a time: asking Mr. OneHook
* again replaces it; Remove is the only other way it goes away. Nothing renders when there is none.
*/
export function CurrentSuggestionCard({
suggestion,
onRemoved,
}: {
suggestion?: CurrentSuggestion | null;
onRemoved: () => void;
}) {
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!suggestion) return null;

const since = Number.isFinite(Date.parse(suggestion.createdAt))
? new Date(suggestion.createdAt).toLocaleDateString()
: null;

const remove = async () => {
setRemoving(true);
setError(null);
try {
await SuggestionApi.remove();
onRemoved();
} catch {
setError("Couldn't remove it right now. Please try again.");
} finally {
setRemoving(false);
}
};

return (
<div className="bg-surface border border-border rounded-xl p-4 space-y-2" data-testid="current-suggestion">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-text-secondary">
<Sparkles className="w-3.5 h-3.5" /> Your Mr. OneHook request
</div>
<p className="text-sm text-text italic">{suggestion.reasoning}</p>
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] text-text-secondary">
{since ? `Since ${since}. ` : ''}Asking Mr. OneHook again replaces it.
</span>
<button
type="button"
onClick={remove}
disabled={removing}
{...trackProps(UiElement.PREFERENCES_SUGGESTION_REMOVE)}
className="shrink-0 px-3 py-1.5 rounded-lg border border-border text-xs font-semibold text-text hover:bg-surface-card disabled:opacity-50 cursor-pointer"
>
{removing ? 'Removing…' : 'Remove'}
</button>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
8 changes: 7 additions & 1 deletion src/features/profile/ProfileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from 'lucide-react';
import type { UserProfile } from '../../types';
import { ProfileApi } from '../../api/profile';
import { CurrentSuggestionCard } from './CurrentSuggestionCard';
import { usePreferences } from '../../hooks/use-api';
import { useAppStore } from '../../store/app-store';
import { useToast } from '../../components/common/Toast';
Expand Down Expand Up @@ -174,7 +175,7 @@ export function ProfileView({
? subscriptionTierDisplayName(userState.subscriptionTier)
: '—';
const { showToast } = useToast();
const { prefs, save: savePreferences, saving } = usePreferences(user.id);
const { prefs, save: savePreferences, saving, refetch: refetchPreferences } = usePreferences(user.id);
const fieldStatus = user.fieldModerationStatus || {};

// Owner-only photo stats (identity from the JWT server-side) + full-screen viewer for own photos.
Expand Down Expand Up @@ -1381,6 +1382,11 @@ export function ProfileView({
</Field>
</div>

<CurrentSuggestionCard
suggestion={prefs?.currentSuggestion}
onRemoved={() => void refetchPreferences()}
/>

<div className="grid grid-cols-2 gap-4">
<Field label="Relationship Goal">
<select
Expand Down
10 changes: 6 additions & 4 deletions src/lib/analytics/generated/telemetryCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// GENERATED FILE - DO NOT EDIT.
// telemetry-integrity: sha256 58537cc25dd8d2b5efc5013938a7736b405cb73c3dde1478ad852430db1e082c
// telemetry-integrity: sha256 f28411e1bad31e806a4ac986fa0ffda82791c4c11dd8cb802e9c5e256587016e
// Source of truth: OneHookBackend/packages/telemetry/src/catalog.ts
// Regenerate: (OneHookBackend) npm run telemetry:generate && npm run telemetry:sync
// Catalog version 1.9.0, hash 7f0a0b33ce88bee5.
// Catalog version 1.10.0, hash 1d9a5b9cd896cae9.
//
// 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.9.0";
export const TELEMETRY_CATALOG_HASH = "7f0a0b33ce88bee5";
export const TELEMETRY_CATALOG_VERSION = "1.10.0";
export const TELEMETRY_CATALOG_HASH = "1d9a5b9cd896cae9";

/** Every signal type the ingest accepts. */
export const SignalType = {
Expand Down Expand Up @@ -452,6 +452,7 @@ export const UiElement = {
PREFERENCES_SAVE: "preferences.save",
PREFERENCES_UPGRADE_OPEN: "preferences.upgrade_open",
PREFERENCES_LOCKED_FILTER_TAP: "preferences.locked_filter_tap",
PREFERENCES_SUGGESTION_REMOVE: "preferences.suggestion_remove",
SETTINGS_EMAIL_MANAGE: "settings.email_manage",
SETTINGS_PHONE_UPDATE: "settings.phone_update",
SETTINGS_GOOGLE_LINK: "settings.google_link",
Expand Down Expand Up @@ -709,6 +710,7 @@ export const UI_ELEMENTS: { readonly [K in UiElementId]: UiElementSpec } = {
"preferences.save": { screen: "PREFERENCES", kind: "button", actions: ["submit"], values: null, platforms: ["ios", "android"] },
"preferences.upgrade_open": { screen: "PREFERENCES", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["ios"] },
"preferences.locked_filter_tap": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["ios"] },
"preferences.suggestion_remove": { screen: "PREFERENCES", kind: "button", actions: ["tap"], values: null, platforms: ["web", "ios", "android"] },
"settings.email_manage": { screen: "SETTINGS", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["web", "ios", "android"] },
"settings.phone_update": { screen: "SETTINGS", kind: "button", actions: ["tap", "dismiss"], values: null, platforms: ["ios", "android"] },
"settings.google_link": { screen: "SETTINGS", kind: "toggle", actions: ["toggle"], values: ["link", "unlink"], platforms: ["web", "ios", "android"] },
Expand Down
23 changes: 13 additions & 10 deletions src/lib/mr-onehook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ export interface MemberChatResponse {
mood: MrOneHookMood;
needsMoreContext: boolean;
matchSuggestion?: MemberMatchSuggestion;
/** Free tier only: messages left today (the server counts; absent for Super and above). */
remainingMessages?: number;
contextRequest?: MemberContextRequest;
contextUsed?: MemberContextUsed;
requestId?: string;
Expand Down Expand Up @@ -260,6 +262,9 @@ export function parseMemberChatResponse(data: unknown): MemberChatResponse {
if (typeof data.requestId === 'string') response.requestId = data.requestId;
const session = parseSessionMetadata(data.session);
if (session) response.session = session;
if (typeof data.remainingMessages === 'number' && Number.isFinite(data.remainingMessages)) {
response.remainingMessages = Math.max(0, Math.floor(data.remainingMessages));
}
if (isRecord(data.matchSuggestion)
&& (data.matchSuggestion.status === 'RECORDED'
|| data.matchSuggestion.status === 'PREMIUM_REQUIRED'
Expand Down Expand Up @@ -323,22 +328,21 @@ export function isSessionErrorCode(code: unknown): boolean {
* Why a member request was refused in a way the UI must treat as terminal (not a transient "try
* again"). These map to the backend auth/permission gate on `POST .../product-connection-chat`:
*
* • NOT_ENTITLED — HTTP 403 `INSUFFICIENT_PERMISSION`: the signed-in member is not entitled to
* Mr.OneHook (not in the `mr-onehook` group / lacks the `context.chat`
* permission). Retrying will not help; the entry point should be hidden or
* disabled and a clear "not available for your account" message shown.
* • LIMIT_REACHED — HTTP 402 `MESSAGE_LIMIT_REACHED`: a free (Must) member has used today's 10
* messages. Every member can chat; this resets at the next UTC midnight or on
* upgrading to Super.
* • NOT_AUTHENTICATED — HTTP 401 `AUTH_REQUIRED` / `INVALID_ACCESS_TOKEN`: the session is missing or
* expired; prompt the user to sign in again.
* • NOT_CONFIGURED — HTTP 404 `ENDPOINT_NOT_FOUND` / 503 `CONTEXT_NOT_CONFIGURED`: the feature is
* not enabled in this environment. Treat as unavailable, not as a user error.
*/
export type MemberChatAvailability =
| 'AVAILABLE'
| 'NOT_ENTITLED'
| 'LIMIT_REACHED'
| 'NOT_AUTHENTICATED'
| 'NOT_CONFIGURED';

const NOT_ENTITLED_CODES: ReadonlySet<string> = new Set(['INSUFFICIENT_PERMISSION']);
const LIMIT_REACHED_CODES: ReadonlySet<string> = new Set(['MESSAGE_LIMIT_REACHED']);
const NOT_AUTHENTICATED_CODES: ReadonlySet<string> = new Set([
'AUTH_REQUIRED',
'INVALID_ACCESS_TOKEN',
Expand All @@ -353,22 +357,21 @@ const NOT_CONFIGURED_CODES: ReadonlySet<string> = new Set([
* undefined when the failure is NOT one of these (i.e. a transient/server error, OR a
* recognised-but-unrelated per-request error such as a validation/session code the caller handles
* elsewhere). The structured `error` code is authoritative; the HTTP status is only a fallback when
* no recognised code is present, so a bare 403 is treated as not-entitled only when the server sent
* no code at all.
* no recognised code is present.
*/
export function classifyMemberChatAvailability(
status: number,
errorCode: unknown,
): Exclude<MemberChatAvailability, 'AVAILABLE'> | undefined {
const code = typeof errorCode === 'string' ? errorCode : '';
if (code) {
if (NOT_ENTITLED_CODES.has(code)) return 'NOT_ENTITLED';
if (LIMIT_REACHED_CODES.has(code)) return 'LIMIT_REACHED';
if (NOT_AUTHENTICATED_CODES.has(code)) return 'NOT_AUTHENTICATED';
if (NOT_CONFIGURED_CODES.has(code)) return 'NOT_CONFIGURED';
// A recognised-but-unrelated code (session/validation) is not a feature-availability outcome.
return undefined;
}
if (status === 403) return 'NOT_ENTITLED';
if (status === 402) return 'LIMIT_REACHED';
if (status === 401) return 'NOT_AUTHENTICATED';
if (status === 404) return 'NOT_CONFIGURED';
return undefined;
Expand Down
13 changes: 9 additions & 4 deletions src/lib/mr-onehook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,17 @@ const SERVER_ERROR_COPY =
const NETWORK_ERROR_COPY =
"Hmm, I can't connect right now. Check your connection and try again in a moment.";
// Terminal availability copy — shown once, not retryable, and used to gate the entry point.
const NOT_ENTITLED_COPY =
'Mr.OneHook isn’t available on your account yet. It’s rolling out gradually — check back soon.';
const LIMIT_REACHED_COPY =
'You’ve used today’s 10 free messages with Mr.OneHook. Upgrade to Super to keep chatting, or come back tomorrow.';
const NOT_AUTHENTICATED_COPY =
'Please sign in again to chat with Mr.OneHook.';
const NOT_CONFIGURED_COPY =
'Mr.OneHook is temporarily unavailable. Please try again later.';

function availabilityCopy(outcome: Exclude<MemberChatAvailability, 'AVAILABLE'>): string {
switch (outcome) {
case 'NOT_ENTITLED':
return NOT_ENTITLED_COPY;
case 'LIMIT_REACHED':
return LIMIT_REACHED_COPY;
case 'NOT_AUTHENTICATED':
return NOT_AUTHENTICATED_COPY;
case 'NOT_CONFIGURED':
Expand Down Expand Up @@ -110,6 +110,8 @@ export interface UseMrOneHookChat {
* stop accepting input once this is anything else.
*/
availability: MemberChatAvailability;
/** Free tier: messages left today, from the last reply (undefined for Super+ or before the first reply). */
remainingMessages?: number;
send: (text: string) => Promise<void>;
confirmReview: () => Promise<void>;
cancelReview: () => void;
Expand Down Expand Up @@ -206,6 +208,7 @@ export function useMrOneHookChat(options: UseMrOneHookChatOptions): UseMrOneHook
const [session, setSession] = useState<MemberSessionMetadata | null>(null);
const [pendingReview, setPendingReview] = useState<PendingExcerptReview | null>(null);
const [availability, setAvailability] = useState<MemberChatAvailability>('AVAILABLE');
const [remainingMessages, setRemainingMessages] = useState<number | undefined>(undefined);

const sessionIdRef = useRef<string | undefined>(loadSessionId(userId, scope));
const resolveRef = useRef<LocalExcerptResolver | undefined>(resolveLocalExcerpts);
Expand Down Expand Up @@ -370,6 +373,7 @@ export function useMrOneHookChat(options: UseMrOneHookChatOptions): UseMrOneHook
: result.reply;
setMessages((prev) => [...prev, { role: 'assistant', content }]);
setMood(result.mood || 'Happy');
setRemainingMessages(result.remainingMessages);
},
[applySession, matchId],
);
Expand Down Expand Up @@ -448,6 +452,7 @@ export function useMrOneHookChat(options: UseMrOneHookChatOptions): UseMrOneHook
session,
pendingReview,
availability,
remainingMessages,
send,
confirmReview,
cancelReview,
Expand Down
17 changes: 13 additions & 4 deletions src/tests/mr-onehook-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,19 @@ describe('endpoint paths', () => {
});

describe('classifyMemberChatAvailability — entitlement/auth gating', () => {
it('classifies 403 INSUFFICIENT_PERMISSION as NOT_ENTITLED', () => {
expect(classifyMemberChatAvailability(403, 'INSUFFICIENT_PERMISSION')).toBe('NOT_ENTITLED');
// Status alone is enough even if the code is absent/changed.
expect(classifyMemberChatAvailability(403, undefined)).toBe('NOT_ENTITLED');
it('classifies 402 MESSAGE_LIMIT_REACHED as LIMIT_REACHED (free tier daily allowance)', () => {
expect(classifyMemberChatAvailability(402, 'MESSAGE_LIMIT_REACHED')).toBe('LIMIT_REACHED');
expect(classifyMemberChatAvailability(402, undefined)).toBe('LIMIT_REACHED');
});

it('no longer has a group/permission gate: a 403 is not an availability outcome', () => {
expect(classifyMemberChatAvailability(403, 'INSUFFICIENT_PERMISSION')).toBeUndefined();
expect(classifyMemberChatAvailability(403, undefined)).toBeUndefined();
});

it('parses remainingMessages for free members', () => {
expect(parseMemberChatResponse({ reply: 'x', mood: 'Happy', remainingMessages: 3 }).remainingMessages).toBe(3);
expect(parseMemberChatResponse({ reply: 'x', mood: 'Happy' }).remainingMessages).toBeUndefined();
});

it('classifies 401 auth codes as NOT_AUTHENTICATED', () => {
Expand Down
Loading