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
Empty file removed found.txt
Empty file.
7 changes: 4 additions & 3 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 @@ -34,7 +34,6 @@
"dependencies": {
"@aws-amplify/ui-react": "^6.15.5",
"@aws-amplify/ui-react-liveness": "^3.6.8",
"onehook-api-client": "file:../OneHookBackend/packages/api-models/build/smithy/source/typescript-codegen",
"@rolldown/pluginutils": "^1.0.1",
"@smithy/fetch-http-handler": "^5.6.13",
"@tailwindcss/vite": "^4.1.14",
Expand All @@ -44,6 +43,7 @@
"constructs": "^10.6.0",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"onehook-api-client": "^1.0.6",
"qrcode.react": "^4.2.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
Expand Down
Empty file removed served.js
Empty file.
127 changes: 127 additions & 0 deletions src/api/chat.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { generateClient } from 'aws-amplify/api';
import { sdkClient } from './sdk-client';
import { apiBaseUrl } from '../utils/env.config';
import type {
Message as ChatMessage,
MessageReceipt,
Expand Down Expand Up @@ -251,6 +252,105 @@ export const ChatMessagingApi = {
},
};

// ─────────────────────────────────────────────────────────────────────────────
// Multi-device registry (wire v2)
//
// The generated Smithy SDK does not (yet) model the `/chat/devices` routes, so these are issued as
// raw authenticated REST calls against the same API domain and Cognito authorizer the SDK uses. The
// request shape mirrors the SDK's `sdk-client.ts` middleware exactly: a Cognito JWT Bearer token and
// an `X-User-Id` header derived from its `sub`. Kept deliberately thin — no plaintext ever crosses
// this boundary; only public device/history keys and ciphertext envelopes do.
// ─────────────────────────────────────────────────────────────────────────────

export interface DeviceRecord {
deviceId: string;
/** Base64 SPKI (P-256 ECDH) public key of the device. */
publicKey: string;
platform: string;
displayName?: string;
maxEnvelopeVersion: number;
createdAt?: number;
lastSeenAt?: number;
revoked?: boolean;
}

export interface AccountHistoryKey {
keyId: string;
/** Base64 SPKI (P-256 ECDH) public key of the account-history key. */
publicKey: string;
}

export interface DeviceRegistry {
devices: DeviceRecord[];
accountHistoryKey?: AccountHistoryKey;
}

export interface RegisterDeviceRequest {
deviceId: string;
publicKey: string;
platform: string;
displayName?: string;
maxEnvelopeVersion: number;
accountHistoryKey?: AccountHistoryKey;
}

export interface RegisterDeviceResponse {
device: DeviceRecord;
accountHistoryKey?: AccountHistoryKey;
ownsAccountHistoryKey: boolean;
}

async function chatRestBaseUrl(): Promise<string> {
if (apiBaseUrl && apiBaseUrl.startsWith('http')) return apiBaseUrl.replace(/\/+$/, '');
if (typeof window !== 'undefined' && window.location?.origin) {
const origin = window.location.origin;
return apiBaseUrl ? `${origin}/${apiBaseUrl.replace(/^\/+|\/+$/g, '')}` : origin;
}
return apiBaseUrl || '';
}

async function chatAuthHeaders(): Promise<Record<string, string>> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (typeof window === 'undefined') return headers;
try {
const { fetchAuthSession } = await import('aws-amplify/auth');
const session = await fetchAuthSession();
const token =
session.tokens?.idToken?.toString() || session.tokens?.accessToken?.toString() || null;
if (token) {
headers['Authorization'] = `Bearer ${token}`;
const sub =
(session.tokens?.idToken?.payload?.sub as string) ||
(session.tokens?.accessToken?.payload?.sub as string) ||
'';
if (sub) headers['X-User-Id'] = sub;
}
} catch {
// Unauthenticated callers get no Authorization header; the API rejects them, which surfaces as
// a thrown error below rather than a silent success.
}
return headers;
}

async function chatRest<T>(method: string, path: string, body?: unknown): Promise<T> {
const base = await chatRestBaseUrl();
const headers = await chatAuthHeaders();
const response = await fetch(`${base}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`chat device request failed (${response.status} ${response.statusText})${text ? `: ${text}` : ''}`
);
}
if (response.status === 204) return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}

/**
* E2EE key-server + match management, served over REST via the generated SDK.
*/
Expand All @@ -274,4 +374,31 @@ export const ChatApi = {

/** Hard-delete all messages for a match (PREMIUM, e.g. on unmatch). */
deleteMatchMessages: (matchId: string) => sdkClient.deleteMatchMessages({ matchId }),

// --- Multi-device registry (wire v2) ---

/**
* Register (or refresh) this device in the caller's device registry and, on first registration,
* propose an account-history key. Returns the caller's full device list plus the canonical
* account-history key and whether the proposed key was accepted.
*/
registerDevice: (request: RegisterDeviceRequest): Promise<RegisterDeviceResponse> =>
chatRest<RegisterDeviceResponse>('POST', '/chat/devices', request),

/** Fetch the caller's own device registry. */
getSelfDevices: (): Promise<DeviceRegistry> => chatRest<DeviceRegistry>('GET', '/chat/devices'),

/**
* Fetch a peer's device registry for a shared match. The server authorizes the read against a
* State-verified mutual match, so `matchId` is required.
*/
getPeerDevices: (userId: string, matchId: string): Promise<DeviceRegistry> =>
chatRest<DeviceRegistry>(
'GET',
`/chat/registry/${encodeURIComponent(userId)}?matchId=${encodeURIComponent(matchId)}`
),

/** Revoke (remove) a device from the caller's registry. */
revokeDevice: (deviceId: string): Promise<void> =>
chatRest<void>('DELETE', `/chat/devices/${encodeURIComponent(deviceId)}`),
};
60 changes: 19 additions & 41 deletions src/app/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@ import { useToast } from '../../src/components/common/Toast';
import { MediaImage } from '../components/common/MediaImage';
import { DiscoveryView } from '../features/discovery/DiscoveryView';
import { MatchesView } from '../features/matches/MatchesView';
import { ChatView } from '../features/chat/ChatView';
import { ProfileView } from '../features/profile/ProfileView';

export function AppContent() {
const navigate = useNavigate();
const [appState, setAppState] = useState<'DISCOVERY' | 'MATCHES' | 'CHAT' | 'PROFILE'>('DISCOVERY');
const [appState, setAppState] = useState<'DISCOVERY' | 'MATCHES' | 'PROFILE'>('DISCOVERY');
const { currentUser, setCurrentUser, logout, userState } = useAppStore();
const {
profile,
Expand Down Expand Up @@ -96,7 +95,7 @@ export function AppContent() {
if (result.matched) {
showToast("It's a match! 🎉", 'success');
setActiveMatchId(result.matchId || null);
setAppState('CHAT');
setAppState('MATCHES');
} else if (direction === 'RIGHT') {
showToast(
'Your interest is on its way — we’ll let you know if they feel the same.',
Expand Down Expand Up @@ -175,17 +174,17 @@ export function AppContent() {
}

return (
<div className="min-h-screen bg-bg text-text selection:bg-accent selection:text-white flex flex-col border-t-4 border-accent">
<div className="h-screen bg-bg text-text selection:bg-accent selection:text-white flex flex-col border-t-4 border-accent overflow-hidden">
{(profileError || candidatesError) && (
<div className="px-10 py-3 bg-red-50 text-red-700 text-[10px] uppercase tracking-[0.2em] font-bold border-b border-red-100">
<div className="px-10 py-3 bg-red-50 text-red-700 text-[10px] uppercase tracking-[0.2em] font-bold border-b border-red-100 shrink-0">
{profileError?.message ||
candidatesError?.message ||
'We’re having trouble loading the latest updates. Here’s what we have for now.'}
</div>
)}

{/* Top Navigation Bar */}
<nav className="flex items-center justify-between px-10 h-20 border-b border-border bg-bg/80 backdrop-blur-md sticky top-0 z-50">
<nav className="flex items-center justify-between px-10 h-20 border-b border-border bg-bg/80 backdrop-blur-md sticky top-0 z-50 shrink-0">
<div className="flex items-center gap-2">
<BrandWordmark className="text-xl font-bold tracking-tighter uppercase" />
<span className="text-[10px] px-2 py-0.5 bg-accent text-white rounded-full tracking-widest font-bold">
Expand All @@ -205,12 +204,6 @@ export function AppContent() {
>
Matches
</button>
<button
onClick={() => setAppState('CHAT')}
className={`hover:opacity-100 transition-opacity ${appState === 'CHAT' ? 'opacity-100 border-b-2 border-accent pb-1' : 'opacity-40'}`}
>
Chat
</button>
<button
onClick={() => setAppState('PROFILE')}
className={`hover:opacity-100 transition-opacity ${appState === 'PROFILE' ? 'opacity-100 border-b-2 border-accent pb-1' : 'opacity-40'}`}
Expand Down Expand Up @@ -246,7 +239,7 @@ export function AppContent() {
</nav>

{/* Main Content Area */}
<main className="flex-1 flex overflow-hidden">
<main className="flex-1 flex overflow-hidden min-h-0">
<AnimatePresence mode="wait">
{appState === 'DISCOVERY' && (
<DiscoveryView
Expand All @@ -263,33 +256,24 @@ export function AppContent() {
key="matches"
currentUser={currentUser}
userState={userState}
onOpenChat={(matchId) => {
setActiveMatchId(matchId);
setAppState('CHAT');
}}
activeMatchId={activeMatchId}
onSelectMatch={(matchId) => setActiveMatchId(matchId)}
onRefetchState={async () => {
await refetchUserState();
}}
onNavigateToDiscovery={() => setAppState('DISCOVERY')}
/>
)}
{appState === 'CHAT' && (
<ChatView
key="chat"
currentUser={currentUser}
matchId={activeMatchId}
onNavigateToMatches={() => setAppState('MATCHES')}
onNavigateToDiscovery={() => setAppState('DISCOVERY')}
/>
)}
{appState === 'PROFILE' && (
<ProfileView
key="profile"
user={currentUser}
onUpgrade={handleUpgrade}
upgrading={upgrading}
onVerified={refetchProfile}
/>
<div className="flex-1 overflow-y-auto w-full h-full p-6 md:p-12">
<ProfileView
key="profile"
user={currentUser}
onUpgrade={handleUpgrade}
upgrading={upgrading}
onVerified={refetchProfile}
/>
</div>
)}
</AnimatePresence>
</main>
Expand Down Expand Up @@ -317,17 +301,11 @@ export function AppContent() {
</p>
</div>
<div className="flex flex-col gap-3">
<button
onClick={() => setAppState('CHAT')}
className="w-full py-4 bg-accent text-white text-[10px] uppercase tracking-[0.3em] font-black hover:opacity-90 transition-opacity"
>
Go to Chat
</button>
<button
onClick={() => setAppState('MATCHES')}
className="w-full py-4 border border-border text-accent text-[10px] uppercase tracking-[0.3em] font-black hover:bg-bg transition-colors"
className="w-full py-4 bg-accent text-white text-[10px] uppercase tracking-[0.3em] font-black hover:opacity-90 transition-opacity"
>
View Matches
Go to Matches & Chat
</button>
</div>
</div>
Expand Down
Loading
Loading