Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 588b843904 | |||
| dbf8030e93 | |||
| 615770722e | |||
| f1cba99b9e | |||
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf | |||
| 787437c3f1 | |||
| be5647281d | |||
| fc8fc275cb | |||
| e19a71e892 | |||
| 11869be443 | |||
| 3fa8b6dbc1 | |||
| f9f8e3fdb8 | |||
| 4ed3f04300 | |||
| a7ffcbff83 | |||
| 82600915f1 | |||
| 93098a74ca | |||
| c8f0e8efd5 | |||
| fd9b8a88d6 | |||
| ab2f7130fe | |||
| b9a3dde1aa | |||
| 65d2446804 | |||
| faa12a4ebb | |||
| 37dd1b4f23 | |||
| d803773261 | |||
| 49855c5d3f | |||
| c9a64bf898 | |||
| 7f704e80f6 | |||
| 940432d287 | |||
| 28b6d64936 |
@@ -107,6 +107,11 @@ export const CHANNELS = {
|
|||||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||||
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||||
|
|
||||||
|
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
|
||||||
|
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
|
||||||
|
// while a view-once image is being revealed. No-op on Linux X11.
|
||||||
|
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
|
||||||
|
|
||||||
// Wipe-on-close — main process pushes this to the renderer right before
|
// Wipe-on-close — main process pushes this to the renderer right before
|
||||||
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
||||||
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
||||||
@@ -271,6 +276,12 @@ export interface UpdateProgress {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Window content protection -------------------------------------------
|
||||||
|
|
||||||
|
export interface WindowSetContentProtectionArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Runtime marker ------------------------------------------------------
|
// ---- Runtime marker ------------------------------------------------------
|
||||||
|
|
||||||
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
|
|||||||
import { register as registerSql } from './modules/sql';
|
import { register as registerSql } from './modules/sql';
|
||||||
import { register as registerTray } from './modules/tray';
|
import { register as registerTray } from './modules/tray';
|
||||||
import { register as registerUpdater } from './modules/updater';
|
import { register as registerUpdater } from './modules/updater';
|
||||||
|
import { register as registerWindowContentProtection } from './modules/window-content-protection';
|
||||||
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
||||||
import { attach as attachWindowState, loadState } from './window-state';
|
import { attach as attachWindowState, loadState } from './window-state';
|
||||||
|
|
||||||
@@ -274,6 +275,7 @@ if (!gotLock) {
|
|||||||
registerTray(mainWindow);
|
registerTray(mainWindow);
|
||||||
registerUpdater(mainWindow);
|
registerUpdater(mainWindow);
|
||||||
registerWindowFullscreen(mainWindow);
|
registerWindowFullscreen(mainWindow);
|
||||||
|
registerWindowContentProtection(mainWindow);
|
||||||
registerAudioLoopback(mainWindow);
|
registerAudioLoopback(mainWindow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Window content-protection adapter. Enables / disables OS-level
|
||||||
|
// screenshot and screen-recording blocking on the host BrowserWindow.
|
||||||
|
//
|
||||||
|
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
|
||||||
|
// appears black in any screen capture tool (OBS, Snipping Tool,
|
||||||
|
// Win+PrtScr, etc.) while protection is enabled.
|
||||||
|
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
|
||||||
|
// Cmd+Shift+3/4, and external recorders.
|
||||||
|
// Linux: No-op. Electron exposes the API on all platforms but the
|
||||||
|
// X11/Wayland compositors don't honour it in Electron 33.
|
||||||
|
//
|
||||||
|
// Called by the renderer during view-once image reveals so the image
|
||||||
|
// cannot be captured by an OS-level screenshot while it is on screen.
|
||||||
|
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
|
||||||
|
(_evt, args: WindowSetContentProtectionArgs) => {
|
||||||
|
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
|
||||||
|
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
|
||||||
|
// Wrapped in try/catch because the window can already be destroyed
|
||||||
|
// by the time this fires during a teardown.
|
||||||
|
try {
|
||||||
|
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
win.setContentProtection(args.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
@@ -96,6 +96,12 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
|
||||||
|
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
|
||||||
|
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
|
||||||
|
* feature-check because the web build has no preload bridge. */
|
||||||
|
setContentProtection?: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
/** Subscribe to the main-process pre-quit notification. Used by the
|
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||||
* "Cache beim Schließen leeren" Settings toggle. */
|
* "Cache beim Schließen leeren" Settings toggle. */
|
||||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ const api = {
|
|||||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||||
|
|
||||||
|
// Window content protection ----------------------------------------------
|
||||||
|
setContentProtection: (enabled: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
|
||||||
|
|
||||||
// OS hostname ------------------------------------------------------------
|
// OS hostname ------------------------------------------------------------
|
||||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.19.1",
|
"version": "0.21.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense, useEffect } 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';
|
||||||
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
|
|||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { hydrateDrafts } from './lib/composerDraftStore';
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
@@ -60,7 +61,23 @@ 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() {
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrateDrafts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary scope="root">
|
<ErrorBoundary scope="root">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
@@ -114,7 +131,7 @@ export function App() {
|
|||||||
path=":id"
|
path=":id"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary scope="conversation">
|
<ErrorBoundary scope="conversation">
|
||||||
<ConversationPage />
|
<ConversationRoute />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
const [duration, setDuration] = useState<number>(0);
|
const [duration, setDuration] = useState<number>(0);
|
||||||
const [position, setPosition] = useState<number>(0);
|
const [position, setPosition] = useState<number>(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
};
|
};
|
||||||
}, [arrayBuf]);
|
}, [arrayBuf]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.playbackRate = speed;
|
||||||
|
}, [speed, blobUrl]);
|
||||||
|
|
||||||
const fallbackPeaks = useMemo(
|
const fallbackPeaks = useMemo(
|
||||||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||||
[peaks],
|
[peaks],
|
||||||
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
<PlayGlyph />
|
<PlayGlyph />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
|
||||||
|
{VOICE_SPEEDS.map((s) => {
|
||||||
|
const active = s === speed;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSpeed(s);
|
||||||
|
setVoiceSpeed(s);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
|
||||||
|
(active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={'Wiedergabegeschwindigkeit ' + s + '×'}
|
||||||
|
>
|
||||||
|
{s}×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
role="slider"
|
role="slider"
|
||||||
|
|||||||
@@ -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' })}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { MonitorShareIcon, PollIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAttachFile: () => void;
|
||||||
|
onCreatePoll: () => void;
|
||||||
|
onCreateWhiteboard: () => void;
|
||||||
|
onStartWatchTogether: () => void;
|
||||||
|
onStartGame: () => void;
|
||||||
|
canStartGame?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposerActionsMenu({
|
||||||
|
anchorRef,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAttachFile,
|
||||||
|
onCreatePoll,
|
||||||
|
onCreateWhiteboard,
|
||||||
|
onStartWatchTogether,
|
||||||
|
onStartGame,
|
||||||
|
canStartGame = true,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const firstItemRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
firstItemRef.current?.focus();
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
if (anchorRef.current?.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open, onClose, anchorRef]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
// Each item: closes the menu, then runs the action.
|
||||||
|
const items: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
|
action: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledTitle?: string;
|
||||||
|
section: 'top' | 'activities';
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'attach',
|
||||||
|
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
|
||||||
|
Icon: PaperclipIcon,
|
||||||
|
action: onAttachFile,
|
||||||
|
section: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'poll',
|
||||||
|
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
|
||||||
|
Icon: PollIcon,
|
||||||
|
action: onCreatePoll,
|
||||||
|
section: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'whiteboard',
|
||||||
|
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
|
||||||
|
Icon: MonitorShareIcon,
|
||||||
|
action: onCreateWhiteboard,
|
||||||
|
section: 'activities',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'watch',
|
||||||
|
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
|
||||||
|
Icon: PlayBoxIcon,
|
||||||
|
action: onStartWatchTogether,
|
||||||
|
section: 'activities',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'game',
|
||||||
|
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
|
||||||
|
Icon: GameIcon,
|
||||||
|
action: onStartGame,
|
||||||
|
disabled: !canStartGame,
|
||||||
|
disabledTitle: t('app:composer.menu.game_dm_only', {
|
||||||
|
defaultValue: 'Nur in 1:1-Chats',
|
||||||
|
}),
|
||||||
|
section: 'activities',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleItemClick = (item: (typeof items)[number]) => {
|
||||||
|
if (item.disabled) return;
|
||||||
|
onClose();
|
||||||
|
item.action();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'button[role="menuitem"]:not([disabled])',
|
||||||
|
);
|
||||||
|
if (!focusable || focusable.length === 0) return;
|
||||||
|
const list = Array.from(focusable);
|
||||||
|
const idx = list.findIndex((el) => el === document.activeElement);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
list[(idx + 1) % list.length]?.focus();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
list[(idx - 1 + list.length) % list.length]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const topItems = items.filter((i) => i.section === 'top');
|
||||||
|
const activityItems = items.filter((i) => i.section === 'activities');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
// Positioned absolutely above the anchor; the wrapping parent (the
|
||||||
|
// composer) must be `position: relative` for this to anchor correctly.
|
||||||
|
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||||
|
>
|
||||||
|
{topItems.map((item, idx) => {
|
||||||
|
const Icon = item.Icon;
|
||||||
|
const isFirst = idx === 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
ref={isFirst ? firstItemRef : undefined}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
title={item.disabled ? item.disabledTitle : undefined}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||||
|
(item.disabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
className="border-t border-line/60"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
|
||||||
|
</div>
|
||||||
|
{activityItems.map((item) => {
|
||||||
|
const Icon = item.Icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
title={item.disabled ? item.disabledTitle : undefined}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||||
|
(item.disabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline icons not in the central icons module ----------------------
|
||||||
|
|
||||||
|
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||||
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { PencilIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Stroke {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
color: string;
|
||||||
|
// normalized 0..1 coordinates so any viewer's canvas size renders consistently
|
||||||
|
points: Array<[number, number]>;
|
||||||
|
bornAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Stable per-share key. Use the share's participantId. */
|
||||||
|
shareKey: string;
|
||||||
|
/** Render annotations transparently (off when the toolbar is closed). */
|
||||||
|
enabled: boolean;
|
||||||
|
onToggleEnabled: (next: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_MS = 8000;
|
||||||
|
const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#000000'] as const;
|
||||||
|
|
||||||
|
export function ScreenShareAnnotations({ shareKey, enabled, onToggleEnabled }: Props) {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const userId = session?.user.id ?? 'anon';
|
||||||
|
const [color, setColor] = useState<string>(COLORS[0]);
|
||||||
|
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||||
|
const draftRef = useRef<Stroke | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const broadcastRef = useRef<((s: Stroke) => void) | null>(null);
|
||||||
|
|
||||||
|
// Subscribe to remote strokes.
|
||||||
|
useEffect(() => {
|
||||||
|
const channel = supabase.channel('screen-annotation:' + shareKey, {
|
||||||
|
config: { broadcast: { self: false } },
|
||||||
|
});
|
||||||
|
channel.on('broadcast', { event: 'stroke' }, (payload) => {
|
||||||
|
const s = payload.payload as Stroke | undefined;
|
||||||
|
if (!s || s.userId === userId) return;
|
||||||
|
setStrokes((prev) => [...prev, { ...s, bornAt: Date.now() }]);
|
||||||
|
});
|
||||||
|
channel.subscribe();
|
||||||
|
broadcastRef.current = (s: Stroke) => {
|
||||||
|
void channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'stroke',
|
||||||
|
payload: s,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
broadcastRef.current = null;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [shareKey, userId]);
|
||||||
|
|
||||||
|
// Garbage-collect faded strokes after FADE_MS + a small grace window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (strokes.length === 0) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const cutoff = Date.now() - FADE_MS - 500;
|
||||||
|
setStrokes((prev) => {
|
||||||
|
const next = prev.filter((s) => s.bornAt > cutoff);
|
||||||
|
return next.length === prev.length ? prev : next;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [strokes.length]);
|
||||||
|
|
||||||
|
// Paint the canvas on every render tick.
|
||||||
|
useEffect(() => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!cv || !container) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
if (cv.width !== rect.width || cv.height !== rect.height) {
|
||||||
|
cv.width = Math.max(1, Math.floor(rect.width));
|
||||||
|
cv.height = Math.max(1, Math.floor(rect.height));
|
||||||
|
}
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
const now = Date.now();
|
||||||
|
const drawStroke = (s: Stroke) => {
|
||||||
|
const age = now - s.bornAt;
|
||||||
|
const alpha = Math.max(0, 1 - age / FADE_MS);
|
||||||
|
if (alpha <= 0) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.strokeStyle = s.color;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < s.points.length; i++) {
|
||||||
|
const [nx, ny] = s.points[i]!;
|
||||||
|
const x = nx * cv.width;
|
||||||
|
const y = ny * cv.height;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
for (const s of strokes) drawStroke(s);
|
||||||
|
if (draftRef.current) drawStroke(draftRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animation frame loop so faded strokes visually decay between paints.
|
||||||
|
useEffect(() => {
|
||||||
|
if (strokes.length === 0 && !draftRef.current) return;
|
||||||
|
let raf = 0;
|
||||||
|
const tick = () => {
|
||||||
|
// Nudge state to force a re-paint. Slightly hacky but cheaper than a
|
||||||
|
// dedicated refresh state.
|
||||||
|
setStrokes((prev) => prev.slice());
|
||||||
|
raf = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = window.requestAnimationFrame(tick);
|
||||||
|
return () => window.cancelAnimationFrame(raf);
|
||||||
|
}, [strokes.length]);
|
||||||
|
|
||||||
|
const normalized = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return [0, 0] as [number, number];
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const nx = (e.clientX - rect.left) / rect.width;
|
||||||
|
const ny = (e.clientY - rect.top) / rect.height;
|
||||||
|
return [Math.min(1, Math.max(0, nx)), Math.min(1, Math.max(0, ny))] as [number, number];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
draftRef.current = {
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
userId,
|
||||||
|
color,
|
||||||
|
points: [normalized(e)],
|
||||||
|
bornAt: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
draftRef.current.points.push(normalized(e));
|
||||||
|
setStrokes((prev) => prev.slice()); // cheap re-render trigger
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget as HTMLDivElement;
|
||||||
|
if (target.hasPointerCapture(e.pointerId)) target.releasePointerCapture(e.pointerId);
|
||||||
|
const draft = draftRef.current;
|
||||||
|
draftRef.current = null;
|
||||||
|
if (!draft || draft.points.length < 2) {
|
||||||
|
setStrokes((prev) => prev.slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStrokes((prev) => [...prev, draft]);
|
||||||
|
broadcastRef.current?.(draft);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
className={
|
||||||
|
'absolute inset-0 z-10 ' +
|
||||||
|
(enabled ? 'cursor-crosshair touch-none' : 'pointer-events-none')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-3 top-12 z-20 flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleEnabled(!enabled)}
|
||||||
|
aria-pressed={enabled}
|
||||||
|
title={enabled ? 'Annotation aus' : 'Annotation an'}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-white shadow-lg transition ' +
|
||||||
|
(enabled ? 'bg-accent' : 'bg-black/60 hover:bg-black/80')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{enabled ? <XIcon className="h-3.5 w-3.5" /> : <PencilIcon className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
{enabled && (
|
||||||
|
<div className="flex items-center gap-1 rounded-full bg-black/60 p-1 shadow-lg">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-label={c}
|
||||||
|
aria-pressed={c === color}
|
||||||
|
className={
|
||||||
|
'h-5 w-5 cursor-pointer rounded-full border-2 transition ' +
|
||||||
|
(c === color ? 'border-white scale-110' : 'border-white/30 hover:scale-105')
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||||
import { MonitorShareIcon } from './icons';
|
import { MonitorShareIcon } from './icons';
|
||||||
|
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
interface ScreenShareViewerProps {
|
||||||
share: RemoteScreenShare;
|
share: RemoteScreenShare;
|
||||||
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
|
|||||||
const { watchingShareUserIds, watchShare } = useCall();
|
const { watchingShareUserIds, watchShare } = useCall();
|
||||||
const watching = watchingShareUserIds.has(share.participantId);
|
const watching = watchingShareUserIds.has(share.participantId);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [annotateEnabled, setAnnotateEnabled] = useState(false);
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,6 +98,7 @@ export function ScreenShareViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{watching ? (
|
{watching ? (
|
||||||
|
<div className="relative h-full w-full flex-1">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -109,8 +112,14 @@ export function ScreenShareViewer({
|
|||||||
// modes (where the video covers the whole tile).
|
// modes (where the video covers the whole tile).
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
className="block h-full w-full cursor-zoom-in bg-black object-contain"
|
||||||
/>
|
/>
|
||||||
|
<ScreenShareAnnotations
|
||||||
|
shareKey={share.participantId}
|
||||||
|
enabled={annotateEnabled}
|
||||||
|
onToggleEnabled={setAnnotateEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
@@ -16,16 +16,34 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Three states:
|
// Three states:
|
||||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
// 1. viewedAt is null AND user is recipient → blurred lock card. Press-and-
|
||||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
// hold reveals the image fullscreen; release closes it AND fires the
|
||||||
|
// mark-viewed RPC.
|
||||||
// 2. viewedAt is set → tombstone "Angesehen am …".
|
// 2. viewedAt is set → tombstone "Angesehen am …".
|
||||||
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
|
// 3. user is sender → normal image, tombstone update appears once recipient
|
||||||
|
// burns it.
|
||||||
|
//
|
||||||
|
// While revealed, the renderer window enables content-protection
|
||||||
|
// (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd
|
||||||
|
// snipping tools, screen recorders) sees a black/empty surface. Re-enabled
|
||||||
|
// on release / unmount.
|
||||||
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||||
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [revealing, setRevealing] = useState(false);
|
||||||
|
const burnedRef = useRef(false);
|
||||||
|
const holdingRef = useRef(false);
|
||||||
|
|
||||||
const burned = revealedAt !== null;
|
const burned = revealedAt !== null;
|
||||||
|
|
||||||
|
// Tear down screen-capture protection if the component unmounts mid-reveal.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (revealing || holdingRef.current) {
|
||||||
|
void window.electronAPI?.setContentProtection?.(false).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [revealing]);
|
||||||
|
|
||||||
if (burned && !isSender) {
|
if (burned && !isSender) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
||||||
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipient, not yet viewed.
|
const startReveal = async (): Promise<void> => {
|
||||||
const handleOpen = async (): Promise<void> => {
|
if (burnedRef.current) return;
|
||||||
|
burnedRef.current = true;
|
||||||
|
holdingRef.current = true;
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.setContentProtection?.(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection enable failed', err);
|
||||||
|
}
|
||||||
|
// The user may have released during the await. If so, skip showing the
|
||||||
|
// dialog and run the close-path directly so we don't leave the renderer
|
||||||
|
// in protected mode with no visible UI.
|
||||||
|
if (!holdingRef.current) {
|
||||||
|
// User released during the IPC await — endReveal already fired and is
|
||||||
|
// responsible for teardown (setContentProtection(false) + mark-viewed).
|
||||||
|
// Skipping teardown here avoids a duplicate markAttachmentViewed RPC.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRevealing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endReveal = async (): Promise<void> => {
|
||||||
|
if (!holdingRef.current && !revealing) return;
|
||||||
|
holdingRef.current = false;
|
||||||
|
if (revealing) setRevealing(false);
|
||||||
|
await teardownReveal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardownReveal = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.setContentProtection?.(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection disable failed', err);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await markAttachmentViewed(supabase, attachmentId);
|
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||||
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('mark-viewed failed', err);
|
console.warn('mark-viewed failed', err);
|
||||||
|
burnedRef.current = false;
|
||||||
}
|
}
|
||||||
setFullscreen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleOpen()}
|
onPointerDown={() => void startReveal()}
|
||||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
onPointerUp={() => void endReveal()}
|
||||||
|
onPointerLeave={() => void endReveal()}
|
||||||
|
onPointerCancel={() => void endReveal()}
|
||||||
|
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40 select-none"
|
||||||
>
|
>
|
||||||
<LockIcon className="h-6 w-6 text-accent" />
|
<LockIcon className="h-6 w-6 text-accent" />
|
||||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
||||||
</button>
|
</button>
|
||||||
{fullscreen && (
|
{revealing && (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
aria-label="Einmal-ansehen Bild"
|
||||||
onClick={() => setFullscreen(false)}
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
|
||||||
>
|
>
|
||||||
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full select-none rounded-lg"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
Loslassen zum Schließen — Aufnahme blockiert
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors';
|
||||||
|
|
||||||
export type WhiteboardTool = 'pen' | 'eraser';
|
export type WhiteboardTool = 'pen' | 'eraser';
|
||||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||||
export type WhiteboardWidth = 2 | 4 | 8;
|
export type WhiteboardWidth = 2 | 4 | 8;
|
||||||
@@ -22,6 +25,8 @@ interface Props {
|
|||||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||||
logicalWidth?: number;
|
logicalWidth?: number;
|
||||||
logicalHeight?: number;
|
logicalHeight?: number;
|
||||||
|
/** Enables live-cursor broadcast when set. */
|
||||||
|
whiteboardId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LOGICAL_W = 1280;
|
const DEFAULT_LOGICAL_W = 1280;
|
||||||
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
|
|||||||
onStroke,
|
onStroke,
|
||||||
logicalWidth = DEFAULT_LOGICAL_W,
|
logicalWidth = DEFAULT_LOGICAL_W,
|
||||||
logicalHeight = DEFAULT_LOGICAL_H,
|
logicalHeight = DEFAULT_LOGICAL_H,
|
||||||
|
whiteboardId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||||
const strokeStartRef = useRef<number>(0);
|
const strokeStartRef = useRef<number>(0);
|
||||||
const [, forceTick] = useState(0);
|
const [, forceTick] = useState(0);
|
||||||
|
|
||||||
|
const { session, profile } = useAuth();
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorEvent & { lastSeen: number }>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
const cursorSessionRef = useRef<CursorSession | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!whiteboardId) return;
|
||||||
|
const me = session?.user;
|
||||||
|
if (!me) return;
|
||||||
|
const displayName = profile?.displayName ?? me.email ?? me.id.slice(0, 8);
|
||||||
|
const s = openCursorSession(
|
||||||
|
whiteboardId,
|
||||||
|
{ userId: me.id, displayName },
|
||||||
|
(ev) => {
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(ev.userId, { ...ev, lastSeen: Date.now() });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cursorSessionRef.current = s;
|
||||||
|
return () => {
|
||||||
|
s.close();
|
||||||
|
cursorSessionRef.current = null;
|
||||||
|
};
|
||||||
|
}, [whiteboardId, session?.user, profile?.displayName]);
|
||||||
|
|
||||||
|
// Stale-cursor sweep: drop cursors that haven't been heard from in 2s. Cheap
|
||||||
|
// poll because the Map is tiny (at most one entry per active collaborator).
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteCursors.size === 0) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [k, v] of next) {
|
||||||
|
if (now - v.lastSeen > 2000) {
|
||||||
|
next.delete(k);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [remoteCursors.size]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cv = canvasRef.current;
|
const cv = canvasRef.current;
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
if (!draftRef.current) return;
|
|
||||||
const [x, y] = canvasPoint(e);
|
const [x, y] = canvasPoint(e);
|
||||||
|
cursorSessionRef.current?.send(x, y);
|
||||||
|
if (!draftRef.current) return;
|
||||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||||
forceTick((n) => n + 1);
|
forceTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
@@ -102,6 +159,10 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
@@ -109,9 +170,40 @@ export function WhiteboardCanvas({
|
|||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={handlePointerUp}
|
onPointerLeave={handlePointerUp}
|
||||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
{Array.from(remoteCursors.values()).map((c) => {
|
||||||
|
const pctX = (c.x / logicalWidth) * 100;
|
||||||
|
const pctY = (c.y / logicalHeight) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.userId}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{ left: pctX + '%', top: pctY + '%', transform: 'translate(-2px, -2px)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-2 w-2 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: colorForUserId(c.userId) }}
|
||||||
|
/>
|
||||||
|
<span className="ml-2 inline-block translate-y-[-2px] rounded-full bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||||||
|
{c.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForUserId(userId: string): string {
|
||||||
|
// Deterministic hue from the user id so each collaborator gets a stable
|
||||||
|
// colour across sessions. Saturation/lightness fixed to keep the cursor
|
||||||
|
// legible against the white canvas.
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
return 'hsl(' + hue + ', 70%, 50%)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStroke(
|
function renderStroke(
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
|||||||
color={color}
|
color={color}
|
||||||
width={width}
|
width={width}
|
||||||
onStroke={(payload) => void insertStroke(payload)}
|
onStroke={(payload) => void insertStroke(payload)}
|
||||||
|
whiteboardId={whiteboardId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,16 +150,15 @@ function EyeOffIconInner(props: IconProps) {
|
|||||||
}
|
}
|
||||||
export const EyeOffIcon = memo(EyeOffIconInner);
|
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||||
|
|
||||||
function CaptionsIconInner(props: IconProps) {
|
function EyeIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export const CaptionsIcon = memo(CaptionsIconInner);
|
export const EyeIcon = memo(EyeIconInner);
|
||||||
|
|
||||||
function PinOffIconInner(props: IconProps) {
|
function PinOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -99,6 +98,7 @@ import {
|
|||||||
subscribeScreenShareVolumes,
|
subscribeScreenShareVolumes,
|
||||||
} from '../lib/screenShareVolumes';
|
} from '../lib/screenShareVolumes';
|
||||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
|
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
|
||||||
import { playEntry } from '../lib/soundboardPlayback';
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
import {
|
import {
|
||||||
getPrefs as getSoundboardPrefs,
|
getPrefs as getSoundboardPrefs,
|
||||||
@@ -209,14 +209,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 +339,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 +817,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 +958,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 +977,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 +1731,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;
|
||||||
@@ -2292,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Global soundboard hotkey registration — runs only while connected so the
|
// Global soundboard hotkey registration — always-on so the OS-level
|
||||||
// OS-level shortcuts don't fire when the user is outside of a call.
|
// shortcuts fire even outside a call (Stream-Deck-style local SFX). Inside
|
||||||
|
// a call we route through `playSoundboard` so peers hear; outside a call
|
||||||
|
// we fall back to `playSoundboardLocal` which plays through the system
|
||||||
|
// default output only.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT depend on `state.kind` in the effect dep array:
|
||||||
|
// every call state transition (idle → connecting → connected → reconnecting
|
||||||
|
// → ...) would trigger a full unregister+re-register cycle through IPC, and
|
||||||
|
// during the 1–50 ms gap the hotkeys are silently dead. Instead we read the
|
||||||
|
// current call state through a ref that's always kept in sync.
|
||||||
|
const callStateKindRef = useRef(state.kind);
|
||||||
|
callStateKindRef.current = state.kind;
|
||||||
|
const playSoundboardRef = useRef(playSoundboard);
|
||||||
|
playSoundboardRef.current = playSoundboard;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.kind !== 'connected') return;
|
|
||||||
const teardown = startSoundboardHotkeys((id) => {
|
const teardown = startSoundboardHotkeys((id) => {
|
||||||
void playSoundboard(id);
|
if (callStateKindRef.current === 'connected') {
|
||||||
|
void playSoundboardRef.current(id);
|
||||||
|
} else {
|
||||||
|
void (async () => {
|
||||||
|
const entries = await listSoundboard();
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (entry) await playSoundboardLocal(entry);
|
||||||
|
})();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return teardown;
|
return teardown;
|
||||||
}, [state.kind, playSoundboard]);
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ inputDeviceId: deviceId });
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
@@ -2603,15 +2590,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 +2604,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -2683,8 +2659,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
|
|||||||
@@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads
|
||||||
|
const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']);
|
||||||
|
|
||||||
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
throw new Error('only image files are accepted');
|
throw new Error('only image files are accepted');
|
||||||
}
|
}
|
||||||
|
// Animated formats bypass the canvas re-encode (which would strip
|
||||||
|
// animation by sampling the first frame). We still validate dimensions
|
||||||
|
// and size so a 40-MB animated WebP can't slip through.
|
||||||
|
if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) {
|
||||||
|
if (file.size > MAX_ANIMATED_BYTES) {
|
||||||
|
throw new Error('animated avatar too large (max 2 MB)');
|
||||||
|
}
|
||||||
|
const dims = await readDimensions(file);
|
||||||
|
if (dims.width > MAX_DIM || dims.height > MAX_DIM) {
|
||||||
|
throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')');
|
||||||
|
}
|
||||||
|
return uploadAvatarBlob(userId, file);
|
||||||
|
}
|
||||||
const blob = await resizeToSquare(file);
|
const blob = await resizeToSquare(file);
|
||||||
return uploadAvatarBlob(userId, blob);
|
return uploadAvatarBlob(userId, blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const i = new Image();
|
||||||
|
i.onload = () => resolve(i);
|
||||||
|
i.onerror = () => reject(new Error('image load failed'));
|
||||||
|
i.src = url;
|
||||||
|
});
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAnimated(file: File): Promise<boolean> {
|
||||||
|
// GIF: any GIF89a/GIF87a header is treated as potentially animated. The
|
||||||
|
// static-GIF case (one image-descriptor block) is rare enough that
|
||||||
|
// re-encoding wouldn't save much, so we accept the false-positives.
|
||||||
|
if (file.type === 'image/gif') return true;
|
||||||
|
// APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the
|
||||||
|
// first 64 KB — APNGs put acTL near the front, before IDAT.
|
||||||
|
if (file.type === 'image/apng' || file.type === 'image/png') {
|
||||||
|
const head = await file.slice(0, 65536).arrayBuffer();
|
||||||
|
return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL'
|
||||||
|
}
|
||||||
|
// Animated WebP: 'ANIM' chunk in the RIFF container.
|
||||||
|
if (file.type === 'image/webp') {
|
||||||
|
const head = await file.slice(0, 65536).arrayBuffer();
|
||||||
|
return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM'
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsBytes(buf: ArrayBuffer, needle: number[]): boolean {
|
||||||
|
const view = new Uint8Array(buf);
|
||||||
|
const len = view.length;
|
||||||
|
const nlen = needle.length;
|
||||||
|
outer: for (let i = 0; i + nlen <= len; i++) {
|
||||||
|
for (let j = 0; j < nlen; j++) {
|
||||||
|
if (view[i + j] !== needle[j]) continue outer;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
||||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||||
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
||||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
const ext =
|
||||||
|
blob.type === 'image/webp' ? 'webp' :
|
||||||
|
blob.type === 'image/gif' ? 'gif' :
|
||||||
|
blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' :
|
||||||
|
'jpg';
|
||||||
// Random filename so old uploads don't get overwritten before we update
|
// Random filename so old uploads don't get overwritten before we update
|
||||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||||
// also forces clients to fetch the new image.
|
// also forces clients to fetch the new image.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetForTests,
|
||||||
|
clearDraft,
|
||||||
|
getDraftSync,
|
||||||
|
hasDraft,
|
||||||
|
hydrateDrafts,
|
||||||
|
setDraft,
|
||||||
|
} from './composerDraftStore';
|
||||||
|
|
||||||
|
const sqlExecuteMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sqlSelectMock = vi.fn().mockResolvedValue([]);
|
||||||
|
const sqlLoadMock = vi.fn().mockResolvedValue('mock-handle');
|
||||||
|
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
electronAPI: {
|
||||||
|
platform: 'electron-chatapp-v1',
|
||||||
|
sqlLoad: sqlLoadMock,
|
||||||
|
sqlExecute: sqlExecuteMock,
|
||||||
|
sqlSelect: sqlSelectMock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('composerDraftStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlExecuteMock.mockClear();
|
||||||
|
sqlSelectMock.mockClear();
|
||||||
|
sqlLoadMock.mockClear();
|
||||||
|
__resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown conversation', () => {
|
||||||
|
expect(getDraftSync('unknown')).toBeNull();
|
||||||
|
expect(hasDraft('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and returns a draft synchronously after set', () => {
|
||||||
|
setDraft('a', { text: 'hi', replyToId: null });
|
||||||
|
const draft = getDraftSync('a');
|
||||||
|
expect(draft).not.toBeNull();
|
||||||
|
expect(draft?.text).toBe('hi');
|
||||||
|
expect(draft?.replyToId).toBeNull();
|
||||||
|
expect(hasDraft('a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates drafts per conversation', () => {
|
||||||
|
setDraft('a', { text: 'one', replyToId: null });
|
||||||
|
setDraft('b', { text: 'two', replyToId: 'msg-9' });
|
||||||
|
expect(getDraftSync('a')?.text).toBe('one');
|
||||||
|
expect(getDraftSync('b')?.replyToId).toBe('msg-9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearDraft removes the draft from memory', () => {
|
||||||
|
setDraft('a', { text: 'one', replyToId: null });
|
||||||
|
clearDraft('a');
|
||||||
|
expect(getDraftSync('a')).toBeNull();
|
||||||
|
expect(hasDraft('a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty-string text + null reply as "no draft"', () => {
|
||||||
|
setDraft('a', { text: '', replyToId: null });
|
||||||
|
expect(getDraftSync('a')).toBeNull();
|
||||||
|
expect(hasDraft('a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrateDrafts populates the in-memory map from SQLite rows', async () => {
|
||||||
|
sqlSelectMock.mockResolvedValueOnce([
|
||||||
|
{ conversation_id: 'a', text: 'persisted', reply_to_id: 'msg-1', updated_at: '2026-05-17T00:00:00Z' },
|
||||||
|
]);
|
||||||
|
await hydrateDrafts();
|
||||||
|
expect(getDraftSync('a')?.text).toBe('persisted');
|
||||||
|
expect(getDraftSync('a')?.replyToId).toBe('msg-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Composer-draft persistence. Two-tier semantics:
|
||||||
|
// * In-memory `Map<convId, Draft>` for instant synchronous reads on
|
||||||
|
// mount (mirrors the `messageMemoryCache` pattern from Phase 7).
|
||||||
|
// * SQLite (`composer_drafts` table, schema in `messageCache.ts`) for
|
||||||
|
// cross-restart persistence. Writes are debounced and fire-and-forget
|
||||||
|
// — losing the last 400ms of typing on a hard crash is acceptable;
|
||||||
|
// blocking the keystroke handler is not.
|
||||||
|
//
|
||||||
|
// Attachments are intentionally NOT serialized:
|
||||||
|
// * Files don't round-trip through SQLite cleanly (binary blobs blow
|
||||||
|
// up the cache size).
|
||||||
|
// * `replyToId` IS persisted; the consuming page looks up the actual
|
||||||
|
// message by id at render time.
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
|
const DB_NAME = 'chatapp-cache';
|
||||||
|
const WRITE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
text: string;
|
||||||
|
replyToId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftRow {
|
||||||
|
conversation_id: string;
|
||||||
|
text: string;
|
||||||
|
reply_to_id: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drafts = new Map<string, Draft>();
|
||||||
|
const pendingWrites = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
let handlePromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function getHandle(): Promise<string | null> {
|
||||||
|
if (handlePromise) return handlePromise;
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
handlePromise = Promise.resolve(null);
|
||||||
|
return handlePromise;
|
||||||
|
}
|
||||||
|
handlePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const handle = await window.electronAPI.sqlLoad({ name: DB_NAME });
|
||||||
|
// Self-contained DDL — the same statement also runs from
|
||||||
|
// `messageCache.ts`'s init path, but we don't want to depend on
|
||||||
|
// call order. SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent
|
||||||
|
// so the double-creation is safe.
|
||||||
|
await window.electronAPI.sqlExecute({
|
||||||
|
handle,
|
||||||
|
query:
|
||||||
|
`CREATE TABLE IF NOT EXISTS composer_drafts (
|
||||||
|
conversation_id TEXT PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
reply_to_id TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
bindings: [],
|
||||||
|
});
|
||||||
|
return handle;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('composerDraftStore: sqlLoad failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return handlePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDraftSync(conversationId: string): Draft | null {
|
||||||
|
const stored = drafts.get(conversationId);
|
||||||
|
if (!stored) return null;
|
||||||
|
return { text: stored.text, replyToId: stored.replyToId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDraft(conversationId: string): boolean {
|
||||||
|
return drafts.has(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDraft(conversationId: string, draft: Draft): void {
|
||||||
|
if (draft.text.length === 0 && draft.replyToId === null) {
|
||||||
|
if (drafts.has(conversationId)) {
|
||||||
|
drafts.delete(conversationId);
|
||||||
|
scheduleWrite(conversationId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drafts.set(conversationId, { text: draft.text, replyToId: draft.replyToId });
|
||||||
|
scheduleWrite(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraft(conversationId: string): void {
|
||||||
|
if (!drafts.has(conversationId)) return;
|
||||||
|
drafts.delete(conversationId);
|
||||||
|
scheduleWrite(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWrite(conversationId: string): void {
|
||||||
|
const existing = pendingWrites.get(conversationId);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingWrites.delete(conversationId);
|
||||||
|
void flushOne(conversationId);
|
||||||
|
}, WRITE_DEBOUNCE_MS);
|
||||||
|
pendingWrites.set(conversationId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushOne(conversationId: string): Promise<void> {
|
||||||
|
const handle = await getHandle();
|
||||||
|
if (!handle) return;
|
||||||
|
const draft = drafts.get(conversationId);
|
||||||
|
try {
|
||||||
|
if (draft) {
|
||||||
|
await window.electronAPI.sqlExecute({
|
||||||
|
handle,
|
||||||
|
query:
|
||||||
|
`INSERT INTO composer_drafts (conversation_id, text, reply_to_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||||
|
text = excluded.text,
|
||||||
|
reply_to_id = excluded.reply_to_id,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
bindings: [conversationId, draft.text, draft.replyToId, new Date().toISOString()],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.sqlExecute({
|
||||||
|
handle,
|
||||||
|
query: 'DELETE FROM composer_drafts WHERE conversation_id = $1',
|
||||||
|
bindings: [conversationId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('composerDraftStore: flush failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateDrafts(): Promise<void> {
|
||||||
|
const handle = await getHandle();
|
||||||
|
if (!handle) return;
|
||||||
|
try {
|
||||||
|
const rows = (await window.electronAPI.sqlSelect({
|
||||||
|
handle,
|
||||||
|
query: 'SELECT conversation_id, text, reply_to_id, updated_at FROM composer_drafts',
|
||||||
|
bindings: [],
|
||||||
|
})) as unknown as DraftRow[];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.conversation_id || typeof r.text !== 'string') continue;
|
||||||
|
if (r.text.length === 0 && r.reply_to_id === null) continue;
|
||||||
|
drafts.set(r.conversation_id, { text: r.text, replyToId: r.reply_to_id });
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('composerDraftStore: hydrate failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetForTests(): void {
|
||||||
|
for (const t of pendingWrites.values()) clearTimeout(t);
|
||||||
|
pendingWrites.clear();
|
||||||
|
drafts.clear();
|
||||||
|
handlePromise = null;
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
Binary file not shown.
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Plays a soundboard entry to the local default audio output. Used when no
|
||||||
|
// call pipeline is active (the in-call path routes via the LiveKit
|
||||||
|
// publishing pipeline so peers hear; this path is local-only). Fetches the
|
||||||
|
// blob via getSoundBlob and creates a short-lived object URL for the audio
|
||||||
|
// element.
|
||||||
|
|
||||||
|
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
|
||||||
|
|
||||||
|
const activeAudios = new Set<HTMLAudioElement>();
|
||||||
|
|
||||||
|
export async function playSoundboardLocal(entry: SoundboardEntry): Promise<void> {
|
||||||
|
const blob = await getSoundBlob(entry.id);
|
||||||
|
if (!blob) return;
|
||||||
|
const src = URL.createObjectURL(blob);
|
||||||
|
const el = new Audio(src);
|
||||||
|
// Use the per-entry gain as local volume. SoundboardEntry exposes `gain`
|
||||||
|
// (0..1) which mirrors the value used in the in-call pipeline.
|
||||||
|
el.volume = Math.max(0, Math.min(1, entry.gain));
|
||||||
|
activeAudios.add(el);
|
||||||
|
const cleanup = () => {
|
||||||
|
activeAudios.delete(el);
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
};
|
||||||
|
el.addEventListener('ended', cleanup);
|
||||||
|
el.addEventListener('error', cleanup);
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
} catch (err) {
|
||||||
|
cleanup();
|
||||||
|
console.warn('soundboardLocalPlay failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSoundboardLocal(): void {
|
||||||
|
for (const el of activeAudios) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeAudios.clear();
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
|
||||||
import {
|
import {
|
||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
|
clearConvKeyCache,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
decryptMessages,
|
decryptMessages,
|
||||||
encryptAndUploadAttachment,
|
encryptAndUploadAttachment,
|
||||||
fetchConversationMessages,
|
fetchConversationMessages,
|
||||||
|
getOrCreateConvKey,
|
||||||
insertAttachmentRow,
|
insertAttachmentRow,
|
||||||
MAX_ATTACHMENT_BYTES,
|
MAX_ATTACHMENT_BYTES,
|
||||||
type MessageWithCipher,
|
type MessageWithCipher,
|
||||||
|
rotateConvKey,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
shareConvKeyToUser,
|
|
||||||
tryGetConvKey,
|
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -75,14 +80,27 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
text: string,
|
text: string,
|
||||||
images?: File[],
|
images?: File[],
|
||||||
replyToId?: string | null,
|
replyToId?: string | null,
|
||||||
opts?: { viewOnce?: boolean },
|
opts?: { viewOnceFlags?: boolean[] },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
pending: OutboxItem[];
|
pending: OutboxItem[];
|
||||||
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) : [],
|
||||||
);
|
);
|
||||||
@@ -107,12 +125,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
// Proactive rewrap sweep: when a conversation opens, ensure the active
|
||||||
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
// conv-key has a `recipient_user_id` bundle for every accepted member.
|
||||||
// for them. Members who are missing one (typically peers who haven't yet
|
//
|
||||||
// migrated to the per-user key model) get a best-effort wrap from the
|
// If any peer is missing a bundle at the active version, the previous
|
||||||
// local conv-key handle. Closes the legacy migration gap so peer B can
|
// implementation called `shareConvKeyToUser` for each missing peer —
|
||||||
// read on first unlock without manual intervention from A.
|
// that helper reads from the module-level conv-key cache first, and if
|
||||||
|
// the cache held a STALE locally-generated key (from a buggy bootstrap
|
||||||
|
// race in an earlier app version), the stale key got propagated to the
|
||||||
|
// peer's row. Both sides then encrypt with mutually un-mergeable keys
|
||||||
|
// and every message is "Nachricht nicht lesbar" forever (incident:
|
||||||
|
// conv aae12d84).
|
||||||
|
//
|
||||||
|
// The replacement: when any peer is missing, call `rotateConvKey` once.
|
||||||
|
// Rotation generates a fresh symmetric key locally, fetches each member's
|
||||||
|
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
|
||||||
|
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
|
||||||
|
// serialises concurrent rotations). This bypasses the cache entirely:
|
||||||
|
// the new version's cache entry is the just-rotated key, and the stale
|
||||||
|
// entry at the old version is irrelevant because nobody reads it any more.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || !userId) return;
|
if (!conversationId || !userId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -136,31 +167,41 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||||
|
|
||||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
// First, make sure we have a usable handle for the active version
|
||||||
if (!handle || cancelled) return;
|
// (this auto-rotates if we're locked out of our own bundle — the
|
||||||
|
// recovery path added in v0.21.1/v0.21.2).
|
||||||
|
const handle = await getOrCreateConvKey(supabase, conversationId, {
|
||||||
|
userId,
|
||||||
|
privateKey: priv,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (handle.keyVersion > version) return; // already rotated by helper
|
||||||
|
|
||||||
|
// Check membership state on the server.
|
||||||
const { data: members, error: mErr } = await supabase
|
const { data: members, error: mErr } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
.select('user_id, accepted')
|
.select('user_id, accepted')
|
||||||
.eq('conversation_id', conversationId);
|
.eq('conversation_id', conversationId);
|
||||||
if (mErr || !members) return;
|
if (mErr || !members) return;
|
||||||
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||||
.filter((m) => m.accepted && m.user_id !== userId)
|
.filter((m) => m.accepted && m.user_id !== userId)
|
||||||
.map((m) => m.user_id);
|
.map((m) => m.user_id);
|
||||||
if (memberIds.length === 0) return;
|
if (peerIds.length === 0) return;
|
||||||
|
|
||||||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
// Count how many of the peers have a recipient_user_id bundle at
|
||||||
for (const peer of peers) {
|
// the active version. If any are missing, rotate to V+1 — the
|
||||||
if (cancelled) return;
|
// rotation will wrap a fresh key for every accepted member with a
|
||||||
const { count, error: cntErr } = await (
|
// user_keys row.
|
||||||
|
const { data: existingRows, error: rowsErr } = await (
|
||||||
supabase as unknown as {
|
supabase as unknown as {
|
||||||
from: (t: string) => {
|
from: (t: string) => {
|
||||||
select: (s: string, o?: object) => {
|
select: (s: string) => {
|
||||||
eq: (...a: unknown[]) => {
|
eq: (c: string, v: string) => {
|
||||||
eq: (...a: unknown[]) => {
|
eq: (c: string, v: number) => {
|
||||||
eq: (
|
in: (c: string, v: string[]) => Promise<{
|
||||||
...a: unknown[]
|
data: Array<{ recipient_user_id: string }> | null;
|
||||||
) => Promise<{ count: number | null; error: unknown }>;
|
error: unknown;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -168,24 +209,34 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.from('conversation_keys')
|
.from('conversation_keys')
|
||||||
.select('recipient_user_id', { count: 'exact', head: true })
|
.select('recipient_user_id')
|
||||||
.eq('conversation_id', conversationId)
|
.eq('conversation_id', conversationId)
|
||||||
.eq('recipient_user_id', peer.userId)
|
.eq('key_version', version)
|
||||||
.eq('key_version', version);
|
.in('recipient_user_id', peerIds);
|
||||||
if (cntErr) continue;
|
if (rowsErr) return;
|
||||||
if ((count ?? 0) === 0) {
|
const wrappedPeerIds = new Set(
|
||||||
try {
|
(existingRows ?? []).map((r) => r.recipient_user_id),
|
||||||
await shareConvKeyToUser(
|
|
||||||
supabase,
|
|
||||||
conversationId,
|
|
||||||
peer.userId,
|
|
||||||
peer.publicKey,
|
|
||||||
{ userId, privateKey: priv },
|
|
||||||
);
|
);
|
||||||
|
const missing = peerIds.filter((id) => !wrappedPeerIds.has(id));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
// At least one peer is missing a bundle — rotate. We deliberately do
|
||||||
|
// NOT use the cached conv-key here. The rotation generates a fresh
|
||||||
|
// key wrapped to every current member's CURRENT pubkey, so any
|
||||||
|
// staleness in the local cache for the OLD version is irrelevant
|
||||||
|
// going forward.
|
||||||
|
try {
|
||||||
|
await rotateConvKey(supabase, conversationId, {
|
||||||
|
userId,
|
||||||
|
privateKey: priv,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('proactive rewrap failed for', peer.userId, err);
|
// Most likely cause: a concurrent peer also called rotate and
|
||||||
}
|
// won the race; their bumped active_key_version makes our
|
||||||
}
|
// `p_new_version <= cur_version` and the RPC raises. That's fine —
|
||||||
|
// the next chat-open / send will fetch the new active version and
|
||||||
|
// unwrap the bundle that peer wrapped for us.
|
||||||
|
console.warn('proactive rotate failed (likely concurrent rotation)', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('proactive rewrap sweep failed', err);
|
console.warn('proactive rewrap sweep failed', err);
|
||||||
@@ -229,6 +280,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 +300,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 +395,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 +417,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 +481,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,25 +489,32 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
[conversationId, deviceId, decryptBatch],
|
[conversationId, deviceId, decryptBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
const handleDelete = useCallback(
|
||||||
|
(row: Record<string, unknown>) => {
|
||||||
const id = String(row.id);
|
const id = String(row.id);
|
||||||
setState((prev) => ({
|
setState((prev) => {
|
||||||
...prev,
|
const next = prev.messages.filter((m) => m.id !== id);
|
||||||
messages: prev.messages.filter((m) => m.id !== id),
|
if (conversationId) setCachedMessages(conversationId, next);
|
||||||
}));
|
return { ...prev, messages: next };
|
||||||
|
});
|
||||||
void deleteCachedMessage(id);
|
void deleteCachedMessage(id);
|
||||||
}, []);
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || !userId || !deviceId) return;
|
if (!conversationId || !userId || !deviceId) return;
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
||||||
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
|
// refetches + decrypts. The first event in a quiet period fires
|
||||||
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
|
// `handleInsert` immediately so single incoming messages don't sit
|
||||||
// the last 100 in one query — cheaper and keeps order stable. For
|
// behind a debounce timer (previous behaviour: 250 ms blank between
|
||||||
// lone inserts the per-id path stays so latency is unchanged.
|
// notification-sound and message body). Subsequent events arriving
|
||||||
const BATCH_WINDOW_MS = 250;
|
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
|
||||||
|
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
|
||||||
|
// `refresh()` instead of N individual refetches.
|
||||||
|
const BATCH_WINDOW_MS = 80;
|
||||||
const BATCH_BURST_THRESHOLD = 3;
|
const BATCH_BURST_THRESHOLD = 3;
|
||||||
let burstBuffer: Array<Record<string, unknown>> = [];
|
let burstBuffer: Array<Record<string, unknown>> = [];
|
||||||
let burstTimer: number | null = null;
|
let burstTimer: number | null = null;
|
||||||
@@ -465,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const queueInsert = (row: Record<string, unknown>) => {
|
const queueInsert = (row: Record<string, unknown>) => {
|
||||||
|
if (burstBuffer.length === 0 && burstTimer === null) {
|
||||||
|
// First event in a quiet period — fire immediately so the user sees
|
||||||
|
// the message right when they hear the notification sound. Arm a
|
||||||
|
// short window in case a burst follows; follow-ups go through the
|
||||||
|
// buffer and may collapse into a refresh.
|
||||||
|
void handleInsert(row);
|
||||||
|
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
burstBuffer.push(row);
|
burstBuffer.push(row);
|
||||||
if (burstTimer === null) {
|
if (burstTimer === null) {
|
||||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||||
@@ -491,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
// Any conversation_keys change for this conv invalidates the cached
|
||||||
// registered a fresh device), re-decrypt the visible messages.
|
// conv-key for the affected version. The module-level cache in
|
||||||
|
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
|
||||||
|
// forever within a session — which is exactly what propagated the
|
||||||
|
// stale local bootstrap key in conv aae12d84, recreating divergent
|
||||||
|
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
|
||||||
|
// DELETE for the conv forces the next `getOrCreateConvKey` /
|
||||||
|
// `tryGetConvKey` call to re-fetch the canonical bundle from the
|
||||||
|
// server. Cheap (a single Map.delete), defensive, and avoids stale-
|
||||||
|
// cache propagation across all of {peer rotation, device wrap, admin
|
||||||
|
// cleanup}.
|
||||||
|
//
|
||||||
|
// We also keep the historical "device wrap → refresh" trigger so a
|
||||||
|
// freshly-registered device of our own re-decrypts in place.
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{
|
{
|
||||||
event: 'INSERT',
|
event: '*',
|
||||||
schema: 'public',
|
schema: 'public',
|
||||||
table: 'conversation_keys',
|
table: 'conversation_keys',
|
||||||
filter: 'conversation_id=eq.' + conversationId,
|
filter: 'conversation_id=eq.' + conversationId,
|
||||||
},
|
},
|
||||||
(payload: { new: { recipient_device_id?: string } }) => {
|
(payload: {
|
||||||
if (payload.new?.recipient_device_id === deviceId) {
|
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||||
|
new: { recipient_device_id?: string; key_version?: number };
|
||||||
|
old: { recipient_device_id?: string; key_version?: number };
|
||||||
|
}) => {
|
||||||
|
const v =
|
||||||
|
payload.eventType === 'DELETE'
|
||||||
|
? payload.old?.key_version
|
||||||
|
: payload.new?.key_version;
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
clearConvKeyCache(conversationId, v);
|
||||||
|
} else {
|
||||||
|
clearConvKeyCache(conversationId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.eventType === 'INSERT' &&
|
||||||
|
payload.new?.recipient_device_id === deviceId
|
||||||
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -552,13 +657,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,
|
|
||||||
messages: [
|
|
||||||
...prev.messages,
|
...prev.messages,
|
||||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||||
],
|
];
|
||||||
};
|
setCachedMessages(convId, next);
|
||||||
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -569,7 +673,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
text: string,
|
text: string,
|
||||||
images: File[] = [],
|
images: File[] = [],
|
||||||
replyToId: string | null = null,
|
replyToId: string | null = null,
|
||||||
opts: { viewOnce?: boolean } = {},
|
opts: { viewOnceFlags?: boolean[] } = {},
|
||||||
) => {
|
) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||||
@@ -626,9 +730,13 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
|
|
||||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||||
// (so the public attachment row can reference the blob-level nonce).
|
// (so the public attachment row can reference the blob-level nonce).
|
||||||
|
// P7.T4: view-once is now a per-attachment flag rather than a
|
||||||
|
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
|
||||||
|
// missing entries (or whole-array absence) default to false.
|
||||||
const handles: AttachmentHandle[] = [];
|
const handles: AttachmentHandle[] = [];
|
||||||
const blobNonceHexByHandleId = new Map<string, string>();
|
const blobNonceHexByHandleId = new Map<string, string>();
|
||||||
for (const file of images) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const file = images[i]!;
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
throw new Error('attachment exceeds max size (10 MB)');
|
throw new Error('attachment exceeds max size (10 MB)');
|
||||||
}
|
}
|
||||||
@@ -649,12 +757,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||||
...(thumbBlob ? { thumbBlob } : {}),
|
...(thumbBlob ? { thumbBlob } : {}),
|
||||||
});
|
});
|
||||||
// Stamp the view-once flag on each handle the caller requested it
|
// Stamp the view-once flag on each handle the caller flagged. The
|
||||||
// for. The flag rides inside the encrypted payload (so peers can
|
// flag rides inside the encrypted payload (so peers can render the
|
||||||
// render the locked card without leaking who-sent-what to the
|
// locked card without leaking who-sent-what to the server) AND
|
||||||
// server) AND lands on the public message_attachments row via
|
// lands on the public message_attachments row via insertAttachmentRow
|
||||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
// below (where the mark-viewed RPC enforces it).
|
||||||
if (opts.viewOnce) {
|
if (opts.viewOnceFlags?.[i]) {
|
||||||
res.handle.viewOnce = true;
|
res.handle.viewOnce = true;
|
||||||
}
|
}
|
||||||
handles.push(res.handle);
|
handles.push(res.handle);
|
||||||
@@ -682,16 +790,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,
|
|
||||||
messages: [
|
|
||||||
...prev.messages,
|
...prev.messages,
|
||||||
{
|
{
|
||||||
...msg,
|
...msg,
|
||||||
plaintext: attachmentsPayload,
|
plaintext: attachmentsPayload,
|
||||||
} as DecryptedMessage,
|
} 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.
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Persists the preferred voice-message playback rate across sessions.
|
||||||
|
// localStorage is fine here — non-sensitive, single source of truth per
|
||||||
|
// device, no cross-device sync needed.
|
||||||
|
|
||||||
|
const KEY = 'chatapp:voice-speed';
|
||||||
|
const ALLOWED = [1, 1.5, 2] as const;
|
||||||
|
export type VoiceSpeed = (typeof ALLOWED)[number];
|
||||||
|
|
||||||
|
export function getVoiceSpeed(): VoiceSpeed {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) return 1;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (ALLOWED.includes(parsed as VoiceSpeed)) return parsed as VoiceSpeed;
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable */
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVoiceSpeed(speed: VoiceSpeed): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, String(speed));
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable — best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOICE_SPEEDS = ALLOWED;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Live-cursor pubsub for the multi-user whiteboard. Uses Supabase's
|
||||||
|
// `broadcast` channel rather than `presence` because we want fire-and-forget
|
||||||
|
// position updates (no need to track join/leave) and presence has higher
|
||||||
|
// minimum latency due to its diff-and-merge semantics.
|
||||||
|
//
|
||||||
|
// Throttled to ~30 fps so a continuous drag doesn't flood the channel.
|
||||||
|
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
const THROTTLE_MS = 33; // ~30 fps
|
||||||
|
|
||||||
|
export interface CursorEvent {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
// logical canvas coordinates (matches WhiteboardCanvas internal space)
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CursorSession {
|
||||||
|
send: (x: number, y: number) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCursorSession(
|
||||||
|
whiteboardId: string,
|
||||||
|
self: { userId: string; displayName: string },
|
||||||
|
onCursor: (ev: CursorEvent) => void,
|
||||||
|
): CursorSession {
|
||||||
|
const channel: RealtimeChannel = supabase.channel('wb-cursor:' + whiteboardId, {
|
||||||
|
config: { broadcast: { self: false } },
|
||||||
|
});
|
||||||
|
channel.on('broadcast', { event: 'cursor' }, (payload) => {
|
||||||
|
const ev = payload.payload as CursorEvent | undefined;
|
||||||
|
if (!ev || ev.userId === self.userId) return;
|
||||||
|
onCursor(ev);
|
||||||
|
});
|
||||||
|
channel.subscribe();
|
||||||
|
|
||||||
|
let lastSentAt = 0;
|
||||||
|
let pending: { x: number; y: number } | null = null;
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
flushTimer = null;
|
||||||
|
if (!pending) return;
|
||||||
|
const { x, y } = pending;
|
||||||
|
pending = null;
|
||||||
|
lastSentAt = Date.now();
|
||||||
|
void channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'cursor',
|
||||||
|
payload: { userId: self.userId, displayName: self.displayName, x, y } satisfies CursorEvent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = (x: number, y: number): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - lastSentAt;
|
||||||
|
if (since >= THROTTLE_MS) {
|
||||||
|
pending = { x, y };
|
||||||
|
flush();
|
||||||
|
} else {
|
||||||
|
pending = { x, y };
|
||||||
|
if (flushTimer === null) {
|
||||||
|
flushTimer = setTimeout(flush, THROTTLE_MS - since);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
pending = null;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { send, close };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
import { EmojiPicker } from '../components/EmojiPicker';
|
import { EmojiPicker } from '../components/EmojiPicker';
|
||||||
import { EmptyState } from '../components/EmptyState';
|
import { EmptyState } from '../components/EmptyState';
|
||||||
@@ -16,10 +17,10 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
EyeIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PollIcon,
|
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
@@ -77,6 +78,7 @@ import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useM
|
|||||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||||
|
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
|
||||||
|
|
||||||
// Discriminated union for rows inside the virtualized message list. Keeping
|
// Discriminated union for rows inside the virtualized message list. Keeping
|
||||||
// pending bubbles and the "load older" tile inside the same Virtuoso
|
// pending bubbles and the "load older" tile inside the same Virtuoso
|
||||||
@@ -93,21 +95,30 @@ 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 }>();
|
||||||
|
|
||||||
|
/** Pending composer attachment: the raw File plus the per-attachment
|
||||||
|
* view-once flag the user can toggle from the thumb hover button (P7.T4).
|
||||||
|
* Lives only in composer state — the flag is forwarded into
|
||||||
|
* `message_attachments.view_once` per row when the message is sent. */
|
||||||
|
interface PendingAttachment {
|
||||||
|
file: File;
|
||||||
|
viewOnce: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
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 }>();
|
||||||
@@ -190,11 +201,18 @@ export function ConversationPage() {
|
|||||||
});
|
});
|
||||||
}, [id, messages, myId]);
|
}, [id, messages, myId]);
|
||||||
|
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState<string>(() => {
|
||||||
|
if (!id) return '';
|
||||||
|
return getDraftSync(id)?.text ?? '';
|
||||||
|
});
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
// Pending composer attachments — each carries its own view-once flag so
|
||||||
|
// the user can mark individual images "burn after viewing" via the hover
|
||||||
|
// toggle on the thumb (P7.T4). Non-image attachments keep viewOnce=false
|
||||||
|
// but the field stays on the object so the shape is uniform.
|
||||||
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||||
@@ -237,10 +255,8 @@ export function ConversationPage() {
|
|||||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
||||||
// Sticky toggle: when on, the next image(s) sent are marked view-once.
|
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||||
// Auto-clears on a successful send so the composer doesn't accidentally
|
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
// burn the message-after-next.
|
|
||||||
const [viewOnceNext, setViewOnceNext] = useState(false);
|
|
||||||
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
|
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
|
||||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||||
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
||||||
@@ -293,19 +309,19 @@ export function ConversationPage() {
|
|||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReplyTo(null);
|
if (!id) return;
|
||||||
setForwardTarget(null);
|
const draft = getDraftSync(id);
|
||||||
setSearchOpen(false);
|
const savedReplyToId = draft?.replyToId ?? null;
|
||||||
setMediaDrawerOpen(false);
|
if (!savedReplyToId) return;
|
||||||
setPollDialogOpen(false);
|
if (replyTo?.id === savedReplyToId) return;
|
||||||
setSearchQuery('');
|
const match = messages.find((m) => m.id === savedReplyToId);
|
||||||
setDisplayCount(150);
|
if (match) setReplyTo(match);
|
||||||
setFirstUnreadId(null);
|
}, [id, messages, replyTo?.id]);
|
||||||
setFirstUnreadJumpDismissed(false);
|
|
||||||
setNewMessagesWhileAway(0);
|
useEffect(() => {
|
||||||
previousMessageIdsRef.current = new Set();
|
if (!id) return;
|
||||||
firstUnreadComputedRef.current = false;
|
setDraft(id, { text, replyToId: replyTo?.id ?? null });
|
||||||
}, [id]);
|
}, [id, text, replyTo?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (firstUnreadComputedRef.current) return;
|
if (firstUnreadComputedRef.current) return;
|
||||||
@@ -721,7 +737,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({
|
||||||
@@ -733,22 +754,44 @@ export function ConversationPage() {
|
|||||||
lastPendingCountRef.current = pending.length;
|
lastPendingCountRef.current = pending.length;
|
||||||
}, [pending.length]);
|
}, [pending.length]);
|
||||||
|
|
||||||
|
// Snap the viewport back to the bottom after a send. The composer
|
||||||
|
// shrinks (cleared text, dismissed reply preview, dropped attachment
|
||||||
|
// thumbs) which lets the Virtuoso area grow vertically — leaving the
|
||||||
|
// just-sent bubble visibly above the new bottom for a frame.
|
||||||
|
// `requestAnimationFrame` defers the scroll until React has committed
|
||||||
|
// the composer-height change, so Virtuoso's ResizeObserver has
|
||||||
|
// already seen the new viewport and `index: 'LAST', align: 'end'`
|
||||||
|
// targets the correct bottom edge.
|
||||||
|
const snapToBottom = useCallback(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
virtuosoRef.current?.scrollToIndex({
|
||||||
|
index: 'LAST',
|
||||||
|
align: 'end',
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSend(e?: React.FormEvent) {
|
async function handleSend(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if ((!text.trim() && attachments.length === 0) || sending) return;
|
if ((!text.trim() && attachments.length === 0) || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
try {
|
try {
|
||||||
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
|
await send(
|
||||||
|
text,
|
||||||
|
attachments.map((a) => a.file),
|
||||||
|
replyTo?.id ?? null,
|
||||||
|
{ viewOnceFlags: attachments.map((a) => a.viewOnce) },
|
||||||
|
);
|
||||||
setText('');
|
setText('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
// Reset the sticky view-once flag so it only applies to the message
|
|
||||||
// the user explicitly armed it for — Snapchat / WhatsApp parity.
|
|
||||||
setViewOnceNext(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
|
if (id) clearDraft(id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
setSendError(
|
setSendError(
|
||||||
@@ -774,13 +817,14 @@ export function ConversationPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
||||||
} finally {
|
} finally {
|
||||||
setPollSending(false);
|
setPollSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[send, replyTo?.id, notifyStopTyping],
|
[send, replyTo?.id, notifyStopTyping, snapToBottom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateWhiteboard = useCallback(async () => {
|
const handleCreateWhiteboard = useCallback(async () => {
|
||||||
@@ -793,12 +837,13 @@ export function ConversationPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setOpenWhiteboardId(board.id);
|
setOpenWhiteboardId(board.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingWhiteboard(false);
|
setCreatingWhiteboard(false);
|
||||||
}
|
}
|
||||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
const handleStartWatchTogether = useCallback(async () => {
|
const handleStartWatchTogether = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -818,12 +863,13 @@ export function ConversationPage() {
|
|||||||
setWatchDialogOpen(false);
|
setWatchDialogOpen(false);
|
||||||
setWatchUrl('');
|
setWatchUrl('');
|
||||||
setOpenWatchSessionId(ws.id);
|
setOpenWatchSessionId(ws.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
||||||
} finally {
|
} finally {
|
||||||
setWatchCreating(false);
|
setWatchCreating(false);
|
||||||
}
|
}
|
||||||
}, [id, watchUrl, send, replyTo?.id]);
|
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
const handleStartGame = useCallback(async (gameType: GameType) => {
|
const handleStartGame = useCallback(async (gameType: GameType) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -850,22 +896,25 @@ export function ConversationPage() {
|
|||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setGameDialogOpen(false);
|
setGameDialogOpen(false);
|
||||||
setOpenGameId(game.id);
|
setOpenGameId(game.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
||||||
} finally {
|
} finally {
|
||||||
setGameCreating(false);
|
setGameCreating(false);
|
||||||
}
|
}
|
||||||
}, [id, conversation, myId, send, replyTo?.id]);
|
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
const next: File[] = [];
|
const next: PendingAttachment[] = [];
|
||||||
for (const f of compressed) {
|
for (const f of compressed) {
|
||||||
if (f.size > 10 * 1024 * 1024) {
|
if (f.size > 10 * 1024 * 1024) {
|
||||||
setSendError('Datei zu groß (max 10 MB)');
|
setSendError('Datei zu groß (max 10 MB)');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next.push(f);
|
// New attachments default to viewOnce=false; user opts in per-thumb
|
||||||
|
// via the eye-toggle button on the preview (P7.T4).
|
||||||
|
next.push({ file: f, viewOnce: false });
|
||||||
}
|
}
|
||||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||||
}
|
}
|
||||||
@@ -1020,14 +1069,32 @@ export function ConversationPage() {
|
|||||||
// the bottom; returning `false` from the callback when they're
|
// the bottom; returning `false` from the callback when they're
|
||||||
// scrolled up preserves their reading position when realtime
|
// scrolled up preserves their reading position when realtime
|
||||||
// messages arrive (critical UX: do NOT jerk the user).
|
// messages arrive (critical UX: do NOT jerk the user).
|
||||||
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
|
//
|
||||||
|
// We deliberately use 'auto' (instant) rather than 'smooth':
|
||||||
|
// with a smooth scroll animation, atBottomStateChange fires
|
||||||
|
// `false` mid-animation (scrollTop is briefly above the new
|
||||||
|
// bottom) and then `true` after settle — that flips
|
||||||
|
// stickToBottom twice, flashing the "Zum neuesten" pill and
|
||||||
|
// re-rendering the whole list. Instant scroll has zero
|
||||||
|
// mid-animation state so the cascade never happens.
|
||||||
|
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
|
||||||
atBottomStateChange={handleAtBottomStateChange}
|
atBottomStateChange={handleAtBottomStateChange}
|
||||||
atBottomThreshold={80}
|
// 250 px tolerance — large enough that appending a tall row
|
||||||
|
// (image, voice note, grouped attachments) doesn't push the
|
||||||
|
// user out of the at-bottom zone. The previous 80 px flipped
|
||||||
|
// stickToBottom on nearly every typical message arrival.
|
||||||
|
atBottomThreshold={250}
|
||||||
rangeChanged={handleRangeChanged}
|
rangeChanged={handleRangeChanged}
|
||||||
startReached={handleStartReached}
|
startReached={handleStartReached}
|
||||||
// Render rows just outside the viewport so fast scrolling
|
// Render rows just outside the viewport so fast scrolling
|
||||||
// doesn't briefly flash empty space.
|
// doesn't briefly flash empty space.
|
||||||
increaseViewportBy={400}
|
increaseViewportBy={400}
|
||||||
|
// Visual breathing space below the last message so a bubble
|
||||||
|
// bottom doesn't sit flush against the composer top — matches
|
||||||
|
// Discord's chat-pane bottom padding.
|
||||||
|
components={{
|
||||||
|
Footer: () => <div style={{ height: '12px' }} />,
|
||||||
|
}}
|
||||||
itemContent={(_index, row) => {
|
itemContent={(_index, row) => {
|
||||||
if (row.kind === 'loader') {
|
if (row.kind === 'loader') {
|
||||||
return (
|
return (
|
||||||
@@ -1198,13 +1265,22 @@ export function ConversationPage() {
|
|||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="mb-2 flex flex-wrap gap-2">
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
{attachments.map((file, idx) => (
|
{attachments.map((a, idx) => (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
key={idx}
|
key={idx}
|
||||||
file={file}
|
file={a.file}
|
||||||
|
viewOnce={a.viewOnce}
|
||||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
{...(file.type.startsWith('image/')
|
{...(a.file.type.startsWith('image/')
|
||||||
? { onEdit: () => setAnnotatingIndex(idx) }
|
? {
|
||||||
|
onEdit: () => setAnnotatingIndex(idx),
|
||||||
|
onToggleViewOnce: () =>
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((x, i) =>
|
||||||
|
i === idx ? { ...x, viewOnce: !x.viewOnce } : x,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1243,55 +1319,32 @@ export function ConversationPage() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => handleFilesChosen(e.target.files)}
|
onChange={(e) => handleFilesChosen(e.target.files)}
|
||||||
/>
|
/>
|
||||||
|
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
|
||||||
<button
|
<button
|
||||||
|
ref={actionsMenuAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => setActionsMenuOpen((v) => !v)}
|
||||||
aria-label="Datei anhängen"
|
aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
|
||||||
title="Datei anhängen"
|
title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
|
||||||
|
aria-expanded={actionsMenuOpen}
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<ComposerActionsMenu
|
||||||
type="button"
|
anchorRef={actionsMenuAnchorRef}
|
||||||
onClick={() => {
|
open={actionsMenuOpen}
|
||||||
|
onClose={() => setActionsMenuOpen(false)}
|
||||||
|
onAttachFile={() => fileInputRef.current?.click()}
|
||||||
|
onCreatePoll={() => {
|
||||||
setPollError(null);
|
setPollError(null);
|
||||||
setPollDialogOpen(true);
|
setPollDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
aria-label="Umfrage erstellen"
|
onCreateWhiteboard={() => void handleCreateWhiteboard()}
|
||||||
title="Umfrage erstellen"
|
onStartWatchTogether={() => setWatchDialogOpen(true)}
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
onStartGame={() => setGameDialogOpen(true)}
|
||||||
>
|
canStartGame={conversation?.members?.length === 2}
|
||||||
<PollIcon className="h-4 w-4" />
|
/>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleCreateWhiteboard()}
|
|
||||||
disabled={creatingWhiteboard}
|
|
||||||
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
|
||||||
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
|
|
||||||
>
|
|
||||||
<WhiteboardIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setWatchDialogOpen(true)}
|
|
||||||
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
|
||||||
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
|
||||||
>
|
|
||||||
<PlayBoxIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setGameDialogOpen(true)}
|
|
||||||
title={t('app:composer.game', { defaultValue: 'Spielen' })}
|
|
||||||
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
|
|
||||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
|
||||||
>
|
|
||||||
<GameIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1337,20 +1390,6 @@ export function ConversationPage() {
|
|||||||
onPick={(gif) => void handleGifPick(gif)}
|
onPick={(gif) => void handleGifPick(gif)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewOnceNext((v) => !v)}
|
|
||||||
aria-pressed={viewOnceNext}
|
|
||||||
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
|
|
||||||
className={
|
|
||||||
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
|
|
||||||
(viewOnceNext
|
|
||||||
? 'bg-accent/20 text-accent'
|
|
||||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<VoiceRecorder
|
<VoiceRecorder
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onComplete={async (file) => {
|
onComplete={async (file) => {
|
||||||
@@ -1487,10 +1526,17 @@ export function ConversationPage() {
|
|||||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
file={attachments[annotatingIndex]!}
|
file={attachments[annotatingIndex]!.file}
|
||||||
onCancel={() => setAnnotatingIndex(null)}
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
onSave={(next) => {
|
onSave={(next) => {
|
||||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
// Preserve the per-attachment viewOnce flag across annotation —
|
||||||
|
// the user's burn-after-viewing intent shouldn't reset just
|
||||||
|
// because they redrew the image.
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) =>
|
||||||
|
i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a,
|
||||||
|
),
|
||||||
|
);
|
||||||
setAnnotatingIndex(null);
|
setAnnotatingIndex(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1847,13 +1893,18 @@ function Banner({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function AttachmentPreview({
|
function AttachmentPreview({
|
||||||
file,
|
file,
|
||||||
|
viewOnce,
|
||||||
onRemove,
|
onRemove,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onToggleViewOnce,
|
||||||
}: {
|
}: {
|
||||||
file: File;
|
file: File;
|
||||||
|
viewOnce: boolean;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
onToggleViewOnce?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1886,6 +1937,42 @@ function AttachmentPreview({
|
|||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isImage && onToggleViewOnce && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleViewOnce}
|
||||||
|
aria-label={
|
||||||
|
viewOnce
|
||||||
|
? t('app:composer.view_once_off', { defaultValue: 'Einmal-Ansicht deaktivieren' })
|
||||||
|
: t('app:composer.view_once_on', { defaultValue: 'Einmal-Ansicht aktivieren' })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
viewOnce
|
||||||
|
? t('app:composer.view_once_on_hint', {
|
||||||
|
defaultValue: 'Empfänger sieht das Bild nur einmal',
|
||||||
|
})
|
||||||
|
: t('app:composer.view_once_off_hint', { defaultValue: 'Einmal-Ansicht ein/aus' })
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
'absolute bottom-1 right-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full transition ' +
|
||||||
|
(viewOnce
|
||||||
|
? 'bg-accent text-accent-fg opacity-100'
|
||||||
|
: 'bg-black/70 text-white opacity-0 hover:bg-accent/80 group-hover:opacity-100')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{viewOnce ? <EyeIcon className="h-3 w-3" /> : <EyeOffIcon className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* When viewOnce is on, overlay a persistent "1×" badge so the user
|
||||||
|
has visual confirmation independent of the small toggle button. */}
|
||||||
|
{isImage && viewOnce && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-1 right-7 rounded-md bg-accent/90 px-1 py-0.5 text-[9px] font-bold text-accent-fg"
|
||||||
|
>
|
||||||
|
1×
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
@@ -1898,32 +1985,3 @@ function AttachmentPreview({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
||||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
|
||||||
<path d="M8 21h8M12 17v4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
||||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
|
||||||
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
||||||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
|
||||||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
@@ -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 50–300 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 RC1–RC7 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`.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,28 @@ export interface ConvKeyHandle {
|
|||||||
const cache = new Map<string, ConvKeyHandle>();
|
const cache = new Map<string, ConvKeyHandle>();
|
||||||
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
||||||
|
|
||||||
export function clearConvKeyCache(): void { cache.clear(); }
|
// Clear the in-memory conv-key cache. Three modes:
|
||||||
|
// * no args → clear everything (e.g. on logout)
|
||||||
|
// * convId only → clear all key-version entries for this conversation
|
||||||
|
// * convId + v → clear just the specific (conv, version) entry
|
||||||
|
//
|
||||||
|
// Callers that observe a peer rotation or a server-side conv-keys mutation
|
||||||
|
// MUST invalidate the affected entries so subsequent `getOrCreateConvKey` /
|
||||||
|
// `tryGetConvKey` calls re-fetch the canonical bundle from the server
|
||||||
|
// instead of returning a now-stale cached key.
|
||||||
|
export function clearConvKeyCache(conversationId?: string, keyVersion?: number): void {
|
||||||
|
if (conversationId === undefined) {
|
||||||
|
cache.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyVersion !== undefined) {
|
||||||
|
cache.delete(cacheKey(conversationId, keyVersion));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of Array.from(cache.keys())) {
|
||||||
|
if (key.startsWith(conversationId + '@')) cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function listMemberPublicKeys(
|
async function listMemberPublicKeys(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
@@ -110,7 +131,26 @@ export async function bootstrapConvKey(
|
|||||||
p_bundles: bundles,
|
p_bundles: bundles,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const handle = { conversationId, keyVersion, key: convKey };
|
|
||||||
|
// `share_conv_keys` uses `ON CONFLICT (conv, recipient_user_id, key_version)
|
||||||
|
// DO NOTHING`. If a concurrent peer bootstrapped first at the same version,
|
||||||
|
// OUR INSERTs were silently skipped server-side and the row on the server
|
||||||
|
// holds THEIR conv-key, not ours. Trusting the locally-generated key here
|
||||||
|
// would leave both clients with mutually un-decryptable bundles (each
|
||||||
|
// encrypting/decrypting with its own key — exactly the bug that broke
|
||||||
|
// conv aae12d84). Re-fetch our own bundle and unwrap to get the CANONICAL
|
||||||
|
// server key. Whoever wrote first wins; the loser converges.
|
||||||
|
const ownBundle = await fetchKeyBundle(client, conversationId, own.userId, keyVersion);
|
||||||
|
if (!ownBundle) {
|
||||||
|
throw new Error('bootstrapConvKey: own bundle missing after share_conv_keys');
|
||||||
|
}
|
||||||
|
const canonicalKey = await unwrapConvKey(
|
||||||
|
ownBundle.encryptedKey,
|
||||||
|
ownBundle.nonce,
|
||||||
|
ownBundle.sender.senderPublicKey,
|
||||||
|
own.privateKey,
|
||||||
|
);
|
||||||
|
const handle = { conversationId, keyVersion, key: canonicalKey };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
@@ -125,12 +165,25 @@ export async function getOrCreateConvKey(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
|
try {
|
||||||
const key = await unwrapConvKey(
|
const key = await unwrapConvKey(
|
||||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
|
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
|
||||||
);
|
);
|
||||||
const handle = { conversationId, keyVersion: version, key };
|
const handle = { conversationId, keyVersion: version, key };
|
||||||
cache.set(cacheKey(conversationId, version), handle);
|
cache.set(cacheKey(conversationId, version), handle);
|
||||||
return handle;
|
return handle;
|
||||||
|
} catch (err) {
|
||||||
|
// A bundle exists for us but our current private key cannot unwrap it.
|
||||||
|
// The most common cause is `reset_user_key`: a fresh user-key pair was
|
||||||
|
// generated locally while the on-server bundle is still wrapped against
|
||||||
|
// the previous public key. Treat this the same as "no bundle for me" —
|
||||||
|
// mint a fresh conv-key at version+1 wrapped to our CURRENT key. Old
|
||||||
|
// messages stay unreadable for us; new ones flow.
|
||||||
|
console.warn(
|
||||||
|
'[conv-key] unwrap own bundle failed at v' + version + ' — auto-rotating',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||||
.select('recipient_user_id', { count: 'exact', head: true })
|
.select('recipient_user_id', { count: 'exact', head: true })
|
||||||
@@ -138,12 +191,13 @@ export async function getOrCreateConvKey(
|
|||||||
.eq('key_version', version);
|
.eq('key_version', version);
|
||||||
if (cntErr) throw cntErr;
|
if (cntErr) throw cntErr;
|
||||||
if ((count ?? 0) > 0) {
|
if ((count ?? 0) > 0) {
|
||||||
// Rows exist for this version, but none for me. Either I lost the device-key
|
// Rows exist for this version, but none usable for me. Either I lost the
|
||||||
// that originally received my bundle, or my own bundle was wiped by the
|
// device-key that originally received my bundle, my own bundle was wiped
|
||||||
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
|
||||||
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
// bundle is unwrappable (handled in the try/catch above). The only way
|
||||||
// unreadable for me; new ones flow.
|
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
|
||||||
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
// we can. Old messages stay unreadable for me; new ones flow.
|
||||||
|
console.info('[conv-key] no usable bundle for me at v' + version + ' — auto-rotating');
|
||||||
return rotateConvKey(client, conversationId, own);
|
return rotateConvKey(client, conversationId, own);
|
||||||
}
|
}
|
||||||
return bootstrapConvKey(client, conversationId, own, version);
|
return bootstrapConvKey(client, conversationId, own, version);
|
||||||
@@ -248,9 +302,24 @@ export async function tryGetConvKey(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
||||||
if (!bundle) return null;
|
if (!bundle) return null;
|
||||||
const key = await unwrapConvKey(
|
let key: Uint8Array;
|
||||||
|
try {
|
||||||
|
key = await unwrapConvKey(
|
||||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Bundle exists but the current private key doesn't unwrap it (typically
|
||||||
|
// after `reset_user_key`). Return null so the caller treats the message
|
||||||
|
// as un-decryptable instead of throwing and killing the whole batch.
|
||||||
|
// The conversation will be auto-rotated to a fresh key on the next send
|
||||||
|
// or chat open via `getOrCreateConvKey`'s own recovery path.
|
||||||
|
console.warn(
|
||||||
|
'[conv-key] tryGetConvKey unwrap failed at v' + keyVersion +
|
||||||
|
' (conv=' + conversationId.slice(0, 8) + ') — marking as un-decryptable',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const handle = { conversationId, keyVersion, key };
|
const handle = { conversationId, keyVersion, key };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return handle;
|
return handle;
|
||||||
|
|||||||
@@ -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,6 +136,12 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
result.migratedConversations += 1;
|
result.migratedConversations += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If anything was actually migrated this run, leave it as console.info
|
||||||
|
// so it's visible in default consoles. If we only re-failed on already-
|
||||||
|
// unrecoverable rows (no local stronghold key), demote to debug — the
|
||||||
|
// migration is idempotent but the noisy "noKey=N" line scared the user
|
||||||
|
// who thought migration was already done.
|
||||||
|
if (result.migratedConversations > 0 || result.decryptFailed > 0 || result.rpcFailed > 0) {
|
||||||
console.info(
|
console.info(
|
||||||
'[crypto-migration] result:',
|
'[crypto-migration] result:',
|
||||||
'attempted=' + result.attempted,
|
'attempted=' + result.attempted,
|
||||||
@@ -144,5 +150,12 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
'decryptFail=' + result.decryptFailed,
|
'decryptFail=' + result.decryptFailed,
|
||||||
'rpcFail=' + result.rpcFailed,
|
'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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user