Compare commits

...

55 Commits

Author SHA1 Message Date
byGalax f438018400 chore(desktop): release v0.21.11 2026-06-02 23:07:34 +02:00
byGalax 89003f71a4 fix(desktop): remove chat-switch reveal flicker (decouple from reactions)
The residual flicker on chat switch was a loading/reveal artifact, not scroll.
listReady gated the MessageList reveal on reactionsReady OR a 300ms timeout, so
on a cache-hit switch (messages already present from the first render) the list
sat at opacity:0 for up to 300ms and then popped in. Drop the reactions/timeout
gate: reveal as soon as messages exist. Reaction chips stream in a beat later;
because the list is pinned to the bottom their height growth re-pins with no
visible jump, and MessageList still defers its own reveal a few frames until the
row-height measurement settles so it appears already at the final bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:02:07 +02:00
byGalax b364c53c61 fix(desktop): degrade missing avatar image to letter circle
Avatar rendered a bare <img> with no error handling, so an avatar_url whose
storage object is unreachable (e.g. a 404 after the server move) showed a
broken image instead of the coloured letter-circle fallback. Track an onError
flag and fall back to the circle; reset it when the URL changes so a fresh
valid avatar is retried. This is client-side resilience only — it does not
restore a genuinely missing storage object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:02:07 +02:00
byGalax 9c5456b492 chore(desktop): release v0.21.10 2026-06-02 22:32:52 +02:00
byGalax b057795735 fix(desktop): stop chat opening at top + flicker on switch
Make stick-to-bottom intent the single source of truth in MessageList and
drive onAtBottomChange from intent, not raw scroll position. A measurement
reflow can no longer flip the intent off (RC1), the second scrollPositions
writer no longer persists a drifting topmost index while stuck (RC2), and a
pin-on-rows layout effect re-pins through the two-phase data swap (RC3).

- scrollController: add tested nextStickIntent() state machine
- MessageList: input-event-based unstick (wheel/key/touch + scrollbar drag),
  reveal after 2 stable frames, tabIndex for keyboard nav, remove debug overlay
- ConversationPage: reuse resolveInitialAnchor; harden handleRangeChanged

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:31:39 +02:00
byGalax c81a036c4e chore(desktop): release v0.21.9 2026-06-02 21:48:43 +02:00
byGalax bdc017e609 fix(desktop): MessageList sticks to bottom via ResizeObserver + scroll guard
Data from the on-screen overlay showed SCROLLABLE=YES but scrollTop=84/0 and atBottom=false: the initial pin happened before rows finished measuring, then a measurement reflow fired onScroll with the stale (top) scrollTop, flipping atBottom=false and disabling re-pinning, so the list never reached the bottom. Fix: a ResizeObserver re-pins to the true bottom as the content measures/grows; a programmatic-scroll guard makes onScroll ignore the scrolls we cause (so measurement reflows no longer flip the stick intent); overflow-anchor:none so the browser doesn't fight us; reveal waits for the height to settle. Overlay kept for one more verification pass.
2026-06-02 21:47:15 +02:00
byGalax 27160145f9 chore(desktop): release v0.21.8 2026-06-02 21:37:29 +02:00
byGalax 8ea2cb48e9 debug(desktop): on-screen scroll-metrics overlay (temporary) 2026-06-02 21:34:04 +02:00
byGalax c3ef995404 chore(desktop): release v0.21.7 2026-06-02 21:04:37 +02:00
byGalax b4ed3aced0 fix(desktop): MessageList anchors via direct scrollTop + flex-1 height
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.
2026-06-02 21:00:19 +02:00
byGalax 30d00194be chore(desktop): release v0.21.6 2026-06-02 20:46:53 +02:00
byGalax 31b394a6e0 chore(desktop): drop react-virtuoso + scroll debug instrumentation 2026-06-02 20:44:56 +02:00
byGalax 271d6fff5c feat(desktop): use MessageList in ConversationPage (replace react-virtuoso) 2026-06-02 20:32:08 +02:00
byGalax 8b8d71bc4d feat(desktop): TanStack-Virtual MessageList with deferred reveal 2026-06-02 20:27:31 +02:00
byGalax 40f36cb182 refactor(desktop): export VirtuosoRow type for MessageList 2026-06-02 20:26:21 +02:00
byGalax 2372731504 feat(desktop): expose reactions reveal-gate flag (ready) 2026-06-02 20:26:00 +02:00
byGalax 43a99a8d6d feat(desktop): pure scroll-decision logic for new message list 2026-06-02 20:25:06 +02:00
byGalax f73abbd860 build(desktop): add @tanstack/react-virtual 2026-06-02 20:24:15 +02:00
byGalax e822f6f58f docs(plan): message-list / scroll rewrite implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:22:28 +02:00
byGalax 255dbdc712 docs(spec): message-list / scroll rewrite design (TanStack Virtual + deferred reveal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 20:16:42 +02:00
byGalax 51a8630114 chore(desktop): release v0.21.5 2026-06-02 19:41:46 +02:00
byGalax d539656535 fix(conversation): anchor message list to bottom on chat switch
initialTopMostItemIndex was a plain index (top-aligned), so react-virtuoso painted with estimated row heights then corrected scrollTop after measuring the real (taller) dynamic bubbles — a visible jump on every chat switch. Use { index: 'LAST', align: 'end' } to pin the bottom edge instead, matching react-virtuoso's canonical chat pattern; the restore-to-saved-row path stays align: 'start'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00
byGalax 5bc30c950c feat(infra): migrate self-hosted backend to netralax.de
Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00
byGalax 588b843904 chore(desktop): release v0.21.4 2026-05-21 23:12:38 +02:00
byGalax dbf8030e93 fix(conv-key): rotate-instead-of-share + cache invalidation; fire-first realtime inserts (no sound-vs-text gap)
Friend-DM "Nachricht nicht lesbar" recurred even after v0.21.3 because the
proactive sweep called shareConvKeyToUser, which reads the module-level
conv-key cache first. After a server-side cleanup the cache still held the
stale locally-bootstrapped key, so each side wrapped its own different key
for the peer and the bundles diverged anew.

Switch the sweep to rotate_conv_key when any peer's user-id bundle is
missing at the active version: a fresh symmetric key is generated, wrapped
for every member at their CURRENT pubkey, and the active version is bumped
under a row-level FOR UPDATE lock. Concurrent rotations are race-safe — the
loser sees "new version must be greater" and bails; the winner's bundles
propagate via realtime.

Realtime conversation_keys subscription now invalidates the cache for the
affected (conversationId, key_version) on any INSERT/UPDATE/DELETE — so
admin cleanups, peer rotations, or device wraps can no longer leave a
stale entry in this client's session cache.

queueInsert now fires the first event of a quiet period immediately and
only collapses follow-up bursts. BATCH_WINDOW_MS dropped 250 → 80 ms.
This closes the ~250 ms gap between the notification sound and the
message body appearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:10:36 +02:00
byGalax 615770722e chore(desktop): release v0.21.3 2026-05-21 22:52:50 +02:00
byGalax f1cba99b9e fix(conv-key): bootstrap re-fetches canonical key after share to handle concurrent race 2026-05-21 22:51:17 +02:00
byGalax f60c5c676a chore(desktop): release v0.21.2 2026-05-18 15:40:56 +02:00
byGalax 8e6be3256d fix(conv-key): rotate on unwrap failure (post-reset_user_key recovery) 2026-05-18 15:38:31 +02:00
byGalax 508c53b451 chore(desktop): release v0.21.1 2026-05-18 14:40:29 +02:00
byGalax e2f86bc377 fix(chat): snap to bottom after send to mask composer-shrink layout shift 2026-05-18 14:29:31 +02:00
byGalax 92a6e01a26 fix(chat): instant scroll + larger at-bottom threshold + footer spacer (Discord-clean) 2026-05-18 14:21:04 +02:00
byGalax 3d959aaadf fix(chat): auto-rotate stuck conv-keys on chat open (receive-side recovery) 2026-05-18 14:03:35 +02:00
byGalax 787437c3f1 chore(desktop): release v0.21.0 2026-05-17 20:04:00 +02:00
byGalax be5647281d chore(mobile): track expo-generated .gitignore 2026-05-17 20:00:18 +02:00
byGalax fc8fc275cb fix(soundboard): mount-once hotkey registration to avoid call-state churn 2026-05-17 17:49:08 +02:00
byGalax e19a71e892 feat(profile): preserve animation on GIF/APNG/animated-WebP avatar uploads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:38:07 +02:00
byGalax 11869be443 feat(soundboard): hotkeys fire outside calls with local-only playback
Lifts the `state.kind === 'connected'` guard from the soundboard hotkey
useEffect so OS-level shortcuts are always registered. Inside a call the
existing `playSoundboard` path routes audio into the LiveKit pipeline so
peers hear; outside a call the new `playSoundboardLocal` helper fetches
the blob via `getSoundBlob`, creates a short-lived object URL, and plays
through a fresh HTMLAudioElement on the system default output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:33:50 +02:00
byGalax 3fa8b6dbc1 feat(call): shared annotation overlay on screen share 2026-05-17 17:30:05 +02:00
byGalax f9f8e3fdb8 feat(whiteboard): live cursors via broadcast channel 2026-05-17 17:24:34 +02:00
byGalax 4ed3f04300 fix(view-once): hold-to-view pattern + content-protection during reveal 2026-05-17 17:21:22 +02:00
byGalax a7ffcbff83 feat(voice): playback-speed toggle (1x/1.5x/2x) with per-user default 2026-05-17 17:09:01 +02:00
byGalax 82600915f1 feat(composer): persist text + reply target per chat across restarts 2026-05-17 17:06:43 +02:00
byGalax 93098a74ca docs(phase8): feature batch implementation plan 2026-05-17 16:54:56 +02:00
byGalax c8f0e8efd5 chore(desktop): release v0.20.1 2026-05-17 15:11:11 +02:00
byGalax fd9b8a88d6 docs(chat-switch): implementation plan for chat-switch flicker fix 2026-05-17 15:09:43 +02:00
byGalax ab2f7130fe fix(chat-switch): seed lastPendingCountRef from outbox to suppress mount scroll 2026-05-17 15:02:40 +02:00
byGalax b9a3dde1aa refactor(chat-switch): drop redundant id-change reset effect 2026-05-17 14:54:31 +02:00
byGalax 65d2446804 fix(chat-switch): remount ConversationPage per conversation id 2026-05-17 14:51:22 +02:00
byGalax faa12a4ebb feat(chat-switch): hydrate useConversationMessages from in-memory cache
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 14:48:48 +02:00
byGalax 37dd1b4f23 feat(chat-switch): in-memory message cache helper 2026-05-17 14:39:01 +02:00
byGalax d803773261 chore(desktop): release v0.20.0 2026-05-17 13:21:50 +02:00
byGalax 49855c5d3f fix(console-noise): pre-warm via auth.getSession + demote stuck crypto-migration logs to debug 2026-05-17 01:43:53 +02:00
byGalax c9a64bf898 feat(call): remove live-captions feature (privacy-inconsistent with E2E, unused) 2026-05-17 01:39:42 +02:00
60 changed files with 7087 additions and 703 deletions
+5 -1
View File
@@ -7,7 +7,11 @@
# stopped publishing Tauri releases to this host.
# Host serving latest.yml + installer artifacts over HTTPS.
UPDATE_HOST=update.netralax.cloud
# NOTE: during the .cloud→.de transition the new VPS must ALSO serve the same
# artifacts under update.netralax.cloud (point its DNS at the new IP) so that
# already-installed clients — which have update.netralax.cloud baked in — can
# still pull the release that switches them over to .de.
UPDATE_HOST=update.netralax.de
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
UPDATE_SSH_USER=chatapp-deploy
+11
View File
@@ -107,6 +107,11 @@ export const CHANNELS = {
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
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
// exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
@@ -271,6 +276,12 @@ export interface UpdateProgress {
total: number;
}
// ---- Window content protection -------------------------------------------
export interface WindowSetContentProtectionArgs {
enabled: boolean;
}
// ---- Runtime marker ------------------------------------------------------
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
+2
View File
@@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
import { register as registerSql } from './modules/sql';
import { register as registerTray } from './modules/tray';
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 { attach as attachWindowState, loadState } from './window-state';
@@ -274,6 +275,7 @@ if (!gotLock) {
registerTray(mainWindow);
registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow);
registerWindowContentProtection(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
View File
@@ -96,6 +96,12 @@ export interface ElectronAPI {
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
* "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
+4
View File
@@ -160,6 +160,10 @@ const api = {
setFullscreen: (enabled: boolean): Promise<void> =>
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 ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.19.1",
"version": "0.21.11",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -24,6 +24,7 @@
"@livekit/components-react": "^2.9.0",
"@livekit/track-processors": "^0.7.2",
"@supabase/supabase-js": "^2.46.0",
"@tanstack/react-virtual": "^3.10.0",
"better-sqlite3": "^11.3.0",
"canvas-confetti": "^1.9.4",
"electron-updater": "^6.3.0",
@@ -35,7 +36,6 @@
"react-easy-crop": "^5.5.7",
"react-i18next": "^15.1.1",
"react-router-dom": "^6.28.0",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.1"
},
"devDependencies": {
@@ -95,7 +95,7 @@
"publish": [
{
"provider": "generic",
"url": "https://update.netralax.cloud/windows/"
"url": "https://update.netralax.de/windows/"
}
]
}
+20 -3
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react';
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { lazy, Suspense, useEffect } from 'react';
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { hydrateDrafts } from './lib/composerDraftStore';
import { AuthPage } from './pages/AuthPage';
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
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() {
useEffect(() => {
void hydrateDrafts();
}, []);
return (
<ErrorBoundary scope="root">
<ThemeProvider>
@@ -114,7 +131,7 @@ export function App() {
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
<ConversationRoute />
</ErrorBoundary>
}
/>
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
import { useEffect, useMemo, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
};
}, [arrayBuf]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.playbackRate = speed;
}, [speed, blobUrl]);
const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks],
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
<PlayGlyph />
)}
</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
role="slider"
+10 -1
View File
@@ -1,6 +1,8 @@
// Reusable avatar that prefers an uploaded image and falls back to a coloured
// letter circle. Use this everywhere the app needs to render a profile.
import { useEffect, useState } from 'react';
import { useCachedAvatarUrl } from '../lib/avatarCache';
interface Props {
@@ -27,7 +29,13 @@ export function Avatar({
loading = 'lazy',
}: Props) {
const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) {
// If the image URL is non-empty but unreachable (e.g. the storage object is
// missing / 404s), the bare <img> would render broken with no fallback.
// Track a load error and degrade to the letter circle instead. Reset on URL
// change so a fresh, valid avatar is retried.
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [effectiveUrl]);
if (effectiveUrl && !failed) {
return (
<img
src={effectiveUrl}
@@ -35,6 +43,7 @@ export function Avatar({
className={'shrink-0 rounded-full object-cover ' + className}
draggable={false}
loading={loading}
onError={() => setFailed(true)}
/>
);
}
@@ -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 {
CaptionsIcon,
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon,
@@ -32,10 +31,6 @@ interface Props {
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
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;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
@@ -59,8 +54,6 @@ export function CallControls({
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
onToggleCaptions,
captionsOn = false,
participantsOpen = false,
compact = false,
glass = false,
@@ -148,22 +141,6 @@ export function CallControls({
<MusicIcon className="h-5 w-5" />
</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 && (
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
@@ -15,14 +15,7 @@ import {
listSounds,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import {
getLiveCaptionsSettings,
isLiveCaptionsSupported,
subscribeLiveCaptionsSettings,
updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
const [sharePickerOpen, setSharePickerOpen] = useState(false);
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
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(() => {
const onKey = (e: KeyboardEvent) => {
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
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()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</>
);
}
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)}
/>
)}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</section>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { isNearBottom, isNearTop, nextStickIntent } from '../lib/scrollController';
import type { VirtuosoRow } from '../pages/ConversationPage';
export interface MessageListHandle {
scrollToBottom(behavior?: ScrollBehavior): void;
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
}
export interface MessageListProps {
rows: VirtuosoRow[];
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
computeKey: (row: VirtuosoRow) => string;
/** Initial scroll target for a freshly-mounted list. */
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
/** Reveal gate — the list stays hidden until reactions/heights are loaded, so
* the post-paint height cascade is never visible. */
ready: boolean;
estimateRowHeight?: number;
atBottomThreshold?: number;
onReachTop?: () => void;
onAtBottomChange?: (atBottom: boolean) => void;
onTopRowChange?: (topIndex: number) => void;
}
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
rows,
renderRow,
computeKey,
initialAnchor,
ready,
estimateRowHeight = 64,
atBottomThreshold = 64,
onReachTop,
onAtBottomChange,
onTopRowChange,
},
ref,
) {
const scrollElRef = useRef<HTMLDivElement>(null);
const [revealed, setRevealed] = useState(false);
// THE single source of truth: should the view stay pinned to the bottom?
// Only a genuine user up-input (wheel / key / touch / scrollbar drag) turns
// this OFF; only reaching the bottom turns it ON. A measurement reflow must
// never flip it — that was the root cause of the chat-switch bug.
const stickRef = useRef(true);
// Debounce for onAtBottomChange — fire the parent only on a real transition.
const lastReportedAtBottomRef = useRef<boolean | null>(null);
// Guard: scrolls WE cause (pin / measure re-pin / scrollToIndex) fire onScroll
// a tick later. Within this window we don't treat a scrollTop decrease as the
// user dragging up.
const programmaticRef = useRef(0);
// Previous scrollTop, to detect a genuine scrollbar/keyboard up-drag.
const lastScrollTopRef = useRef(0);
// Load-older preservation: remember the first row key + scrollHeight so a
// prepend can be detected and the viewport restored.
const prevFirstKeyRef = useRef<string | null>(null);
const prevScrollHeightRef = useRef(0);
// Latest onAtBottomChange, read through a ref so the input-listener effect
// can stay mounted once (deps []) without capturing a stale callback.
const onAtBottomChangeRef = useRef(onAtBottomChange);
onAtBottomChangeRef.current = onAtBottomChange;
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElRef.current,
estimateSize: () => estimateRowHeight,
overscan: 8,
getItemKey: (index) => computeKey(rows[index]!),
});
const readMetrics = useCallback(() => {
const el = scrollElRef.current;
return el
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
}, []);
const pinToBottom = useCallback(() => {
const el = scrollElRef.current;
if (!el) return;
programmaticRef.current = performance.now();
el.scrollTop = el.scrollHeight;
lastScrollTopRef.current = el.scrollTop;
}, []);
// Report at-bottom to the parent only on a true transition, always driven by
// the INTENT (stickRef) — never the raw position. This is what kills the
// feedback loop: a transient "not at bottom" mid-reflow is never persisted.
const reportAtBottom = useCallback((atBottom: boolean) => {
if (lastReportedAtBottomRef.current === atBottom) return;
lastReportedAtBottomRef.current = atBottom;
onAtBottomChangeRef.current?.(atBottom);
}, []);
// A genuine user up-input: drop the stick intent immediately.
const markUserMovedUp = useCallback(() => {
if (!stickRef.current) return;
stickRef.current = false;
reportAtBottom(false);
}, [reportAtBottom]);
// Re-pin to the true bottom whenever the content (or viewport) resizes while
// sticking. ResizeObserver fires after layout / before paint, so as rows
// measure and the list grows the bottom stays pinned with no stale frame.
useEffect(() => {
const el = scrollElRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const e = scrollElRef.current;
if (stickRef.current && e) {
programmaticRef.current = performance.now();
e.scrollTop = e.scrollHeight;
lastScrollTopRef.current = e.scrollTop;
}
});
ro.observe(el);
const inner = el.firstElementChild;
if (inner) ro.observe(inner);
return () => ro.disconnect();
}, []);
// Genuine-user-intent listeners. These are the ONLY way (besides reaching the
// bottom) the stick intent turns off, so a reflow can never unstick the list.
useEffect(() => {
const el = scrollElRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) markUserMovedUp();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'PageUp' || e.key === 'Home' || e.key === 'ArrowUp') markUserMovedUp();
};
let touchStartY = 0;
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0]?.clientY ?? 0;
};
const onTouchMove = (e: TouchEvent) => {
const y = e.touches[0]?.clientY ?? 0;
// Finger dragged DOWN (content scrolls up toward older messages). Guard on
// scrollTop>0 so an overscroll bounce at the bottom doesn't unstick.
if (y - touchStartY > 8 && (scrollElRef.current?.scrollTop ?? 0) > 0) markUserMovedUp();
};
el.addEventListener('wheel', onWheel, { passive: true });
el.addEventListener('keydown', onKeyDown);
el.addEventListener('touchstart', onTouchStart, { passive: true });
el.addEventListener('touchmove', onTouchMove, { passive: true });
return () => {
el.removeEventListener('wheel', onWheel);
el.removeEventListener('keydown', onKeyDown);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
};
}, [markUserMovedUp]);
// Deferred reveal: when ready, pin to the anchor and keep pinning each frame
// until the list height has SETTLED over two consecutive frames, THEN reveal —
// so what appears is already at its final position with no top-then-jump.
useLayoutEffect(() => {
if (!ready || revealed || rows.length === 0) return;
const el = scrollElRef.current;
if (!el) return;
const rowIdx = Math.max(
0,
Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1),
);
if (initialAnchor.type === 'bottom') {
stickRef.current = true;
pinToBottom();
} else {
stickRef.current = false;
programmaticRef.current = performance.now();
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
}
reportAtBottom(stickRef.current);
let prevSH = -1;
let stableFrames = 0;
const settle = (attempts: number): void => {
const e = scrollElRef.current;
if (!e) {
setRevealed(true);
return;
}
programmaticRef.current = performance.now();
if (stickRef.current) {
e.scrollTop = e.scrollHeight;
lastScrollTopRef.current = e.scrollTop;
} else {
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
}
const sh = e.scrollHeight;
// Require TWO consecutive stable-height frames: a single stable frame can
// land mid-cascade (between reactions and the unread divider measuring)
// and reveal a not-yet-final layout that then jumps.
stableFrames = sh === prevSH ? stableFrames + 1 : 0;
prevSH = sh;
if (stableFrames >= 2 || attempts <= 0) {
setRevealed(true);
} else {
requestAnimationFrame(() => settle(attempts - 1));
}
};
requestAnimationFrame(() => settle(12));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, rows.length]);
// Load-older preservation: if rows were prepended (first key changed and the
// user is near the top), restore scrollTop by the height delta so the viewport
// stays put instead of jumping.
useLayoutEffect(() => {
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
const el = scrollElRef.current;
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
const delta = el.scrollHeight - prevScrollHeightRef.current;
if (delta > 0 && el.scrollTop < atBottomThreshold * 4) {
el.scrollTop += delta;
lastScrollTopRef.current = el.scrollTop;
}
}
prevFirstKeyRef.current = firstKey;
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
// Re-pin on any rows change while sticking. Covers the two-phase data swap
// (the cached array is replaced by the freshly-decrypted one ~100ms after
// reveal) which the ResizeObserver can miss when the new content happens to
// measure to the same height.
useLayoutEffect(() => {
if (revealed && stickRef.current) pinToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const handleScroll = useCallback(() => {
const m = readMetrics();
const programmatic = performance.now() - programmaticRef.current < 120;
const nearBottom = isNearBottom(m, atBottomThreshold);
// A scrollbar drag or keyboard scroll surfaces here as a scrollTop decrease.
// Suppress it inside the programmatic window so our own re-pin / settle is
// never mistaken for the user moving up. 2px deadzone absorbs sub-pixel jitter.
const userMovedUp = !programmatic && m.scrollTop < lastScrollTopRef.current - 2;
lastScrollTopRef.current = m.scrollTop;
stickRef.current = nextStickIntent(stickRef.current, { nearBottom, userMovedUp });
reportAtBottom(stickRef.current);
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
const first = virtualizer.getVirtualItems()[0];
if (first) onTopRowChange?.(first.index);
}, [atBottomThreshold, onReachTop, onTopRowChange, readMetrics, reportAtBottom, virtualizer]);
useImperativeHandle(
ref,
() => ({
scrollToBottom: () => {
stickRef.current = true;
reportAtBottom(true);
pinToBottom();
},
scrollToRow: (index, align = 'center') => {
// The user is jumping to a specific row — drop the stick intent first so
// the ResizeObserver doesn't immediately drag the target back to the bottom.
stickRef.current = false;
reportAtBottom(false);
programmaticRef.current = performance.now();
virtualizer.scrollToIndex(index, { align });
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[virtualizer, rows.length, reportAtBottom, pinToBottom],
);
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollElRef}
onScroll={handleScroll}
tabIndex={0}
className="min-h-0 flex-1 overflow-y-auto"
style={{
opacity: revealed ? 1 : 0,
position: 'relative',
overflowAnchor: 'none',
outline: 'none',
}}
>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{items.map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vi.start}px)`,
}}
>
{renderRow(vi.index, rows[vi.index]!)}
</div>
))}
</div>
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
<div style={{ height: 12 }} />
</div>
);
});
@@ -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 { MonitorShareIcon } from './icons';
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
interface ScreenShareViewerProps {
share: RemoteScreenShare;
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
const { watchingShareUserIds, watchShare } = useCall();
const watching = watchingShareUserIds.has(share.participantId);
const [isFullscreen, setIsFullscreen] = useState(false);
const [annotateEnabled, setAnnotateEnabled] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
useEffect(() => {
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
</div>
{watching ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
// Suppress the browser's built-in <video> context menu
// ("Save Video As…", PiP, …) so the right-click event bubbles
// to the wrapping tile div in InCallPanel — that's where the
// app's volume / mute menu is wired up. Without this, the
// native menu opens on top of ours in focus + fullscreen
// modes (where the video covers the whole tile).
onContextMenu={(e) => e.preventDefault()}
onDoubleClick={toggleFullscreen}
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
/>
<div className="relative h-full w-full flex-1">
<video
ref={videoRef}
autoPlay
playsInline
muted
// Suppress the browser's built-in <video> context menu
// ("Save Video As…", PiP, …) so the right-click event bubbles
// to the wrapping tile div in InCallPanel — that's where the
// app's volume / mute menu is wired up. Without this, the
// native menu opens on top of ours in focus + fullscreen
// modes (where the video covers the whole tile).
onContextMenu={(e) => e.preventDefault()}
onDoubleClick={toggleFullscreen}
className="block h-full w-full cursor-zoom-in bg-black object-contain"
/>
<ScreenShareAnnotations
shareKey={share.participantId}
enabled={annotateEnabled}
onToggleEnabled={setAnnotateEnabled}
/>
</div>
) : (
<button
type="button"
+76 -15
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { markAttachmentViewed } from '@chat-app/shared/chat';
@@ -16,16 +16,34 @@ interface Props {
}
// Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
// fullscreen lightbox AND fires the mark-viewed RPC.
// 1. viewedAt is null AND user is recipient → blurred lock card. Press-and-
// hold reveals the image fullscreen; release closes it AND fires the
// mark-viewed RPC.
// 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) {
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;
// 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) {
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">
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props)
);
}
// Recipient, not yet viewed.
const handleOpen = async (): Promise<void> => {
const startReveal = 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 {
const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) {
console.warn('mark-viewed failed', err);
burnedRef.current = false;
}
setFullscreen(true);
};
return (
<>
<button
type="button"
onClick={() => void handleOpen()}
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"
onPointerDown={() => void startReveal()}
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" />
<span className="text-xs font-medium">Einmal ansehen antippen</span>
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
</button>
{fullscreen && (
{revealing && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
onClick={() => setFullscreen(false)}
aria-label="Einmal-ansehen Bild"
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>
)}
</>
@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react';
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 WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
export type WhiteboardWidth = 2 | 4 | 8;
@@ -22,6 +25,8 @@ interface Props {
onStroke: (payload: WhiteboardStrokePayload) => void;
logicalWidth?: number;
logicalHeight?: number;
/** Enables live-cursor broadcast when set. */
whiteboardId?: string | null;
}
const DEFAULT_LOGICAL_W = 1280;
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
onStroke,
logicalWidth = DEFAULT_LOGICAL_W,
logicalHeight = DEFAULT_LOGICAL_H,
whiteboardId,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
const strokeStartRef = useRef<number>(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(() => {
const cv = canvasRef.current;
if (!cv) return;
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!draftRef.current) return;
const [x, y] = canvasPoint(e);
cursorSessionRef.current?.send(x, y);
if (!draftRef.current) return;
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
forceTick((n) => n + 1);
};
@@ -102,18 +159,53 @@ export function WhiteboardCanvas({
};
return (
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={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"
<div
className="relative"
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
/>
>
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={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"
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(
ctx: CanvasRenderingContext2D,
s: Partial<WhiteboardStrokePayload>,
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
color={color}
width={width}
onStroke={(payload) => void insertStroke(payload)}
whiteboardId={whiteboardId}
/>
)}
</div>
-11
View File
@@ -160,17 +160,6 @@ function EyeIconInner(props: IconProps) {
}
export const EyeIcon = memo(EyeIconInner);
function CaptionsIconInner(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="6" width="18" height="12" rx="2" />
<path d="M7 13a2 2 0 1 1 0-2" />
<path d="M14 13a2 2 0 1 1 0-2" />
</Base>
);
}
export const CaptionsIcon = memo(CaptionsIconInner);
function PinOffIconInner(props: IconProps) {
return (
<Base {...props}>
+6 -1
View File
@@ -207,9 +207,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Pre-warm Supabase: fires the first round-trip in the background so the
// first user-triggered query (e.g. loading conversations) doesn't pay
// 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(() => {
if (!session) return;
void supabase.from('profiles').select('id').limit(1).then(() => undefined);
void supabase.auth.getSession();
}, [session]);
// Phase 3: ensure this install owns exactly one devices row. The row is
+27 -53
View File
@@ -39,7 +39,6 @@ import {
playUndeafenBeep,
playUnmuteBeep,
} from '../lib/callSounds';
import { useLiveCaptions } from '../lib/useLiveCaptions';
import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify';
import {
@@ -99,6 +98,7 @@ import {
subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
import { playEntry } from '../lib/soundboardPlayback';
import {
getPrefs as getSoundboardPrefs,
@@ -209,14 +209,6 @@ interface CallContextValue {
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
* but only in group calls. Null while idle or in 1:1 contexts. */
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.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
* 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
// process-tree exclusion isn't watertight.
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 [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
@@ -828,7 +817,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteScreenShares([]);
setConnectionQualities({});
setCallHostId(null);
setCaptions({});
setIsScreenSharing(false);
setIsE2EEActive(false);
}
@@ -970,8 +958,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
type?: string;
deafened?: boolean;
muted?: boolean;
captionText?: string;
captionFinal?: boolean;
};
const id: string = participant.identity;
if (msg.type === 'presence') {
@@ -991,15 +977,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
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 {
/* 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 r = roomRef.current;
if (!r) return;
@@ -2292,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []);
// Global soundboard hotkey registration — runs only while connected so the
// OS-level shortcuts don't fire when the user is outside of a call.
// Global soundboard hotkey registration — always-on so the OS-level
// 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 150 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(() => {
if (state.kind !== 'connected') return;
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;
}, [state.kind, playSoundboard]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId });
@@ -2603,15 +2590,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKey);
}, [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>(
() => ({
state,
@@ -2626,8 +2604,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute,
connectionQualities,
callHostId,
captions,
pushLocalCaption,
remoteScreenShares,
lastCallConversationId,
callMode,
@@ -2683,8 +2659,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute,
connectionQualities,
callHostId,
captions,
pushLocalCaption,
remoteScreenShares,
lastCallConversationId,
callMode,
+68 -1
View File
@@ -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> {
if (!file.type.startsWith('image/')) {
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);
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
// through the legacy center-crop. Caller is responsible for sizing — the
// dialog already clamps to MAX_DIM via its outputWidth.
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
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image.
+6 -2
View File
@@ -1,11 +1,15 @@
// In-app changelog feed.
//
// The release script (`scripts/release.mjs`) maintains a single
// `changelog.json` file alongside `latest.json` on update.netralax.cloud.
// `changelog.json` file alongside `latest.json` on update.netralax.de.
// The list is newest-first, capped at 200 entries server-side, and rewritten
// after every release.
//
// NOTE: already-installed clients still fetch this from update.netralax.cloud
// (baked into their bundle), so Caddy on the new VPS must keep serving the
// update.netralax.cloud vhost from the same directory during the transition.
const CHANGELOG_URL = 'https://update.netralax.cloud/windows/changelog.json';
const CHANGELOG_URL = 'https://update.netralax.de/windows/changelog.json';
export interface ChangelogEntry {
version: string;
@@ -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');
});
});
+160
View File
@@ -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;
}
-119
View File
@@ -1,119 +0,0 @@
// Discord-style live captions. Uses the browser's SpeechRecognition API to
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
// to peers via the LiveKit DataChannel. Receivers store and display them.
//
// Privacy note: speech recognition runs in the browser. On Chromium-based
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
// own engine, which today reaches Google's cloud — same trade-off as Discord.
// We ship a hard off switch and require an explicit user toggle.
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
export interface LiveCaptionsSettings {
enabled: boolean;
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
lang: string | null;
}
const DEFAULTS: LiveCaptionsSettings = {
enabled: false,
lang: null,
};
type Listener = (s: LiveCaptionsSettings) => void;
const listeners = new Set<Listener>();
let cached: LiveCaptionsSettings | null = null;
function read(): LiveCaptionsSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
lang:
typeof parsed.lang === 'string' && parsed.lang.length > 0
? parsed.lang
: DEFAULTS.lang,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: LiveCaptionsSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
return read();
}
export function updateLiveCaptionsSettings(
patch: Partial<LiveCaptionsSettings>,
): LiveCaptionsSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
// outright. Returns the constructor or null.
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
interface SpeechRecognitionLike extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
onend: (() => void) | null;
}
interface SpeechRecognitionEventLike {
resultIndex: number;
results: ArrayLike<{
isFinal: boolean;
[index: number]: { transcript: string };
length: number;
}>;
}
interface SpeechRecognitionErrorLike {
error: string;
}
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
}
export function isLiveCaptionsSupported(): boolean {
return getSpeechRecognitionCtor() !== null;
}
export type {
SpeechRecognitionLike,
SpeechRecognitionEventLike,
SpeechRecognitionErrorLike,
};
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,73 @@
import { describe, expect, it } from 'vitest';
import { isNearBottom, isNearTop, nextStickIntent, resolveInitialAnchor } from './scrollController';
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
scrollTop,
scrollHeight,
clientHeight,
});
describe('isNearBottom', () => {
it('true exactly at the bottom', () => {
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
});
it('true within threshold', () => {
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
});
it('false beyond threshold', () => {
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
});
});
describe('isNearTop', () => {
it('true at top', () => {
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
});
it('false past threshold', () => {
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
});
});
describe('resolveInitialAnchor', () => {
it('anchors to last row at end by default (no saved position)', () => {
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
});
it('anchors to bottom when saved position stuck to bottom', () => {
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
index: 49,
align: 'end',
});
});
it('restores the saved row at the top when scrolled up', () => {
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
index: 12,
align: 'start',
});
});
it('clamps a stale saved index to the current row count', () => {
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
index: 49,
align: 'start',
});
});
it('handles an empty list', () => {
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
});
});
describe('nextStickIntent', () => {
it('turns ON when the bottom is reached', () => {
expect(nextStickIntent(false, { nearBottom: true, userMovedUp: false })).toBe(true);
});
it('turns OFF when the user genuinely moves up', () => {
expect(nextStickIntent(true, { nearBottom: false, userMovedUp: true })).toBe(false);
});
it('keeps the previous intent on a neutral scroll (measurement reflow)', () => {
expect(nextStickIntent(true, { nearBottom: false, userMovedUp: false })).toBe(true);
expect(nextStickIntent(false, { nearBottom: false, userMovedUp: false })).toBe(false);
});
it('reaching the bottom wins over a simultaneous move-up signal', () => {
expect(nextStickIntent(false, { nearBottom: true, userMovedUp: true })).toBe(true);
});
});
+62
View File
@@ -0,0 +1,62 @@
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
// tricky math is verified without a browser (jsdom has no layout).
export interface ScrollMetrics {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
/** Distance from the bottom edge is within `threshold` px. */
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
}
/** Scroll offset is within `threshold` px of the top. */
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
return m.scrollTop <= threshold;
}
export interface SavedPosition {
topmostIndex: number;
stickToBottom: boolean;
}
export interface Anchor {
index: number;
align: 'start' | 'end';
}
/**
* Where a freshly-opened chat should start.
* - default / "left at bottom" → last row, aligned to the viewport bottom.
* - "left scrolled up" → the saved top-most row, aligned to the viewport top
* (clamped in case the cached row count shrank).
*/
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
if (rowCount <= 0) return { index: 0, align: 'end' };
if (saved && !saved.stickToBottom) {
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
return { index, align: 'start' };
}
return { index: rowCount - 1, align: 'end' };
}
/**
* The next stick-to-bottom intent given the current intent and the latest
* scroll signal. Intent only flips on a *definitive* signal:
* - reaching the bottom turns it ON,
* - a genuine user move-up turns it OFF.
* A neutral scroll — e.g. a measurement reflow that grows the content while
* rows settle — leaves the intent unchanged. This is the core fix for the
* chat-switch bug: a reflow must never be mistaken for the user scrolling up
* and so must never silently unstick the list.
*/
export function nextStickIntent(
prev: boolean,
signal: { nearBottom: boolean; userMovedUp: boolean },
): boolean {
if (signal.nearBottom) return true;
if (signal.userMovedUp) return false;
return prev;
}
@@ -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();
}
+186 -83
View File
@@ -1,16 +1,16 @@
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
clearConvKeyCache,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
getOrCreateConvKey,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
rotateConvKey,
sendEncryptedMessage,
shareConvKeyToUser,
tryGetConvKey,
} from '@chat-app/shared/chat';
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -33,6 +33,11 @@ import {
shouldGiveUp,
subscribeOutbox,
} from './messageOutbox';
import {
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
import { supabase } from './supabase';
import { cachedUserKey } from './userIdentity';
@@ -82,7 +87,20 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
retryPending: (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[]>(() =>
conversationId ? getOutbox(conversationId) : [],
);
@@ -107,12 +125,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
}, [userId]);
// Proactive rewrap sweep: when a conversation opens, walk every accepted
// member and ensure the active conv-key has a `recipient_user_id` bundle
// 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
// local conv-key handle. Closes the legacy migration gap so peer B can
// read on first unlock without manual intervention from A.
// Proactive rewrap sweep: when a conversation opens, ensure the active
// conv-key has a `recipient_user_id` bundle for every accepted member.
//
// If any peer is missing a bundle at the active version, the previous
// implementation called `shareConvKeyToUser` for each missing peer
// 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(() => {
if (!conversationId || !userId) return;
let cancelled = false;
@@ -136,56 +167,76 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// 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 handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
if (!handle || cancelled) return;
// First, make sure we have a usable handle for the active version
// (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
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
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)
.map((m) => m.user_id);
if (memberIds.length === 0) return;
if (peerIds.length === 0) return;
const peers = await fetchPeerPublicKeys(supabase, memberIds);
for (const peer of peers) {
if (cancelled) return;
const { count, error: cntErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (s: string, o?: object) => {
eq: (...a: unknown[]) => {
eq: (...a: unknown[]) => {
eq: (
...a: unknown[]
) => Promise<{ count: number | null; error: unknown }>;
};
// Count how many of the peers have a recipient_user_id bundle at
// the active version. If any are missing, rotate to V+1 — the
// rotation will wrap a fresh key for every accepted member with a
// user_keys row.
const { data: existingRows, error: rowsErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (s: string) => {
eq: (c: string, v: string) => {
eq: (c: string, v: number) => {
in: (c: string, v: string[]) => Promise<{
data: Array<{ recipient_user_id: string }> | null;
error: unknown;
}>;
};
};
};
}
)
.from('conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
.eq('conversation_id', conversationId)
.eq('recipient_user_id', peer.userId)
.eq('key_version', version);
if (cntErr) continue;
if ((count ?? 0) === 0) {
try {
await shareConvKeyToUser(
supabase,
conversationId,
peer.userId,
peer.publicKey,
{ userId, privateKey: priv },
);
} catch (err) {
console.warn('proactive rewrap failed for', peer.userId, err);
}
};
}
)
.from('conversation_keys')
.select('recipient_user_id')
.eq('conversation_id', conversationId)
.eq('key_version', version)
.in('recipient_user_id', peerIds);
if (rowsErr) return;
const wrappedPeerIds = new Set(
(existingRows ?? []).map((r) => r.recipient_user_id),
);
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) {
// 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) {
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 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.
@@ -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.
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 };
});
});
@@ -338,7 +395,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (!decrypted) return;
setState((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],
@@ -358,6 +417,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
if (partial.editedAt && !partial.deletedAt) {
@@ -421,6 +481,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted!;
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
}
@@ -428,25 +489,32 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
[conversationId, deviceId, decryptBatch],
);
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);
}, []);
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],
);
useEffect(() => {
if (!conversationId || !userId || !deviceId) return;
void refresh();
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
// the last 100 in one query — cheaper and keeps order stable. For
// lone inserts the per-id path stays so latency is unchanged.
const BATCH_WINDOW_MS = 250;
// refetches + decrypts. The first event in a quiet period fires
// `handleInsert` immediately so single incoming messages don't sit
// behind a debounce timer (previous behaviour: 250 ms blank between
// notification-sound and message body). Subsequent events arriving
// 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;
let burstBuffer: Array<Record<string, unknown>> = [];
let burstTimer: number | null = null;
@@ -465,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
};
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);
if (burstTimer === null) {
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
// registered a fresh device), re-decrypt the visible messages.
// Any conversation_keys change for this conv invalidates the cached
// 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(
'postgres_changes',
{
event: 'INSERT',
event: '*',
schema: 'public',
table: 'conversation_keys',
filter: 'conversation_id=eq.' + conversationId,
},
(payload: { new: { recipient_device_id?: string } }) => {
if (payload.new?.recipient_device_id === deviceId) {
(payload: {
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();
}
},
@@ -552,13 +657,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
],
};
const next = [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
];
setCachedMessages(convId, next);
return { ...prev, messages: next };
});
},
[],
@@ -686,16 +790,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{
...msg,
plaintext: attachmentsPayload,
} as DecryptedMessage,
],
};
const next = [
...prev.messages,
{
...msg,
plaintext: attachmentsPayload,
} as DecryptedMessage,
];
setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
// 4. Insert public attachment metadata rows pointing at the new message.
-142
View File
@@ -1,142 +0,0 @@
// Hook that runs SpeechRecognition on the local mic when live-captions are
// enabled and a Room is connected. Each interim/final result is broadcast as
// a `caption`-typed message via the LiveKit DataChannel so peers can render
// it. Recognition stops cleanly when the call ends or the toggle flips off.
import type { Room } from 'livekit-client';
import { useEffect, useRef } from 'react';
import {
type LiveCaptionsSettings,
getLiveCaptionsSettings,
getSpeechRecognitionCtor,
type SpeechRecognitionEventLike,
type SpeechRecognitionLike,
subscribeLiveCaptionsSettings,
} from './liveCaptions';
interface Args {
room: Room | null;
/** True while we're connected and want captions to flow. */
active: boolean;
/** Callback fired locally for our own captions so the overlay can show
* them without going through the SFU round-trip. */
onLocalCaption: (text: string, final: boolean) => void;
}
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
useEffect(() => {
return subscribeLiveCaptionsSettings((s) => {
settingsRef.current = s;
});
}, []);
useEffect(() => {
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) return; // unsupported runtime
if (!active || !room) return;
if (!getLiveCaptionsSettings().enabled) return;
const send = (text: string, final: boolean) => {
onLocalCaption(text, final);
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
);
// Reliable channel — captions are infrequent enough to afford it,
// and dropping interims looks worse than slight lag.
void room.localParticipant.publishData(payload, { reliable: true });
} catch {
/* ignore — best-effort */
}
};
const start = () => {
const r = new Ctor();
r.continuous = true;
r.interimResults = true;
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
r.lang = lang;
r.onresult = (e: SpeechRecognitionEventLike) => {
// Pull whichever results arrived since last fire. Interim fires
// many times per second; the final one is sticky and persists.
for (let i = e.resultIndex; i < e.results.length; i++) {
const result = e.results[i];
if (!result || result.length === 0) continue;
const alt = result[0];
if (!alt) continue;
const transcript = alt.transcript.trim();
if (!transcript) continue;
send(transcript, result.isFinal);
}
};
r.onerror = () => {
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
// `service-not-allowed` are permission-permanent — bail.
try {
r.stop();
} catch {
/* ignore */
}
};
r.onend = () => {
// SpeechRecognition tends to auto-stop after silence — if we still
// want captions, restart it. Guard against tear-down race.
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
try {
r.start();
} catch {
/* already running or browser refused */
}
}
};
try {
r.start();
recognitionRef.current = r;
} catch {
// Some browsers throw when start() is called too soon after a
// previous abort — wait a tick and retry.
window.setTimeout(() => {
try {
r.start();
recognitionRef.current = r;
} catch {
/* give up */
}
}, 250);
}
};
start();
const unsub = subscribeLiveCaptionsSettings((s) => {
const cur = recognitionRef.current;
if (!s.enabled && cur) {
recognitionRef.current = null;
try {
cur.abort();
} catch {
/* ignore */
}
} else if (s.enabled && !cur) {
start();
}
});
return () => {
unsub();
const cur = recognitionRef.current;
recognitionRef.current = null;
if (cur) {
try {
cur.abort();
} catch {
/* ignore */
}
}
};
}, [active, room, onLocalCaption]);
}
+11 -1
View File
@@ -19,6 +19,10 @@ export interface UseMessageReactionsResult {
byMessage: Map<string, AggregatedReaction[]>;
toggle: (messageId: string, emoji: string) => Promise<void>;
voteExclusive: (messageId: string, emoji: string, exclusiveEmojis: string[]) => Promise<void>;
// True once the reactions for the current message-id set have been fetched
// (or there are no messages). Drives MessageList's deferred reveal so the
// chat opens already showing reaction chips — no post-paint height jump.
ready: boolean;
}
// Batch-fetches reactions for the given message ids + subscribes to the
@@ -29,10 +33,12 @@ export function useMessageReactions(
): UseMessageReactionsResult {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [rows, setRows] = useState<MessageReaction[]>([]);
const [readyKey, setReadyKey] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (messageIds.length === 0) {
setRows([]);
setReadyKey(idsKey);
return;
}
try {
@@ -40,6 +46,8 @@ export function useMessageReactions(
setRows(data);
} catch (err: unknown) {
console.error('listReactionsForMessages failed', err);
} finally {
setReadyKey(idsKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey]);
@@ -129,5 +137,7 @@ export function useMessageReactions(
[byMessage, myId, refresh],
);
return { byMessage, toggle, voteExclusive };
const ready = readyKey === idsKey;
return { byMessage, toggle, voteExclusive, ready };
}
+1 -1
View File
@@ -213,7 +213,7 @@ async function runLegacyMigration(
}
}
console.info(
console.debug(
'[crypto-migration] vault scan:',
'serverDevices=' + report.serverDevices,
'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;
+81
View File
@@ -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 };
}
+111 -86
View File
@@ -3,7 +3,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { MessageList, type MessageListHandle } from '../components/MessageList';
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader';
@@ -54,6 +54,7 @@ import {
createWatchTogetherPayload,
createGamePayload,
} from '../lib/conversationFeatures';
import { resolveInitialAnchor } from '../lib/scrollController';
const WhiteboardModal = lazy(() =>
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
);
@@ -78,12 +79,13 @@ import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useM
import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel';
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
// Discriminated union for rows inside the virtualized message list. Keeping
// pending bubbles and the "load older" tile inside the same Virtuoso
// instance means scroll-to-bottom / followOutput stay coherent across both
// (we don't need a sibling scroll container for pending items).
type VirtuosoRow =
export type VirtuosoRow =
| { kind: 'loader'; key: string }
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
| { kind: 'pending'; key: string; item: OutboxItem };
@@ -94,18 +96,18 @@ type VirtuosoRow =
// change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = [];
// 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.
// 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 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.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
@@ -144,6 +146,17 @@ export function ConversationPage() {
voteExclusive: votePoll,
} = useMessageReactions(messageIds, session?.user.id);
// Reveal gate for MessageList: as soon as messages exist (cache hit = first
// render, so no spinner and no wait), let the list reveal. We deliberately do
// NOT gate on reactions readiness: on a cache-hit chat switch the messages are
// already present, and gating on the async reactions fetch held the list at
// opacity:0 for up to 300ms and then "popped" it in — that was the residual
// chat-switch flicker. Reaction chips stream in a beat later; because the list
// is pinned to the bottom, their height growth re-pins with no visible jump.
// MessageList still defers its own reveal a few frames until the row-height
// measurement settles, so the list still appears already at the final bottom.
const listReady = !loading && messages.length > 0;
const myId = session?.user.id;
const ownMessageIds = useMemo(
@@ -200,7 +213,10 @@ export function ConversationPage() {
});
}, [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 [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true);
@@ -299,25 +315,25 @@ export function ConversationPage() {
},
[send],
);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const listRef = useRef<MessageListHandle>(null);
const topmostIndexRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setMediaDrawerOpen(false);
setPollDialogOpen(false);
setSearchQuery('');
setDisplayCount(150);
setFirstUnreadId(null);
setFirstUnreadJumpDismissed(false);
setNewMessagesWhileAway(0);
previousMessageIdsRef.current = new Set();
firstUnreadComputedRef.current = false;
}, [id]);
if (!id) return;
const draft = getDraftSync(id);
const savedReplyToId = draft?.replyToId ?? null;
if (!savedReplyToId) return;
if (replyTo?.id === savedReplyToId) return;
const match = messages.find((m) => m.id === savedReplyToId);
if (match) setReplyTo(match);
}, [id, messages, replyTo?.id]);
useEffect(() => {
if (!id) return;
setDraft(id, { text, replyToId: replyTo?.id ?? null });
}, [id, text, replyTo?.id]);
useEffect(() => {
if (firstUnreadComputedRef.current) return;
@@ -467,18 +483,14 @@ export function ConversationPage() {
// previous visit to this chat AND the user wasn't sticking to the
// bottom, restore the saved row index (clamped to the current row
// count in case the cache was trimmed).
const initialTopMostIndex = useMemo(() => {
const saved = savedPositionRef.current;
if (saved && !saved.stickToBottom) {
return Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1));
}
return virtuosoRows.length - 1;
// virtuosoRows.length changes when the conversation loads — that's the
// intentional trigger so a freshly-loaded chat anchors to the bottom
// on first paint. We deliberately don't re-derive this on every row
// append; Virtuoso owns scroll position from that point on.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [virtuosoRows.length > 0]);
// Reuses the unit-tested resolveInitialAnchor so the "where do I open" rule
// lives in one tested place. MessageList only reads this at reveal time (when
// rows are loaded), so depending on the row count clamps a stale saved index
// correctly without freezing a mount-time count of 0.
const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
const anchor = resolveInitialAnchor(savedPositionRef.current, virtuosoRows.length);
return anchor.align === 'end' ? { type: 'bottom' } : { type: 'row', index: anchor.index };
}, [virtuosoRows.length]);
const jumpToMessage = useCallback(
(targetId: string) => {
@@ -505,11 +517,7 @@ export function ConversationPage() {
// resolve the target row. Without this, the scroll either no-ops or
// lands on a stale row.
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: rowIndex,
align: 'center',
behavior: 'smooth',
});
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
});
setHighlightedId(targetId);
window.setTimeout(
@@ -705,23 +713,26 @@ export function ConversationPage() {
const handleRangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
topmostIndexRef.current = range.startIndex;
if (id) {
if (!id) return;
if (stickToBottom) {
// Pinned to the bottom: the topmost-visible row drifts as the
// virtualizer mounts/unmounts rows. Persisting it would later reopen the
// chat at that arbitrary row (the second writer behind the scrollTop=0
// bug). Keep the last real reading position and only refresh the flag.
const prev = scrollPositions.get(id);
scrollPositions.set(id, {
topmostIndex: range.startIndex,
stickToBottom: prev?.stickToBottom ?? true,
topmostIndex: prev?.topmostIndex ?? range.startIndex,
stickToBottom: true,
});
} else {
scrollPositions.set(id, { topmostIndex: range.startIndex, stickToBottom: false });
}
},
[id],
[id, stickToBottom],
);
const jumpToBottom = useCallback(() => {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'smooth',
});
listRef.current?.scrollToBottom('smooth');
setStickToBottom(true);
setNewMessagesWhileAway(0);
}, []);
@@ -733,18 +744,33 @@ export function ConversationPage() {
// 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
// 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(() => {
if (pending.length > lastPendingCountRef.current) {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'auto',
});
listRef.current?.scrollToBottom('auto');
}
lastPendingCountRef.current = 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(() => {
listRef.current?.scrollToBottom('auto');
});
}, []);
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
@@ -763,6 +789,8 @@ export function ConversationPage() {
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
if (id) clearDraft(id);
snapToBottom();
} catch (err: unknown) {
const code = extractErrorCode(err);
setSendError(
@@ -788,13 +816,14 @@ export function ConversationPage() {
setReplyTo(null);
setStickToBottom(true);
notifyStopTyping();
snapToBottom();
} catch (err: unknown) {
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
} finally {
setPollSending(false);
}
},
[send, replyTo?.id, notifyStopTyping],
[send, replyTo?.id, notifyStopTyping, snapToBottom],
);
const handleCreateWhiteboard = useCallback(async () => {
@@ -807,12 +836,13 @@ export function ConversationPage() {
setReplyTo(null);
setStickToBottom(true);
setOpenWhiteboardId(board.id);
snapToBottom();
} catch (err: unknown) {
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
} finally {
setCreatingWhiteboard(false);
}
}, [id, creatingWhiteboard, send, replyTo?.id]);
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
const handleStartWatchTogether = useCallback(async () => {
if (!id) return;
@@ -832,12 +862,13 @@ export function ConversationPage() {
setWatchDialogOpen(false);
setWatchUrl('');
setOpenWatchSessionId(ws.id);
snapToBottom();
} catch (err: unknown) {
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
} finally {
setWatchCreating(false);
}
}, [id, watchUrl, send, replyTo?.id]);
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
const handleStartGame = useCallback(async (gameType: GameType) => {
if (!id) return;
@@ -864,12 +895,13 @@ export function ConversationPage() {
setStickToBottom(true);
setGameDialogOpen(false);
setOpenGameId(game.id);
snapToBottom();
} catch (err: unknown) {
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
} finally {
setGameCreating(false);
}
}, [id, conversation, myId, send, replyTo?.id]);
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
@@ -1020,31 +1052,24 @@ export function ConversationPage() {
/>
</div>
) : (
<Virtuoso
ref={virtuosoRef}
className="flex-1"
style={{ height: '100%' }}
data={virtuosoRows}
computeItemKey={(_idx, row) => row.key}
// Initial position: either restored from per-conv memory, or
// pinned to the bottom for fresh entry. Virtuoso applies this
// synchronously before its first paint so the user doesn't see
// a "loaded at top, then jumped" flicker (matches the layout-
// effect behavior we used in the non-virtualized version).
initialTopMostItemIndex={initialTopMostIndex}
// followOutput auto-scrolls only when the user was already at
// the bottom; returning `false` from the callback when they're
// scrolled up preserves their reading position when realtime
// messages arrive (critical UX: do NOT jerk the user).
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
atBottomStateChange={handleAtBottomStateChange}
atBottomThreshold={80}
rangeChanged={handleRangeChanged}
startReached={handleStartReached}
// Render rows just outside the viewport so fast scrolling
// doesn't briefly flash empty space.
increaseViewportBy={400}
itemContent={(_index, row) => {
<MessageList
ref={listRef}
rows={virtuosoRows}
// Deferred reveal: the list stays hidden until messages + reactions
// + the unread divider are loaded, then anchors and reveals — so the
// post-paint height cascade is never visible (no chat-switch flicker).
ready={listReady}
computeKey={(row) => row.key}
initialAnchor={initialAnchor}
// 250px at-bottom tolerance — a tall appended row (image, voice note,
// grouped attachments) shouldn't push the user out of the at-bottom zone.
atBottomThreshold={250}
onReachTop={handleStartReached}
onAtBottomChange={handleAtBottomStateChange}
onTopRowChange={(topIndex) =>
handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
}
renderRow={(_index, row) => {
if (row.kind === 'loader') {
return (
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+733
View File
@@ -0,0 +1,733 @@
# Migrations-Runbook: Self-Hosted Backend von `*.netralax.cloud` auf `*.netralax.de` (neuer VPS)
> **Zweck:** Vollständiger Umzug des selbstgehosteten Chat-Backends (Supabase + LiveKit/coturn + Update-Host) vom ALTEN VPS (`46.225.156.249`, `*.netralax.cloud`) auf einen FRISCHEN, leeren NEUEN VPS, der danach `*.netralax.de` UND während der Übergangsphase weiterhin `*.netralax.cloud` ausliefert.
>
> **Lesbar als:** Copy-paste-Runbook. Überschriften und Erklärungen sind deutsch; alle Befehle, Pfade, Variablennamen und Konfig-Snippets bleiben wörtlich/literal.
---
## ⚠️ Zwei nicht verhandelbare Kontinuitäts-Garantien (vor allem anderen lesen)
Die bereits installierten Desktop- (Vite/electron) und Mobile- (Expo) Clients tragen die ALTEN Hostnamen **und** den anon-JWT **fest im Bundle einkompiliert** (`SUPABASE_URL`, `LIVEKIT_URL`, `SUPABASE_ANON_KEY`, `VITE_VAPID_PUBLIC_KEY`, Update-Host). Daraus folgen zwei Garantien, deren Verletzung **alle bestehenden Installationen sofort und lautlos zerstört**:
1. **`JWT_SECRET` (und damit `ANON_KEY`, `SERVICE_ROLE_KEY`) MÜSSEN byte-für-byte vom ALTEN Server übernommen werden.** Der anon-JWT in den Bundles ist mit dem alten `JWT_SECRET` signiert. Ein anderes Secret → Kong/PostgREST/GoTrue verwerfen **jedes** Token → **alle** Sessions fallen aus, niemand kann sich mehr anmelden. Es gibt keine Fehlermeldung, die das offensichtlich macht.
2. **Das VAPID-Schlüsselpaar (`VAPID_PUBLIC_KEY` + `VAPID_PRIVATE_KEY`) MUSS identisch übernommen werden.** Bestehende Web-Push-Subscriptions sind an den öffentlichen VAPID-Key gebunden. Ändert er sich, brechen **alle** vorhandenen Push-Abos Benachrichtigungen verstummen lautlos.
Zusätzlich: Der NEUE Caddy **muss die Legacy-Vhosts `*.netralax.cloud` mitbedienen** und die `.cloud`-DNS-A-Records müssen auf die NEUE IP zeigen, sonst sterben alte Clients in dem Moment, in dem der alte VPS abgeschaltet wird.
---
## 0. Voraussetzungen & Übersicht
### 0.1 Architektur (unverändert auf beiden Servern)
| Komponente | Verzeichnis | Intern | Öffentlich (neu) | Öffentlich (Legacy, weiter bedient) |
|---|---|---|---|---|
| Supabase (Postgres 17, GoTrue, PostgREST, Realtime, Storage, Kong, edge-runtime, Mailpit) | `/opt/supabase` | Kong `127.0.0.1:8000` | `supabase.netralax.de` | `supabase.netralax.cloud` |
| LiveKit SFU (Signaling-WS) | `/opt/livekit` | `127.0.0.1:7880` | `livekit.netralax.de` | `livekit.netralax.cloud` |
| coturn (TURN/TURNS) | `/opt/livekit` | `:3478`, `:5349` (TLS) | `turn.netralax.de:5349` | `turn.netralax.cloud:5349` |
| Update-Host (electron-updater) | `/var/www/updates/windows` | `file_server` | `update.netralax.de` | `update.netralax.cloud` |
TLS-Terminierung für Supabase/LiveKit/Update via **Caddy** (automatisches Let's Encrypt). **TURNS auf `5349` läuft NICHT über Caddy** und braucht ein eigenes Zertifikat auf der Platte.
### 0.2 Was du brauchst
- SSH-Zugang: User `prox` auf dem **alten** (.cloud) VPS, User `debian` auf dem **neuen** (.de) VPS. Der neue VPS ist leer.
- Die NEUE öffentliche IP des `.de`-VPS: **`141.95.34.204`** (bereits in `scripts/migrate/config.sh``NEW_HOST` und `scripts/prod/config.sh``PROD_SERVER` eingetragen). Login-User: `debian`.
- Lese-Zugriff auf die ALTE `/opt/supabase/.env` (enthält alle zu kopierenden Secrets).
- DNS-Verwaltung für `netralax.de` **und** `netralax.cloud`.
- Entwickler-Laptop mit Bash (Linux/macOS/WSL), `ssh`, `rsync`, `openssl`.
- Ein Wartungsfenster (Schreibstopp auf der App), siehe Abschnitt 5.
### 0.3 Reihenfolge der Arbeit (Überblick)
```
1. DNS vorbereiten (niedrige TTL setzen, noch NICHT umbiegen)
2. Neuen VPS bootstrappen → scripts/migrate/01-bootstrap-new-server.sh
3. Secrets 1:1 in /opt/supabase/.env übernehmen (JWT_SECRET/VAPID identisch!)
4. Stacks LEER hochfahren (init der Rollen)
5. Wartungsfenster: DB + Storage migrieren → scripts/migrate/02-migrate-data.sh
6. LiveKit/coturn Prod-Config + Firewall + TURNS-Zertifikat
7. Caddy mit BEIDEN Domain-Sätzen (.de + .cloud)
8. Edge-Functions + deren Secrets deployen
9. Update-Host migrieren + Dual-Publish (.de UND .cloud)
10. Cutover: DNS scharf schalten (alle .de + Repoint aller .cloud)
11. Smoke-Tests (inkl. ALTER .cloud-Client)
12. Repo-Edits + neues Desktop-/Mobile-Release ausliefern
13. Rollback-Plan (bereithalten)
14. Aufräumen / .cloud später abschalten
```
### 0.4 Konventionen der Migrate-Skripte (Interface-Contract)
Alle `scripts/migrate/*`-Skripte sourcen `scripts/migrate/config.sh`. Dieses kennt **beide** Hosts und ist bewusst **unabhängig** von `scripts/prod/config.sh` (das bereits auf den End-Zustand `.de` zeigt). `config.sh` exportiert:
```bash
OLD_HOST="46.225.156.249"
OLD_USER="prox"
NEW_HOST="141.95.34.204"
NEW_USER="debian" # neuer .de-Server: User debian (alt: prox)
SUPABASE_DIR="/opt/supabase"
LIVEKIT_DIR="/opt/livekit"
OLD_SSH="${OLD_USER}@${OLD_HOST}"
NEW_SSH="${NEW_USER}@${NEW_HOST}"
SSH_OPTS="-o StrictHostKeyChecking=accept-new"
```
…und die Helfer `old_remote()` / `new_remote()`, die per `ssh ${SSH_OPTS}` zum jeweiligen Host verbinden.
---
## 1. DNS-Plan
> **Wichtig:** In diesem Schritt wird DNS **noch nicht** umgebogen (außer der TTL-Absenkung). Das eigentliche Scharfschalten passiert erst im **Cutover (Abschnitt 10)**, wenn der neue VPS vollständig steht und getestet ist.
### 1.1 Jetzt (Vorbereitung): TTL absenken
Setze auf **allen** unten genannten A-Records die TTL auf **300 Sekunden (5 min)**, mindestens 2448 h vor dem geplanten Cutover. So wird die spätere Umstellung schnell wirksam.
### 1.2 Beim Cutover (Abschnitt 10): A-Records auf `141.95.34.204`
**Neue `.de`-Records (anlegen):**
| Record | Typ | Ziel |
|---|---|---|
| `supabase.netralax.de` | A | `141.95.34.204` |
| `livekit.netralax.de` | A | `141.95.34.204` |
| `turn.netralax.de` | A | `141.95.34.204` |
| `update.netralax.de` | A | `141.95.34.204` |
**Legacy `.cloud`-Records (REPOINT von alter IP `46.225.156.249` auf neue IP):**
| Record | Typ | Neues Ziel |
|---|---|---|
| `supabase.netralax.cloud` | A | `141.95.34.204` |
| `livekit.netralax.cloud` | A | `141.95.34.204` |
| `turn.netralax.cloud` | A | `141.95.34.204` |
| `update.netralax.cloud` | A | `141.95.34.204` |
> **⚠️ Den Repoint der `.cloud`-Records NICHT vergessen.** Alle bereits installierten Clients sprechen `*.netralax.cloud` an. Bleiben diese Records auf der alten IP, brechen sämtliche Installationen, sobald der alte VPS abgeschaltet wird. Der neue Caddy bedient die `.cloud`-Vhosts mit (Abschnitt 7), und Let's Encrypt stellt für `.cloud` erst dann gültige Zertifikate aus, **wenn** die `.cloud`-A-Records auf die neue IP zeigen.
### 1.3 Verifikation nach dem Cutover
```bash
for h in supabase livekit turn update; do
echo "== $h.netralax.de =="; dig +short $h.netralax.de
echo "== $h.netralax.cloud =="; dig +short $h.netralax.cloud
done
```
Alle acht müssen `141.95.34.204` zurückgeben.
---
## 2. Neuen Server bootstrappen
Das Skript **`scripts/migrate/01-bootstrap-new-server.sh`** wird **auf den neuen VPS kopiert und dort als root** ausgeführt. Es ist idempotent, erfindet **keine** Secrets und gibt am Ende klare NEXT-STEP-Hinweise.
### 2.1 Skript übertragen und ausführen
```bash
# Vom Laptop aus:
scp -o StrictHostKeyChecking=accept-new \
scripts/migrate/01-bootstrap-new-server.sh \
debian@141.95.34.204:/tmp/
ssh -o StrictHostKeyChecking=accept-new debian@141.95.34.204 \
'sudo bash /tmp/01-bootstrap-new-server.sh'
```
### 2.2 Was das Bootstrap-Skript tut
- Installiert **Docker Engine + compose-plugin**.
- Installiert + aktiviert **ufw** und öffnet die Ports (siehe Abschnitt 6 für die vollständige Liste): `22/tcp`, `80/tcp`, `443/tcp`, `7880/tcp`, `7881/tcp`, `50000:50100/udp`, `3478/tcp`, `3478/udp`, `5349/tcp`, `50200:50300/udp`.
- Klont `https://github.com/supabase/supabase` und kopiert `supabase/docker/` nach **`/opt/supabase`** (inkl. `docker-compose.yml`, `volumes/`, `.env.example`). Hinweis: Wir vendoren die Supabase-Compose-Datei **nicht** im Repo sie wird beim Bootstrap frisch geklont.
- Legt **`/opt/livekit`** an und schreibt Platzhalter `docker-compose.yml` + `livekit.yaml` + `coturn.conf`.
- Installiert **Caddy** und legt eine Platzhalter-`/etc/caddy/Caddyfile` an.
- Legt **`/var/www/updates/windows`** an (Artefakt-Verzeichnis; Caddy-Docroot ist das Eltern-Verzeichnis `/var/www/updates`, siehe §7).
- Setzt in `/opt/supabase/.env` die sicherheitskritischen Secrets (`JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `POSTGRES_PASSWORD`, …) auf den Sentinel `__COPY_FROM_OLD_SERVER__`, damit ein vergessener Wert **laut scheitert** statt still die öffentlich bekannten Upstream-Defaults zu benutzen.
- Druckt am Ende die NEXT-STEPS: `/opt/supabase/.env` befüllen (Abschnitt 3), Prod-Compose + `livekit.yaml` + `coturn.conf` einsetzen (Abschnitt 6), `Caddyfile` einsetzen (Abschnitt 7).
> **Das Bootstrap-Skript erfindet KEINE Secrets.** Die sicherheitskritischen Keys stehen danach auf dem Sentinel `__COPY_FROM_OLD_SERVER__` (fail-loud); Custom-Secrets wie `VAPID_*` / `PUSH_FANOUT_SHARED_SECRET` sind im Upstream-`.env` gar nicht vorhanden und müssen ergänzt werden. Alle echten Werte kommen in Abschnitt 3 vom alten Server.
---
## 3. Secrets 1:1 übernehmen
Alle Server-Secrets leben auf dem Server in **`/opt/supabase/.env`**. Hole zuerst die ALTE Datei:
```bash
# ALTE .env lokal sichern (nur lesend, nichts ändern):
ssh -o StrictHostKeyChecking=accept-new prox@46.225.156.249 \
'cat /opt/supabase/.env' > old.env.backup
chmod 600 old.env.backup
```
### 3.1 Entscheidungstabelle: identisch kopieren vs. auf neuen Host umstellen
**Spalte „Aktion": `IDENTISCH` = byte-für-byte aus `old.env.backup` übernehmen; `NEU` = auf den neuen Host/Wert setzen.**
| Variable | Aktion | Woher / Neuer Wert | Begründung |
|---|---|---|---|
| `POSTGRES_PASSWORD` | **IDENTISCH** | old.env | Dump trägt Rollen-Passwort-Hashes; muss vor Restore passen, sonst können interne Dienste sich nicht an Postgres anmelden. |
| `JWT_SECRET` | **🔴 IDENTISCH** | old.env | **Signiert die eingebackenen anon/service-role-JWTs. Abweichung = alle Sessions tot.** |
| `ANON_KEY` | **🔴 IDENTISCH** | old.env | Eingebackener anon-JWT der Clients. |
| `SERVICE_ROLE_KEY` | **IDENTISCH** | old.env | service-role-JWT für Edge-Functions/Admin-Skripte; muss zu `JWT_SECRET` passen. |
| `SECRET_KEY_BASE` | **IDENTISCH** | old.env | Realtime (Phoenix) + Vault: signiert Channel-Tokens/Cookies. |
| `VAULT_ENC_KEY` | **IDENTISCH** | old.env | Entschlüsselt vault/pgsodium-verschlüsselte Zeilen aus dem Dump. |
| `PG_META_CRYPTO_KEY` | **IDENTISCH** | old.env | postgres-meta-Crypto-Key; stabil halten. |
| `SMTP_HOST` | **IDENTISCH** | old.env | Magic-Link-Mailversand erhalten (externes Relay / Mailpit). |
| `SMTP_PORT` | **IDENTISCH** | old.env | s.o. |
| `SMTP_USER` | **IDENTISCH** | old.env | s.o. |
| `SMTP_PASS` | **IDENTISCH** | old.env | s.o. |
| `SMTP_ADMIN_EMAIL` | **IDENTISCH** | old.env | Absender/SPF-Konsistenz. |
| `SMTP_SENDER_NAME` | **IDENTISCH** | old.env | Anzeigename konsistent. |
| `FUNCTIONS_VERIFY_JWT` | **IDENTISCH** | old.env (`false`) | `notify-push` nutzt Shared-Secret-Header statt User-JWT; bleibt `false`. |
| `LIVEKIT_API_KEY` | **IDENTISCH** | old.env | Muss = `keys:`-Block in `livekit.prod.yaml`, sonst SFU-Reject (403). |
| `LIVEKIT_API_SECRET` | **IDENTISCH** | old.env | s.o. |
| `VAPID_PUBLIC_KEY` | **🔴 IDENTISCH** | old.env | **Bindet bestehende Push-Abos. Abweichung = alle Push-Subscriptions tot.** |
| `VAPID_PRIVATE_KEY` | **🔴 IDENTISCH** | old.env | Muss mit unverändertem Public-Key paaren. |
| `VAPID_SUBJECT` | **IDENTISCH** | old.env | Konsistenz (mailto/URL). |
| `PUSH_FANOUT_SHARED_SECRET` | **IDENTISCH** | old.env | `x-shared-secret`-Header zwischen DB-Trigger und `notify-push`. |
| `SUPABASE_SERVICE_ROLE_KEY` | **IDENTISCH** | = `SERVICE_ROLE_KEY` | Edge-Function-Alias. |
| `SUPABASE_ANON_KEY` | **IDENTISCH** | = `ANON_KEY` | Edge-Function-Alias (mint-livekit-token RLS-Client). |
| `SITE_URL` | **NEU** | `https://supabase.netralax.de` | GoTrue-Basis-URL für Magic-Link-Redirects. |
| `API_EXTERNAL_URL` | **NEU** | `https://supabase.netralax.de` | Öffentliche Kong-URL, die GoTrue/Studio bewerben. |
| `SUPABASE_PUBLIC_URL` | **NEU** | `https://supabase.netralax.de` | Studio/Kong-Asset-/Link-Generierung. |
| `ADDITIONAL_REDIRECT_URLS` | **NEU** (Superset) | siehe 3.2 | GoTrue-Redirect-Allow-List inkl. Deep-Link-Schemata. |
| `SUPABASE_URL` (Edge-Function) | **NEU** | `https://supabase.netralax.de` (oder internes Kong) | Funktionen müssen es nur erreichen. |
| `LIVEKIT_URL` (Edge-Function) | **NEU** | `wss://livekit.netralax.de` | wss-URL für neue Builds; alte Clients nutzen `.cloud` (vom neuen Caddy mitbedient). |
| `DASHBOARD_USERNAME` | **NEU** | frei wählbar | Studio-Basic-Auth; nicht client-kritisch. |
| `DASHBOARD_PASSWORD` | **NEU** | starkes neues Passwort | s.o. |
| `POSTGRES_HOST` | Default | `db` | nicht host-spezifisch. |
| `POSTGRES_DB` | Default | `postgres` | s.o. |
| `POSTGRES_PORT` | Default | `5432` (nur an localhost gebunden) | s.o. |
| `KONG_HTTP_PORT` | Default | `8000` | muss zum Caddyfile passen. |
| `KONG_HTTPS_PORT` | Default | `8443` (ungenutzt) | Caddy terminiert TLS. |
### 3.2 `ADDITIONAL_REDIRECT_URLS` (exakt, ohne Leerzeichen)
```
ADDITIONAL_REDIRECT_URLS=chatapp://auth/callback,netralax://auth/callback,https://supabase.netralax.de,https://supabase.netralax.cloud
```
> **⚠️ GoTrue lehnt jeden Magic-Link-Redirect ab, der nicht exakt auf der Allow-List steht.** Beide Deep-Link-Schemata (`chatapp://auth/callback` **und** `netralax://auth/callback`) müssen drin sein, sonst scheitert der Native-App-Login.
### 3.3 Werte übertragen
Bearbeite `/opt/supabase/.env` auf dem neuen Server und setze die `IDENTISCH`-Werte aus `old.env.backup`, die `NEU`-Werte aus der Tabelle:
```bash
ssh debian@141.95.34.204 'sudo nano /opt/supabase/.env'
```
> **Reihenfolge-Falle:** `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `POSTGRES_PASSWORD` und das VAPID-Paar müssen in der `.env` stehen, **bevor** in Abschnitt 4 der Stack hochfährt und **bevor** in Abschnitt 5 der Restore läuft. Setze sie jetzt vollständig.
### 3.4 Verifikation (Hashes vergleichen, nicht Klartext loggen)
```bash
# Stelle sicher, dass die kritischen Secrets identisch sind:
for v in JWT_SECRET ANON_KEY SERVICE_ROLE_KEY POSTGRES_PASSWORD VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY; do
old=$(ssh prox@46.225.156.249 "grep -E \"^${v}=\" /opt/supabase/.env | cut -d= -f2-" | sha256sum)
new=$(ssh debian@141.95.34.204 "grep -E \"^${v}=\" /opt/supabase/.env | cut -d= -f2-" | sha256sum)
[ "$old" = "$new" ] && echo "OK $v" || echo "DIFF $v <-- FIX BEFORE RESTORE"
done
```
Jede Zeile muss `OK` sein.
---
## 4. Stacks leer hochfahren
Bevor Daten restauriert werden, muss der frische Supabase-Stack **einmal** hochfahren, damit die Init-Skripte die Rollen anlegen (`supabase_admin`, `authenticator`, `anon`, `authenticated`, `service_role`, `supabase_auth_admin`, `supabase_storage_admin`, …), Extensions und Grants. Voraussetzung: `POSTGRES_PASSWORD` und `JWT_SECRET` sind bereits identisch gesetzt (Abschnitt 3).
```bash
# DB-Container zuerst hochfahren (legt Rollen + Extensions an):
ssh debian@141.95.34.204 \
'cd /opt/supabase && docker compose up -d db && sleep 20'
# Health-Check:
ssh debian@141.95.34.204 \
'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres'
```
> Den **vollständigen** Stack (`docker compose up -d`) fahren wir erst **nach** dem Daten-Restore hoch (Abschnitt 5, Schritt 3), damit alle Dienste gegen die wiederbefüllte DB neu verbinden.
---
## 5. Datenmigration: DB + Storage
Genutzt wird **`scripts/migrate/02-migrate-data.sh`** (läuft vom Laptop, sourct `config.sh`, `set -euo pipefail`, jeder destruktive Schritt ist abgesichert).
### 5.1 🔴 Wartungsfenster: Schreibstopp ZUERST
> **Friere Schreibvorgänge ein, bevor du dumpst und bevor du Storage rsyncst.** Sonst werden DB-Zeilen und Storage-Volume inkonsistent (Objekte auf der Platte ohne Metadaten-Zeile oder umgekehrt). Setze die App in Wartungsmodus / stoppe neue Uploads/Nachrichten auf dem ALTEN System.
Pre-Flight (beide Stacks gesund):
```bash
ssh prox@46.225.156.249 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres'
ssh debian@141.95.34.204 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres'
```
### 5.2 Postgres (Major-Version 17) `pg_dumpall`, gestreamt ALT → NEU
Faithful Full-Cluster-Dump (Rollen **inkl. Passwort-Hashes** + alle DBs + auth/storage/realtime/public-Schemata), direkt vom alten in den neuen Container gestreamt:
```bash
old_remote 'cd /opt/supabase && docker compose exec -T db pg_dumpall -U postgres --clean --if-exists' \
| new_remote 'cd /opt/supabase && docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=0'
```
Wichtige Hinweise zu diesem Befehl:
- **`pg_dumpall` (nicht `pg_dump`)** ist nötig, weil es die ROLLEN-Definitionen samt Passwort-Hashes (md5/scram) mitnimmt. Da `POSTGRES_PASSWORD` auf beiden Hosts identisch ist, passen die restaurierten Rollen-Passwörter zu dem, was die Dienste benutzen.
- **`--clean --if-exists`** macht den Dump gegen den bereits initialisierten Cluster wiederholbar (droppt/erzeugt Objekte neu).
- **`ON_ERROR_STOP=0` (nicht `=1`):** `pg_dumpall` versucht, bereits existierende Rollen wie `supabase_admin`/`postgres` per `CREATE ROLE` anzulegen → harmlose „already exists"-Fehler. Mit `ON_ERROR_STOP=1` würde der erste davon einen guten Restore abbrechen. `=0` schluckt aber **auch echte Fehler** (FK/Constraint/Ownership) und hinterlässt eine teil-restaurierte DB, die „erfolgreich" aussieht. **Deshalb scannt `02-migrate-data.sh` den Restore automatisch:** es teet die Ausgabe in ein Log, grept nach `ERROR/FATAL/PANIC` abzüglich der harmlosen Muster und **bricht VOR dem Storage-rsync ab**, falls echte Fehler übrig bleiben (bewusster Override: `FORCE_RESTORE_OK=1`).
- Erfasst in einem Rutsch **alle** Schemata: `auth` (User/Identities/Sessions), `storage` (Buckets + Objekt-Metadaten), `realtime` (Tenants/Subscriptions), `public` (App-Tabellen), ggf. `_realtime`/`_analytics`.
> **🔴 Migrationen NICHT erneut anwenden.** Alle Migrationen stecken bereits im Dump. **`scripts/prod/push-migrations.sh` nach dem Restore NICHT ausführen** das riskiert Drift/Duplicate-Object-Fehler.
**Alternative (nur falls Cluster-Level scheitert):** Single-DB `pg_dump -Fc` + `pg_restore --clean --if-exists --no-owner`, plus separat `pg_dumpall --roles-only`. Der `pg_dumpall`-Pfad oben ist für self-hosted→self-hosted vorzuziehen.
### 5.3 Vollständigen Stack neu hochfahren
```bash
new_remote 'cd /opt/supabase && docker compose down && docker compose up -d'
```
### 5.4 Storage-Objekte `rsync` (ALT → NEU)
Die Objekt-Bytes liegen unter `/opt/supabase/volumes/storage` (Bind-Mount → Container `/var/lib/storage`); die Metadaten-Zeilen kamen bereits mit dem Dump. **Schreibstopp muss noch aktiv sein.** Trailing-Slashes beachten:
```bash
# Direkt ALT -> NEU (Daten fließen Server-zu-Server, wenn alt den neuen erreicht):
old_remote "sudo rsync -aHAX --numeric-ids --delete \
-e 'ssh -o StrictHostKeyChecking=accept-new' \
/opt/supabase/volumes/storage/ ${NEW_USER}@${NEW_HOST}:/opt/supabase/volumes/storage/"
```
Falls die Server sich gegenseitig **nicht** per SSH erreichen, zwei-stufig über den Laptop:
```bash
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" ${OLD_USER}@${OLD_HOST}:/opt/supabase/volumes/storage/ ./_storage_stage/
rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" ./_storage_stage/ ${NEW_USER}@${NEW_HOST}:/opt/supabase/volumes/storage/
```
- `-aHAX` erhält Hardlinks/ACLs/xattrs; `--delete` macht das Ziel zum exakten Spiegel (**nur sicher bei eingefrorenen Schreibvorgängen**).
Danach Storage-Service neu starten, damit die UID-/Ownership-Erwartung passt:
```bash
new_remote 'cd /opt/supabase && docker compose restart storage imgproxy'
```
### 5.5 Daten-Verifikation
```bash
# Tabellen-/User-Counts vergleichen (Beispiel):
new_remote 'cd /opt/supabase && docker compose exec -T db psql -U postgres -d postgres \
-c "select count(*) as users from auth.users;" \
-c "select count(*) as objects from storage.objects;"'
```
`02-migrate-data.sh` macht zusätzlich eine **Zeilen-Paritätsprüfung OLD vs NEU** über tragende Tabellen (`auth.users`, `auth.identities`, `public.profiles`, `public.messages`, `public.conversation_members`, `storage.objects`) und meldet jede Abweichung — eine reine User-/Objekt-Zählung würde Teilverluste in `messages`/`members` übersehen. Ein bekanntes Objekt sollte zudem über das neue Gateway ladbar sein (Test nach Caddy-Setup, Abschnitt 11).
> **Cold-Volume-Copy-Alternative:** Nur falls Image-Tags byte-identisch sind, kann man statt Logical-Dump **beide** DBs stoppen und `volumes/db/data` (PGDATA) **plus** das `db-config`-Named-Volume (enthält den pgsodium-Key) rsyncen. Nur mit gestoppten DBs und identischen Postgres-Image-Tags; ansonsten den Logical-Dump oben bevorzugen.
---
## 6. LiveKit/coturn Prod-Config + Firewall-Ports + TURNS-Zertifikat
> **Die Prod-Config unterscheidet sich von der Dev-`infra/livekit/livekit.yaml` im Repo.** Prod setzt `rtc.use_external_ip: true` und enthält **KEIN** `node_ip: 127.0.0.1` (das ist Dev-only).
> **🟢 Sicherster Weg — die ALTE, funktionierende Config übernehmen.** Die `.example`-Templates sind eine Referenz; produktiv erprobt ist aber die Config, die auf dem alten Server **bereits läuft**. Hol dir die echten Dateien vom alten VPS und ändere nur das Nötigste — so bleibt insbesondere erhalten, **wie** den Clients die TURN-Server/ICE-Credentials angekündigt werden (das macht der alte `livekit.yaml`-`turn:`/`rtc:`-Block bzw. die coturn-`user=`-Zeile; `mint-livekit-token` liefert nur LiveKit-URL+Token, nicht die TURN-Creds):
> ```bash
> # vom Laptop:
> scp prox@46.225.156.249:/opt/livekit/livekit.yaml ./_livekit_old.yaml
> scp prox@46.225.156.249:/opt/livekit/coturn.conf ./_coturn_old.conf
> # dann NUR anpassen: external-ip (neue IP), cert/pkey-Pfade (turn.netralax.de),
> # und — falls vorhanden — eine externe IP/Domain im livekit.yaml turn-Block.
> # Danach als /opt/livekit/{livekit.yaml,coturn.conf} auf den neuen Server.
> ```
> Wenn die alten Dateien nicht greifbar sind, nutze die Templates unten und stelle sicher, dass die coturn-`user=`-Credentials zu dem passen, was deine Clients heute für TURN verwenden.
### 6.1 Prod-Compose + `livekit.yaml` + `coturn.conf` einsetzen
Auf dem alten Server lief LiveKit/coturn über ein Compose in `/opt/livekit`. Das Repo liefert dafür **`infra/livekit/docker-compose.prod.yml.example`** (die Dev-`infra/livekit/docker-compose.yml` ist **nicht** prod-tauglich: coturn läuft dort mit `--no-tls`, ohne `5349`, ohne Zertifikat). Drei Dateien auf den Server kopieren — die **on-server-Namen** sind bewusst `livekit.yaml` / `coturn.conf` (genau die, die auch `scripts/prod/rotate-livekit-keys.sh` editiert):
| Repo-Template | → on-server |
|---|---|
| `infra/livekit/docker-compose.prod.yml.example` | `/opt/livekit/docker-compose.yml` |
| `infra/livekit/livekit.prod.yaml.example` | `/opt/livekit/livekit.yaml` |
| `infra/livekit/coturn.prod.conf.example` | `/opt/livekit/coturn.conf` |
`keys:`-Block in **`/opt/livekit/livekit.yaml`** mit den Werten aus Abschnitt 3 (`LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`) füllen:
```yaml
port: 7880
log_level: info
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
# KEIN node_ip: 127.0.0.1 — das ist dev-only und würde alle Remote-Clients
# ihre Medien an den eigenen Loopback schicken lassen (Call ohne Audio/Video).
keys:
__LIVEKIT_API_KEY__: __LIVEKIT_API_SECRET__
turn:
enabled: false # coturn läuft separat
```
> **🔴 `node_ip: 127.0.0.1` aus der Dev-Config NICHT übernehmen.** Sonst verbinden Calls zwar, haben aber **keinen Ton und kein Bild**, weil jeder Remote-Client Medien an seinen eigenen Loopback sendet.
>
> **🔴 `LIVEKIT_API_KEY`/`SECRET` im `keys:`-Block MÜSSEN exakt den Edge-Function-Werten in `/opt/supabase/.env` entsprechen.** Sonst signiert `mint-livekit-token` Tokens, die der SFU mit 403 ablehnt.
### 6.2 coturn Prod-Config einsetzen
Template: **`infra/livekit/coturn.prod.conf.example`** → **`/opt/livekit/coturn.conf`**. Die Zertifikatspfade zeigen auf `/etc/letsencrypt/...` — genau das Verzeichnis, das das Prod-Compose read-only in den coturn-Container einhängt:
```conf
realm=netralax.de
listening-port=3478
tls-listening-port=5349
external-ip=141.95.34.204
min-port=50200
max-port=50300
cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem
pkey=/etc/letsencrypt/live/turn.netralax.de/privkey.pem
lt-cred-mech
user=__TURN_USER__:__TURN_PASSWORD__
fingerprint
no-multicast-peers
```
### 6.3 TURNS-Zertifikat für `turn.netralax.de` (NICHT über Caddy)
> **TURNS auf `5349` geht NICHT durch Caddy** coturn braucht ein eigenes TLS-Cert+Key auf der Platte (`cert`/`pkey`-Pfade oben). Ein reines Caddy-Cert deckt das nicht ab.
Zwei Wege, das Zertifikat bereitzustellen:
**A) certbot standalone (empfohlen, einfachster Pfad).** Schreibt direkt nach `/etc/letsencrypt/live/turn.netralax.de/` — also genau die Pfade, die `coturn.conf` referenziert und die das Prod-Compose in den Container einhängt. Kein Kopieren nötig:
```bash
# Port 80 muss kurz frei sein (Caddy ggf. stoppen oder DNS-01 nutzen):
sudo certbot certonly --standalone -d turn.netralax.de
# Renewal-Hook, damit coturn das erneuerte Cert lädt:
sudo certbot renew --deploy-hook 'docker compose -f /opt/livekit/docker-compose.yml restart turn'
```
**B) Caddy-Cert wiederverwenden.** Caddy hat ohnehin ein gültiges Cert für `turn.netralax.de`, sobald der DNS-Record steht und der Host in der Caddy-Config ist. PEM/Key aus Caddys Storage (`/var/lib/caddy/.local/share/caddy/certificates/...`) an die `/etc/letsencrypt/live/turn.netralax.de/`-Pfade symlinken/kopieren und coturn nach Renewals neu starten. Umständlicher als (A) — nur, wenn certbot nicht in Frage kommt.
> coturn liest das Cert **beim Start**; nach jeder Erneuerung den `turn`-Container neu starten (Hook oben). Das `external-ip` muss die **neue** öffentliche IP sein.
### 6.4 Firewall-Ports (ufw) ALLE öffnen, sonst kein A/V
> Diese Ports **umgehen Caddy** und müssen direkt in ufw offen sein. Fehlt einer, haben Calls **keinen Ton/kein Bild**.
```bash
ssh debian@141.95.34.204 'sudo bash -s' <<'EOF'
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 7880/tcp # LiveKit Signaling (hinter Caddy)
ufw allow 7881/tcp # RTC TCP-Fallback
ufw allow 50000:50100/udp # RTC Media
ufw allow 3478/udp # coturn STUN/TURN
ufw allow 3478/tcp # coturn STUN/TURN
ufw allow 5349/tcp # coturn TURNS (TLS)
ufw allow 50200:50300/udp # coturn TURN-Relay
ufw --force enable
ufw status verbose
EOF
```
> **Postgres NICHT öffentlich öffnen.** `5432` bleibt nur an `localhost` gebunden (wie auf dem alten Server). Für Remote-`psql` das bestehende Tunnel-Muster nutzen: `./scripts/prod/tunnel-db.sh` (SSH-Tunnel `localhost:5433 → server:5432`).
### 6.5 LiveKit-Stack starten
Voraussetzung: `/opt/livekit/docker-compose.yml` ist das **Prod**-Compose aus §6.1 (host-networking, mountet `livekit.yaml` + `coturn.conf` + `/etc/letsencrypt`), nicht das Dev-Compose.
```bash
ssh debian@141.95.34.204 'cd /opt/livekit && docker compose up -d && docker compose ps'
# coturn lauscht jetzt auf 5349/TLS? prüfen:
ssh debian@141.95.34.204 'ss -tlnp | grep -E "5349|3478" ; docker compose -f /opt/livekit/docker-compose.yml logs turn --tail=20'
```
---
## 7. Caddy mit BEIDEN Domain-Sätzen (.de + .cloud Legacy)
Template: **`infra/caddy/Caddyfile`** → auf dem Server `/etc/caddy/Caddyfile`. Caddy terminiert TLS (automatisches Let's Encrypt) und reverse-proxyt Klartext-HTTP an die lokalen Backends. **Pro Vhost genau EIN `reverse_proxy`** Kong multiplext bereits alle Supabase-Routen; keine Pfad-Splits in Caddy.
```caddyfile
# Caddyfile — Dual-Domain-Übergang .cloud -> .de
#
# Während der Migration bedient dieser Caddy BEIDE Domain-Sätze aus denselben
# lokalen Backends:
# - *.netralax.de = neue, primäre Hostnamen (neue Client-Builds)
# - *.netralax.cloud = Legacy-Hostnamen, die in bereits installierten
# Desktop-/Mobile-Bundles fest einkompiliert sind.
# Die .cloud-DNS-A-Records zeigen (nach dem Cutover) auf DIESELBE neue IP, damit
# alte Installationen weiterlaufen, bis sie sich selbst auf .de aktualisieren.
# NICHT entfernen, solange noch alte Clients .cloud ansprechen (siehe Abschnitt 14).
# AKTIV ab jetzt: nur die .de-Hosts. Die .cloud-Blöcke stehen auskommentiert
# darunter und werden ERST beim Cutover (§10) aktiviert — sonst läuft Caddy ins
# Let's-Encrypt-Rate-Limit, weil .cloud-DNS noch auf den alten Server zeigt.
# --- Supabase (Kong-Gateway :8000 multiplext auth/rest/realtime/storage/functions/Studio) ---
# Realtime-WS (/realtime/v1/websocket) wird von reverse_proxy transparent upgegradet.
supabase.netralax.de {
reverse_proxy localhost:8000
}
# --- LiveKit Signaling-WS (:7880). Caddy reicht Upgrade/Connection-Header durch. ---
livekit.netralax.de {
reverse_proxy localhost:7880
}
# --- Update-Host (electron-updater: latest.yml + .exe + changelog.json) ---
# 🔴 docroot ist /var/www/updates, NICHT .../windows: release.mjs lädt nach
# /var/www/updates/windows/ hoch, Clients holen unter URL-Pfad /windows/…
# Mit root=.../windows entstünde /windows/windows/ → 404 für JEDES Update.
update.netralax.de {
root * /var/www/updates
file_server
}
# --- CUTOVER (§10): erst NACH .cloud-DNS-Repoint einkommentieren + caddy reload ---
# supabase.netralax.cloud { reverse_proxy localhost:8000 }
# livekit.netralax.cloud { reverse_proxy localhost:7880 }
# update.netralax.cloud { root * /var/www/updates
# file_server }
```
> **🔴 Pfad-Matcher, die WS-Endpunkte ausschließen, sind tabu.** Caddy v2 reicht WebSocket-Upgrades transparent durch aber nur, wenn der **ganze** Host reverse-proxyt wird (kein Sub-Path-Matching). Das gilt für Realtime (`/realtime/v1/websocket`) **und** LiveKit (`/rtc`). Es gibt kein „websocket"-Flag und es wird keins gebraucht.
Aktivieren:
```bash
ssh debian@141.95.34.204 'sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy'
```
> Let's Encrypt stellt für die `.cloud`-Namen erst gültige Zertifikate aus, **nachdem** die `.cloud`-A-Records auf die neue IP zeigen (Cutover, Abschnitt 10). Bis dahin schlägt die Cert-Ausstellung für `.cloud` fehl das ist erwartbar und löst sich mit dem DNS-Repoint.
---
## 8. Edge-Functions deployen + Secrets
Edge-Functions liegen im Repo unter `supabase/functions/`: **`mint-livekit-token`**, **`notify-push`**, **`og-preview`**. Deploy via bestehendem Skript (kopiert `supabase/functions/<name>/` nach `/opt/supabase/volumes/functions/<name>/` und startet `functions`-Container neu).
> **Achtung Host-Pinning des Deploy-Skripts:** `scripts/prod/push-edge-function.sh` sourct `scripts/prod/config.sh`, das auf `PROD_SERVER="141.95.34.204"` (neuer `.de`-VPS, User `debian`) zeigt. Diese Befehle pushen also auf den NEUEN Server — erst ausführen, nachdem Bootstrap + Secrets dort stehen:
```bash
./scripts/prod/push-edge-function.sh mint-livekit-token
./scripts/prod/push-edge-function.sh notify-push
./scripts/prod/push-edge-function.sh og-preview
```
### 8.1 Erwartete Edge-Function-Secrets in `/opt/supabase/.env`
Aus dem Code verifiziert; alle in `/opt/supabase/.env` (in Abschnitt 3 bereits gesetzt):
`LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_URL`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`, `PUSH_FANOUT_SHARED_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ANON_KEY`.
Erinnerung: `FUNCTIONS_VERIFY_JWT=false` lassen (notify-push gatet über `x-shared-secret`-Header, nicht über User-JWT).
> **🔴 Custom-Secrets müssen den `functions`-Container auch erreichen.** Im **frisch geklonten** Supabase-Compose bekommt der `functions`-Service nur die env-Variablen, die in seinem `environment:`/`env_file:`-Block stehen. `LIVEKIT_API_KEY/SECRET`, `VAPID_*`, `PUSH_FANOUT_SHARED_SECRET` und `SUPABASE_ANON_KEY` sind **Custom-Variablen** und stehen dort per Default **nicht** drin. Auf dem alten Server ist das verdrahtet (es läuft ja) — auf dem neuen muss es nachgezogen werden: entweder `env_file: .env` am `functions`-Service ergänzen oder die Variablen explizit in dessen `environment:` listen. Sonst sieht `mint-livekit-token` leere Strings → `livekit-not-configured` (500) und `notify-push` lehnt mangels `SHARED_SECRET` jede Anfrage ab.
### 8.2 Verifikation
```bash
# 1) Erreichen die Secrets den Container wirklich? (vor dem Funktionstest!)
ssh debian@141.95.34.204 'cd /opt/supabase && docker compose exec -T functions \
env | grep -E "LIVEKIT_API_KEY|LIVEKIT_API_SECRET|VAPID_PUBLIC_KEY|PUSH_FANOUT_SHARED_SECRET|SUPABASE_ANON_KEY"'
# -> Es müssen NICHT-leere Werte erscheinen. Fehlt einer: env_file/environment im
# functions-Service nachziehen und 'docker compose up -d functions'.
# 2) Logs:
./scripts/prod/logs.sh # bzw. docker compose logs functions --tail=20
# 403 bei mint-livekit-token? -> LIVEKIT_API_KEY/SECRET stimmen nicht mit /opt/livekit/livekit.yaml überein.
```
---
## 9. Update-Host migrieren + Dual-Publish (.de UND .cloud)
Der Update-Host ist ein statisches Verzeichnis `/var/www/updates/windows` mit `latest.yml`, `.exe`-Installern und `changelog.json`, ausgeliefert per `file_server` (Abschnitt 7). SSH-Deploy-User: `chatapp-deploy`.
### 9.1 Bestehende Artefakte ALT → NEU spiegeln
```bash
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \
chatapp-deploy@46.225.156.249:/var/www/updates/windows/ ./_updates_stage/
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \
./_updates_stage/ chatapp-deploy@141.95.34.204:/var/www/updates/windows/
```
### 9.2 Dual-Publish-Garantie
Beide Hosts (`update.netralax.de` und ab Cutover `update.netralax.cloud`) haben im Caddyfile denselben docroot **`/var/www/updates`** (nicht `…/windows`). Die Artefakte liegen physisch in `/var/www/updates/windows/` und werden so unter dem URL-Pfad `/windows/latest.yml` usw. ausgeliefert unter **beiden** Hosts aus **einem** Verzeichnis. (Den Docroot-Fallstrick `/windows/windows/` → 404 siehe §7.)
> **🔴 Alte Clients prüfen `update.netralax.cloud`.** Liegt die Switch-over-Release nicht (auch) unter `.cloud`, können alte Installationen sich **niemals** auf `.de` aktualisieren. Der `changelog.ts` der neuen Builds zeigt zwar auf `https://update.netralax.de/windows/changelog.json`, aber die im Bundle der **alten** Clients eingebackene URL ist `.cloud` beide müssen funktionieren.
### 9.3 Deploy-Konfiguration
`.env.release` ist bereits gesetzt (`UPDATE_HOST=update.netralax.de`, `UPDATE_SSH_USER=chatapp-deploy`, `UPDATE_REMOTE_PATH=/var/www/updates/windows`). Stelle sicher, dass der Deploy-User `chatapp-deploy` auf dem neuen VPS existiert und Schreibrechte auf `/var/www/updates/windows` hat.
---
## 10. Cutover & DNS scharf schalten
> **Erst hier wird DNS umgebogen.** Voraussetzung: Abschnitte 29 abgeschlossen, neuer VPS steht, Stacks laufen, Caddy lädt (für `.de` bereits mit gültigem Cert), Storage + DB migriert, Wartungsfenster ggf. noch aktiv.
### 10.1 Reihenfolge
1. **`.de`-A-Records anlegen** (Abschnitt 1.2, neue Records) → Caddy holt sofort Let's-Encrypt-Certs für `.de`.
2. Interner Smoke-Test über `.de` (Abschnitt 11) **bevor** alte Clients umgeschwenkt werden.
3. **`.cloud`-A-Records repointen** auf `141.95.34.204` (Abschnitt 1.2, Legacy-Records) → Caddy stellt jetzt auch für `.cloud` Certs aus; alte Clients landen ab jetzt auf dem neuen VPS.
4. Propagation prüfen (Abschnitt 1.3).
5. **Wartungsmodus aufheben**, Schreibvorgänge auf dem **neuen** System freigeben.
### 10.2 Verifikation der TLS-Ausstellung
```bash
for h in supabase.netralax.de supabase.netralax.cloud livekit.netralax.de livekit.netralax.cloud update.netralax.de update.netralax.cloud; do
echo "== $h =="
echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null | openssl x509 -noout -subject -dates
done
```
Jeder Host muss ein gültiges, nicht abgelaufenes Cert liefern.
---
## 11. Smoke-Test-Checkliste
Nach dem Cutover, in dieser Reihenfolge:
### 11.1 Supabase / Auth / Magic-Link
- [ ] `https://supabase.netralax.de/auth/v1/health` und `https://supabase.netralax.cloud/auth/v1/health` liefern `200`.
- [ ] **Login per Magic-Link, pro Plattform mit dem JEWEILS registrierten Schema** testen: **Desktop** über `chatapp://auth/callback`, **Mobile** über `netralax://auth/callback` (das in `apps/mobile/app.json` registrierte Schema). `ADDITIONAL_REDIRECT_URLS` enthält beide, daher akzeptiert GoTrue beides — aber das OS routet nur das tatsächlich registrierte Schema zurück in die App.
> ⚠️ Vorbestehend (nicht durch den Umzug verursacht): `apps/mobile/.env.local` setzt aktuell `EXPO_PUBLIC_AUTH_REDIRECT_URL=chatapp://auth/callback`, `app.json` registriert aber nur `netralax://`. Für funktionierende Mobile-Magic-Links sollte das App-Team den Mobile-Wert auf `netralax://auth/callback` setzen (Desktop bleibt `chatapp://`). Außerhalb des Server-Umzugs — hier nur als Flag.
- [ ] PostgREST-Zugriff mit dem **eingebackenen** anon-Key wird akzeptiert (kein 401 wegen falschem `JWT_SECRET`):
```bash
curl -s -H "apikey: <ANON_KEY>" "https://supabase.netralax.de/rest/v1/" | head
```
### 11.2 Nachricht senden / Realtime
- [ ] Zwei eingeloggte Clients: Nachricht von A erscheint bei B in Echtzeit (Realtime-WS `/realtime/v1/websocket` über Caddy).
- [ ] Storage: Upload + Re-Download eines Bildes (`/storage/v1/object/...`) funktioniert (DB-Metadaten + Volume-Bytes konsistent).
### 11.3 Voice-Call mit echtem Ton (über TURN)
- [ ] **Call zwischen zwei Geräten in unterschiedlichen Netzen** (mind. eins hinter NAT/CGNAT, das TURN erzwingt): Verbindung steht **und es ist echter Ton/Bild hörbar/sichtbar**.
- [ ] Bestätigt indirekt: `rtc.use_external_ip: true`, **kein** `node_ip: 127.0.0.1`, alle Media-Ports offen, TURNS-Cert für `turn.netralax.de` gültig.
- [ ] `mint-livekit-token` liefert ein Token, das der SFU akzeptiert (kein 403 → Keys stimmen mit `/opt/livekit/livekit.yaml` überein).
### 11.4 Web-Push
- [ ] Ein **bestehender** (vor der Migration angelegter) Push-Abonnent erhält weiterhin Benachrichtigungen → bestätigt identisches VAPID-Paar.
- [ ] Neue Subscription + Test-Push über `notify-push` (mit korrektem `x-shared-secret` / `PUSH_FANOUT_SHARED_SECRET`) kommt an.
### 11.5 Auto-Update-Check von einem ALTEN `.cloud`-Client
- [ ] `latest.yml` ist unter **beiden** Hosts mit echtem `200` abrufbar (nicht nur „erreichbar" — der Docroot-Bug aus §7 würde hier 404 liefern):
```bash
curl -sI https://update.netralax.de/windows/latest.yml | head -1 # HTTP/2 200
curl -sI https://update.netralax.cloud/windows/latest.yml | head -1 # HTTP/2 200
curl -sI https://update.netralax.cloud/windows/changelog.json | head -1
```
- [ ] Eine **bestehende, alte** Desktop-Installation (Hostnamen `.cloud` eingebacken) prüft auf Updates: electron-updater findet die Switch-over-Release, lädt sie und installiert.
- [ ] Nach dem Update zeigt der Client auf `.de` (neue Bundle-Werte) und funktioniert vollständig (Login, Nachricht, Call, Push).
> **Dieser letzte Test ist der wichtigste.** Er beweist den gesamten Übergangspfad: alter Client → `.cloud` (neue IP) → lädt Update → wird zu `.de`-Client.
---
## 12. Repo-Änderungen + neues Release bauen/ausliefern
### 12.1 Bereits gemachte Edits (verifiziert im Repo)
| Datei | Änderung | Status |
|---|---|---|
| `scripts/prod/config.sh` | `PROD_SERVER="141.95.34.204"`, `PROD_DOMAIN_SUPABASE=supabase.netralax.de`, `PROD_DOMAIN_LIVEKIT=livekit.netralax.de` | ✅ erledigt (End-Zustand) |
| `apps/desktop/.env` | `SUPABASE_URL` + `VITE_SUPABASE_URL` = `https://supabase.netralax.de`; `VITE_LIVEKIT_URL=wss://livekit.netralax.de`; anon-Key + `VITE_VAPID_PUBLIC_KEY` (unverändert übernommen) | ✅ erledigt |
| `apps/mobile/.env.local` | `EXPO_PUBLIC_SUPABASE_URL=https://supabase.netralax.de` (anon-Key, redirect-Schema unverändert) | ✅ erledigt |
| `.env.release` | `UPDATE_HOST=update.netralax.de`, `UPDATE_SSH_USER=chatapp-deploy`, `UPDATE_REMOTE_PATH=/var/www/updates/windows` | ✅ erledigt |
| `package.json` | `release`-Script + `prod:*`-Scripts vorhanden (unverändert; nutzen `scripts/prod/config.sh`) | ✅ vorhanden |
| `apps/desktop/src/lib/changelog.ts` | `CHANGELOG_URL='https://update.netralax.de/windows/changelog.json'` (mit Kommentar, dass alte Clients weiter `.cloud` abfragen) | ✅ erledigt |
> **✅ Erledigt:** Die neue IP `141.95.34.204` ist in `scripts/prod/config.sh` (`PROD_SERVER`) und `scripts/migrate/config.sh` (`NEW_HOST`) eingetragen; Login-User dort ist `debian`.
### 12.2 Neues Desktop-Release bauen + dual publizieren
```bash
# Vom Laptop, mit korrektem .env.release:
pnpm install
pnpm --filter @chat-app/desktop build
pnpm release # = node scripts/release.mjs
```
`scripts/release.mjs` lädt `latest.yml` + `.exe` + aktualisiertes `changelog.json` nach `UPDATE_HOST` (`update.netralax.de`). Da Caddy `update.netralax.de` **und** `update.netralax.cloud` aus demselben Verzeichnis bedient, ist diese eine Veröffentlichung **automatisch** unter beiden Hosts verfügbar (Dual-Publish, Abschnitt 9).
> **🔴 Diese Release MUSS unter `.cloud` erreichbar sein**, denn nur sie schaltet alte Installationen auf `.de` um. Nach dem Upload mit Abschnitt 11.5 verifizieren.
### 12.3 Neues Mobile-Release
```bash
pnpm --filter @chat-app/mobile typecheck
# Expo-Build/Submit nach eurem üblichen EAS-/Store-Prozess.
# .env.local trägt bereits EXPO_PUBLIC_SUPABASE_URL=https://supabase.netralax.de.
```
> Mobile-Clients aktualisieren über die App-Stores, nicht über den Update-Host. Bis ein User die neue Store-Version installiert, hält ihn der `.cloud`-Vhost am Leben.
---
## 13. Rollback-Plan
Der alte VPS bleibt **vollständig intakt und laufend**, bis der neue verifiziert ist. Rollback heißt im Kern: **DNS zurückbiegen**.
1. **Schnell-Rollback (DNS):** Alle `.cloud`-A-Records zurück auf `46.225.156.249` (alte IP), `.de`-Records entfernen oder ebenfalls auf alt zeigen lassen. Dank niedriger TTL (Abschnitt 1.1) greift das in Minuten. Alte Clients landen wieder auf dem alten, intakten Server.
2. **Voraussetzung dafür:** Während der Migration **keine destruktiven Änderungen am alten Server** (alter Stack nicht löschen, alte Volumes nicht anfassen). Der Schreibstopp (Abschnitt 5.1) bedeutet nur Wartungsmodus, kein Datenverlust.
3. **Daten-Divergenz beachten:** Wurden nach dem Cutover bereits Schreibvorgänge auf dem **neuen** Server akzeptiert, gehen diese bei einem reinen DNS-Rollback verloren. Deshalb: Cutover (Abschnitt 10.5, Schreibfreigabe) erst nach den Smoke-Tests; bis dahin ist der Rollback verlustfrei.
4. **Update-Host-Rollback:** `.exe`/`latest.yml` auf dem alten Host wurden nicht verändert; alte Clients, die noch nicht aktualisiert haben, finden dort weiterhin den alten Stand.
5. Wenn nur **eine** Komponente klemmt (z. B. nur TURN ohne Ton), kann punktuell zurückgerollt werden, indem nur der betroffene `.cloud`-Record zurückzeigt die übrigen können auf neu bleiben.
---
## 14. Aufräumen / `.cloud` später abschalten
Die `.cloud`-Hosts dürfen **erst** verschwinden, wenn praktisch keine alten Clients mehr darauf zugreifen.
### 14.1 Reihenfolge der Abschaltung (frühestens → spätestens)
1. **Alten VPS dekommissionieren:** Erst nachdem `.cloud`-DNS auf den **neuen** VPS repointet ist und über die neue IP läuft. (Der alte Server liefert dann ohnehin keinen Traffic mehr.) Vorher als Rollback-Sicherheit behalten (Abschnitt 13).
2. **Supabase-/LiveKit-`.cloud`-Vhosts in Caddy** entfernen, sobald Telemetrie/Logs zeigen, dass praktisch alle aktiven Sessions auf `.de` laufen (d. h. die meisten Desktop-Clients haben die Switch-over-Release gezogen und Mobile-Clients die neue Store-Version).
3. **Update-`.cloud`-Vhost als LETZTES abschalten.**
### 14.2 Warum der Update-Host am längsten bleiben muss
> Eine Desktop-Installation, die **noch nie** die Switch-over-Release gezogen hat, kennt **nur** `update.netralax.cloud` (eingebacken). Sie erreicht `.de` ausschließlich, indem sie die neue Version über **`.cloud`** herunterlädt. Schaltest du `update.netralax.cloud` zu früh ab, **stranden** alle noch nicht aktualisierten Clients dauerhaft auf der alten Version sie können sich nie mehr selbst auf `.de` updaten und müssten manuell neu installiert werden.
>
> Faustregel: `update.netralax.cloud` so lange behalten, bis die Update-Metriken zeigen, dass der Long-Tail alter Installationen vernachlässigbar ist (eher Monate als Wochen). Supabase-/LiveKit-`.cloud` können früher fallen als Update-`.cloud`, aber niemals umgekehrt.
### 14.3 Endzustand
- DNS: nur noch `*.netralax.de` aktiv; `*.netralax.cloud` entfernt (zuletzt `update.netralax.cloud`).
- Caddyfile: nur noch die `.de`-Vhosts (Legacy-Block + Kommentar entfernt).
- `scripts/prod/config.sh` ist die alleinige Live-Konfiguration; `scripts/migrate/` wird nicht mehr gebraucht (kann archiviert bleiben).
- Lokale Sicherungen (`old.env.backup`, `_storage_stage/`, `_updates_stage/`) sicher löschen (`shred`/Secure-Delete), da sie Secrets enthalten.
---
**Grounding-Hinweise (Repo-Fakten):** Postgres-Major-Version aus `supabase/config.toml` = `17`. Edge-Functions im Repo: `supabase/functions/{mint-livekit-token,notify-push,og-preview}`. Dev-`infra/livekit/livekit.yaml` enthält absichtlich `use_external_ip: false` + `node_ip: 127.0.0.1` (Dev-only — in Prod invertiert/entfernt). `apps/desktop/.env`, `apps/mobile/.env.local`, `.env.release`, `scripts/prod/config.sh` und `apps/desktop/src/lib/changelog.ts` sind bereits auf `.de` umgestellt (verifiziert).
@@ -0,0 +1,703 @@
# Chat-Switch Flicker — Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate the visible flicker / scroll-jump when switching between conversations so the transition matches Discord's "instant content swap" feel.
**Architecture:** Two complementary fixes that, together, kill seven independent state-bleed root causes:
1. **Force a fresh `ConversationPage` instance per `:id` route param** via a wrapper component that reads `useParams` and passes `key={id}`. Today the same React component instance handles every conversation because React Router does not remount the route element when only a param changes — that is what lets chat A's state (messages, scroll position, composer drafts, Virtuoso scroll cache) bleed into chat B's first render.
2. **Add a module-scoped in-memory cache** (`Map<convId, DecryptedMessage[]>`) in `useConversationMessages` so the freshly-mounted hook starts with prior data synchronously on the first render. SQLite cache still hydrates async for never-seen chats. Net effect: instant content for any previously-visited chat, no spinner-flash.
**Tech Stack:** React 18, React Router v6 (v7_startTransition opt-in), react-virtuoso, better-sqlite3 (Electron main-process bridge via `window.electronAPI.sql*`), Vitest.
---
## Root-Cause Findings (Phase 1 evidence)
| # | Symptom | File:line | Why it happens |
|---|---------|-----------|----------------|
| RC1 | **Ghost messages of previous chat** for 50300 ms | `apps/desktop/src/lib/useConversationMessages.ts:85, 220-243, 249-263` | `useState<State>({messages: [], loading: true})` only runs on first mount. On `conversationId` change, the state still holds chat A's messages; `refresh()` skips `loading: true` because `prev.messages.length > 0`; the cache effect also bails (`if (prev.messages.length > 0) return prev`). Chat A's data renders under chat B's id until the network round-trip finishes. |
| RC2 | **Scroll jumps to wrong row** on switch | `apps/desktop/src/pages/ConversationPage.tsx:1023, 1034, 470-481` | Virtuoso instance is preserved across switches (parent isn't remounted). `initialTopMostItemIndex` is only honoured on Virtuoso's first mount, so chat A's last scroll position is what Virtuoso tries to keep when chat B's rows replace chat A's. |
| RC3 | **Saved positions corrupted across switches** | `apps/desktop/src/pages/ConversationPage.tsx:705-716` | While chat A's rows are still rendered under chat B's id (RC1 window), `rangeChanged` fires for chat A's visible range and writes the index into `scrollPositions[chatBid]`. Next time you re-enter chat B, it restores chat A's row index. |
| RC4 | **Stale composer / reply / search / unread state** for one render | `apps/desktop/src/pages/ConversationPage.tsx:307-320` | `useEffect([id])` resets `replyTo`, `displayCount`, `firstUnreadId`, etc. — but effects fire **after** the first render of the new id. The first paint of chat B briefly shows chat A's reply preview and `displayCount`. |
| RC5 | **Phantom scroll-to-bottom** after switching | `apps/desktop/src/pages/ConversationPage.tsx:736-746` | `lastPendingCountRef` is never reset per-chat. Switching from a chat with 0 pending to a chat with N pending triggers `pending.length > lastPendingCountRef.current``scrollToIndex(LAST)` even though that pending state was always there. |
| RC6 | **`newMessagesWhileAway` briefly off** | `apps/desktop/src/pages/ConversationPage.tsx:335-346` | `previousMessageIdsRef.current` contains chat A's ids on the first render of chat B → the diff classifies every chat B message as "new while away" until the reset effect lands. |
| RC7 | **Cache load loses race with network refresh** | `apps/desktop/src/lib/useConversationMessages.ts:249-263` | Because state still holds chat A's messages, the cache-effect bails. Then refresh writes chat B's network result. Then the now-irrelevant `loadCachedMessages(chatB)` promise resolves and would no-op (length check still > 0 by then) — but the path is fragile and depends on timing. |
**All of RC1, RC3, RC4, RC5, RC6, RC7 are fixed in one stroke by Task 3 (`key={id}` remount).** RC2 is fixed because Virtuoso unmounts with its parent and re-applies `initialTopMostItemIndex` on the fresh mount. The remaining flicker after a remount — the brief spinner before async cache hydration completes — is eliminated by Task 2 (in-memory cache, synchronous on first render).
---
## File Structure
- **Create**: `apps/desktop/src/lib/messageMemoryCache.ts` — small module-scoped Map plus `get` / `set` / `has` API. Lets us unit-test the cache logic without rendering the hook.
- **Create**: `apps/desktop/src/lib/messageMemoryCache.test.ts` — vitest unit tests for the cache.
- **Modify**: `apps/desktop/src/lib/useConversationMessages.ts` — initialize `useState` from `messageMemoryCache`, sync to it on every state change.
- **Modify**: `apps/desktop/src/App.tsx` — add `ConversationRoute` wrapper that reads `useParams` and renders `<ConversationPage key={id} />`.
- **Modify**: `apps/desktop/src/pages/ConversationPage.tsx` — delete the now-redundant `useEffect([id])` reset block and update the `scrollPositions` doc comment.
Each task below is self-contained and can be committed independently.
---
## Task 1: In-memory message cache helper
**Files:**
- Create: `apps/desktop/src/lib/messageMemoryCache.ts`
- Test: `apps/desktop/src/lib/messageMemoryCache.test.ts`
- [ ] **Step 1: Write the failing test**
Create `apps/desktop/src/lib/messageMemoryCache.test.ts`:
```ts
import { afterEach, describe, expect, it } from 'vitest';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import {
__resetForTests,
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
function msg(id: string): DecryptedMessage {
return {
id,
conversationId: 'conv-1',
senderId: 'sender-1',
senderDeviceId: null,
replyToId: null,
editedAt: null,
deletedAt: null,
createdAt: '2026-05-17T00:00:00Z',
ciphertext: new Uint8Array(),
nonce: new Uint8Array(),
keyVersion: 1,
plaintext: 'hi ' + id,
};
}
describe('messageMemoryCache', () => {
afterEach(() => {
__resetForTests();
});
it('returns empty array when nothing is cached', () => {
expect(getCachedMessages('unknown')).toEqual([]);
expect(hasCachedMessages('unknown')).toBe(false);
});
it('stores and returns messages per conversation', () => {
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
expect(hasCachedMessages('a')).toBe(true);
});
it('isolates conversations', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('b', [msg('m9')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
});
it('overwrites prior cache when set again', () => {
setCachedMessages('a', [msg('m1')]);
setCachedMessages('a', [msg('m1'), msg('m2')]);
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
});
it('treats an explicit empty list as "cached"', () => {
// A conversation that genuinely has zero messages should still be
// flagged as cached so the hook skips the loading spinner on re-entry.
setCachedMessages('a', []);
expect(hasCachedMessages('a')).toBe(true);
expect(getCachedMessages('a')).toEqual([]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
Expected: FAIL — module `./messageMemoryCache` does not exist.
- [ ] **Step 3: Implement the helper**
Create `apps/desktop/src/lib/messageMemoryCache.ts`:
```ts
// In-memory cache of the most-recently-rendered messages for each
// conversation. Survives React component unmount/remount (used by
// `useConversationMessages` to initialize state synchronously when
// ConversationPage is remounted on chat switch). Session-scoped — lost
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
// source of truth for cross-session persistence; this layer just shaves
// off the round-trip-to-disk spinner flash.
//
// Two-tier semantics:
// * `hasCachedMessages(id)` returns true even for a known-empty chat
// so the hook can suppress the loading spinner on re-entry.
// * `getCachedMessages(id)` returns a defensive copy so callers can't
// mutate the cached array.
import type { DecryptedMessage } from '@chat-app/shared/chat';
const cache = new Map<string, DecryptedMessage[]>();
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
const stored = cache.get(conversationId);
return stored ? stored.slice() : [];
}
export function hasCachedMessages(conversationId: string): boolean {
return cache.has(conversationId);
}
export function setCachedMessages(
conversationId: string,
messages: DecryptedMessage[],
): void {
cache.set(conversationId, messages.slice());
}
export function __resetForTests(): void {
cache.clear();
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
Expected: PASS — all five test cases.
- [ ] **Step 5: Commit**
```bash
git add apps/desktop/src/lib/messageMemoryCache.ts apps/desktop/src/lib/messageMemoryCache.test.ts
git commit -m "feat(chat-switch): in-memory message cache helper"
```
---
## Task 2: Wire the memory cache into `useConversationMessages`
**Files:**
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:85` (initial state)
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:226-243` (refresh) — sync to memory cache
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:249-263` (SQLite cache effect) — skip on memory-hit, sync after hydrate
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:339, 350, 419, 433, 553` (realtime + optimistic-send paths) — keep cache in sync with state
The hook keeps using `setState` everywhere — we just mirror writes into the memory cache and read from it on first render. No behavioural change for other code paths.
- [ ] **Step 1: Import the helper and initialize state from cache**
In `apps/desktop/src/lib/useConversationMessages.ts`, add the import near the other local-lib imports (around line 36):
```ts
import {
getCachedMessages,
hasCachedMessages,
setCachedMessages,
} from './messageMemoryCache';
```
Replace the initial `useState` at line 85:
```ts
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
```
with:
```ts
// Initialize from the in-memory cache so a previously-viewed chat shows
// content on the very first render after the parent remounts on `:id`
// change. `loading` stays true ONLY for never-seen conversations (cache
// miss) so the spinner doesn't flash on every chat switch.
const [state, setState] = useState<State>(() => {
if (conversationId && hasCachedMessages(conversationId)) {
return {
messages: getCachedMessages(conversationId),
loading: false,
error: null,
};
}
return { messages: [], loading: true, error: null };
});
```
- [ ] **Step 2: Mirror successful refreshes into the memory cache**
In the `refresh` function (around line 229-235), replace:
```ts
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
```
with:
```ts
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
setCachedMessages(conversationId, decrypted);
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
```
- [ ] **Step 3: Skip SQLite hydration on memory-cache hit, mirror cold-miss into memory**
Replace the cache-hydration effect (around line 249-263):
```ts
useEffect(() => {
if (!conversationId) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
return { messages: cached, loading: false, error: null };
});
});
return () => {
cancelled = true;
};
}, [conversationId]);
```
with:
```ts
useEffect(() => {
if (!conversationId) return;
// Memory cache already populated state synchronously — skip the disk
// round-trip entirely. The canonical data lands shortly via refresh();
// the SQLite cache only matters for cold-start hydration.
if (hasCachedMessages(conversationId)) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
setCachedMessages(conversationId, cached);
return { messages: cached, loading: false, error: null };
});
});
return () => {
cancelled = true;
};
}, [conversationId]);
```
- [ ] **Step 4: Mirror realtime INSERT into the memory cache**
In `handleInsert` (around line 339), replace:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted!] };
});
```
with:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
const next = [...prev.messages, decrypted!];
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
```
- [ ] **Step 5: Mirror realtime UPDATE (both partial and re-decrypt paths)**
In `handleUpdate` partial-update path (around line 350-362), replace:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === partial.id);
if (idx === -1) return prev;
const existing = prev.messages[idx];
if (!existing) return prev;
const next = [...prev.messages];
next[idx] = {
...existing,
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
return { ...prev, messages: next };
});
```
with:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === partial.id);
if (idx === -1) return prev;
const existing = prev.messages[idx];
if (!existing) return prev;
const next = [...prev.messages];
next[idx] = {
...existing,
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
```
In the same function's re-decrypt path (around line 419-425), replace:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted!;
return { ...prev, messages: next };
});
```
with:
```ts
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted!;
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
```
- [ ] **Step 6: Mirror realtime DELETE**
Replace `handleDelete` (around line 431-438):
```ts
const handleDelete = useCallback((row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => ({
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
void deleteCachedMessage(id);
}, []);
```
with:
```ts
const handleDelete = useCallback(
(row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => {
const next = prev.messages.filter((m) => m.id !== id);
if (conversationId) setCachedMessages(conversationId, next);
return { ...prev, messages: next };
});
void deleteCachedMessage(id);
},
[conversationId],
);
```
- [ ] **Step 7: Mirror optimistic send (sendText)**
In `sendText` (around line 553-562), replace:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
],
};
});
```
with:
```ts
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
const next = [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
];
setCachedMessages(convId, next);
return { ...prev, messages: next };
});
```
(`convId` is already a parameter of `sendText` — no extra capture needed.)
- [ ] **Step 8: Run all desktop tests to verify nothing regressed**
Run: `pnpm --filter @chat-app/desktop test`
Expected: PASS — existing tests still green, the new `messageMemoryCache` tests still pass.
- [ ] **Step 9: Commit**
```bash
git add apps/desktop/src/lib/useConversationMessages.ts
git commit -m "feat(chat-switch): hydrate useConversationMessages from in-memory cache"
```
---
## Task 3: Force fresh `ConversationPage` mount per `:id`
**Files:**
- Modify: `apps/desktop/src/App.tsx:2` (import `useParams`)
- Modify: `apps/desktop/src/App.tsx:55-61` area (add wrapper)
- Modify: `apps/desktop/src/App.tsx:113-120` (the `:id` route)
- [ ] **Step 1: Add `useParams` to the router import**
Change line 2:
```ts
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
```
to:
```ts
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
```
- [ ] **Step 2: Add the wrapper component**
Below the `RouteBoundary` function (around line 61), add:
```tsx
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
// the previous conversation entirely on switch. Without this, the same
// component instance handles every conversation, which leaks state
// between chats (messages, scroll position, composer drafts) for one
// render frame and gives the "flicker" we're trying to remove.
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
// fresh mount so previously-visited chats still render instantly.
function ConversationRoute() {
const { id } = useParams<{ id: string }>();
return <ConversationPage key={id ?? '__no_id__'} />;
}
```
- [ ] **Step 3: Use the wrapper in the route definition**
Replace lines 113-120:
```tsx
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
</ErrorBoundary>
}
/>
```
with:
```tsx
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationRoute />
</ErrorBoundary>
}
/>
```
- [ ] **Step 4: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS — no type errors.
- [ ] **Step 5: Manual smoke test in dev**
Run: `pnpm desktop:dev`
In the app:
1. Open two conversations with cached messages.
2. Toggle between them rapidly (5+ switches).
3. Verify: no "ghost" of the previous chat's last message ever appears, and the spinner does **not** flash on switch.
4. Scroll chat A up by ~10 messages, switch to B, switch back to A. The Virtuoso list lands at the same scroll row, not the bottom and not at row 0.
- [ ] **Step 6: Commit**
```bash
git add apps/desktop/src/App.tsx
git commit -m "fix(chat-switch): remount ConversationPage per conversation id"
```
---
## Task 4: Drop redundant id-change reset effect & update doc comment
Because the parent is remounted per id (Task 3), every state in ConversationPage is already fresh on chat switch. The manual reset effect and related comments are now misleading dead weight.
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:97-110` — update `scrollPositions` doc comment
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:307-320` — delete the reset effect
- [ ] **Step 1: Update the `scrollPositions` doc comment**
Replace lines 97-110:
```ts
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the topmost-visible row index so a chat the user left at the
// bottom keeps auto-following new messages when they return; a chat
// scrolled up returns to roughly the same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across re-mounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
```
with:
```ts
// Per-conversation scroll memory. Module-scoped so it survives the
// per-id remount of ConversationPage (see `ConversationRoute` in
// App.tsx). Session-only (lost on reload, like Discord). The
// `stickToBottom` flag is preserved alongside the topmost-visible row
// index so a chat the user left at the bottom keeps auto-following new
// messages when they return; a chat scrolled up returns to roughly the
// same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across remounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
```
- [ ] **Step 2: Delete the manual reset effect**
Delete lines 307-320 entirely (the effect that resets `replyTo`, `forwardTarget`, `searchOpen`, `mediaDrawerOpen`, `pollDialogOpen`, `searchQuery`, `displayCount`, `firstUnreadId`, `firstUnreadJumpDismissed`, `newMessagesWhileAway`, `previousMessageIdsRef`, `firstUnreadComputedRef`):
```ts
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setMediaDrawerOpen(false);
setPollDialogOpen(false);
setSearchQuery('');
setDisplayCount(150);
setFirstUnreadId(null);
setFirstUnreadJumpDismissed(false);
setNewMessagesWhileAway(0);
previousMessageIdsRef.current = new Set();
firstUnreadComputedRef.current = false;
}, [id]);
```
- [ ] **Step 3: Typecheck + tests**
Run in parallel:
```bash
pnpm --filter @chat-app/desktop typecheck
pnpm --filter @chat-app/desktop test
```
Expected: both PASS.
- [ ] **Step 4: Commit**
```bash
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "refactor(chat-switch): drop redundant id-change reset effect"
```
---
## Task 5: Final QA in dev mode
Verification only — no code changes, no commit.
- [ ] **Step 1: Start dev**
Run: `pnpm desktop:dev`
- [ ] **Step 2: Confirm each fix landed**
Switch repeatedly between three chats (A, B, C). All of the following must hold:
| Behaviour | Pass criteria |
|-----------|---------------|
| Ghost messages | Never see chat A's messages under chat B's header. |
| Spinner flash | First visit to a chat = spinner OK. Subsequent visits = no spinner. |
| Scroll restore | Chat A scrolled up 10 rows → switch to B → back to A → lands at row 10 ±1. |
| Composer state | Type "draft" in chat A composer → switch to B → composer is empty (drafts intentionally don't persist). |
| Reply preview | Click reply on chat A → switch to B → no reply preview in B. |
| Pending bubbles | Send while offline → bubble shows in correct chat → switch away and back → bubble still in same chat. |
| Realtime updates | Send a message in chat A from another device → it lands in chat A list → switch to B → switch back to A → message is still there (verifies memory cache stays in sync with realtime inserts). |
- [ ] **Step 3: If any check fails**
Open `superpowers:systematic-debugging` and form a single new hypothesis per failing check. Do NOT layer fixes — return to Phase 1, gather evidence, then patch.
---
## Self-Review (post-write checklist)
**Spec coverage**: Each RC1RC7 is addressed:
- RC1 (state bleed) → Task 3 (key-based remount) + Task 2 (memory cache so the fresh mount is instant).
- RC2 (Virtuoso scroll) → Task 3 (Virtuoso unmounts with parent, `initialTopMostItemIndex` re-applied on fresh mount).
- RC3 (scrollPositions corruption) → Task 3 (no more cross-chat render under wrong id).
- RC4 (stale local state for one render) → Task 3 + Task 4 (cleanup).
- RC5 (phantom scroll-to-bottom) → Task 3 (fresh ref).
- RC6 (newMessagesWhileAway misfire) → Task 3 (fresh ref).
- RC7 (cache vs network race) → Task 2 (deterministic init from memory cache, SQLite hydration only on cold cache miss).
**Placeholders**: none — every step lists exact files, exact code, exact commands.
**Type consistency**: `ConversationRoute` is the only new component; `messageMemoryCache` exports (`getCachedMessages`, `hasCachedMessages`, `setCachedMessages`, `__resetForTests`) match the test imports exactly.
---
## Out of scope
- **Cross-fade animation between chats.** A small `view-transition` or opacity tween could further polish the swap, but the user reported jumpiness, not the absence of an animation. Tackle in a follow-up if it still feels too "snap-y" after this lands.
- **Persisting composer drafts per chat.** Today every chat-switch loses the in-progress draft. The remount in Task 3 *preserves* that behaviour deliberately (no regression). A draft-persistence feature is a separate spec.
- **Mobile (`apps/mobile`).** The mobile chat list uses a different virtualization stack; this plan only covers `apps/desktop`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,673 @@
# Message-List / Scroll Rewrite — 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:** Replace the `react-virtuoso` message list with a TanStack-Virtual list that opens/switches chats flicker-free (Discord-like), preserving every existing behavior.
**Architecture:** Pure scroll-decision logic (`scrollController.ts`, unit-tested) + an isolated virtualization component (`MessageList.tsx`, TanStack Virtual, deferred reveal) + `ConversationPage` wiring. The flicker is killed by keeping the list hidden until messages+reactions+divider are stable, then anchoring before paint.
**Tech Stack:** React 18, TypeScript, `@tanstack/react-virtual` (new), vitest, electron-vite.
Spec: `docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md`
---
## File Structure
- Create: `apps/desktop/src/lib/scrollController.ts` — pure scroll math (no DOM/React).
- Create: `apps/desktop/src/lib/scrollController.test.ts` — vitest unit tests.
- Create: `apps/desktop/src/components/MessageList.tsx` — TanStack virtual list + reveal/stick/load-older. Exports `MessageList`, `MessageListHandle`, `VirtuosoRow` is imported from ConversationPage's shared type (moved in Task 5).
- Modify: `apps/desktop/src/lib/useMessageReactions.ts` — add `ready` flag for the reveal gate.
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` — export the row type, swap `<Virtuoso>` for `<MessageList>`, drive the handle, pass `ready`.
- Modify: `apps/desktop/package.json` — add `@tanstack/react-virtual`; remove `react-virtuoso` (Task 8).
---
## Task 1: Add the TanStack Virtual dependency
**Files:**
- Modify: `apps/desktop/package.json`
- [ ] **Step 1: Install**
Run (from repo root `chat-app/`):
```bash
pnpm --filter @chat-app/desktop add @tanstack/react-virtual@^3.10.0
```
Expected: adds `@tanstack/react-virtual` to `apps/desktop/package.json` dependencies; lockfile updated.
- [ ] **Step 2: Verify it resolves**
Run: `pnpm --filter @chat-app/desktop exec node -e "require.resolve('@tanstack/react-virtual'); console.log('ok')"`
Expected: `ok`
- [ ] **Step 3: Commit**
```bash
git add apps/desktop/package.json pnpm-lock.yaml
git commit -m "build(desktop): add @tanstack/react-virtual"
```
---
## Task 2: Pure scroll-decision logic (TDD)
**Files:**
- Create: `apps/desktop/src/lib/scrollController.ts`
- Test: `apps/desktop/src/lib/scrollController.test.ts`
- [ ] **Step 1: Write the failing tests**
```ts
// apps/desktop/src/lib/scrollController.test.ts
import { describe, expect, it } from 'vitest';
import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController';
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
scrollTop,
scrollHeight,
clientHeight,
});
describe('isNearBottom', () => {
it('true exactly at the bottom', () => {
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
});
it('true within threshold', () => {
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
});
it('false beyond threshold', () => {
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
});
});
describe('isNearTop', () => {
it('true at top', () => {
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
});
it('false past threshold', () => {
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
});
});
describe('resolveInitialAnchor', () => {
it('anchors to last row at end by default (no saved position)', () => {
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
});
it('anchors to bottom when saved position stuck to bottom', () => {
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
index: 49,
align: 'end',
});
});
it('restores the saved row at the top when scrolled up', () => {
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
index: 12,
align: 'start',
});
});
it('clamps a stale saved index to the current row count', () => {
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
index: 49,
align: 'start',
});
});
it('handles an empty list', () => {
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
});
});
```
- [ ] **Step 2: Run, verify FAIL**
Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts`
Expected: FAIL — "Failed to resolve import './scrollController'".
- [ ] **Step 3: Implement**
```ts
// apps/desktop/src/lib/scrollController.ts
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
// tricky math is verified without a browser (jsdom has no layout).
export interface ScrollMetrics {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
/** Distance from the bottom edge is within `threshold` px. */
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
}
/** Scroll offset is within `threshold` px of the top. */
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
return m.scrollTop <= threshold;
}
export interface SavedPosition {
topmostIndex: number;
stickToBottom: boolean;
}
export interface Anchor {
index: number;
align: 'start' | 'end';
}
/**
* Where a freshly-opened chat should start.
* - default / "left at bottom" → last row, aligned to the viewport bottom.
* - "left scrolled up" → the saved top-most row, aligned to the viewport top
* (clamped in case the cached row count shrank).
*/
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
if (rowCount <= 0) return { index: 0, align: 'end' };
if (saved && !saved.stickToBottom) {
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
return { index, align: 'start' };
}
return { index: rowCount - 1, align: 'end' };
}
```
- [ ] **Step 4: Run, verify PASS**
Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts`
Expected: PASS (11 tests).
- [ ] **Step 5: Commit**
```bash
git add apps/desktop/src/lib/scrollController.ts apps/desktop/src/lib/scrollController.test.ts
git commit -m "feat(desktop): pure scroll-decision logic for new message list"
```
---
## Task 3: Reveal-gate flag on `useMessageReactions`
**Files:**
- Modify: `apps/desktop/src/lib/useMessageReactions.ts`
Reactions are the main post-paint height changer. The list reveal waits on their first
fetch, so add a `ready` flag that is true once reactions for the current message-id set
have been fetched (or there are no messages).
- [ ] **Step 1: Add `ready` to the result type + state**
In `UseMessageReactionsResult` add:
```ts
ready: boolean;
```
After `const [rows, setRows] = useState<MessageReaction[]>([]);` add:
```ts
const [readyKey, setReadyKey] = useState<string | null>(null);
```
- [ ] **Step 2: Set the key after each fetch**
Replace the `refresh` callback body so both branches stamp `readyKey`:
```ts
const refresh = useCallback(async () => {
if (messageIds.length === 0) {
setRows([]);
setReadyKey(idsKey);
return;
}
try {
const data = await listReactionsForMessages(supabase, messageIds);
setRows(data);
} catch (err: unknown) {
console.error('listReactionsForMessages failed', err);
} finally {
setReadyKey(idsKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey]);
```
- [ ] **Step 3: Derive + return `ready`**
Before the `return`:
```ts
const ready = readyKey === idsKey;
```
And add `ready` to the returned object:
```ts
return { byMessage, toggle, voteExclusive, ready };
```
- [ ] **Step 4: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS (no output).
- [ ] **Step 5: Commit**
```bash
git add apps/desktop/src/lib/useMessageReactions.ts
git commit -m "feat(desktop): expose reactions reveal-gate flag (ready)"
```
---
## Task 4: Export the shared row type from ConversationPage
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
`MessageList` needs the row union. Export it from ConversationPage (smallest change;
the type already lives there).
- [ ] **Step 1: Export the type**
Change the `type VirtuosoRow = …` declaration (near the top of the file) to:
```ts
export type VirtuosoRow =
| { kind: 'loader'; key: string }
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
| { kind: 'pending'; key: string; item: OutboxItem };
```
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "refactor(desktop): export VirtuosoRow type for MessageList"
```
---
## Task 5: The `MessageList` component
**Files:**
- Create: `apps/desktop/src/components/MessageList.tsx`
This is the integration unit. It is verified by typecheck here and **visually in dev**
in Task 7 (jsdom can't layout-test it). The TanStack specifics (scrollToIndex timing,
prepend offset) are the parts to refine during dev iteration.
- [ ] **Step 1: Implement**
```tsx
// apps/desktop/src/components/MessageList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import {
forwardRef,
useCallback as _unused, // placeholder removed below
} from 'react';
```
> NOTE for the implementer: write the file with the imports below (the line above is
> illustrative only — do not keep it). Full file:
```tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import { isNearBottom, isNearTop, resolveInitialAnchor, type Anchor } from '../lib/scrollController';
import type { VirtuosoRow } from '../pages/ConversationPage';
export interface MessageListHandle {
scrollToBottom(behavior?: ScrollBehavior): void;
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
}
export interface MessageListProps {
rows: VirtuosoRow[];
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
computeKey: (row: VirtuosoRow) => string;
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
/** Reveal gate — list stays hidden behind a spinner until true (no flicker). */
ready: boolean;
estimateRowHeight?: number;
atBottomThreshold?: number;
onReachTop?: () => void;
onAtBottomChange?: (atBottom: boolean) => void;
onTopRowChange?: (topIndex: number) => void;
}
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
{
rows,
renderRow,
computeKey,
initialAnchor,
ready,
estimateRowHeight = 64,
atBottomThreshold = 64,
onReachTop,
onAtBottomChange,
onTopRowChange,
},
ref,
) {
const scrollElRef = useRef<HTMLDivElement>(null);
const [revealed, setRevealed] = useState(false);
const atBottomRef = useRef(true);
// Load-older preservation: remember scrollHeight + first key across renders.
const prevFirstKeyRef = useRef<string | null>(null);
const prevScrollHeightRef = useRef(0);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollElRef.current,
estimateSize: () => estimateRowHeight,
overscan: 8,
getItemKey: (index) => computeKey(rows[index]!),
});
const metrics = () => {
const el = scrollElRef.current;
return el
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
};
const applyAnchor = useCallback(
(anchor: Anchor) => {
virtualizer.scrollToIndex(anchor.index, { align: anchor.align });
// Re-apply on the next frame: dynamic measurement settles after the first
// paint, so a single scrollToIndex can land a few px off. The list is still
// hidden here, so this correction is never visible.
requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align }));
},
[virtualizer],
);
// Deferred reveal: when ready, anchor (before paint) then reveal.
useLayoutEffect(() => {
if (!ready || revealed || rows.length === 0) return;
const anchor: Anchor =
initialAnchor.type === 'bottom'
? { index: rows.length - 1, align: 'end' }
: { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' };
applyAnchor(anchor);
atBottomRef.current = initialAnchor.type === 'bottom';
onAtBottomChange?.(atBottomRef.current);
requestAnimationFrame(() => setRevealed(true));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, rows.length]);
// Stick-to-bottom: when content grows and we were at the bottom, re-pin.
useLayoutEffect(() => {
if (!revealed) return;
if (atBottomRef.current) {
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows.length, virtualizer.getTotalSize()]);
// Load-older preservation: if rows were prepended (first key changed and count
// grew), restore scrollTop by the height delta so the viewport stays put.
useLayoutEffect(() => {
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
const el = scrollElRef.current;
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
const delta = el.scrollHeight - prevScrollHeightRef.current;
if (delta > 0 && el.scrollTop < atBottomThreshold) {
el.scrollTop += delta;
}
}
prevFirstKeyRef.current = firstKey;
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows]);
const handleScroll = useCallback(() => {
const m = metrics();
const atBottom = isNearBottom(m, atBottomThreshold);
if (atBottom !== atBottomRef.current) {
atBottomRef.current = atBottom;
onAtBottomChange?.(atBottom);
}
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
const first = virtualizer.getVirtualItems()[0];
if (first) onTopRowChange?.(first.index);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, virtualizer]);
useImperativeHandle(
ref,
() => ({
scrollToBottom: () => {
atBottomRef.current = true;
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
},
scrollToRow: (index, align = 'center') => {
virtualizer.scrollToIndex(index, { align });
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[virtualizer, rows.length],
);
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollElRef}
onScroll={handleScroll}
className="h-full overflow-y-auto"
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{items.map((vi) => (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
{renderRow(vi.index, rows[vi.index]!)}
</div>
))}
</div>
{/* 12px bottom breathing space (matches the old Footer). */}
<div style={{ height: 12 }} />
</div>
);
});
```
> Implementer note: delete the illustrative first `import` snippet; keep only the full
> file. The `requestAnimationFrame` timing in `applyAnchor`/reveal is the most likely
> spot to refine during dev (Task 7).
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/desktop/src/components/MessageList.tsx
git commit -m "feat(desktop): TanStack-Virtual MessageList with deferred reveal"
```
---
## Task 6: Wire `MessageList` into `ConversationPage`
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
- [ ] **Step 1: Imports + reveal gate**
Replace the `react-virtuoso` import with:
```ts
import { MessageList, type MessageListHandle } from '../components/MessageList';
```
Capture the reactions `ready` flag — change the `useMessageReactions` destructure to also pull `ready`:
```ts
const {
byMessage: reactionsByMessage,
toggle: toggleReaction,
voteExclusive: votePoll,
ready: reactionsReady,
} = useMessageReactions(messageIds, session?.user.id);
```
Add a reveal gate with a 300ms max-timeout fallback (so empty/slow reactions never hang):
```ts
const [revealTimedOut, setRevealTimedOut] = useState(false);
useEffect(() => {
if (!id || loading || messages.length === 0) return;
const t = window.setTimeout(() => setRevealTimedOut(true), 300);
return () => window.clearTimeout(t);
}, [id, loading, messages.length]);
const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
```
- [ ] **Step 2: Replace the `virtuosoRef` type + handle**
Change:
```ts
const virtuosoRef = useRef<VirtuosoHandle>(null);
```
to:
```ts
const listRef = useRef<MessageListHandle>(null);
```
Replace every `virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior })`
call (in `jumpToBottom`, the pending-snap effect, `snapToBottom`) with:
```ts
listRef.current?.scrollToBottom('auto');
```
Replace the `jumpToMessage` scroll (`virtuosoRef.current?.scrollToIndex({ index: rowIndex, align: 'center', behavior: 'smooth' })`) with:
```ts
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
```
- [ ] **Step 3: Compute `initialAnchor`**
Replace the `initialTopMostIndex` `useMemo` (the `IndexLocationWithAlign` one from the
earlier hotfix) with:
```ts
const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
const saved = savedPositionRef.current;
if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex };
return { type: 'bottom' };
}, []);
```
Remove the now-unused `IndexLocationWithAlign` import.
- [ ] **Step 4: Swap the JSX**
Replace the entire `<Virtuoso … />` element with:
```tsx
<MessageList
ref={listRef}
rows={virtuosoRows}
ready={listReady}
computeKey={(row) => row.key}
initialAnchor={initialAnchor}
atBottomThreshold={250}
onReachTop={handleStartReached}
onAtBottomChange={handleAtBottomStateChange}
onTopRowChange={(topIndex) => handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })}
renderRow={(_index, row) => {
// ...exact same body the old `itemContent` had (loader / pending /
// message branches) — move it verbatim from the deleted <Virtuoso>.
return renderConversationRow(row);
}}
/>
```
Move the old `itemContent` body into a local `renderConversationRow(row)` helper (or inline it) so the message/loader/pending branches are unchanged. `handleRangeChanged` already accepts `{ startIndex, endIndex }`.
- [ ] **Step 5: Typecheck + unit tests**
Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add apps/desktop/src/pages/ConversationPage.tsx
git commit -m "feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)"
```
---
## Task 7: Dev verification (with the user) — iterate until smooth
**Files:** none (runtime verification)
- [ ] **Step 1: Run the dev build**
User runs (in `chat-app/`): `pnpm desktop:dev`
- [ ] **Step 2: Verify behaviors live**
Switch between several chats repeatedly and confirm, using the `SCROLL_DEBUG` console
output where helpful:
- No jump and no multi-flicker on chat switch (opens cleanly at the bottom / saved row).
- New message while at bottom auto-scrolls; while scrolled up shows the pill.
- Unread divider present without a later shift.
- Scroll to top loads older without the viewport jumping.
- Jump-to-message (reply tap / pinned / search) scrolls to the target.
- Sent/pending message snaps to bottom.
- [ ] **Step 3: Refine**
If any behavior is off, adjust `MessageList.tsx` (most likely the `applyAnchor`/reveal
`requestAnimationFrame` timing or the stick-to-bottom effect) and re-verify. Commit each
refinement:
```bash
git commit -am "fix(desktop): refine MessageList <specific behavior>"
```
---
## Task 8: Cleanup + release 0.21.6
**Files:**
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (remove instrumentation)
- Modify: `apps/desktop/package.json` (remove `react-virtuoso`)
- [ ] **Step 1: Remove the `SCROLL_DEBUG` instrumentation**
Delete the `SCROLL_DEBUG`/`dbgNow`/`dbgLog` block, the render-logger `useEffect`, and the
`dbgLog(...)` calls inside `handleRangeChanged` / `handleAtBottomStateChange`.
- [ ] **Step 2: Remove the old dependency**
Run: `pnpm --filter @chat-app/desktop remove react-virtuoso`
Then confirm no references remain:
Run: `grep -rn "react-virtuoso\|Virtuoso\b" apps/desktop/src || echo "clean"`
Expected: `clean`.
- [ ] **Step 3: Typecheck + tests + commit**
Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test`
Expected: PASS.
```bash
git add -A
git commit -m "chore(desktop): drop react-virtuoso + scroll debug instrumentation"
```
- [ ] **Step 4: Release**
Run (from `chat-app/`, tree clean): `node scripts/release.mjs 0.21.6 "- Nachrichtenliste komplett überarbeitet: Chat-Wechsel öffnet jetzt ruckel- und flackerfrei direkt unten\n- Älteren Verlauf laden springt nicht mehr"`
Then verify `latest.yml` shows 0.21.6 on `update.netralax.de` **and** `update.netralax.cloud`.
---
## Self-Review
- **Spec coverage:** deferred reveal (Tasks 3,5,6) ✓; TanStack virtualization (Tasks 1,5) ✓; isolation into MessageList + scrollController (Tasks 2,5) ✓; stick-to-bottom (Task 5) ✓; load-older preservation (Task 5) ✓; preserved behaviors incl. jump-to-message/pill/divider/pending (Task 6) ✓; pure-logic unit tests (Task 2) ✓; dev verification (Task 7) ✓; cleanup + release (Task 8) ✓.
- **Placeholders:** the only prose-only steps are the deliberately runtime Task 7 (no code possible) and the "move itemContent verbatim" in Task 6 Step 4 (the body is large and unchanged — copying it verbatim, not rewriting). The illustrative throwaway import in Task 5 Step 1 is explicitly flagged for deletion.
- **Type consistency:** `MessageListHandle.scrollToBottom/scrollToRow`, `VirtuosoRow`, `Anchor`, `ScrollMetrics`, `resolveInitialAnchor` signatures are consistent across Tasks 2/5/6. `ready` flag added in Task 3 is consumed in Task 6.
@@ -0,0 +1,184 @@
# Message-List / Scroll Rewrite — Design
Date: 2026-06-02
Status: Approved (brainstorming) — pending spec review → implementation plan
Scope: Desktop app only (`apps/desktop`). Mobile is out of scope.
## 1. Problem & Root Cause
Switching conversations causes a visible jump and then a multi-flicker. Root cause
(established via systematic debugging, not guessing):
- The message list is **virtualized** (`react-virtuoso`). Virtualization paints rows
with *estimated* heights, then measures real heights and corrects `scrollTop`.
- On chat open, several async sources change **row heights after the first paint**:
message **reactions** (`useMessageReactions`), the **unread divider**
(`firstUnreadId`), delivery/read **receipts**, and the **two-phase message load**
(in-memory cache render → server `refresh()` replaces the array).
- Each post-paint height change makes the virtualizer re-measure and re-anchor →
the viewport visibly moves several times = "flickert paar mal".
A first targeted fix (`initialTopMostItemIndex: { index: 'LAST', align: 'end' }`)
addressed only the *initial* anchor, not the post-paint cascade — so the flicker
remained/worsened. Conclusion: re-architect the scroll system.
## 2. Goals / Success Criteria
1. Opening or switching a chat lands cleanly at the bottom (or the saved scrolled-up
row) with **no visible jump or flicker**.
2. Discord-like live behavior: auto-scroll on new message when at bottom; "X new
messages" pill when scrolled up; unread divider; load-older without the viewport
jumping; jump-to-message / search / pin scroll.
3. Scales to **large conversations** (thousands of messages, deep back-scroll) —
virtualization stays.
4. No regression of the existing features that live in `ConversationPage`.
## 3. Decision
Build on **`@tanstack/react-virtual`** (MIT, free) as the virtualization primitive,
and kill the flicker at its root with a **deferred-reveal** strategy: never show the
list while its row heights are still settling.
Rejected alternatives: keeping `react-virtuoso` (we are fighting it); the commercial
`@virtuoso.dev/message-list` (license cost); dropping virtualization entirely
(large chats would render thousands of DOM nodes).
## 4. Architecture (isolation)
The scroll/virtualization logic moves out of the ~2000-line `ConversationPage` into
two focused, independently-testable units:
- **`apps/desktop/src/components/MessageList.tsx`** — owns the scroll container,
the TanStack virtualizer, dynamic measurement, deferred reveal, stick-to-bottom,
and load-older position preservation. Receives rows + a render function; emits
scroll events + exposes an imperative handle. Knows nothing about messages,
reactions, drafts, calls, etc.
- **`apps/desktop/src/lib/scrollController.ts`** — the **pure**, DOM-free decision
logic (anchor computation, "should auto-scroll to bottom?", load-older index/offset
math, at-bottom threshold). Unit-tested with vitest.
- **`ConversationPage`** keeps all feature state and rendering; it builds the same
`VirtuosoRow[]` discriminated union (`loader | message | pending`), passes them +
the existing per-row render (`itemContent`) into `<MessageList>`, and drives the
imperative handle for jump-to-message/search.
Boundary contract: *in* = rows + renderRow; *out* = scroll events + an imperative
handle. The internals of `MessageList` can change without touching `ConversationPage`.
## 5. No-Flicker Core
### 5.1 Deferred reveal
`MessageList` is always mounted (so TanStack can measure the initial window), but
rendered **visually hidden** (`opacity: 0`, pointer-events none) behind a spinner
until `ready` is true. When `ready` flips true, in a `useLayoutEffect` (before the
browser paints) it scrolls to `initialAnchor` (bottom, or the saved row), then
reveals (`opacity: 1`) and removes the spinner. The user sees: brief spinner →
final, correctly-anchored list. The height-changing cascade happens **while hidden**.
`ready` (owned by `ConversationPage`, passed in) is defined as:
- messages loaded (`!loading && rows.length > 0`), **AND**
- the initial **reactions** fetch for the current message-id set has completed
(requires adding a `ready`/`loaded` flag to `useMessageReactions`), **AND**
- a hard **max-timeout of ~300 ms** fallback so a slow/empty reactions fetch never
hangs the reveal.
The unread divider is computed synchronously in an effect right after messages load,
i.e. before reactions resolve — so it is present before reveal. Delivery/read receipts
render as inline ticks (no meaningful height change) and are intentionally **not**
gated.
### 5.2 Stick-to-bottom
A `ResizeObserver` on the inner content element: while the user is at the bottom
(within `atBottomThreshold`, default 64px), any content-size growth re-pins the view
to the bottom in a layout effect (before paint) — so a live incoming message/reaction
never leaves the newest message half-scrolled.
### 5.3 Dynamic measurement
TanStack `measureElement` (ResizeObserver per rendered row) handles variable bubble
heights. Only the virtual window (visible + overscan ~8 rows) is rendered/measured.
### 5.4 Load-older without jump
On `onReachTop`, `ConversationPage` grows `displayCount` (prepending older rows).
Because prepending shifts indices, `MessageList` preserves position: capture
`scrollHeight` before the row growth, then after re-render set
`scrollTop += (newScrollHeight oldScrollHeight)` in a layout effect keyed on
"rows grew at the top". Items keep stable keys via `computeKey` (message id). This
also fixes the second audit gap (older-load jump).
## 6. Preserved Behaviors
| Behavior | New mechanism |
|---|---|
| Open → bottom / saved row | `initialAnchor` applied in `useLayoutEffect` before reveal |
| New message while at bottom → follow | stick-to-bottom controller |
| New message while scrolled up → pill | `onAtBottomChange` drives the existing pill |
| Unread divider | computed before reveal → no later height change |
| Load older (scroll to top) | `onReachTop` + scrollHeight-delta preservation |
| Jump-to-message / search / pin | imperative `scrollToRow(index, align)` |
| Pending (outbox) bubbles | stay as a row kind in the same list |
| Per-conversation scroll memory | unchanged module-scoped `scrollPositions` map, fed by `onAtBottomChange` + `onTopRowChange` |
## 7. Interface
```ts
export interface MessageListHandle {
scrollToBottom(behavior?: 'auto' | 'smooth'): void;
scrollToRow(index: number, align?: 'center' | 'end', behavior?: 'auto' | 'smooth'): void;
}
export interface MessageListProps {
rows: VirtuosoRow[]; // loader | message | pending
renderRow: (index: number, row: VirtuosoRow) => React.ReactNode;
computeKey: (row: VirtuosoRow) => string; // message id / pending id / '__loader__'
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
ready: boolean; // reveal gate (§5.1)
estimateRowHeight?: number; // default ~64
atBottomThreshold?: number; // default 64px
onReachTop(): void; // load older
onAtBottomChange(atBottom: boolean): void;
onTopRowChange(topIndex: number): void; // scroll memory
}
```
## 8. Error Handling / Edge Cases
- Empty conversation: `rows.length === 0``MessageList` renders nothing; `ready`
short-circuits to the existing empty state in `ConversationPage`.
- Single / very short conversation (content < viewport): bottom-anchor is a no-op;
reveal immediately.
- Rapid chat switching: each switch remounts `ConversationPage` (per-id key) → a
fresh `MessageList` instance with fresh measurements (no stale sizes carried over).
- Very large `displayCount` after deep back-scroll: only the virtual window renders;
memory bounded by overscan.
- Reactions fetch error/empty: max-timeout reveals the list anyway.
## 9. Testing
- **Unit (vitest):** `scrollController.ts` pure functions — anchor resolution,
should-auto-scroll decision, load-older offset math, at-bottom threshold.
- **Manual (dev):** iterate in `pnpm desktop:dev` with the temporary `SCROLL_DEBUG`
logging until chat-switch is flicker-free and all §6 behaviors verified live.
- No automated DOM/layout test (jsdom has no layout); the running app is the test.
## 10. Rollout
1. Add `@tanstack/react-virtual`.
2. Build `scrollController.ts` (+ tests) and `MessageList.tsx`.
3. Swap the `<Virtuoso>` block in `ConversationPage` for `<MessageList>`; add the
`ready` flag to `useMessageReactions`.
4. Verify in dev with the user (flicker-free + all behaviors).
5. Remove the `SCROLL_DEBUG` instrumentation and the `react-virtuoso` dependency.
6. Release **0.21.6** to `update.netralax.de` (served on `.de` + `.cloud`).
## 11. Out of Scope
Composer, header, dialogs, calls, search UI, message rendering (`MessageBubble`),
encryption/data layer, mobile app. Reaction/receipt *data* loading is touched only to
add the `ready` flag for the reveal gate.
## 12. Open Risks
- TanStack prepend position-preservation needs careful layout-effect timing; mitigated
by dev iteration before release.
- The `ready` reveal adds a brief (≤300 ms) spinner on chat open even for cached
chats; acceptable trade-off vs flicker. A future in-memory reaction cache could make
revisits instant (not in this scope).
+91
View File
@@ -0,0 +1,91 @@
# ─────────────────────────────────────────────────────────────────────────────
# Caddyfile — Produktion (NEW VPS, netralax.de)
#
# Dual-Domain-Übergang (.de + .cloud):
# Bereits installierte Desktop- (Vite) und Mobile- (Expo) Clients haben die
# ALTEN Hostnamen fest in ihre Bundles eingebacken
# (supabase.netralax.cloud, livekit.netralax.cloud, update.netralax.cloud).
# Deshalb bedient dieser NEUE Server BEIDE Domains aus denselben Backends:
# - die neuen *.netralax.de Hosts für aktuelle/neue Releases
# - die legacy *.netralax.cloud Hosts NUR damit Alt-Installationen weiter
# funktionieren, bis sie sich per Auto-Update auf .de umgestellt haben.
# Voraussetzung: die .cloud-DNS-A-Records müssen auf die NEUE VPS-IP zeigen.
# Die .cloud-Blöcke dürfen NICHT entfernt werden, solange noch Alt-Clients
# im Umlauf sind — sonst brechen alle bestehenden Installationen.
#
# TLS: Automatisches HTTPS via Let's Encrypt für alle Hosts.
# WebSockets: Caddy v2 reicht Upgrade/Connection-Header bei reverse_proxy
# transparent durch — sowohl für Supabase Realtime (/realtime/v1/websocket)
# als auch für LiveKit (/rtc). KEINE websocket-Direktive nötig/vorhanden.
#
# WICHTIG: Nur der Signaling-WS (7880) und das Supabase-Gateway (Kong 8000)
# laufen über Caddy. RTC-Medien (7881/tcp, 50000-50100/udp) und coturn
# (3478, 5349/TLS, 50200-50300/udp) gehen NICHT über Caddy und müssen direkt
# in der ufw geöffnet werden. TURNS auf 5349 braucht ein EIGENES Zertifikat
# für turn.netralax.de (siehe coturn.prod.conf.example).
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# AKTIV ab Bootstrap: die NEUEN .de-Hosts.
# Die .cloud-Legacy-Blöcke stehen weiter unten und werden ERST beim Cutover
# (Runbook §10) einkommentiert — nämlich NACHDEM die .cloud-A-Records auf die
# neue VPS-IP zeigen. Grund: stehen die .cloud-Namen schon vorher in der aktiven
# Config, scheitert Caddy wiederholt an der Let's-Encrypt-Ausstellung (DNS zeigt
# noch auf den alten Server) und läuft ins ACME-Rate-Limit (5 Fehler/Host/Stunde).
# ─────────────────────────────────────────────────────────────────────────────
# Supabase API-Gateway (Kong multiplext auth/rest/realtime/storage/functions
# + Studio). EIN reverse_proxy genügt — KEINE Routen in Caddy aufsplitten.
supabase.netralax.de {
reverse_proxy localhost:8000
}
# LiveKit Signaling-WebSocket. Caddy übernimmt den WS-Upgrade automatisch.
# CORS-Header + OPTIONS-Preflight wie auf dem alten Server (Browser/Electron-
# Clients erwarten sie beim Token-/Connect-Handshake).
livekit.netralax.de {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
header Access-Control-Allow-Headers "Authorization, Content-Type"
header Access-Control-Expose-Headers "*"
@options method OPTIONS
handle @options {
respond 204
}
reverse_proxy localhost:7880
}
# electron-updater Artefakte (latest.yml + .exe + changelog.json).
# WICHTIG: docroot ist /var/www/updates (NICHT .../windows). release.mjs lädt
# nach /var/www/updates/windows/ hoch und die Clients holen unter dem URL-Pfad
# /windows/latest.yml — der Pfad-Präfix /windows/ muss also auf das Unterverzeichnis
# mappen. Mit root=/var/www/updates/windows entstünde .../windows/windows → 404.
update.netralax.de {
root * /var/www/updates
file_server
}
# ─────────────────────────────────────────────────────────────────────────────
# LEGACY .cloud-Hosts — AKTIV seit dem Cutover (DNS .cloud → neue VPS-IP).
# Liefern aus denselben Backends wie die .de-Hosts, damit bereits installierte
# Clients weiterlaufen, bis sie sich per Auto-Update auf .de umgestellt haben.
# NICHT entfernen, solange Alt-Clients im Umlauf sind.
# ─────────────────────────────────────────────────────────────────────────────
supabase.netralax.cloud {
reverse_proxy localhost:8000
}
livekit.netralax.cloud {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
header Access-Control-Allow-Headers "Authorization, Content-Type"
header Access-Control-Expose-Headers "*"
@options method OPTIONS
handle @options {
respond 204
}
reverse_proxy localhost:7880
}
update.netralax.cloud {
root * /var/www/updates
file_server
}
+50
View File
@@ -0,0 +1,50 @@
# ─────────────────────────────────────────────────────────────────────────────
# coturn — Produktionskonfiguration (turnserver.conf) für turn.netralax.de
#
# coturn läuft EIGENSTÄNDIG (LiveKit-internes TURN ist deaktiviert).
# TURNS (5349/TLS) läuft NICHT über Caddy und braucht daher ein EIGENES
# TLS-Zertifikat für turn.netralax.de auf der Platte (cert/pkey unten).
#
# Zertifikat besorgen — zwei Wege:
# (a) certbot standalone (Port 80 muss frei sein, nicht von Caddy belegt):
# certbot certonly --standalone -d turn.netralax.de
# -> liefert /etc/letsencrypt/live/turn.netralax.de/{fullchain,privkey}.pem
# coturn nach Renewals neu laden (z. B. certbot --deploy-hook 'systemctl reload coturn').
# (b) Caddy-Zertifikat wiederverwenden: lasse Caddy zusätzlich turn.netralax.de
# ausstellen und kopiere/symlinke das Zert aus Caddys data-Verzeichnis
# (~/.local/share/caddy/certificates/...) an die Pfade unten. Achtung:
# coturn braucht Leserechte auf cert+pkey.
#
# ufw muss offen sein: 3478/udp+tcp, 5349/tcp (TURNS), 50200-50300/udp (Relay).
# Diese Ports gehen NICHT über Caddy.
#
# external-ip auf die ÖFFENTLICHE IP der NEUEN VPS setzen.
# lt-cred-mech-User muss zu dem passen, den mint-livekit-token / die Clients
# erwarten (Platzhalter unten ersetzen).
# ─────────────────────────────────────────────────────────────────────────────
listening-port=3478
tls-listening-port=5349
# Öffentliche IP der neuen VPS.
external-ip=141.95.34.204
# Relay-Port-Range (muss in ufw offen sein).
min-port=50200
max-port=50300
realm=netralax.de
# Long-Term-Credential-Mechanismus. User-Platzhalter ersetzen
# (Format: user=NAME:PASSWORT). Passwort z. B. via `openssl rand -hex 16`.
lt-cred-mech
user=turnuser:<REPLACE_WITH_TURN_PASSWORD>
# TLS-Material für TURNS (turn.netralax.de) — siehe Kopf-Kommentar.
cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem
pkey=/etc/letsencrypt/live/turn.netralax.de/privkey.pem
# Härtung / Korrektheit.
fingerprint
no-multicast-peers
no-cli
@@ -0,0 +1,41 @@
# ─────────────────────────────────────────────────────────────────────────────
# LiveKit + coturn — Produktions-Compose (NEW VPS, netralax.de)
#
# Dies ist die PROD-Variante von infra/livekit/docker-compose.yml (das ist nur
# Dev: coturn läuft dort mit --no-tls/--no-dtls, ohne 5349, ohne Zertifikat).
#
# Auf den Server kopieren als /opt/livekit/docker-compose.yml und daneben:
# /opt/livekit/livekit.yaml <- infra/livekit/livekit.prod.yaml.example (Keys eintragen)
# /opt/livekit/coturn.conf <- infra/livekit/coturn.prod.conf.example (external-ip + Cert)
# Start: cd /opt/livekit && docker compose up -d && docker compose ps
#
# network_mode: host — auf einem Linux-Server ist das für WebRTC der robusteste
# Weg: die RTC-UDP-Range (50000-50100) und die TURN-Relay-Range (50200-50300)
# müssen NICHT einzeln gemappt werden, und coturn/LiveKit sehen die echten
# Quell-IPs. Welche Ports tatsächlich erreichbar sind, regelt ufw (siehe
# Runbook §6.4). Auf macOS/Docker-Desktop wird host-networking NICHT unterstützt
# — dort gilt weiterhin die Dev-Compose mit explizitem Port-Mapping.
# ─────────────────────────────────────────────────────────────────────────────
services:
livekit:
image: livekit/livekit-server:latest
restart: unless-stopped
network_mode: host
command: ["--config", "/etc/livekit.yaml"]
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
turn:
image: coturn/coturn:4.6
restart: unless-stopped
network_mode: host
# Prod: vollständige turnserver.conf statt der Dev-CLI-Flags. Diese Datei
# aktiviert TURNS auf 5349 mit dem Zertifikat für turn.netralax.de.
command: ["-c", "/etc/coturn/turnserver.conf"]
volumes:
- ./coturn.conf:/etc/coturn/turnserver.conf:ro
# TLS-Material für turn.netralax.de. coturn.conf verweist mit
# cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem (und privkey)
# auf genau diese Pfade — daher /etc/letsencrypt read-only einhängen.
- /etc/letsencrypt:/etc/letsencrypt:ro
+41
View File
@@ -0,0 +1,41 @@
# ─────────────────────────────────────────────────────────────────────────────
# LiveKit — Produktionskonfiguration (NEW VPS)
#
# Diese Datei ERSETZT die Dev-Werte aus infra/livekit/livekit.yaml.
# Unterschiede zur Dev-Config (WICHTIG):
# - rtc.use_external_ip: true (Dev: false)
# - KEIN rtc.node_ip: 127.0.0.1 (Dev-only — würde im Prod jeden Client
# veranlassen, Medien an seinen eigenen Loopback zu senden: Call verbindet,
# aber KEIN Audio/Video).
# - echte keys: (Platzhalter unten) statt der öffentlich bekannten devkey.
#
# Die keys: müssen EXAKT zu LIVEKIT_API_KEY / LIVEKIT_API_SECRET in
# /opt/supabase/.env passen (mint-livekit-token signiert damit). Wird nur eine
# Seite rotiert, lehnt die SFU die Tokens beim Join ab (403).
#
# ufw muss offen sein: 7880/tcp (Signaling, hinter Caddy), 7881/tcp (RTC TCP),
# 50000-50100/udp (RTC). Diese Ports außer 7880 gehen NICHT über Caddy.
#
# Kopiere diese Datei als /opt/livekit/livekit.yaml und trage echte Keys ein.
# ─────────────────────────────────────────────────────────────────────────────
port: 7880
log_level: info
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
# Prod: öffentliche IP des Servers ankündigen (NICHT Loopback wie im Dev).
use_external_ip: true
# KEIN node_ip hier — das war dev-only (127.0.0.1) und bricht im Prod die Medien.
# Produktionsschlüssel — Platzhalter. Muss zu /opt/supabase/.env passen
# (LIVEKIT_API_KEY = der key, LIVEKIT_API_SECRET = das secret).
# Erzeugen z. B. mit: openssl rand -hex 32
keys:
APIxxxxxxxxxxxx: <REPLACE_WITH_LIVEKIT_API_SECRET>
# coturn läuft separat (siehe coturn.prod.conf.example) — eingebauter TURN aus.
turn:
enabled: false
+86 -17
View File
@@ -28,7 +28,28 @@ export interface ConvKeyHandle {
const cache = new Map<string, ConvKeyHandle>();
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(
client: AppSupabaseClient,
@@ -110,7 +131,26 @@ export async function bootstrapConvKey(
p_bundles: bundles,
});
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);
return handle;
}
@@ -125,12 +165,25 @@ export async function getOrCreateConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
if (bundle) {
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
try {
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), 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')
.select('recipient_user_id', { count: 'exact', head: true })
@@ -138,12 +191,13 @@ export async function getOrCreateConvKey(
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
// Rows exist for this version, but none for me. Either I lost the device-key
// that originally received my bundle, or my own bundle was wiped by the
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
// unreadable for me; new ones flow.
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
// Rows exist for this version, but none usable for me. Either I lost the
// device-key that originally received my bundle, my own bundle was wiped
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
// bundle is unwrappable (handled in the try/catch above). The only way
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
// 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 bootstrapConvKey(client, conversationId, own, version);
@@ -248,9 +302,24 @@ export async function tryGetConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
if (!bundle) return null;
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
);
let key: Uint8Array;
try {
key = await unwrapConvKey(
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 };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
+23 -10
View File
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 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;
}
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
.not('recipient_device_id', 'is', null);
if (error) throw error;
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;
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
@@ -136,13 +136,26 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
result.migratedConversations += 1;
}
console.info(
'[crypto-migration] result:',
'attempted=' + result.attempted,
'migrated=' + result.migratedConversations,
'noKey=' + result.noStrongholdKey,
'decryptFail=' + result.decryptFailed,
'rpcFail=' + result.rpcFailed,
);
// 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(
'[crypto-migration] result:',
'attempted=' + result.attempted,
'migrated=' + result.migratedConversations,
'noKey=' + result.noStrongholdKey,
'decryptFail=' + result.decryptFailed,
'rpcFail=' + result.rpcFailed,
);
} else {
console.debug(
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
'attempted=' + result.attempted,
'noKey=' + result.noStrongholdKey,
);
}
return result;
}
+20 -14
View File
@@ -65,6 +65,9 @@ importers:
'@supabase/supabase-js':
specifier: ^2.46.0
version: 2.103.3
'@tanstack/react-virtual':
specifier: ^3.10.0
version: 3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
better-sqlite3:
specifier: ^11.3.0
version: 11.10.0
@@ -98,9 +101,6 @@ importers:
react-router-dom:
specifier: ^6.28.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-virtuoso:
specifier: ^4.18.7
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zustand:
specifier: ^5.0.1
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -1915,6 +1915,15 @@ packages:
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
'@tanstack/react-virtual@3.14.2':
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@testing-library/react-native@12.9.0':
resolution: {integrity: sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw==}
deprecated: React Native Testing Library v12 is no longer maintained. Please upgrade to v13 or v14.
@@ -5479,12 +5488,6 @@ packages:
peerDependencies:
react: ^18.3.1
react-virtuoso@4.18.7:
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
peerDependencies:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -8760,6 +8763,14 @@ snapshots:
dependencies:
defer-to-connect: 2.0.1
'@tanstack/react-virtual@3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.17.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.17.0': {}
'@testing-library/react-native@12.9.0(react-native@0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
jest-matcher-utils: 29.7.0
@@ -12972,11 +12983,6 @@ snapshots:
react-shallow-renderer: 16.15.0(react@18.3.1)
scheduler: 0.23.2
react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
loose-envify: 1.4.0
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
#
# Bootstrap a fresh netralax.de VPS so it can host the Supabase + LiveKit stack.
#
# COPY THIS SCRIPT TO THE NEW SERVER AND RUN IT THERE as root (or via sudo):
# scp scripts/migrate/01-bootstrap-new-server.sh debian@141.95.34.204:/tmp/
# ssh debian@141.95.34.204 'sudo bash /tmp/01-bootstrap-new-server.sh'
#
# It is idempotent: re-running it only fills in what is missing. It installs
# Docker CE + the compose plugin, opens the firewall, clones supabase/supabase,
# prepares /opt/livekit, installs Caddy, creates the update host + deploy user,
# and writes placeholder config. It NEVER fabricates secret values — those you
# copy from the old server (see the NEXT STEPS block it prints at the end).
set -euo pipefail
# --- must run as root ------------------------------------------------------
if [[ "${EUID}" -ne 0 ]]; then
echo "this script must run as root (use: sudo bash $0)" >&2
exit 1
fi
SUPABASE_DIR="/opt/supabase"
LIVEKIT_DIR="/opt/livekit"
UPDATES_DIR="/var/www/updates/windows"
DEPLOY_USER="chatapp-deploy"
log() { echo "==> $*"; }
# --- base packages ---------------------------------------------------------
log "updating apt and installing base packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y \
ca-certificates curl gnupg lsb-release git ufw rsync apt-transport-https
# --- Docker CE + compose plugin -------------------------------------------
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
log "docker + compose plugin already installed — skipping"
else
log "installing Docker CE + compose plugin (official repo)"
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL https://download.docker.com/linux/debian/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
fi
. /etc/os-release
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
fi
# Let the login user run docker/compose without sudo (effective on next login).
usermod -aG docker "${SUDO_USER:-debian}" || true
# --- firewall (ufw) --------------------------------------------------------
# Media + TURN ports bypass Caddy entirely and MUST be open or calls have no A/V.
log "configuring ufw"
ufw allow 22/tcp comment 'ssh'
ufw allow 80/tcp comment 'http (caddy / lets encrypt)'
ufw allow 443/tcp comment 'https (caddy)'
ufw allow 7880/tcp comment 'livekit signaling ws (behind caddy)'
ufw allow 7881/tcp comment 'livekit rtc tcp fallback'
ufw allow 50000:50100/udp comment 'livekit rtc udp'
ufw allow 3478/tcp comment 'coturn'
ufw allow 3478/udp comment 'coturn'
ufw allow 5349/tcp comment 'coturn turns (tls)'
ufw allow 50200:50300/udp comment 'coturn turn relay'
# Enable non-interactively (idempotent — re-enabling is a no-op).
ufw --force enable
ufw status verbose || true
# --- Supabase (clone upstream, prepare .env) -------------------------------
if [[ -d "${SUPABASE_DIR}/.git" || -f "${SUPABASE_DIR}/docker-compose.yml" ]]; then
log "${SUPABASE_DIR} already populated — skipping clone"
else
log "cloning supabase/supabase into a temp dir and laying out ${SUPABASE_DIR}"
tmp="$(mktemp -d)"
git clone --depth 1 https://github.com/supabase/supabase "${tmp}/supabase"
mkdir -p "${SUPABASE_DIR}"
# The runnable self-hosted stack lives in supabase/docker.
cp -r "${tmp}/supabase/docker/." "${SUPABASE_DIR}/"
rm -rf "${tmp}"
fi
# Prepare .env from the example WITHOUT inventing secrets.
#
# IMPORTANT: Supabase's upstream .env.example does NOT ship blank secrets — it
# ships well-known PUBLIC default values (JWT_SECRET=your-super-secret..., the
# matching default ANON_KEY/SERVICE_ROLE_KEY, POSTGRES_PASSWORD, etc.). Booting
# with those is both a security hole AND wrong: the baked anon key in installed
# clients is signed with the OLD server's JWT_SECRET, so a default secret makes
# the gateway reject every token and drop all sessions — silently. So we
# OVERWRITE the security-critical keys with a loud sentinel that fails fast if
# someone forgets to fill them from the old server.
SENTINEL="__COPY_FROM_OLD_SERVER__"
CRIT_KEYS=(POSTGRES_PASSWORD JWT_SECRET ANON_KEY SERVICE_ROLE_KEY \
SECRET_KEY_BASE VAULT_ENC_KEY DASHBOARD_PASSWORD)
if [[ -f "${SUPABASE_DIR}/.env" ]]; then
log "${SUPABASE_DIR}/.env already exists — leaving it untouched"
elif [[ -f "${SUPABASE_DIR}/.env.example" ]]; then
cp "${SUPABASE_DIR}/.env.example" "${SUPABASE_DIR}/.env"
for k in "${CRIT_KEYS[@]}"; do
sed -i "s|^${k}=.*|${k}=${SENTINEL}|" "${SUPABASE_DIR}/.env" || true
done
log "wrote ${SUPABASE_DIR}/.env — critical secrets set to ${SENTINEL}."
log "These are NOT blank by default upstream; you MUST copy the real values"
log "1:1 from the OLD server's /opt/supabase/.env (esp. JWT_SECRET + VAPID)."
else
log "WARNING: no .env.example found in ${SUPABASE_DIR}; create .env by hand"
fi
# --- LiveKit dir -----------------------------------------------------------
log "preparing ${LIVEKIT_DIR}"
mkdir -p "${LIVEKIT_DIR}"
if [[ ! -f "${LIVEKIT_DIR}/livekit.yaml" ]]; then
cat > "${LIVEKIT_DIR}/livekit.yaml" <<'YAML'
# PLACEHOLDER — replace with infra/livekit/livekit.prod.yaml.example contents.
# Prod config MUST set rtc.use_external_ip: true and must NOT hardcode
# node_ip: 127.0.0.1 (that is dev-only). Fill the keys: block with the SAME
# API key/secret as LIVEKIT_API_KEY / LIVEKIT_API_SECRET in /opt/supabase/.env.
YAML
log "wrote placeholder ${LIVEKIT_DIR}/livekit.yaml"
fi
if [[ ! -f "${LIVEKIT_DIR}/coturn.conf" ]]; then
cat > "${LIVEKIT_DIR}/coturn.conf" <<'CONF'
# PLACEHOLDER — replace with infra/livekit/coturn.prod.conf.example contents.
# Set external-ip to this VPS's public IP, point cert/pkey at the TLS cert for
# turn.netralax.de, and set a real lt-cred-mech user/password.
CONF
log "wrote placeholder ${LIVEKIT_DIR}/coturn.conf"
fi
if [[ ! -f "${LIVEKIT_DIR}/docker-compose.yml" ]]; then
cat > "${LIVEKIT_DIR}/docker-compose.yml" <<'YAML'
# PLACEHOLDER — replace with infra/livekit/docker-compose.prod.yml.example.
# The dev infra/livekit/docker-compose.yml is NOT suitable for prod (coturn runs
# with --no-tls, no 5349, no cert). The prod compose uses network_mode: host,
# mounts ./livekit.yaml + ./coturn.conf, runs coturn with -c turnserver.conf,
# and mounts /etc/letsencrypt for the turn.netralax.de TURNS cert.
YAML
log "wrote placeholder ${LIVEKIT_DIR}/docker-compose.yml"
fi
# --- Caddy (official apt repo) --------------------------------------------
if command -v caddy >/dev/null 2>&1; then
log "caddy already installed — skipping"
else
log "installing Caddy (official repo)"
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -y
apt-get install -y caddy
systemctl enable caddy
fi
# Write a placeholder Caddyfile if none exists (do not clobber a real one).
if [[ ! -s /etc/caddy/Caddyfile ]] || grep -q 'PLACEHOLDER' /etc/caddy/Caddyfile 2>/dev/null; then
cat > /etc/caddy/Caddyfile <<'CADDY'
# PLACEHOLDER Caddyfile — replace with infra/caddy/Caddyfile from the repo.
# Serve the .de vhosts now; add the legacy .cloud vhosts only at cutover (after
# the .cloud DNS is repointed) so they keep already-installed clients working:
# supabase.netralax.de { reverse_proxy localhost:8000 }
# livekit.netralax.de { reverse_proxy localhost:7880 }
# update.netralax.de { root * /var/www/updates # NOT .../windows — see Caddyfile
# file_server }
CADDY
log "wrote placeholder /etc/caddy/Caddyfile"
fi
# --- update host + deploy user --------------------------------------------
log "preparing update host at ${UPDATES_DIR}"
mkdir -p "${UPDATES_DIR}"
if id "${DEPLOY_USER}" >/dev/null 2>&1; then
log "user ${DEPLOY_USER} already exists — skipping"
else
log "creating deploy user ${DEPLOY_USER}"
useradd --create-home --shell /bin/bash "${DEPLOY_USER}"
mkdir -p "/home/${DEPLOY_USER}/.ssh"
chmod 700 "/home/${DEPLOY_USER}/.ssh"
touch "/home/${DEPLOY_USER}/.ssh/authorized_keys"
chmod 600 "/home/${DEPLOY_USER}/.ssh/authorized_keys"
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
fi
# Let the deploy user write release artifacts.
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${UPDATES_DIR}"
# --- next steps ------------------------------------------------------------
cat <<EOF
============================================================================
BOOTSTRAP DONE — manual NEXT STEPS (this script invents NO secrets):
============================================================================
1. Fill ${SUPABASE_DIR}/.env. Copy these 1:1 from the OLD server's
/opt/supabase/.env so baked-in client tokens + push keep working:
POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY,
SECRET_KEY_BASE, VAULT_ENC_KEY, PG_META_CRYPTO_KEY,
SMTP_*, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT,
PUSH_FANOUT_SHARED_SECRET, LIVEKIT_API_KEY, LIVEKIT_API_SECRET.
Set these to the NEW host:
SITE_URL / API_EXTERNAL_URL / SUPABASE_PUBLIC_URL = https://supabase.netralax.de
SUPABASE_URL = https://supabase.netralax.de
LIVEKIT_URL = wss://livekit.netralax.de
ADDITIONAL_REDIRECT_URLS must include (comma-separated, no spaces):
chatapp://auth/callback,netralax://auth/callback,
https://supabase.netralax.de,https://supabase.netralax.cloud
2. Drop the real LiveKit + coturn config in ${LIVEKIT_DIR}:
docker-compose.yml <- infra/livekit/docker-compose.prod.yml.example
livekit.yaml <- infra/livekit/livekit.prod.yaml.example
coturn.conf <- infra/livekit/coturn.prod.conf.example
Set rtc.use_external_ip: true, NO node_ip: 127.0.0.1, coturn external-ip
= this VPS's public IP, and a TLS cert for turn.netralax.de.
The LiveKit keys: block MUST match LIVEKIT_API_KEY/SECRET in .env.
(The on-server filenames are livekit.yaml / coturn.conf — same names
rotate-livekit-keys.sh expects.)
3. Place the real Caddyfile:
cp infra/caddy/Caddyfile /etc/caddy/Caddyfile && systemctl reload caddy
(serves both .de and .cloud vhosts).
4. Bring the Supabase DB up ONCE so init scripts create the roles, then
restore data from the laptop:
cd ${SUPABASE_DIR} && docker compose up -d db && sleep 20
# then on the laptop: ./scripts/migrate/02-migrate-data.sh
5. Repoint DNS A-records to THIS VPS's IP for BOTH domains:
supabase.netralax.de / .cloud, livekit.netralax.de / .cloud,
turn.netralax.de, update.netralax.de / .cloud.
6. Add the chatapp-deploy public key to
/home/${DEPLOY_USER}/.ssh/authorized_keys
and mirror electron-updater artifacts (latest.yml, *.exe, changelog.json)
under ${UPDATES_DIR} so BOTH update.netralax.de and .cloud serve them.
============================================================================
EOF
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
#
# One-time data move: OLD (.cloud) -> NEW (.de). Run this FROM THE DEV LAPTOP
# (Linux / macOS / WSL), not on a server. It:
# 1. pre-flight checks both stacks are reachable and the DB containers are up,
# 2. streams a full-cluster pg_dumpall from OLD straight into NEW (psql),
# 3. rsyncs ${SUPABASE_DIR}/volumes/storage from OLD to NEW.
#
# Usage:
# ./scripts/migrate/02-migrate-data.sh # interactive, asks to confirm
# ./scripts/migrate/02-migrate-data.sh --check # pre-flight only, no changes
# FORCE=1 ./scripts/migrate/02-migrate-data.sh # skip the confirm prompt
#
# BEFORE running: put the OLD app into maintenance / freeze writes, and make
# sure ${SUPABASE_DIR}/.env on NEW already has the SAME POSTGRES_PASSWORD and
# JWT_SECRET as OLD, and that NEW's db container has been started once so the
# Supabase init scripts created the roles (see bootstrap NEXT STEPS step 4).
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
require_new_host
mode="${1:-}"
log() { echo "==> $*"; }
# --- pre-flight ------------------------------------------------------------
log "pre-flight: checking SSH reachability"
old_remote 'echo ok' >/dev/null || { echo "cannot ssh to OLD (${OLD_SSH})" >&2; exit 1; }
new_remote 'echo ok' >/dev/null || { echo "cannot ssh to NEW (${NEW_SSH})" >&2; exit 1; }
log "pre-flight: checking OLD Supabase db is up"
old_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" \
|| { echo "OLD db not ready — start the stack first" >&2; exit 1; }
log "pre-flight: checking NEW Supabase db is up (must be initialized once)"
new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" \
|| { echo "NEW db not ready — run 'docker compose up -d db' on NEW first" >&2; exit 1; }
log "pre-flight: checking NEW storage volume dir exists"
new_remote "test -d ${SUPABASE_DIR}/volumes/storage || mkdir -p ${SUPABASE_DIR}/volumes/storage"
if [[ "${mode}" == "--check" ]]; then
log "pre-flight OK — --check requested, stopping before any changes."
exit 0
fi
# --- loud confirm ----------------------------------------------------------
cat <<EOF
----------------------------------------------------------------------------
ABOUT TO MIGRATE DATA: OLD ${OLD_SSH} -> NEW ${NEW_SSH}
----------------------------------------------------------------------------
This will:
* pg_dumpall the WHOLE OLD cluster and restore it into the NEW db
(DROP/CREATE objects on NEW via --clean --if-exists),
* rsync ${SUPABASE_DIR}/volumes/storage OLD -> NEW with --delete
(the NEW storage dir becomes an EXACT mirror of OLD).
MAKE SURE FIRST:
* the OLD app is in MAINTENANCE / writes are FROZEN (no new uploads,
no new rows) so DB + storage stay consistent,
* NEW /opt/supabase/.env already has the OLD POSTGRES_PASSWORD + JWT_SECRET,
* you have a backup / you can roll DNS back to the OLD VPS.
----------------------------------------------------------------------------
EOF
if [[ "${FORCE:-0}" != "1" ]]; then
read -rp "Type 'migrate' to proceed: " confirm
if [[ "${confirm}" != "migrate" ]]; then
echo "aborted — nothing changed."
exit 1
fi
fi
# --- 1) Postgres: full-cluster dump OLD -> restore NEW ---------------------
# pg_dumpall (not pg_dump) carries the ROLE definitions + password hashes, so
# with an identical POSTGRES_PASSWORD on both hosts the restored roles line up
# with what the services use. ON_ERROR_STOP=0 because pg_dumpall will try to
# CREATE ROLE supabase_admin/postgres etc. that already exist on the freshly
# initialized NEW cluster — those 'already exists' errors are harmless.
#
# ALTERNATIVE (highest fidelity): if both servers run the SAME Postgres image
# tag, a cold volume copy avoids logical-restore-over-initialized-cluster
# fragility entirely: stop both DB containers, rsync ${SUPABASE_DIR}/volumes/db
# OLD -> NEW, start both again. Use that if the scan below keeps flagging errors.
log "dumping OLD cluster and restoring into NEW (streamed over SSH)"
log "this can take a while; harmless 'already exists' errors are expected."
restore_log="$(mktemp)"
set +e
old_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_dumpall -U postgres --clean --if-exists" \
| new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=0" \
2>&1 | tee "${restore_log}"
set -e
# ON_ERROR_STOP=0 keeps the restore going past harmless 'already exists', but it
# ALSO swallows genuine failures (FK/constraint/ownership/extension errors)
# that would leave a partially-restored DB looking successful. Surface any
# non-benign ERROR/FATAL/PANIC and refuse to continue to the storage rsync.
real_errors="$(grep -E 'ERROR:|FATAL:|PANIC:' "${restore_log}" 2>/dev/null \
| grep -Eiv 'already exists|cannot drop the currently open database|is being accessed by other users|must be member of role|role .* cannot be dropped|current transaction is aborted' \
|| true)"
if [[ -n "${real_errors}" ]]; then
echo >&2
echo "!!! Non-benign errors during restore (full log: ${restore_log}):" >&2
echo "${real_errors}" | head -n 50 >&2
if [[ "${FORCE_RESTORE_OK:-0}" != "1" ]]; then
echo "Aborting BEFORE the storage rsync. Inspect/fix and re-run, or consider" >&2
echo "the cold volume-copy path. Override with FORCE_RESTORE_OK=1 only if you" >&2
echo "are certain these are harmless." >&2
exit 1
fi
log "FORCE_RESTORE_OK=1 — continuing despite the errors above."
else
log "restore output scanned: no non-benign errors found."
fi
log "DO NOT run push-migrations.sh: all migrations are already in the dump."
# --- 2) Storage objects: rsync OLD -> NEW ----------------------------------
# Object bytes live on the bind-mounted volume; their metadata rows came with
# the dump above. -aHAX keeps perms/hardlinks/ACLs/xattrs; --delete makes NEW an
# exact mirror (safe only because writes are frozen). Trailing slashes matter.
log "rsyncing storage volume OLD -> NEW (server-to-server via SSH)"
if old_remote "command -v rsync >/dev/null 2>&1"; then
# Direct server-to-server: the OLD host pushes to NEW. Needs the OLD host to
# be able to ssh to NEW (key in OLD ~/.ssh, NEW in known_hosts).
old_remote "sudo rsync -aHAX --numeric-ids --delete \
-e 'ssh -o StrictHostKeyChecking=accept-new' \
${SUPABASE_DIR}/volumes/storage/ ${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" \
|| {
log "direct server-to-server rsync failed — falling back to two-hop via laptop"
stage="$(mktemp -d)"
log "staging into ${stage}"
# shellcheck disable=SC2086
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \
"${OLD_SSH}:${SUPABASE_DIR}/volumes/storage/" "${stage}/"
# shellcheck disable=SC2086
rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" \
"${stage}/" "${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/"
rm -rf "${stage}"
}
else
log "rsync missing on OLD — using two-hop via laptop"
stage="$(mktemp -d)"
log "staging into ${stage}"
# shellcheck disable=SC2086
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \
"${OLD_SSH}:${SUPABASE_DIR}/volumes/storage/" "${stage}/"
# shellcheck disable=SC2086
rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" \
"${stage}/" "${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/"
rm -rf "${stage}"
fi
# --- 3) Restart NEW stack so every service reconnects to the new data ------
log "restarting the NEW Supabase stack (down + up -d)"
new_remote "cd ${SUPABASE_DIR} && docker compose down && docker compose up -d"
# --- 4) Row-count parity check OLD vs NEW (load-bearing tables) -------------
# A users/objects-only check can miss partial loss in messages/members/etc.,
# so compare the tables the app actually depends on. Non-fatal (table names can
# legitimately vary), but a mismatch on auth.users / public.messages is a red
# flag — do NOT cut over until it is understood.
log "waiting for NEW db to accept connections, then checking row-count parity"
for _ in $(seq 1 30); do
new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" >/dev/null 2>&1 && break
sleep 2
done
count_on() { # $1=old|new $2=table
local q="select count(*) from $2;"
local runner=old_remote
[[ "$1" == "new" ]] && runner=new_remote
"${runner}" "cd ${SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -tAc \"${q}\"" 2>/dev/null | tr -d '[:space:]'
}
parity_fail=0
for t in auth.users auth.identities public.profiles public.messages \
public.conversation_members storage.objects; do
o="$(count_on old "$t" 2>/dev/null || echo '?')"
n="$(count_on new "$t" 2>/dev/null || echo '?')"
if [[ -n "$o" && "$o" == "$n" ]]; then
log " OK ${t}: ${o}"
else
log " MISMATCH ${t}: OLD=${o:-?} NEW=${n:-?}"
parity_fail=1
fi
done
[[ "${parity_fail}" == "1" ]] && log "⚠ row-count mismatch — investigate BEFORE cutover."
cat <<EOF
============================================================================
DATA MIGRATION DONE.
============================================================================
Verify on NEW:
* users can log in (existing baked anon JWT must be accepted),
* a known storage object downloads via
https://supabase.netralax.de/storage/v1/object/...,
* realtime + push still work.
If storage objects 403 due to ownership, on NEW run:
cd ${SUPABASE_DIR} && docker compose restart storage imgproxy
Do NOT decommission the OLD VPS until the .cloud DNS A-records point at NEW
and old clients have had a chance to auto-update.
============================================================================
EOF
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// 03-copy-secrets.mjs — merge the OLD server's /opt/supabase/.env into the NEW
// server's .env, keeping every secret byte-identical, then force the public-URL
// / redirect vars to the netralax.de host. Run from the dev laptop.
//
// WHY Node (not bash/sed): secret values (JWT tokens, base64 keys, SMTP
// passwords) contain characters that wreck shell/sed escaping. Here the values
// only ever travel over SSH stdin/stdout and are handled as plain JS strings —
// never interpolated into a shell command. Nothing is written to the laptop disk.
//
// MERGE SEMANTICS (loss-free):
// - base = the NEW .env (fresh upstream structure + comments + new-only keys)
// - for every key that exists on BOTH sides -> take the OLD value
// - for every key that exists ONLY on OLD -> append it (this is how the
// custom edge secrets VAPID_*/PUSH_FANOUT_SHARED_SECRET/LIVEKIT_API_*
// survive — they are not in the fresh upstream .env)
// - keys ONLY on NEW -> keep their fresh default
// - finally, the OVERRIDES below are upserted (public host = .de)
//
// Usage:
// node scripts/migrate/03-copy-secrets.mjs --check # show plan, change nothing
// node scripts/migrate/03-copy-secrets.mjs # back up + apply on NEW
//
// Pre-req: SSH works to BOTH hosts (prox@OLD, debian@NEW) and NEW's .env exists
// (bootstrap step done). OLD/NEW are read from scripts/migrate/config.sh.
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createHash } from 'node:crypto';
const here = dirname(fileURLToPath(import.meta.url));
// --- read hosts from config.sh (single source of truth) --------------------
const cfg = readFileSync(join(here, 'config.sh'), 'utf8');
const cfgVal = (name) => {
const m = cfg.match(new RegExp(`^export ${name}="([^"]*)"`, 'm'));
if (!m) throw new Error(`could not find ${name} in config.sh`);
return m[1];
};
const OLD_USER = cfgVal('OLD_USER');
const OLD_HOST = cfgVal('OLD_HOST');
const NEW_USER = cfgVal('NEW_USER');
const NEW_HOST = cfgVal('NEW_HOST');
const SUPABASE_DIR = cfgVal('SUPABASE_DIR');
const ENV_PATH = `${SUPABASE_DIR}/.env`;
const OLD_SSH = `${OLD_USER}@${OLD_HOST}`;
const NEW_SSH = `${NEW_USER}@${NEW_HOST}`;
const SSH_OPTS = ['-o', 'StrictHostKeyChecking=accept-new'];
if (NEW_HOST === '__NETRALAX_DE_SERVER_IP__' || !NEW_HOST) {
console.error('NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first.');
process.exit(1);
}
// --- public-host overrides (forced to .de AFTER the merge) -----------------
// NOTE: SUPABASE_URL is deliberately NOT overridden — for the edge-runtime it
// is the INTERNAL gateway URL and is handled by the compose env in §8, not here.
const NEW_SITE = 'https://supabase.netralax.de';
const NEW_LIVEKIT = 'wss://livekit.netralax.de';
const OVERRIDES = {
SITE_URL: NEW_SITE,
API_EXTERNAL_URL: NEW_SITE,
SUPABASE_PUBLIC_URL: NEW_SITE,
LIVEKIT_URL: NEW_LIVEKIT,
ADDITIONAL_REDIRECT_URLS:
'chatapp://auth/callback,netralax://auth/callback,' +
'https://supabase.netralax.de,https://supabase.netralax.cloud',
};
// Continuity-critical keys: their value MUST end up identical to OLD.
const CRITICAL = [
'JWT_SECRET', 'ANON_KEY', 'SERVICE_ROLE_KEY', 'POSTGRES_PASSWORD',
'VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'PUSH_FANOUT_SHARED_SECRET',
'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET',
];
const check = process.argv.includes('--check');
// --- ssh helpers (values flow via stdio, never via argv) -------------------
function ssh(target, remoteCmd, input) {
return execFileSync('ssh', [...SSH_OPTS, target, remoteCmd], {
encoding: 'utf8',
input: input ?? undefined,
maxBuffer: 16 * 1024 * 1024,
});
}
const readOldEnv = () => ssh(OLD_SSH, `sudo cat ${ENV_PATH} 2>/dev/null || cat ${ENV_PATH}`);
const readNewEnv = () => ssh(NEW_SSH, `sudo cat ${ENV_PATH}`);
// --- env parsing (split on FIRST '='; keep comments/blank lines as raw) -----
const KEY_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
function parse(text) {
const map = new Map();
for (const line of text.split('\n')) {
const m = line.match(KEY_RE);
if (m) map.set(m[1], m[2]);
}
return map;
}
const sha = (s) => createHash('sha256').update(s ?? '').digest('hex').slice(0, 12);
// --- main ------------------------------------------------------------------
console.log(`OLD: ${OLD_SSH} NEW: ${NEW_SSH} file: ${ENV_PATH}\n`);
let oldText, newText;
try { oldText = readOldEnv(); } catch (e) {
console.error(`Failed to read OLD .env via ssh ${OLD_SSH}.\n${e.message}`);
process.exit(1);
}
try { newText = readNewEnv(); } catch (e) {
console.error(`Failed to read NEW .env via ssh ${NEW_SSH} (bootstrap done?).\n${e.message}`);
process.exit(1);
}
const oldMap = parse(oldText);
const newMap = parse(newText);
const onlyOld = [...oldMap.keys()].filter((k) => !newMap.has(k)).sort();
const onlyNew = [...newMap.keys()].filter((k) => !oldMap.has(k)).sort();
const shared = [...oldMap.keys()].filter((k) => newMap.has(k)).sort();
console.log(`shared keys (value taken from OLD): ${shared.length}`);
console.log(`OLD-only keys (appended — incl. custom edge secrets): ${onlyOld.length}`);
onlyOld.forEach((k) => console.log(` + ${k}`));
console.log(`NEW-only keys (kept at fresh default): ${onlyNew.length}`);
onlyNew.forEach((k) => console.log(` . ${k}`));
console.log(`\noverrides forced to the .de host:`);
for (const [k, v] of Object.entries(OVERRIDES)) console.log(` ${k}=${v}`);
// sanity: warn if a continuity-critical key is missing on OLD
const missingCrit = CRITICAL.filter((k) => !oldMap.has(k));
if (missingCrit.length) {
console.log(`\n⚠ NOTE: these critical keys are absent on OLD (verify they aren't named differently): ${missingCrit.join(', ')}`);
}
// --- build merged content (preserve NEW order/comments) --------------------
const used = new Set();
let lines = newText.split('\n').map((line) => {
const m = line.match(KEY_RE);
if (m && oldMap.has(m[1])) { used.add(m[1]); return `${m[1]}=${oldMap.get(m[1])}`; }
return line;
});
// append OLD-only keys
if (onlyOld.length) {
if (lines.length && lines[lines.length - 1] !== '') lines.push('');
lines.push('# --- merged from OLD server (keys not present in fresh upstream .env) ---');
for (const k of onlyOld) { lines.push(`${k}=${oldMap.get(k)}`); used.add(k); }
}
// upsert overrides
for (const [k, v] of Object.entries(OVERRIDES)) {
let hit = false;
lines = lines.map((line) => {
const m = line.match(KEY_RE);
if (m && m[1] === k) { hit = true; return `${k}=${v}`; }
return line;
});
if (!hit) lines.push(`${k}=${v}`);
}
const merged = lines.join('\n');
if (check) {
console.log('\n--check: nothing written. Re-run without --check to apply.');
process.exit(0);
}
// --- apply on NEW: backup, then write via `sudo tee` (content via stdin) ----
console.log('\nbacking up NEW .env and writing merged result...');
ssh(NEW_SSH, `sudo cp ${ENV_PATH} ${ENV_PATH}.bak.$(date +%s)`);
ssh(NEW_SSH, `sudo tee ${ENV_PATH} > /dev/null`, merged.endsWith('\n') ? merged : merged + '\n');
// --- verify continuity: critical values identical OLD vs NEW ---------------
const newAfter = parse(readNewEnv());
console.log('\nverifying continuity (OLD value == NEW value):');
let fail = 0;
for (const k of CRITICAL) {
if (!oldMap.has(k)) { console.log(` skip ${k} (not on OLD)`); continue; }
const ok = oldMap.get(k) === newAfter.get(k);
console.log(` ${ok ? 'OK ' : 'FAIL'} ${k} (sha ${sha(oldMap.get(k))} vs ${sha(newAfter.get(k))})`);
if (!ok) fail++;
}
console.log('\noverrides now on NEW:');
for (const k of Object.keys(OVERRIDES)) console.log(` ${k}=${newAfter.get(k)}`);
if (fail) {
console.error(`\n${fail} critical key(s) did not match — DO NOT proceed. Restore from the .bak.* backup and investigate.`);
process.exit(1);
}
console.log('\n✓ secrets merged; JWT_SECRET + VAPID + LiveKit keys are identical to OLD. Continue with runbook §6 (LiveKit/coturn) and §5 (data).');
+132
View File
@@ -0,0 +1,132 @@
# Server-Umzug: netralax.cloud -> netralax.de
Einmalige Migration des selbst gehosteten Chat-Backends vom **alten VPS**
(`46.225.156.249`, `*.netralax.cloud`) auf einen **neuen, leeren VPS**
(`*.netralax.de`). Der neue Server bedient anschliessend **beide** Domains,
damit bereits installierte Desktop-/Mobile-Clients (die alte Hostnamen und den
alten anon-JWT fest eingebaut haben) weiterlaufen, bis sie sich selbst
aktualisieren.
> Diese Skripte sind bewusst getrennt von `scripts/prod/`. `scripts/prod/config.sh`
> kennt nur den jeweils **aktiven** Server; der Umzug braucht **beide** Hosts und
> hat deshalb seine eigene `scripts/migrate/config.sh`.
## Dateien
| Datei | Wo ausführen | Zweck |
|-------|--------------|-------|
| `config.sh` | | Gemeinsame Konfiguration (alter + neuer Host, SSH-Helfer). Wird von den anderen Skripten eingebunden. |
| `01-bootstrap-new-server.sh` | **auf dem neuen VPS** (als root / sudo) | Richtet den leeren Server ein: Docker, ufw, Supabase-Clone, LiveKit-Verzeichnis, Caddy, Update-Host, Deploy-User. |
| `02-migrate-data.sh` | **auf dem Entwickler-Laptop** | Überträgt Postgres-Daten (pg_dumpall) und die Storage-Objekte (rsync) von alt nach neu. |
## Voraussetzungen / Einrichtung (einmalig)
1. **Neue Server-IP — bereits eingetragen.** `scripts/migrate/config.sh` hat
`NEW_HOST="141.95.34.204"` und `NEW_USER="debian"`. (Der `require_new_host`-
Guard greift nur, falls der Platzhalter wieder drinsteht.)
2. **SSH-Zugriff.** Vom Laptop muss `ssh prox@46.225.156.249` (alt) **und**
`ssh debian@141.95.34.204` (neu) ohne Passwort funktionieren:
```
ssh-copy-id prox@46.225.156.249
ssh-copy-id debian@141.95.34.204
```
Für den direkten Storage-Transfer (Server-zu-Server) muss zusätzlich der
**alte** Server per SSH auf den **neuen** zugreifen können. Klappt das nicht,
fällt `02-migrate-data.sh` automatisch auf den Umweg über den Laptop zurück.
3. **Skripte ausführbar machen:**
```
chmod +x scripts/migrate/*.sh
```
## Ablauf (Reihenfolge unbedingt einhalten)
1. **Bootstrap auf dem neuen Server.** Skript hochladen und als root ausführen:
```
scp scripts/migrate/01-bootstrap-new-server.sh debian@141.95.34.204:/tmp/
ssh debian@141.95.34.204 'sudo bash /tmp/01-bootstrap-new-server.sh'
```
Das Skript ist idempotent (mehrfaches Ausführen schadet nicht) und gibt am
Ende einen **NEXT STEPS**-Block aus.
2. **Secrets eintragen.** `/opt/supabase/.env` auf dem neuen Server befüllen.
Diese Werte **1:1 vom alten Server kopieren** (sonst brechen eingebaute
Tokens, Sessions und Web-Push):
`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`,
`SECRET_KEY_BASE`, `VAULT_ENC_KEY`, `PG_META_CRYPTO_KEY`, alle `SMTP_*`,
`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`,
`PUSH_FANOUT_SHARED_SECRET`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`.
Auf die **neue** Domain zeigen:
`SITE_URL`, `API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL`, `SUPABASE_URL`
= `https://supabase.netralax.de`, `LIVEKIT_URL` = `wss://livekit.netralax.de`.
`ADDITIONAL_REDIRECT_URLS` (komma-getrennt, **ohne Leerzeichen**) muss
enthalten: `chatapp://auth/callback`, `netralax://auth/callback` sowie
`https://supabase.netralax.de` und `https://supabase.netralax.cloud`.
3. **Server-Konfig platzieren.**
- `infra/livekit/docker-compose.prod.yml.example` -> `/opt/livekit/docker-compose.yml`
(Prod-Compose: host-networking, mountet `livekit.yaml` + `coturn.conf` +
`/etc/letsencrypt`; das Dev-Compose taugt **nicht** für Prod).
- `infra/livekit/livekit.prod.yaml.example` -> `/opt/livekit/livekit.yaml`
(`rtc.use_external_ip: true`, **kein** `node_ip: 127.0.0.1`, `keys:`-Block
identisch zu `LIVEKIT_API_KEY/SECRET` aus der `.env`).
- `infra/livekit/coturn.prod.conf.example` -> `/opt/livekit/coturn.conf`
(`external-ip` = öffentliche IP des neuen VPS, TLS-Cert für
`turn.netralax.de`).
- `infra/caddy/Caddyfile` -> `/etc/caddy/Caddyfile`, danach
`systemctl reload caddy`. Caddy bedient **beide** Domains (.de und .cloud).
4. **Stacks starten DB zuerst einmal hochfahren**, damit die Supabase-Init-
Skripte die Rollen anlegen (vor dem Restore):
```
ssh debian@141.95.34.204 'cd /opt/supabase && docker compose up -d db && sleep 20'
```
5. **Schreibzugriffe auf dem ALTEN System einfrieren** (Wartungsmodus). Sonst
landen während des Umzugs neue Uploads/Zeilen nur auf einer Seite und
DB + Storage werden inkonsistent.
6. **Daten migrieren** (vom Laptop). Erst der Trockenlauf, dann die Migration:
```
./scripts/migrate/02-migrate-data.sh --check # nur Pre-Flight, keine Änderung
./scripts/migrate/02-migrate-data.sh # fragt nach Bestätigung
```
Das Skript dumpt den **gesamten** Cluster per `pg_dumpall` und spielt ihn auf
dem neuen Server ein, danach rsync der Storage-Objekte. `push-migrations.sh`
**nicht** erneut ausführen die Migrationen sind bereits im Dump enthalten.
7. **DNS umstellen.** A-Records für **beide** Domains auf die neue IP zeigen
lassen: `supabase.netralax.de` / `.cloud`, `livekit.netralax.de` / `.cloud`,
`turn.netralax.de`, `update.netralax.de` / `.cloud`.
8. **Update-Artefakte spiegeln.** electron-updater-Dateien (`latest.yml`,
`*.exe`, `changelog.json`) unter `/var/www/updates/windows` ablegen, sodass
**sowohl** `update.netralax.de` **als auch** `update.netralax.cloud` sie
ausliefern. Nur so können alte (.cloud-)Clients die Umstiegs-Version ziehen.
## Sicherheitshinweise
- **`JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`** müssen byteweise identisch
vom alten Server stammen, **bevor** der erste Client den neuen Server trifft
sonst werden alle eingebauten Tokens abgelehnt und alle Sessions fliegen raus.
- **`POSTGRES_PASSWORD`** muss vor dem Restore identisch gesetzt sein, weil der
Dump die Rollen-Passwort-Hashes mitbringt. Sonst können sich die internen
Dienste (auth/rest/storage) nach dem Restore nicht mehr an Postgres anmelden.
- **VAPID-Schlüsselpaar** identisch übernehmen, sonst sind alle bestehenden
Web-Push-Abos ungültig.
- **Medien-/TURN-Ports** müssen in ufw offen sein (7880/7881 tcp, 50000-50100
udp, coturn 3478 tcp+udp, 5349 tcp, 50200-50300 udp) sonst haben Anrufe kein
Audio/Video. Diese Ports laufen **nicht** über Caddy.
- **TURNS auf 5349** braucht ein eigenes TLS-Zertifikat für `turn.netralax.de`
auf der Platte (Pfade in `coturn.conf`) ein reines Caddy-Zertifikat reicht
nicht.
- **Alten VPS nicht abschalten**, bevor die `.cloud`-DNS-Einträge auf den neuen
Server zeigen und alte Clients Zeit zum Auto-Update hatten.
- Beim Restore werden harmlose `already exists`-Fehler für vorhandene Rollen
(`supabase_admin`, `postgres` …) ausgegeben das ist gewollt
(`ON_ERROR_STOP=0`). `02-migrate-data.sh` scannt die Restore-Ausgabe
**automatisch** auf echte `ERROR/FATAL/PANIC` und **bricht vor dem Storage-
rsync ab**, wenn welche übrig bleiben (Override: `FORCE_RESTORE_OK=1`).
Danach macht es eine Zeilen-Paritätsprüfung (alt vs. neu) über die tragenden
Tabellen.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Shared config for the one-time netralax.cloud -> netralax.de server move.
#
# This is SEPARATE from scripts/prod/config.sh on purpose: the migration knows
# BOTH the old (.cloud) and the new (.de) host, whereas scripts/prod/config.sh
# only ever points at the live server. Source this in each migrate script:
# source "$(dirname "$0")/config.sh"
#
# Fill NEW_HOST once the netralax.de VPS exists. OLD_HOST is the .cloud VPS.
# Old, currently-live VPS (Supabase + LiveKit on *.netralax.cloud).
export OLD_HOST="46.225.156.249"
export OLD_USER="prox"
# New, empty VPS that will serve *.netralax.de (and keep serving *.netralax.cloud
# for already-installed clients). Fill in the IP before running 02-migrate-data.sh.
# NOTE: the login user on the new .de VPS is "debian" (the old .cloud VPS uses "prox").
export NEW_HOST="141.95.34.204"
export NEW_USER="debian"
# Paths on BOTH servers (same layout on old and new).
export SUPABASE_DIR="/opt/supabase"
export LIVEKIT_DIR="/opt/livekit"
# SSH helper opts: accept new host keys on first connect without prompting.
# Override SSH_OPTS from the environment if you need a jumphost etc.
export SSH_OPTS="${SSH_OPTS:--o StrictHostKeyChecking=accept-new}"
# Convenience SSH targets.
export OLD_SSH="${OLD_USER}@${OLD_HOST}"
export NEW_SSH="${NEW_USER}@${NEW_HOST}"
# Run a command on the OLD server.
old_remote() {
# shellcheck disable=SC2086
ssh ${SSH_OPTS} "${OLD_SSH}" "$@"
}
# Run a command on the NEW server.
new_remote() {
# shellcheck disable=SC2086
ssh ${SSH_OPTS} "${NEW_SSH}" "$@"
}
# Guard: refuse to run anything against the unfilled new-host placeholder.
require_new_host() {
if [[ "${NEW_HOST}" == "__NETRALAX_DE_SERVER_IP__" || -z "${NEW_HOST}" ]]; then
echo "NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first." >&2
exit 1
fi
}
+3 -2
View File
@@ -8,8 +8,9 @@ All commands read shared config from `config.sh`.
1. Copy your SSH key to the server so scripts don't prompt for a password:
```
ssh-keygen -t ed25519 # only if you don't already have one
ssh-copy-id prox@46.225.156.249
ssh prox@46.225.156.249 'echo ok'
# PROD now points at the netralax.de VPS (user "debian"; see config.sh).
ssh-copy-id debian@141.95.34.204
ssh debian@141.95.34.204 'echo ok'
```
2. Make the scripts executable:
```
+13 -5
View File
@@ -5,16 +5,24 @@
# Customize here when the server IP / domains change — all other scripts pick
# the values up automatically.
export PROD_SERVER="46.225.156.249"
export PROD_USER="prox"
# End state after the netralax.de migration. The old .cloud VPS was
# 46.225.156.249 — for the one-time move (data dump/restore, storage rsync)
# use scripts/migrate/, which knows the old host explicitly. Fill in the new
# server IP once the netralax.de VPS exists, then this becomes the live config
# for all push-migrations / push-edge-function / create-invite / logs scripts.
export PROD_SERVER="141.95.34.204"
# Login user on the new .de VPS is "debian" (the old .cloud VPS used "prox").
export PROD_USER="debian"
# Paths on the remote server.
export PROD_SUPABASE_DIR="/opt/supabase"
export PROD_LIVEKIT_DIR="/opt/livekit"
# Public domains (served via Caddy on the same VPS).
export PROD_DOMAIN_SUPABASE="supabase.netralax.cloud"
export PROD_DOMAIN_LIVEKIT="livekit.netralax.cloud"
# Public domains (served via Caddy on the new VPS). Caddy also keeps serving
# the legacy supabase.netralax.cloud / livekit.netralax.cloud vhosts (same
# backends) so already-installed clients keep working until they auto-update.
export PROD_DOMAIN_SUPABASE="supabase.netralax.de"
export PROD_DOMAIN_LIVEKIT="livekit.netralax.de"
# SSH helper: forwards the standard `-o StrictHostKeyChecking=accept-new` so
# first connections don't prompt. Override SSH_OPTS from the environment if