Compare commits

..

10 Commits

17 changed files with 917 additions and 494 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.19.1", "version": "0.20.1",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+14 -2
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
import { AppShell } from './components/AppShell'; import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast'; import { CrashToast } from './components/CrashToast';
@@ -60,6 +60,18 @@ function RouteBoundary({ scope }: { scope: string }) {
); );
} }
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
// the previous conversation entirely on switch. Without this, the same
// component instance handles every conversation, which leaks state
// between chats (messages, scroll position, composer drafts) for one
// render frame and gives the "flicker" we're trying to remove.
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
// fresh mount so previously-visited chats still render instantly.
function ConversationRoute() {
const { id } = useParams<{ id: string }>();
return <ConversationPage key={id ?? '__no_id__'} />;
}
export function App() { export function App() {
return ( return (
<ErrorBoundary scope="root"> <ErrorBoundary scope="root">
@@ -114,7 +126,7 @@ export function App() {
path=":id" path=":id"
element={ element={
<ErrorBoundary scope="conversation"> <ErrorBoundary scope="conversation">
<ConversationPage /> <ConversationRoute />
</ErrorBoundary> </ErrorBoundary>
} }
/> />
@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useCall } from '../context/CallContext';
interface Props {
conversation: ConversationSummary;
}
const STALE_AFTER_MS = 5000;
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
* call surface; renders the most recent caption per participant, fading
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
* too so the speaker can sanity-check what's being broadcast. */
export function CallCaptionsOverlay({ conversation }: Props) {
const { captions } = useCall();
// Re-render every second so stale entries fade without needing the data
// channel to fire — captions module just stores timestamps.
const [, setNow] = useState(Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const now = Date.now();
const visible = Object.entries(captions)
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
if (visible.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
{visible.map(([identity, c]) => {
const member = conversation.members.find((m) => m.userId === identity);
const name = member?.profile?.displayName ?? '?';
const age = now - c.timestamp;
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
return (
<div
key={identity}
style={{ opacity: Math.max(0, opacity) }}
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
>
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
{name}
</span>
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
</div>
);
})}
</div>
);
}
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
CaptionsIcon,
HeadphonesIcon, HeadphonesIcon,
HeadphonesOffIcon, HeadphonesOffIcon,
MicIcon, MicIcon,
@@ -32,10 +31,6 @@ interface Props {
/** Toggle the in-call soundboard popover. Active = panel currently open. */ /** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void; onToggleSoundboard?: () => void;
soundboardOpen?: boolean; soundboardOpen?: boolean;
/** Discord-style live-captions toggle. Optional — pages that don't support
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
onToggleCaptions?: () => void;
captionsOn?: boolean;
participantsOpen?: boolean; participantsOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */ /** Compact variant used inside the docked call (36px buttons). */
compact?: boolean; compact?: boolean;
@@ -59,8 +54,6 @@ export function CallControls({
onOpenParticipants, onOpenParticipants,
onToggleSoundboard, onToggleSoundboard,
soundboardOpen = false, soundboardOpen = false,
onToggleCaptions,
captionsOn = false,
participantsOpen = false, participantsOpen = false,
compact = false, compact = false,
glass = false, glass = false,
@@ -148,22 +141,6 @@ export function CallControls({
<MusicIcon className="h-5 w-5" /> <MusicIcon className="h-5 w-5" />
</CallButton> </CallButton>
)} )}
{onToggleCaptions && (
<CallButton
label={
captionsOn
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
}
active={captionsOn}
activeTone="accent"
onClick={onToggleCaptions}
glass={glass}
className={btnSize}
>
<CaptionsIcon className="h-5 w-5" />
</CallButton>
)}
{onOpenParticipants && ( {onOpenParticipants && (
<CallButton <CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })} label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
@@ -15,14 +15,7 @@ import {
listSounds, listSounds,
subscribeSoundboardChanges, subscribeSoundboardChanges,
} from '../lib/soundboardStorage'; } from '../lib/soundboardStorage';
import {
getLiveCaptionsSettings,
isLiveCaptionsSupported,
subscribeLiveCaptionsSettings,
updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers'; import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile'; import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay'; import { CallStatsOverlay } from './CallStatsOverlay';
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
const [sharePickerOpen, setSharePickerOpen] = useState(false); const [sharePickerOpen, setSharePickerOpen] = useState(false);
// Discord-style debug stats overlay (Ctrl+Shift+S toggles). // Discord-style debug stats overlay (Ctrl+Shift+S toggles).
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false); const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
// controls bar can show an "active" state without polling. Captions
// broadcasting is wired in CallContext via useLiveCaptions; this only
// tracks the toggle state for the button.
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
() => getLiveCaptionsSettings().enabled,
);
useEffect(
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
[],
);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos. // Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
soundboardOpen, soundboardOpen,
} }
: {})} : {})}
// Live-Captions only when SpeechRecognition is available in the
// runtime — Firefox lacks it, would just show a dead button.
{...(isLiveCaptionsSupported()
? {
onToggleCaptions: () =>
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
captionsOn: captionsEnabled,
}
: {})}
onHangup={() => void hangup()} onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'} compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'} glass={callMode === 'fullscreen'}
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)} onClose={() => setStatsOverlayOpen(false)}
/> />
)} )}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</> </>
); );
} }
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)} onClose={() => setStatsOverlayOpen(false)}
/> />
)} )}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</section> </section>
); );
} }
-11
View File
@@ -160,17 +160,6 @@ function EyeIconInner(props: IconProps) {
} }
export const EyeIcon = memo(EyeIconInner); export const EyeIcon = memo(EyeIconInner);
function CaptionsIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="6" width="18" height="12" rx="2" />
<path d="M7 13a2 2 0 1 1 0-2" />
<path d="M14 13a2 2 0 1 1 0-2" />
</Base>
);
}
export const CaptionsIcon = memo(CaptionsIconInner);
function PinOffIconInner(props: IconProps) { function PinOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
+6 -1
View File
@@ -207,9 +207,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Pre-warm Supabase: fires the first round-trip in the background so the // Pre-warm Supabase: fires the first round-trip in the background so the
// first user-triggered query (e.g. loading conversations) doesn't pay // first user-triggered query (e.g. loading conversations) doesn't pay
// the cold-connection latency. // the cold-connection latency.
//
// Uses auth.getSession() instead of a `profiles` SELECT because the
// SELECT race-fired before the supabase client committed its JWT to
// request headers, causing a 400 from PostgREST on app boot. Auth
// endpoints don't depend on RLS and tolerate the race.
useEffect(() => { useEffect(() => {
if (!session) return; if (!session) return;
void supabase.from('profiles').select('id').limit(1).then(() => undefined); void supabase.auth.getSession();
}, [session]); }, [session]);
// Phase 3: ensure this install owns exactly one devices row. The row is // Phase 3: ensure this install owns exactly one devices row. The row is
-48
View File
@@ -39,7 +39,6 @@ import {
playUndeafenBeep, playUndeafenBeep,
playUnmuteBeep, playUnmuteBeep,
} from '../lib/callSounds'; } from '../lib/callSounds';
import { useLiveCaptions } from '../lib/useLiveCaptions';
import { setCallWakeLock } from '../lib/wakeLock'; import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify'; import { notify } from '../lib/osNotify';
import { import {
@@ -209,14 +208,6 @@ interface CallContextValue {
* invite (fromUserId). Cleared on disconnect. Drives the crown badge, * invite (fromUserId). Cleared on disconnect. Drives the crown badge,
* but only in group calls. Null while idle or in 1:1 contexts. */ * but only in group calls. Null while idle or in 1:1 contexts. */
callHostId: string | null; callHostId: string | null;
/** identity -> latest live-caption fragment received via data channel.
* Includes own captions for self-overlay. Receivers prune entries whose
* timestamp is older than ~5s so stale lines fade out. */
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
/** Surface a caption for the local user — the live-captions hook calls
* this on every interim/final SpeechRecognition result so the overlay
* shows our own line without going through the SFU round-trip. */
pushLocalCaption: (text: string, final: boolean) => void;
/** identity -> mute state. Broadcast from peer whenever mic-gain flips. /** identity -> mute state. Broadcast from peer whenever mic-gain flips.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled — * Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
* the mic pipeline keeps the track published with sound flowing even * the mic pipeline keeps the track published with sound flowing even
@@ -347,9 +338,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
// useEffect) so peers don't hear themselves echoed back when the OS-level // useEffect) so peers don't hear themselves echoed back when the OS-level
// process-tree exclusion isn't watertight. // process-tree exclusion isn't watertight.
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false); const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
const [captions, setCaptions] = useState<
Record<string, { text: string; final: boolean; timestamp: number }>
>({});
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid'); const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null); const [focusedId, setFocusedIdState] = useState<string | null>(null);
@@ -828,7 +816,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteScreenShares([]); setRemoteScreenShares([]);
setConnectionQualities({}); setConnectionQualities({});
setCallHostId(null); setCallHostId(null);
setCaptions({});
setIsScreenSharing(false); setIsScreenSharing(false);
setIsE2EEActive(false); setIsE2EEActive(false);
} }
@@ -970,8 +957,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
type?: string; type?: string;
deafened?: boolean; deafened?: boolean;
muted?: boolean; muted?: boolean;
captionText?: string;
captionFinal?: boolean;
}; };
const id: string = participant.identity; const id: string = participant.identity;
if (msg.type === 'presence') { if (msg.type === 'presence') {
@@ -991,15 +976,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
return; return;
} }
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
const text2 = msg.captionText;
const final = msg.captionFinal === true;
setCaptions((prev) => ({
...prev,
[id]: { text: text2, final, timestamp: Date.now() },
}));
return;
}
} catch { } catch {
/* ignore malformed */ /* ignore malformed */
} }
@@ -1754,17 +1730,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
const pushLocalCaption = useCallback(
(text: string, final: boolean) => {
if (!myId) return;
setCaptions((prev) => ({
...prev,
[myId]: { text, final, timestamp: Date.now() },
}));
},
[myId],
);
const toggleCamera = useCallback(async () => { const toggleCamera = useCallback(async () => {
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (!r) return;
@@ -2603,15 +2568,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]); }, [callMode, state.kind]);
// Discord-style live-captions broadcaster — runs on the local mic while
// we're connected, and ships interim/final transcripts on the LiveKit
// DataChannel so peers can render them.
useLiveCaptions({
room,
active: state.kind === 'connected' || state.kind === 'reconnecting',
onLocalCaption: pushLocalCaption,
});
const value = useMemo<CallContextValue>( const value = useMemo<CallContextValue>(
() => ({ () => ({
state, state,
@@ -2626,8 +2582,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute, remoteMute,
connectionQualities, connectionQualities,
callHostId, callHostId,
captions,
pushLocalCaption,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -2683,8 +2637,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute, remoteMute,
connectionQualities, connectionQualities,
callHostId, callHostId,
captions,
pushLocalCaption,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
-119
View File
@@ -1,119 +0,0 @@
// Discord-style live captions. Uses the browser's SpeechRecognition API to
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
// to peers via the LiveKit DataChannel. Receivers store and display them.
//
// Privacy note: speech recognition runs in the browser. On Chromium-based
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
// own engine, which today reaches Google's cloud — same trade-off as Discord.
// We ship a hard off switch and require an explicit user toggle.
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
export interface LiveCaptionsSettings {
enabled: boolean;
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
lang: string | null;
}
const DEFAULTS: LiveCaptionsSettings = {
enabled: false,
lang: null,
};
type Listener = (s: LiveCaptionsSettings) => void;
const listeners = new Set<Listener>();
let cached: LiveCaptionsSettings | null = null;
function read(): LiveCaptionsSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
lang:
typeof parsed.lang === 'string' && parsed.lang.length > 0
? parsed.lang
: DEFAULTS.lang,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: LiveCaptionsSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
return read();
}
export function updateLiveCaptionsSettings(
patch: Partial<LiveCaptionsSettings>,
): LiveCaptionsSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
// outright. Returns the constructor or null.
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
interface SpeechRecognitionLike extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
onend: (() => void) | null;
}
interface SpeechRecognitionEventLike {
resultIndex: number;
results: ArrayLike<{
isFinal: boolean;
[index: number]: { transcript: string };
length: number;
}>;
}
interface SpeechRecognitionErrorLike {
error: string;
}
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export function isLiveCaptionsSupported(): boolean {
return getSpeechRecognitionCtor() !== null;
}
export type {
SpeechRecognitionLike,
SpeechRecognitionEventLike,
SpeechRecognitionErrorLike,
};
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from 'vitest';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import {
__resetForTests,
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
function msg(id: string): DecryptedMessage {
return {
id,
conversationId: 'conv-1',
senderId: 'sender-1',
senderDeviceId: null,
replyToId: null,
editedAt: null,
deletedAt: null,
createdAt: '2026-05-17T00:00:00Z',
plaintext: 'hi ' + id,
};
}
describe('messageMemoryCache', () => {
afterEach(() => {
__resetForTests();
});
it('returns empty array when nothing is cached', () => {
expect(getCachedMessages('unknown')).toEqual([]);
expect(hasCachedMessages('unknown')).toBe(false);
});
it('stores and returns messages per conversation', () => {
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
expect(hasCachedMessages('a')).toBe(true);
});
it('isolates conversations', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('b', [msg('m9')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
});
it('overwrites prior cache when set again', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
});
it('treats an explicit empty list as "cached"', () => {
// A conversation that genuinely has zero messages should still be
// flagged as cached so the hook skips the loading spinner on re-entry.
setCachedMessages('a', []);
expect(hasCachedMessages('a')).toBe(true);
expect(getCachedMessages('a')).toEqual([]);
});
});
@@ -0,0 +1,37 @@
// In-memory cache of the most-recently-rendered messages for each
// conversation. Survives React component unmount/remount (used by
// `useConversationMessages` to initialize state synchronously when
// ConversationPage is remounted on chat switch). Session-scoped — lost
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
// source of truth for cross-session persistence; this layer just shaves
// off the round-trip-to-disk spinner flash.
//
// Two-tier semantics:
// * `hasCachedMessages(id)` returns true even for a known-empty chat
// so the hook can suppress the loading spinner on re-entry.
// * `getCachedMessages(id)` returns a defensive copy so callers can't
// mutate the cached array.
import type { DecryptedMessage } from '@chat-app/shared/chat';
const cache = new Map<string, DecryptedMessage[]>();
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
const stored = cache.get(conversationId);
return stored ? stored.slice() : [];
}
export function hasCachedMessages(conversationId: string): boolean {
return cache.has(conversationId);
}
export function setCachedMessages(
conversationId: string,
messages: DecryptedMessage[],
): void {
cache.set(conversationId, messages.slice());
}
export function __resetForTests(): void {
cache.clear();
}
+57 -27
View File
@@ -33,6 +33,11 @@ import {
shouldGiveUp, shouldGiveUp,
subscribeOutbox, subscribeOutbox,
} from './messageOutbox'; } from './messageOutbox';
import {
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
import { supabase } from './supabase'; import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity'; import { cachedUserKey } from './userIdentity';
@@ -82,7 +87,20 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
retryPending: (id: string) => void; retryPending: (id: string) => void;
cancelPending: (id: string) => void; cancelPending: (id: string) => void;
} { } {
const [state, setState] = useState<State>({ messages: [], loading: true, error: null }); // Initialize from the in-memory cache so a previously-viewed chat shows
// content on the very first render after the parent remounts on `:id`
// change. `loading` stays true ONLY for never-seen conversations (cache
// miss) so the spinner doesn't flash on every chat switch.
const [state, setState] = useState<State>(() => {
if (conversationId && hasCachedMessages(conversationId)) {
return {
messages: getCachedMessages(conversationId),
loading: false,
error: null,
};
}
return { messages: [], loading: true, error: null };
});
const [pending, setPending] = useState<OutboxItem[]>(() => const [pending, setPending] = useState<OutboxItem[]>(() =>
conversationId ? getOutbox(conversationId) : [], conversationId ? getOutbox(conversationId) : [],
); );
@@ -229,6 +247,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const rows = await fetchConversationMessages(supabase, conversationId); const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows); const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null }); setState({ messages: decrypted, loading: false, error: null });
setCachedMessages(conversationId, decrypted);
// Persist the fresh batch to the local cache so next conversation // Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache // switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible. // write failure is never user-visible.
@@ -248,12 +267,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// when the server response lands. On cache-miss this is a ~5ms no-op. // when the server response lands. On cache-miss this is a ~5ms no-op.
useEffect(() => { useEffect(() => {
if (!conversationId) return; if (!conversationId) return;
// Memory cache already populated state synchronously — skip the disk
// round-trip entirely. The canonical data lands shortly via refresh();
// the SQLite cache only matters for cold-start hydration.
if (hasCachedMessages(conversationId)) return;
let cancelled = false; let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => { void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return; if (cancelled || cached.length === 0) return;
setState((prev) => { setState((prev) => {
// Don't clobber a fresh server response that already landed. // Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev; if (prev.messages.length > 0) return prev;
setCachedMessages(conversationId, cached);
return { messages: cached, loading: false, error: null }; return { messages: cached, loading: false, error: null };
}); });
}); });
@@ -338,7 +362,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (!decrypted) return; if (!decrypted) return;
setState((prev) => { setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev; if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted!] }; const next = [...prev.messages, decrypted!];
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
}); });
}, },
[conversationId, deviceId, decryptBatch], [conversationId, deviceId, decryptBatch],
@@ -358,6 +384,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
editedAt: partial.editedAt, editedAt: partial.editedAt,
deletedAt: partial.deletedAt, deletedAt: partial.deletedAt,
}; };
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
if (partial.editedAt && !partial.deletedAt) { if (partial.editedAt && !partial.deletedAt) {
@@ -421,6 +448,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (idx === -1) return prev; if (idx === -1) return prev;
const next = [...prev.messages]; const next = [...prev.messages];
next[idx] = decrypted!; next[idx] = decrypted!;
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
} }
@@ -428,14 +456,18 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
[conversationId, deviceId, decryptBatch], [conversationId, deviceId, decryptBatch],
); );
const handleDelete = useCallback((row: Record<string, unknown>) => { const handleDelete = useCallback(
const id = String(row.id); (row: Record<string, unknown>) => {
setState((prev) => ({ const id = String(row.id);
...prev, setState((prev) => {
messages: prev.messages.filter((m) => m.id !== id), const next = prev.messages.filter((m) => m.id !== id);
})); if (conversationId) setCachedMessages(conversationId, next);
void deleteCachedMessage(id); return { ...prev, messages: next };
}, []); });
void deleteCachedMessage(id);
},
[conversationId],
);
useEffect(() => { useEffect(() => {
if (!conversationId || !userId || !deviceId) return; if (!conversationId || !userId || !deviceId) return;
@@ -552,13 +584,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}); });
setState((prev) => { setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev; if (prev.messages.some((m) => m.id === msg.id)) return prev;
return { const next = [
...prev, ...prev.messages,
messages: [ { ...msg, plaintext: text } as DecryptedMessage,
...prev.messages, ];
{ ...msg, plaintext: text } as DecryptedMessage, setCachedMessages(convId, next);
], return { ...prev, messages: next };
};
}); });
}, },
[], [],
@@ -686,16 +717,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
: JSON.stringify({ v: 1, text: trimmed, attachments: handles }); : JSON.stringify({ v: 1, text: trimmed, attachments: handles });
setState((prev) => { setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev; if (prev.messages.some((m) => m.id === msg.id)) return prev;
return { const next = [
...prev, ...prev.messages,
messages: [ {
...prev.messages, ...msg,
{ plaintext: attachmentsPayload,
...msg, } as DecryptedMessage,
plaintext: attachmentsPayload, ];
} as DecryptedMessage, setCachedMessages(conversationId, next);
], return { ...prev, messages: next };
};
}); });
// 4. Insert public attachment metadata rows pointing at the new message. // 4. Insert public attachment metadata rows pointing at the new message.
-142
View File
@@ -1,142 +0,0 @@
// Hook that runs SpeechRecognition on the local mic when live-captions are
// enabled and a Room is connected. Each interim/final result is broadcast as
// a `caption`-typed message via the LiveKit DataChannel so peers can render
// it. Recognition stops cleanly when the call ends or the toggle flips off.
import type { Room } from 'livekit-client';
import { useEffect, useRef } from 'react';
import {
type LiveCaptionsSettings,
getLiveCaptionsSettings,
getSpeechRecognitionCtor,
type SpeechRecognitionEventLike,
type SpeechRecognitionLike,
subscribeLiveCaptionsSettings,
} from './liveCaptions';
interface Args {
room: Room | null;
/** True while we're connected and want captions to flow. */
active: boolean;
/** Callback fired locally for our own captions so the overlay can show
* them without going through the SFU round-trip. */
onLocalCaption: (text: string, final: boolean) => void;
}
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
useEffect(() => {
return subscribeLiveCaptionsSettings((s) => {
settingsRef.current = s;
});
}, []);
useEffect(() => {
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) return; // unsupported runtime
if (!active || !room) return;
if (!getLiveCaptionsSettings().enabled) return;
const send = (text: string, final: boolean) => {
onLocalCaption(text, final);
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
);
// Reliable channel — captions are infrequent enough to afford it,
// and dropping interims looks worse than slight lag.
void room.localParticipant.publishData(payload, { reliable: true });
} catch {
/* ignore — best-effort */
}
};
const start = () => {
const r = new Ctor();
r.continuous = true;
r.interimResults = true;
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
r.lang = lang;
r.onresult = (e: SpeechRecognitionEventLike) => {
// Pull whichever results arrived since last fire. Interim fires
// many times per second; the final one is sticky and persists.
for (let i = e.resultIndex; i < e.results.length; i++) {
const result = e.results[i];
if (!result || result.length === 0) continue;
const alt = result[0];
if (!alt) continue;
const transcript = alt.transcript.trim();
if (!transcript) continue;
send(transcript, result.isFinal);
}
};
r.onerror = () => {
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
// `service-not-allowed` are permission-permanent — bail.
try {
r.stop();
} catch {
/* ignore */
}
};
r.onend = () => {
// SpeechRecognition tends to auto-stop after silence — if we still
// want captions, restart it. Guard against tear-down race.
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
try {
r.start();
} catch {
/* already running or browser refused */
}
}
};
try {
r.start();
recognitionRef.current = r;
} catch {
// Some browsers throw when start() is called too soon after a
// previous abort — wait a tick and retry.
window.setTimeout(() => {
try {
r.start();
recognitionRef.current = r;
} catch {
/* give up */
}
}, 250);
}
};
start();
const unsub = subscribeLiveCaptionsSettings((s) => {
const cur = recognitionRef.current;
if (!s.enabled && cur) {
recognitionRef.current = null;
try {
cur.abort();
} catch {
/* ignore */
}
} else if (s.enabled && !cur) {
start();
}
});
return () => {
unsub();
const cur = recognitionRef.current;
recognitionRef.current = null;
if (cur) {
try {
cur.abort();
} catch {
/* ignore */
}
}
};
}, [active, room, onLocalCaption]);
}
+1 -1
View File
@@ -213,7 +213,7 @@ async function runLegacyMigration(
} }
} }
console.info( console.debug(
'[crypto-migration] vault scan:', '[crypto-migration] vault scan:',
'serverDevices=' + report.serverDevices, 'serverDevices=' + report.serverDevices,
'keysFromServerList=' + report.strongholdKeysFromServerDevices, 'keysFromServerList=' + report.strongholdKeysFromServerDevices,
+14 -24
View File
@@ -94,18 +94,18 @@ type VirtuosoRow =
// change on every parent render. // change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = []; const EMPTY_REACTIONS: AggregatedReaction[] = [];
// Per-conversation scroll memory. Module-scoped so it survives re-mounts // Per-conversation scroll memory. Module-scoped so it survives the
// of ConversationPage when the route param (`id`) changes — switching // per-id remount of ConversationPage (see `ConversationRoute` in
// chats unmounts/remounts the page in our router setup. Session-only // App.tsx). Session-only (lost on reload, like Discord). The
// (lost on reload, like Discord). The `stickToBottom` flag is preserved // `stickToBottom` flag is preserved alongside the topmost-visible row
// alongside the topmost-visible row index so a chat the user left at the // index so a chat the user left at the bottom keeps auto-following new
// bottom keeps auto-following new messages when they return; a chat // messages when they return; a chat scrolled up returns to roughly the
// scrolled up returns to roughly the same row the user was reading. // same row the user was reading.
// //
// We track the topmost-visible row index rather than a pixel `scrollTop` // We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll // because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and // element's pixel offset depends on dynamically-measured row heights and
// is not stable across re-mounts. Using a row index restores the user's // is not stable across remounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights. // reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>(); const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
@@ -304,21 +304,6 @@ export function ConversationPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null); const composerRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setMediaDrawerOpen(false);
setPollDialogOpen(false);
setSearchQuery('');
setDisplayCount(150);
setFirstUnreadId(null);
setFirstUnreadJumpDismissed(false);
setNewMessagesWhileAway(0);
previousMessageIdsRef.current = new Set();
firstUnreadComputedRef.current = false;
}, [id]);
useEffect(() => { useEffect(() => {
if (firstUnreadComputedRef.current) return; if (firstUnreadComputedRef.current) return;
if (!id || messages.length === 0) return; if (!id || messages.length === 0) return;
@@ -733,7 +718,12 @@ export function ConversationPage() {
// now, so we have to call it explicitly. Tracked via a ref so we only // now, so we have to call it explicitly. Tracked via a ref so we only
// scroll when the count actually grew (not on every render where it // scroll when the count actually grew (not on every render where it
// happens to be > 0). // happens to be > 0).
const lastPendingCountRef = useRef(0); // Initialize from the current pending count rather than 0 so we don't
// fire scrollToIndex(LAST) on the very first render of a chat that
// already has outbox-queued items. Only growth of `pending.length`
// across renders should trigger the snap-to-bottom (i.e., the user
// just submitted something new).
const lastPendingCountRef = useRef(pending.length);
useEffect(() => { useEffect(() => {
if (pending.length > lastPendingCountRef.current) { if (pending.length > lastPendingCountRef.current) {
virtuosoRef.current?.scrollToIndex({ virtuosoRef.current?.scrollToIndex({
@@ -0,0 +1,703 @@
# Chat-Switch Flicker — Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate the visible flicker / scroll-jump when switching between conversations so the transition matches Discord's "instant content swap" feel.
**Architecture:** Two complementary fixes that, together, kill seven independent state-bleed root causes:
1. **Force a fresh `ConversationPage` instance per `:id` route param** via a wrapper component that reads `useParams` and passes `key={id}`. Today the same React component instance handles every conversation because React Router does not remount the route element when only a param changes — that is what lets chat A's state (messages, scroll position, composer drafts, Virtuoso scroll cache) bleed into chat B's first render.
2. **Add a module-scoped in-memory cache** (`Map<convId, DecryptedMessage[]>`) in `useConversationMessages` so the freshly-mounted hook starts with prior data synchronously on the first render. SQLite cache still hydrates async for never-seen chats. Net effect: instant content for any previously-visited chat, no spinner-flash.
**Tech Stack:** React 18, React Router v6 (v7_startTransition opt-in), react-virtuoso, better-sqlite3 (Electron main-process bridge via `window.electronAPI.sql*`), Vitest.
---
## Root-Cause Findings (Phase 1 evidence)
| # | Symptom | File:line | Why it happens |
|---|---------|-----------|----------------|
| RC1 | **Ghost messages of previous chat** for 50300 ms | `apps/desktop/src/lib/useConversationMessages.ts:85, 220-243, 249-263` | `useState<State>({messages: [], loading: true})` only runs on first mount. On `conversationId` change, the state still holds chat A's messages; `refresh()` skips `loading: true` because `prev.messages.length > 0`; the cache effect also bails (`if (prev.messages.length > 0) return prev`). Chat A's data renders under chat B's id until the network round-trip finishes. |
| RC2 | **Scroll jumps to wrong row** on switch | `apps/desktop/src/pages/ConversationPage.tsx:1023, 1034, 470-481` | Virtuoso instance is preserved across switches (parent isn't remounted). `initialTopMostItemIndex` is only honoured on Virtuoso's first mount, so chat A's last scroll position is what Virtuoso tries to keep when chat B's rows replace chat A's. |
| RC3 | **Saved positions corrupted across switches** | `apps/desktop/src/pages/ConversationPage.tsx:705-716` | While chat A's rows are still rendered under chat B's id (RC1 window), `rangeChanged` fires for chat A's visible range and writes the index into `scrollPositions[chatBid]`. Next time you re-enter chat B, it restores chat A's row index. |
| RC4 | **Stale composer / reply / search / unread state** for one render | `apps/desktop/src/pages/ConversationPage.tsx:307-320` | `useEffect([id])` resets `replyTo`, `displayCount`, `firstUnreadId`, etc. — but effects fire **after** the first render of the new id. The first paint of chat B briefly shows chat A's reply preview and `displayCount`. |
| RC5 | **Phantom scroll-to-bottom** after switching | `apps/desktop/src/pages/ConversationPage.tsx:736-746` | `lastPendingCountRef` is never reset per-chat. Switching from a chat with 0 pending to a chat with N pending triggers `pending.length > lastPendingCountRef.current``scrollToIndex(LAST)` even though that pending state was always there. |
| RC6 | **`newMessagesWhileAway` briefly off** | `apps/desktop/src/pages/ConversationPage.tsx:335-346` | `previousMessageIdsRef.current` contains chat A's ids on the first render of chat B → the diff classifies every chat B message as "new while away" until the reset effect lands. |
| RC7 | **Cache load loses race with network refresh** | `apps/desktop/src/lib/useConversationMessages.ts:249-263` | Because state still holds chat A's messages, the cache-effect bails. Then refresh writes chat B's network result. Then the now-irrelevant `loadCachedMessages(chatB)` promise resolves and would no-op (length check still > 0 by then) — but the path is fragile and depends on timing. |
**All of RC1, RC3, RC4, RC5, RC6, RC7 are fixed in one stroke by Task 3 (`key={id}` remount).** RC2 is fixed because Virtuoso unmounts with its parent and re-applies `initialTopMostItemIndex` on the fresh mount. The remaining flicker after a remount — the brief spinner before async cache hydration completes — is eliminated by Task 2 (in-memory cache, synchronous on first render).
---
## File Structure
- **Create**: `apps/desktop/src/lib/messageMemoryCache.ts` — small module-scoped Map plus `get` / `set` / `has` API. Lets us unit-test the cache logic without rendering the hook.
- **Create**: `apps/desktop/src/lib/messageMemoryCache.test.ts` — vitest unit tests for the cache.
- **Modify**: `apps/desktop/src/lib/useConversationMessages.ts` — initialize `useState` from `messageMemoryCache`, sync to it on every state change.
- **Modify**: `apps/desktop/src/App.tsx` — add `ConversationRoute` wrapper that reads `useParams` and renders `<ConversationPage key={id} />`.
- **Modify**: `apps/desktop/src/pages/ConversationPage.tsx` — delete the now-redundant `useEffect([id])` reset block and update the `scrollPositions` doc comment.
Each task below is self-contained and can be committed independently.
---
## Task 1: In-memory message cache helper
**Files:**
- Create: `apps/desktop/src/lib/messageMemoryCache.ts`
- Test: `apps/desktop/src/lib/messageMemoryCache.test.ts`
- [ ] **Step 1: Write the failing test**
Create `apps/desktop/src/lib/messageMemoryCache.test.ts`:
```ts
import { afterEach, describe, expect, it } from 'vitest';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import {
__resetForTests,
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
function msg(id: string): DecryptedMessage {
return {
id,
conversationId: 'conv-1',
senderId: 'sender-1',
senderDeviceId: null,
replyToId: null,
editedAt: null,
deletedAt: null,
createdAt: '2026-05-17T00:00:00Z',
ciphertext: new Uint8Array(),
nonce: new Uint8Array(),
keyVersion: 1,
plaintext: 'hi ' + id,
};
}
describe('messageMemoryCache', () => {
afterEach(() => {
__resetForTests();
});
it('returns empty array when nothing is cached', () => {
expect(getCachedMessages('unknown')).toEqual([]);
expect(hasCachedMessages('unknown')).toBe(false);
});
it('stores and returns messages per conversation', () => {
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
expect(hasCachedMessages('a')).toBe(true);
});
it('isolates conversations', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('b', [msg('m9')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
});
it('overwrites prior cache when set again', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
});
it('treats an explicit empty list as "cached"', () => {
// A conversation that genuinely has zero messages should still be
// flagged as cached so the hook skips the loading spinner on re-entry.
setCachedMessages('a', []);
expect(hasCachedMessages('a')).toBe(true);
expect(getCachedMessages('a')).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
Expected: FAIL — module `./messageMemoryCache` does not exist.
- [ ] **Step 3: Implement the helper**
Create `apps/desktop/src/lib/messageMemoryCache.ts`:
```ts
// In-memory cache of the most-recently-rendered messages for each
// conversation. Survives React component unmount/remount (used by
// `useConversationMessages` to initialize state synchronously when
// ConversationPage is remounted on chat switch). Session-scoped — lost
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
// source of truth for cross-session persistence; this layer just shaves
// off the round-trip-to-disk spinner flash.
//
// Two-tier semantics:
// * `hasCachedMessages(id)` returns true even for a known-empty chat
// so the hook can suppress the loading spinner on re-entry.
// * `getCachedMessages(id)` returns a defensive copy so callers can't
// mutate the cached array.
import type { DecryptedMessage } from '@chat-app/shared/chat';
const cache = new Map<string, DecryptedMessage[]>();
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
const stored = cache.get(conversationId);
return stored ? stored.slice() : [];
}
export function hasCachedMessages(conversationId: string): boolean {
return cache.has(conversationId);
}
export function setCachedMessages(
conversationId: string,
messages: DecryptedMessage[],
): void {
cache.set(conversationId, messages.slice());
}
export function __resetForTests(): void {
cache.clear();
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
Expected: PASS — all five test cases.
- [ ] **Step 5: Commit**
```bash
git add apps/desktop/src/lib/messageMemoryCache.ts apps/desktop/src/lib/messageMemoryCache.test.ts
git commit -m "feat(chat-switch): in-memory message cache helper"
```
---
## Task 2: Wire the memory cache into `useConversationMessages`
**Files:**
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:85` (initial state)
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:226-243` (refresh) — sync to memory cache
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:249-263` (SQLite cache effect) — skip on memory-hit, sync after hydrate
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:339, 350, 419, 433, 553` (realtime + optimistic-send paths) — keep cache in sync with state
The hook keeps using `setState` everywhere — we just mirror writes into the memory cache and read from it on first render. No behavioural change for other code paths.
- [ ] **Step 1: Import the helper and initialize state from cache**
In `apps/desktop/src/lib/useConversationMessages.ts`, add the import near the other local-lib imports (around line 36):
```ts
import {
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
```
Replace the initial `useState` at line 85:
```ts
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
```
with:
```ts
// Initialize from the in-memory cache so a previously-viewed chat shows
// content on the very first render after the parent remounts on `:id`
// change. `loading` stays true ONLY for never-seen conversations (cache
// miss) so the spinner doesn't flash on every chat switch.
const [state, setState] = useState<State>(() => {
if (conversationId && hasCachedMessages(conversationId)) {
return {
messages: getCachedMessages(conversationId),
loading: false,
error: null,
};
}
return { messages: [], loading: true, error: null };
});
```
- [ ] **Step 2: Mirror successful refreshes into the memory cache**
In the `refresh` function (around line 229-235), replace:
```ts
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
```
with:
```ts
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
setCachedMessages(conversationId, decrypted);
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
```
- [ ] **Step 3: Skip SQLite hydration on memory-cache hit, mirror cold-miss into memory**
Replace the cache-hydration effect (around line 249-263):
```ts
useEffect(() => {
if (!conversationId) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
return { messages: cached, loading: false, error: null };
});
});
return () => {
cancelled = true;
};
}, [conversationId]);
```
with:
```ts
useEffect(() => {
if (!conversationId) return;
// Memory cache already populated state synchronously — skip the disk
// round-trip entirely. The canonical data lands shortly via refresh();
// the SQLite cache only matters for cold-start hydration.
if (hasCachedMessages(conversationId)) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
setCachedMessages(conversationId, cached);
return { messages: cached, loading: false, error: null };
});
});
return () => {
cancelled = true;
};
}, [conversationId]);
```
- [ ] **Step 4: Mirror realtime INSERT into the memory cache**
In `handleInsert` (around line 339), replace:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted!] };
});
```
with:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
const next = [...prev.messages, decrypted!];
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
```
- [ ] **Step 5: Mirror realtime UPDATE (both partial and re-decrypt paths)**
In `handleUpdate` partial-update path (around line 350-362), replace:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === partial.id);
if (idx === -1) return prev;
const existing = prev.messages[idx];
if (!existing) return prev;
const next = [...prev.messages];
next[idx] = {
...existing,
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
return { ...prev, messages: next };
});
```
with:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === partial.id);
if (idx === -1) return prev;
const existing = prev.messages[idx];
if (!existing) return prev;
const next = [...prev.messages];
next[idx] = {
...existing,
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
```
In the same function's re-decrypt path (around line 419-425), replace:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted!;
return { ...prev, messages: next };
});
```
with:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted!;
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
```
- [ ] **Step 6: Mirror realtime DELETE**
Replace `handleDelete` (around line 431-438):
```ts
const handleDelete = useCallback((row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => ({
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
void deleteCachedMessage(id);
}, []);
```
with:
```ts
const handleDelete = useCallback(
(row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => {
const next = prev.messages.filter((m) => m.id !== id);
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
void deleteCachedMessage(id);
},
[conversationId],
);
```
- [ ] **Step 7: Mirror optimistic send (sendText)**
In `sendText` (around line 553-562), replace:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
],
};
});
```
with:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
const next = [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
];
setCachedMessages(convId, next);
return { ...prev, messages: next };
});
```
(`convId` is already a parameter of `sendText` — no extra capture needed.)
- [ ] **Step 8: Run all desktop tests to verify nothing regressed**
Run: `pnpm --filter @chat-app/desktop test`
Expected: PASS — existing tests still green, the new `messageMemoryCache` tests still pass.
- [ ] **Step 9: Commit**
```bash
git add apps/desktop/src/lib/useConversationMessages.ts
git commit -m "feat(chat-switch): hydrate useConversationMessages from in-memory cache"
```
---
## Task 3: Force fresh `ConversationPage` mount per `:id`
**Files:**
- Modify: `apps/desktop/src/App.tsx:2` (import `useParams`)
- Modify: `apps/desktop/src/App.tsx:55-61` area (add wrapper)
- Modify: `apps/desktop/src/App.tsx:113-120` (the `:id` route)
- [ ] **Step 1: Add `useParams` to the router import**
Change line 2:
```ts
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
```
to:
```ts
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
```
- [ ] **Step 2: Add the wrapper component**
Below the `RouteBoundary` function (around line 61), add:
```tsx
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
// the previous conversation entirely on switch. Without this, the same
// component instance handles every conversation, which leaks state
// between chats (messages, scroll position, composer drafts) for one
// render frame and gives the "flicker" we're trying to remove.
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
// fresh mount so previously-visited chats still render instantly.
function ConversationRoute() {
const { id } = useParams<{ id: string }>();
return <ConversationPage key={id ?? '__no_id__'} />;
}
```
- [ ] **Step 3: Use the wrapper in the route definition**
Replace lines 113-120:
```tsx
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
</ErrorBoundary>
}
/>
```
with:
```tsx
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationRoute />
</ErrorBoundary>
}
/>
```
- [ ] **Step 4: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS — no type errors.
- [ ] **Step 5: Manual smoke test in dev**
Run: `pnpm desktop:dev`
In the app:
1. Open two conversations with cached messages.
2. Toggle between them rapidly (5+ switches).
3. Verify: no "ghost" of the previous chat's last message ever appears, and the spinner does **not** flash on switch.
4. Scroll chat A up by ~10 messages, switch to B, switch back to A. The Virtuoso list lands at the same scroll row, not the bottom and not at row 0.
- [ ] **Step 6: Commit**
```bash
git add apps/desktop/src/App.tsx
git commit -m "fix(chat-switch): remount ConversationPage per conversation id"
```
---
## Task 4: Drop redundant id-change reset effect & update doc comment
Because the parent is remounted per id (Task 3), every state in ConversationPage is already fresh on chat switch. The manual reset effect and related comments are now misleading dead weight.
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:97-110` — update `scrollPositions` doc comment
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:307-320` — delete the reset effect
- [ ] **Step 1: Update the `scrollPositions` doc comment**
Replace lines 97-110:
```ts
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the topmost-visible row index so a chat the user left at the
// bottom keeps auto-following new messages when they return; a chat
// scrolled up returns to roughly the same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across re-mounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
```
with:
```ts
// Per-conversation scroll memory. Module-scoped so it survives the
// per-id remount of ConversationPage (see `ConversationRoute` in
// App.tsx). Session-only (lost on reload, like Discord). The
// `stickToBottom` flag is preserved alongside the topmost-visible row
// index so a chat the user left at the bottom keeps auto-following new
// messages when they return; a chat scrolled up returns to roughly the
// same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across remounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
```
- [ ] **Step 2: Delete the manual reset effect**
Delete lines 307-320 entirely (the effect that resets `replyTo`, `forwardTarget`, `searchOpen`, `mediaDrawerOpen`, `pollDialogOpen`, `searchQuery`, `displayCount`, `firstUnreadId`, `firstUnreadJumpDismissed`, `newMessagesWhileAway`, `previousMessageIdsRef`, `firstUnreadComputedRef`):
```ts
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setMediaDrawerOpen(false);
setPollDialogOpen(false);
setSearchQuery('');
setDisplayCount(150);
setFirstUnreadId(null);
setFirstUnreadJumpDismissed(false);
setNewMessagesWhileAway(0);
previousMessageIdsRef.current = new Set();
firstUnreadComputedRef.current = false;
}, [id]);
```
- [ ] **Step 3: Typecheck + tests**
Run in parallel:
```bash
pnpm --filter @chat-app/desktop typecheck
pnpm --filter @chat-app/desktop test
```
Expected: both PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "refactor(chat-switch): drop redundant id-change reset effect"
```
---
## Task 5: Final QA in dev mode
Verification only — no code changes, no commit.
- [ ] **Step 1: Start dev**
Run: `pnpm desktop:dev`
- [ ] **Step 2: Confirm each fix landed**
Switch repeatedly between three chats (A, B, C). All of the following must hold:
| Behaviour | Pass criteria |
|-----------|---------------|
| Ghost messages | Never see chat A's messages under chat B's header. |
| Spinner flash | First visit to a chat = spinner OK. Subsequent visits = no spinner. |
| Scroll restore | Chat A scrolled up 10 rows → switch to B → back to A → lands at row 10 ±1. |
| Composer state | Type "draft" in chat A composer → switch to B → composer is empty (drafts intentionally don't persist). |
| Reply preview | Click reply on chat A → switch to B → no reply preview in B. |
| Pending bubbles | Send while offline → bubble shows in correct chat → switch away and back → bubble still in same chat. |
| Realtime updates | Send a message in chat A from another device → it lands in chat A list → switch to B → switch back to A → message is still there (verifies memory cache stays in sync with realtime inserts). |
- [ ] **Step 3: If any check fails**
Open `superpowers:systematic-debugging` and form a single new hypothesis per failing check. Do NOT layer fixes — return to Phase 1, gather evidence, then patch.
---
## Self-Review (post-write checklist)
**Spec coverage**: Each RC1RC7 is addressed:
- RC1 (state bleed) → Task 3 (key-based remount) + Task 2 (memory cache so the fresh mount is instant).
- RC2 (Virtuoso scroll) → Task 3 (Virtuoso unmounts with parent, `initialTopMostItemIndex` re-applied on fresh mount).
- RC3 (scrollPositions corruption) → Task 3 (no more cross-chat render under wrong id).
- RC4 (stale local state for one render) → Task 3 + Task 4 (cleanup).
- RC5 (phantom scroll-to-bottom) → Task 3 (fresh ref).
- RC6 (newMessagesWhileAway misfire) → Task 3 (fresh ref).
- RC7 (cache vs network race) → Task 2 (deterministic init from memory cache, SQLite hydration only on cold cache miss).
**Placeholders**: none — every step lists exact files, exact code, exact commands.
**Type consistency**: `ConversationRoute` is the only new component; `messageMemoryCache` exports (`getCachedMessages`, `hasCachedMessages`, `setCachedMessages`, `__resetForTests`) match the test imports exactly.
---
## Out of scope
- **Cross-fade animation between chats.** A small `view-transition` or opacity tween could further polish the swap, but the user reported jumpiness, not the absence of an animation. Tackle in a follow-up if it still feels too "snap-y" after this lands.
- **Persisting composer drafts per chat.** Today every chat-switch loses the in-progress draft. The remount in Task 3 *preserves* that behaviour deliberately (no regression). A draft-persistence feature is a separate spec.
- **Mobile (`apps/mobile`).** The mobile chat list uses a different virtualization stack; this plan only covers `apps/desktop`.
+23 -10
View File
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0, attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
}; };
if (params.ownLegacyDeviceIds.length === 0) { if (params.ownLegacyDeviceIds.length === 0) {
console.info('[crypto-migration] no legacy device-ids to consider — skipping'); console.debug('[crypto-migration] no legacy device-ids to consider — skipping');
return result; return result;
} }
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
.not('recipient_device_id', 'is', null); .not('recipient_device_id', 'is', null);
if (error) throw error; if (error) throw error;
const rows = (rowsRaw ?? []) as LegacyRow[]; const rows = (rowsRaw ?? []) as LegacyRow[];
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length); console.debug('[crypto-migration] legacy rows visible to me: ' + rows.length);
if (rows.length === 0) return result; if (rows.length === 0) return result;
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean))); const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
@@ -136,13 +136,26 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
result.migratedConversations += 1; result.migratedConversations += 1;
} }
console.info( // If anything was actually migrated this run, leave it as console.info
'[crypto-migration] result:', // so it's visible in default consoles. If we only re-failed on already-
'attempted=' + result.attempted, // unrecoverable rows (no local stronghold key), demote to debug — the
'migrated=' + result.migratedConversations, // migration is idempotent but the noisy "noKey=N" line scared the user
'noKey=' + result.noStrongholdKey, // who thought migration was already done.
'decryptFail=' + result.decryptFailed, if (result.migratedConversations > 0 || result.decryptFailed > 0 || result.rpcFailed > 0) {
'rpcFail=' + result.rpcFailed, console.info(
); '[crypto-migration] result:',
'attempted=' + result.attempted,
'migrated=' + result.migratedConversations,
'noKey=' + result.noStrongholdKey,
'decryptFail=' + result.decryptFailed,
'rpcFail=' + result.rpcFailed,
);
} else {
console.debug(
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
'attempted=' + result.attempted,
'noKey=' + result.noStrongholdKey,
);
}
return result; return result;
} }