refactor(desktop): AuthContext exposes userKeyState instead of device record
Replaces the per-device DeviceRecord lookup with a per-user discriminated union (loading | needs-setup | needs-unlock | unlocked). Heartbeat block deleted (telemetry no longer device-bound); webPush keyed by install-id.
This commit is contained in:
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||||
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
import { cachedUserKey } from '../lib/userIdentity';
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||||
@@ -12,7 +13,7 @@ import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { session, device } = useAuth();
|
const { session } = useAuth();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||||
// re-asking if the user already responded this session.
|
// re-asking if the user already responded this session.
|
||||||
@@ -20,20 +21,25 @@ export function AppShell() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user.id || !device?.id) return;
|
if (!session?.user.id) return;
|
||||||
const userId = session.user.id;
|
const userId = session.user.id;
|
||||||
const deviceId = device.id;
|
// Per-install id replaces the per-device record now that crypto is
|
||||||
const stopKeySync = startConversationKeySync(userId, deviceId);
|
// user-keyed. The legacy conv-key-sync and device-approval subsystems
|
||||||
|
// still take a "deviceId" argument for telemetry / row identity; passing
|
||||||
|
// the install id keeps those wires intact until they are themselves
|
||||||
|
// reworked in later tasks (Task 16/17/19 territory).
|
||||||
|
const installId = ensureInstallId();
|
||||||
|
const stopKeySync = startConversationKeySync(userId, installId);
|
||||||
const stopApproval = startDeviceApprovalListener({
|
const stopApproval = startDeviceApprovalListener({
|
||||||
ownUserId: userId,
|
ownUserId: userId,
|
||||||
ownDeviceId: deviceId,
|
ownDeviceId: installId,
|
||||||
getPriv: () => cachedUserKey(userId),
|
getPriv: () => cachedUserKey(userId),
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
stopKeySync();
|
stopKeySync();
|
||||||
stopApproval();
|
stopApproval();
|
||||||
};
|
};
|
||||||
}, [session?.user.id, device?.id]);
|
}, [session?.user.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { cachedUserKey } from '../lib/userIdentity';
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
@@ -32,7 +33,7 @@ interface Props {
|
|||||||
// preview hints at the dropped attachment.
|
// preview hints at the dropped attachment.
|
||||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { session, device } = useAuth();
|
const { session } = useAuth();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
@@ -83,7 +84,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!session?.user.id || !device?.id || !message) return;
|
if (!session?.user.id || !message) return;
|
||||||
if (selected.size === 0) return;
|
if (selected.size === 0) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -137,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
|||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
plaintext: text,
|
plaintext: text,
|
||||||
senderUserId: session.user.id,
|
senderUserId: session.user.id,
|
||||||
senderDeviceId: device.id,
|
senderDeviceId: ensureInstallId(),
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { cachedUserKey } from '../lib/userIdentity';
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
@@ -97,7 +98,7 @@ export function MessageBubble({
|
|||||||
highlighted = false,
|
highlighted = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { session, device } = useAuth();
|
const { session } = useAuth();
|
||||||
|
|
||||||
const parsed = parseMessagePayload(message.plaintext);
|
const parsed = parseMessagePayload(message.plaintext);
|
||||||
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
||||||
@@ -190,7 +191,7 @@ export function MessageBubble({
|
|||||||
}, [pickerOpen]);
|
}, [pickerOpen]);
|
||||||
|
|
||||||
const handleEditSave = useCallback(async () => {
|
const handleEditSave = useCallback(async () => {
|
||||||
if (!session || !device) return;
|
if (!session) return;
|
||||||
const trimmed = editText.trim();
|
const trimmed = editText.trim();
|
||||||
if (!trimmed || trimmed === bodyText) {
|
if (!trimmed || trimmed === bodyText) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -208,7 +209,7 @@ export function MessageBubble({
|
|||||||
conversationId,
|
conversationId,
|
||||||
newPlaintext: trimmed,
|
newPlaintext: trimmed,
|
||||||
senderUserId: session.user.id,
|
senderUserId: session.user.id,
|
||||||
senderDeviceId: device.id,
|
senderDeviceId: ensureInstallId(),
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -224,7 +225,7 @@ export function MessageBubble({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
|
}, [editText, message.id, message.plaintext, conversationId, session, t]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ export function RequireAuth() {
|
|||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forces a registered device on this install. Sends to /device otherwise.
|
// Forces a usable per-user encrypted key blob on this install. Sends to
|
||||||
|
// /device (the setup/unlock page) when the blob is missing or locked.
|
||||||
export function RequireDevice() {
|
export function RequireDevice() {
|
||||||
const { device, deviceLookupDone } = useAuth();
|
const { userKeyState } = useAuth();
|
||||||
if (!deviceLookupDone) return <FullScreenSpinner />;
|
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
|
||||||
if (!device) return <Navigate to="/device" replace />;
|
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
|
||||||
|
return <Navigate to="/device" replace />;
|
||||||
|
}
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type DeviceRecord,
|
fetchUserKeyBlob,
|
||||||
getOwnProfile,
|
getOwnProfile,
|
||||||
signOut as supabaseSignOut,
|
signOut as supabaseSignOut,
|
||||||
type Profile,
|
type Profile,
|
||||||
touchDeviceLastSeen,
|
|
||||||
updateOwnProfile,
|
updateOwnProfile,
|
||||||
} from '@chat-app/shared/auth';
|
} from '@chat-app/shared/auth';
|
||||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||||
@@ -20,23 +19,34 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { findExistingDevice } from '../lib/device';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
|
|
||||||
import { setSecretStoreUser } from '../lib/secretStore';
|
import { setSecretStoreUser } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { registerWebPush } from '../lib/webPush';
|
import { registerWebPush } from '../lib/webPush';
|
||||||
|
|
||||||
|
// Discriminated union describing the per-user encrypted key blob lifecycle:
|
||||||
|
//
|
||||||
|
// loading — initial state, or refresh in flight
|
||||||
|
// needs-setup — no row exists on Supabase; user must pick a PIN
|
||||||
|
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
|
||||||
|
// required. `lockedUntil` non-null means the server-side
|
||||||
|
// rate limiter is currently rejecting attempts.
|
||||||
|
// unlocked — private key is in the local secret store and ready to use
|
||||||
|
export type UserKeyState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'needs-setup' }
|
||||||
|
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||||
|
| { status: 'unlocked' };
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
device: DeviceRecord | null;
|
userKeyState: UserKeyState;
|
||||||
// null while we're still resolving the very first auth state.
|
// null while we're still resolving the very first auth state.
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
// null until a device lookup has finished for the current session.
|
|
||||||
deviceLookupDone: boolean;
|
|
||||||
refreshProfile: () => Promise<void>;
|
refreshProfile: () => Promise<void>;
|
||||||
refreshDevice: () => Promise<void>;
|
refreshUserKeyState: () => Promise<void>;
|
||||||
setDevice: (device: DeviceRecord | null) => void;
|
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
|
||||||
const autoOnlineUserRef = useRef<string | null>(null);
|
const autoOnlineUserRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Initial session + auth subscription. We verify the cached JWT against the
|
// Initial session + auth subscription. We verify the cached JWT against the
|
||||||
@@ -112,41 +121,72 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [session, i18n.resolvedLanguage]);
|
}, [session, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
const refreshDevice = useCallback(async () => {
|
// Resolves the current state of the per-user encrypted key blob:
|
||||||
|
// 1. local cache hit → 'unlocked'
|
||||||
|
// 2. no remote row → 'needs-setup'
|
||||||
|
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
|
||||||
|
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
|
||||||
|
// recovery-code blob is present so the UI can
|
||||||
|
// conditionally offer the recovery affordance
|
||||||
|
const refreshUserKeyState = useCallback(async () => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
setDevice(null);
|
setUserKeyState({ status: 'loading' });
|
||||||
setDeviceLookupDone(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDeviceLookupDone(false);
|
setUserKeyState({ status: 'loading' });
|
||||||
const found = await findExistingDevice(session.user.id);
|
const cached = await cachedUserKey(session.user.id);
|
||||||
setDevice(found);
|
if (cached) {
|
||||||
setDeviceLookupDone(true);
|
setUserKeyState({ status: 'unlocked' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await fetchUserKeyBlob(supabase, session.user.id);
|
||||||
|
if (!blob || !blob.exists) {
|
||||||
|
setUserKeyState({ status: 'needs-setup' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blob.locked) {
|
||||||
|
setUserKeyState({
|
||||||
|
status: 'needs-unlock',
|
||||||
|
lockedUntil: blob.lockedUntil,
|
||||||
|
hasRecovery: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserKeyState({
|
||||||
|
status: 'needs-unlock',
|
||||||
|
lockedUntil: null,
|
||||||
|
hasRecovery: blob.recoverySealedPrivateKey !== null,
|
||||||
|
});
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
// Re-pull profile + device whenever session flips.
|
// Re-pull profile + user-key state whenever session flips.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setDevice(null);
|
setUserKeyState({ status: 'loading' });
|
||||||
setDeviceLookupDone(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshProfile().catch((err: unknown) => {
|
void refreshProfile().catch((err: unknown) => {
|
||||||
console.error('refreshProfile failed', err);
|
console.error('refreshProfile failed', err);
|
||||||
});
|
});
|
||||||
void refreshDevice().catch((err: unknown) => {
|
void refreshUserKeyState().catch((err: unknown) => {
|
||||||
console.error('refreshDevice failed', err);
|
console.error('refreshUserKeyState failed', err);
|
||||||
setDeviceLookupDone(true);
|
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
|
||||||
|
// least drives the user toward the setup/unlock page rather than
|
||||||
|
// hanging forever on the spinner.
|
||||||
|
setUserKeyState({ status: 'needs-setup' });
|
||||||
});
|
});
|
||||||
}, [session, refreshProfile, refreshDevice]);
|
}, [session, refreshProfile, refreshUserKeyState]);
|
||||||
|
|
||||||
// Best-effort web-push registration once we know the device id. No-op on
|
// Best-effort web-push registration once we have a session. Keyed by an
|
||||||
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
// install-id (localStorage UUID) since there's no longer a per-device
|
||||||
|
// crypto record to key by. No-op on Tauri (uses native notifications) or
|
||||||
|
// when VITE_VAPID_PUBLIC_KEY is unset.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!device?.id) return;
|
if (!session) return;
|
||||||
void registerWebPush(device.id);
|
const installId = ensureInstallId();
|
||||||
}, [device?.id]);
|
void registerWebPush(installId);
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
// Auto online/offline transition.
|
// Auto online/offline transition.
|
||||||
//
|
//
|
||||||
@@ -188,32 +228,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [session, profile, refreshProfile]);
|
}, [session, profile, refreshProfile]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session || !device?.id) return;
|
|
||||||
|
|
||||||
const touch = () => {
|
|
||||||
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
|
|
||||||
console.warn('presence heartbeat failed', err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const touchWhenVisible = () => {
|
|
||||||
if (document.visibilityState === 'visible') touch();
|
|
||||||
};
|
|
||||||
|
|
||||||
touch();
|
|
||||||
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
|
|
||||||
window.addEventListener('focus', touch);
|
|
||||||
window.addEventListener('online', touch);
|
|
||||||
document.addEventListener('visibilitychange', touchWhenVisible);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(heartbeat);
|
|
||||||
window.removeEventListener('focus', touch);
|
|
||||||
window.removeEventListener('online', touch);
|
|
||||||
document.removeEventListener('visibilitychange', touchWhenVisible);
|
|
||||||
};
|
|
||||||
}, [session, device?.id]);
|
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||||
console.warn('offline update before sign-out failed', err);
|
console.warn('offline update before sign-out failed', err);
|
||||||
@@ -225,15 +239,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
() => ({
|
() => ({
|
||||||
session,
|
session,
|
||||||
profile,
|
profile,
|
||||||
device,
|
userKeyState,
|
||||||
ready,
|
ready,
|
||||||
deviceLookupDone,
|
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
refreshDevice,
|
refreshUserKeyState,
|
||||||
setDevice,
|
|
||||||
signOut,
|
signOut,
|
||||||
}),
|
}),
|
||||||
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
|
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ import {
|
|||||||
type ScreenSharePreset,
|
type ScreenSharePreset,
|
||||||
updateScreenShareSettings,
|
updateScreenShareSettings,
|
||||||
} from '../lib/screenShareSettings';
|
} from '../lib/screenShareSettings';
|
||||||
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { cachedUserKey } from '../lib/userIdentity';
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
||||||
@@ -324,7 +325,7 @@ function newCallId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CallProvider({ children }: { children: ReactNode }) {
|
export function CallProvider({ children }: { children: ReactNode }) {
|
||||||
const { session, device, profile } = useAuth();
|
const { session, profile } = useAuth();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
const myId = session?.user.id;
|
const myId = session?.user.id;
|
||||||
|
|
||||||
@@ -445,7 +446,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
mediaKind: CallKind,
|
mediaKind: CallKind,
|
||||||
durationSec: number,
|
durationSec: number,
|
||||||
) => {
|
) => {
|
||||||
if (!session?.user.id || !device?.id) return;
|
if (!session?.user.id) return;
|
||||||
try {
|
try {
|
||||||
const priv = await cachedUserKey(session.user.id);
|
const priv = await cachedUserKey(session.user.id);
|
||||||
if (!priv) return;
|
if (!priv) return;
|
||||||
@@ -461,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
conversationId,
|
conversationId,
|
||||||
plaintext: payload,
|
plaintext: payload,
|
||||||
senderUserId: session.user.id,
|
senderUserId: session.user.id,
|
||||||
senderDeviceId: device.id,
|
senderDeviceId: ensureInstallId(),
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('emitCallEvent failed', err);
|
console.error('emitCallEvent failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[session?.user.id, device?.id],
|
[session?.user.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearRingTimer = useCallback(() => {
|
const clearRingTimer = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Per-install browser identifier. Replaces the legacy per-device crypto
|
||||||
|
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
|
||||||
|
// metadata) where we just need a stable, opaque id for this browser/install.
|
||||||
|
//
|
||||||
|
// The user-key model has no device-bound crypto; the `devices` table and
|
||||||
|
// `messages.sender_device_id` column still exist but they're now plain
|
||||||
|
// telemetry. Using a localStorage UUID keeps the column populated without
|
||||||
|
// any of the old key-management overhead.
|
||||||
|
|
||||||
|
const KEY = 'chatapp.installId';
|
||||||
|
|
||||||
|
export function ensureInstallId(): string {
|
||||||
|
let id = window.localStorage.getItem(KEY);
|
||||||
|
if (!id) {
|
||||||
|
id = crypto.randomUUID();
|
||||||
|
window.localStorage.setItem(KEY, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import { useCall } from '../context/CallContext';
|
|||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||||
import { compressImages } from '../lib/imageCompress';
|
import { compressImages } from '../lib/imageCompress';
|
||||||
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { searchCachedMessages } from '../lib/messageCache';
|
import { searchCachedMessages } from '../lib/messageCache';
|
||||||
import type { OutboxItem } from '../lib/messageOutbox';
|
import type { OutboxItem } from '../lib/messageOutbox';
|
||||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||||
@@ -61,7 +62,7 @@ const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: bool
|
|||||||
export function ConversationPage() {
|
export function ConversationPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { session, device } = useAuth();
|
const { session } = useAuth();
|
||||||
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
||||||
|
|
||||||
const conversation = useMemo(
|
const conversation = useMemo(
|
||||||
@@ -75,7 +76,7 @@ export function ConversationPage() {
|
|||||||
useConversationMessages({
|
useConversationMessages({
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
userId: session?.user.id,
|
userId: session?.user.id,
|
||||||
deviceId: device?.id,
|
deviceId: ensureInstallId(),
|
||||||
});
|
});
|
||||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||||
const {
|
const {
|
||||||
|
|||||||
Reference in New Issue
Block a user