Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c54166a4 | |||
| f438018400 | |||
| 89003f71a4 | |||
| b364c53c61 | |||
| 9c5456b492 | |||
| b057795735 | |||
| c81a036c4e | |||
| bdc017e609 | |||
| 27160145f9 | |||
| 8ea2cb48e9 | |||
| c3ef995404 | |||
| b4ed3aced0 | |||
| 30d00194be | |||
| 31b394a6e0 | |||
| 271d6fff5c | |||
| 8b8d71bc4d | |||
| 40f36cb182 | |||
| 2372731504 | |||
| 43a99a8d6d | |||
| f73abbd860 | |||
| e822f6f58f | |||
| 255dbdc712 | |||
| 51a8630114 | |||
| d539656535 | |||
| 5bc30c950c | |||
| 588b843904 | |||
| dbf8030e93 | |||
| 615770722e | |||
| f1cba99b9e | |||
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf | |||
| 787437c3f1 | |||
| be5647281d | |||
| fc8fc275cb | |||
| e19a71e892 | |||
| 11869be443 | |||
| 3fa8b6dbc1 | |||
| f9f8e3fdb8 | |||
| 4ed3f04300 | |||
| a7ffcbff83 | |||
| 82600915f1 | |||
| 93098a74ca |
@@ -7,7 +7,11 @@
|
|||||||
# stopped publishing Tauri releases to this host.
|
# stopped publishing Tauri releases to this host.
|
||||||
|
|
||||||
# Host serving latest.yml + installer artifacts over HTTPS.
|
# 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.
|
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
|
||||||
UPDATE_SSH_USER=chatapp-deploy
|
UPDATE_SSH_USER=chatapp-deploy
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ export const CHANNELS = {
|
|||||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||||
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||||
|
|
||||||
|
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
|
||||||
|
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
|
||||||
|
// while a view-once image is being revealed. No-op on Linux X11.
|
||||||
|
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
|
||||||
|
|
||||||
// Wipe-on-close — main process pushes this to the renderer right before
|
// Wipe-on-close — main process pushes this to the renderer right before
|
||||||
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
||||||
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
||||||
@@ -271,6 +276,12 @@ export interface UpdateProgress {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Window content protection -------------------------------------------
|
||||||
|
|
||||||
|
export interface WindowSetContentProtectionArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Runtime marker ------------------------------------------------------
|
// ---- Runtime marker ------------------------------------------------------
|
||||||
|
|
||||||
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
|
|||||||
import { register as registerSql } from './modules/sql';
|
import { register as registerSql } from './modules/sql';
|
||||||
import { register as registerTray } from './modules/tray';
|
import { register as registerTray } from './modules/tray';
|
||||||
import { register as registerUpdater } from './modules/updater';
|
import { register as registerUpdater } from './modules/updater';
|
||||||
|
import { register as registerWindowContentProtection } from './modules/window-content-protection';
|
||||||
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
||||||
import { attach as attachWindowState, loadState } from './window-state';
|
import { attach as attachWindowState, loadState } from './window-state';
|
||||||
|
|
||||||
@@ -274,6 +275,7 @@ if (!gotLock) {
|
|||||||
registerTray(mainWindow);
|
registerTray(mainWindow);
|
||||||
registerUpdater(mainWindow);
|
registerUpdater(mainWindow);
|
||||||
registerWindowFullscreen(mainWindow);
|
registerWindowFullscreen(mainWindow);
|
||||||
|
registerWindowContentProtection(mainWindow);
|
||||||
registerAudioLoopback(mainWindow);
|
registerAudioLoopback(mainWindow);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Window content-protection adapter. Enables / disables OS-level
|
||||||
|
// screenshot and screen-recording blocking on the host BrowserWindow.
|
||||||
|
//
|
||||||
|
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
|
||||||
|
// appears black in any screen capture tool (OBS, Snipping Tool,
|
||||||
|
// Win+PrtScr, etc.) while protection is enabled.
|
||||||
|
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
|
||||||
|
// Cmd+Shift+3/4, and external recorders.
|
||||||
|
// Linux: No-op. Electron exposes the API on all platforms but the
|
||||||
|
// X11/Wayland compositors don't honour it in Electron 33.
|
||||||
|
//
|
||||||
|
// Called by the renderer during view-once image reveals so the image
|
||||||
|
// cannot be captured by an OS-level screenshot while it is on screen.
|
||||||
|
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
|
||||||
|
(_evt, args: WindowSetContentProtectionArgs) => {
|
||||||
|
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
|
||||||
|
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
|
||||||
|
// Wrapped in try/catch because the window can already be destroyed
|
||||||
|
// by the time this fires during a teardown.
|
||||||
|
try {
|
||||||
|
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
win.setContentProtection(args.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
@@ -96,6 +96,12 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
|
||||||
|
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
|
||||||
|
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
|
||||||
|
* feature-check because the web build has no preload bridge. */
|
||||||
|
setContentProtection?: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
/** Subscribe to the main-process pre-quit notification. Used by the
|
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||||
* "Cache beim Schließen leeren" Settings toggle. */
|
* "Cache beim Schließen leeren" Settings toggle. */
|
||||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ const api = {
|
|||||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||||
|
|
||||||
|
// Window content protection ----------------------------------------------
|
||||||
|
setContentProtection: (enabled: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
|
||||||
|
|
||||||
// OS hostname ------------------------------------------------------------
|
// OS hostname ------------------------------------------------------------
|
||||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.20.1",
|
"version": "0.21.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
"@livekit/track-processors": "^0.7.2",
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
|
"@tanstack/react-virtual": "^3.10.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
@@ -35,7 +36,6 @@
|
|||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-i18next": "^15.1.1",
|
"react-i18next": "^15.1.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-virtuoso": "^4.18.7",
|
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
"provider": "generic",
|
"provider": "generic",
|
||||||
"url": "https://update.netralax.cloud/windows/"
|
"url": "https://update.netralax.de/windows/"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense, useEffect } from 'react';
|
||||||
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
|
|||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { hydrateDrafts } from './lib/composerDraftStore';
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
@@ -73,6 +74,10 @@ function ConversationRoute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrateDrafts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary scope="root">
|
<ErrorBoundary scope="root">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
const [duration, setDuration] = useState<number>(0);
|
const [duration, setDuration] = useState<number>(0);
|
||||||
const [position, setPosition] = useState<number>(0);
|
const [position, setPosition] = useState<number>(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
};
|
};
|
||||||
}, [arrayBuf]);
|
}, [arrayBuf]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.playbackRate = speed;
|
||||||
|
}, [speed, blobUrl]);
|
||||||
|
|
||||||
const fallbackPeaks = useMemo(
|
const fallbackPeaks = useMemo(
|
||||||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||||
[peaks],
|
[peaks],
|
||||||
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
<PlayGlyph />
|
<PlayGlyph />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
|
||||||
|
{VOICE_SPEEDS.map((s) => {
|
||||||
|
const active = s === speed;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSpeed(s);
|
||||||
|
setVoiceSpeed(s);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
|
||||||
|
(active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={'Wiedergabegeschwindigkeit ' + s + '×'}
|
||||||
|
>
|
||||||
|
{s}×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
role="slider"
|
role="slider"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
// 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.
|
// letter circle. Use this everywhere the app needs to render a profile.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,7 +29,13 @@ export function Avatar({
|
|||||||
loading = 'lazy',
|
loading = 'lazy',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const effectiveUrl = useCachedAvatarUrl(url);
|
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 (
|
return (
|
||||||
<img
|
<img
|
||||||
src={effectiveUrl}
|
src={effectiveUrl}
|
||||||
@@ -35,6 +43,7 @@ export function Avatar({
|
|||||||
className={'shrink-0 rounded-full object-cover ' + className}
|
className={'shrink-0 rounded-full object-cover ' + className}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||||
import { MonitorShareIcon } from './icons';
|
import { MonitorShareIcon } from './icons';
|
||||||
|
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
interface ScreenShareViewerProps {
|
||||||
share: RemoteScreenShare;
|
share: RemoteScreenShare;
|
||||||
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
|
|||||||
const { watchingShareUserIds, watchShare } = useCall();
|
const { watchingShareUserIds, watchShare } = useCall();
|
||||||
const watching = watchingShareUserIds.has(share.participantId);
|
const watching = watchingShareUserIds.has(share.participantId);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [annotateEnabled, setAnnotateEnabled] = useState(false);
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{watching ? (
|
{watching ? (
|
||||||
<video
|
<div className="relative h-full w-full flex-1">
|
||||||
ref={videoRef}
|
<video
|
||||||
autoPlay
|
ref={videoRef}
|
||||||
playsInline
|
autoPlay
|
||||||
muted
|
playsInline
|
||||||
// Suppress the browser's built-in <video> context menu
|
muted
|
||||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
// Suppress the browser's built-in <video> context menu
|
||||||
// to the wrapping tile div in InCallPanel — that's where the
|
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||||
// app's volume / mute menu is wired up. Without this, the
|
// to the wrapping tile div in InCallPanel — that's where the
|
||||||
// native menu opens on top of ours in focus + fullscreen
|
// app's volume / mute menu is wired up. Without this, the
|
||||||
// modes (where the video covers the whole tile).
|
// native menu opens on top of ours in focus + fullscreen
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
// modes (where the video covers the whole tile).
|
||||||
onDoubleClick={toggleFullscreen}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
@@ -16,16 +16,34 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Three states:
|
// Three states:
|
||||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
// 1. viewedAt is null AND user is recipient → blurred lock card. Press-and-
|
||||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
// hold reveals the image fullscreen; release closes it AND fires the
|
||||||
|
// mark-viewed RPC.
|
||||||
// 2. viewedAt is set → tombstone "Angesehen am …".
|
// 2. viewedAt is set → tombstone "Angesehen am …".
|
||||||
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
|
// 3. user is sender → normal image, tombstone update appears once recipient
|
||||||
|
// burns it.
|
||||||
|
//
|
||||||
|
// While revealed, the renderer window enables content-protection
|
||||||
|
// (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd
|
||||||
|
// snipping tools, screen recorders) sees a black/empty surface. Re-enabled
|
||||||
|
// on release / unmount.
|
||||||
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||||
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [revealing, setRevealing] = useState(false);
|
||||||
|
const burnedRef = useRef(false);
|
||||||
|
const holdingRef = useRef(false);
|
||||||
|
|
||||||
const burned = revealedAt !== null;
|
const burned = revealedAt !== null;
|
||||||
|
|
||||||
|
// Tear down screen-capture protection if the component unmounts mid-reveal.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (revealing || holdingRef.current) {
|
||||||
|
void window.electronAPI?.setContentProtection?.(false).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [revealing]);
|
||||||
|
|
||||||
if (burned && !isSender) {
|
if (burned && !isSender) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
||||||
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipient, not yet viewed.
|
const startReveal = async (): Promise<void> => {
|
||||||
const handleOpen = async (): Promise<void> => {
|
if (burnedRef.current) return;
|
||||||
|
burnedRef.current = true;
|
||||||
|
holdingRef.current = true;
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.setContentProtection?.(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection enable failed', err);
|
||||||
|
}
|
||||||
|
// The user may have released during the await. If so, skip showing the
|
||||||
|
// dialog and run the close-path directly so we don't leave the renderer
|
||||||
|
// in protected mode with no visible UI.
|
||||||
|
if (!holdingRef.current) {
|
||||||
|
// User released during the IPC await — endReveal already fired and is
|
||||||
|
// responsible for teardown (setContentProtection(false) + mark-viewed).
|
||||||
|
// Skipping teardown here avoids a duplicate markAttachmentViewed RPC.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRevealing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endReveal = async (): Promise<void> => {
|
||||||
|
if (!holdingRef.current && !revealing) return;
|
||||||
|
holdingRef.current = false;
|
||||||
|
if (revealing) setRevealing(false);
|
||||||
|
await teardownReveal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardownReveal = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.setContentProtection?.(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection disable failed', err);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await markAttachmentViewed(supabase, attachmentId);
|
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||||
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('mark-viewed failed', err);
|
console.warn('mark-viewed failed', err);
|
||||||
|
burnedRef.current = false;
|
||||||
}
|
}
|
||||||
setFullscreen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleOpen()}
|
onPointerDown={() => void startReveal()}
|
||||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
onPointerUp={() => void endReveal()}
|
||||||
|
onPointerLeave={() => void endReveal()}
|
||||||
|
onPointerCancel={() => void endReveal()}
|
||||||
|
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40 select-none"
|
||||||
>
|
>
|
||||||
<LockIcon className="h-6 w-6 text-accent" />
|
<LockIcon className="h-6 w-6 text-accent" />
|
||||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
||||||
</button>
|
</button>
|
||||||
{fullscreen && (
|
{revealing && (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
aria-label="Einmal-ansehen Bild"
|
||||||
onClick={() => setFullscreen(false)}
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
|
||||||
>
|
>
|
||||||
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full select-none rounded-lg"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
Loslassen zum Schließen — Aufnahme blockiert
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors';
|
||||||
|
|
||||||
export type WhiteboardTool = 'pen' | 'eraser';
|
export type WhiteboardTool = 'pen' | 'eraser';
|
||||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||||
export type WhiteboardWidth = 2 | 4 | 8;
|
export type WhiteboardWidth = 2 | 4 | 8;
|
||||||
@@ -22,6 +25,8 @@ interface Props {
|
|||||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||||
logicalWidth?: number;
|
logicalWidth?: number;
|
||||||
logicalHeight?: number;
|
logicalHeight?: number;
|
||||||
|
/** Enables live-cursor broadcast when set. */
|
||||||
|
whiteboardId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LOGICAL_W = 1280;
|
const DEFAULT_LOGICAL_W = 1280;
|
||||||
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
|
|||||||
onStroke,
|
onStroke,
|
||||||
logicalWidth = DEFAULT_LOGICAL_W,
|
logicalWidth = DEFAULT_LOGICAL_W,
|
||||||
logicalHeight = DEFAULT_LOGICAL_H,
|
logicalHeight = DEFAULT_LOGICAL_H,
|
||||||
|
whiteboardId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||||
const strokeStartRef = useRef<number>(0);
|
const strokeStartRef = useRef<number>(0);
|
||||||
const [, forceTick] = useState(0);
|
const [, forceTick] = useState(0);
|
||||||
|
|
||||||
|
const { session, profile } = useAuth();
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorEvent & { lastSeen: number }>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
const cursorSessionRef = useRef<CursorSession | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!whiteboardId) return;
|
||||||
|
const me = session?.user;
|
||||||
|
if (!me) return;
|
||||||
|
const displayName = profile?.displayName ?? me.email ?? me.id.slice(0, 8);
|
||||||
|
const s = openCursorSession(
|
||||||
|
whiteboardId,
|
||||||
|
{ userId: me.id, displayName },
|
||||||
|
(ev) => {
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(ev.userId, { ...ev, lastSeen: Date.now() });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cursorSessionRef.current = s;
|
||||||
|
return () => {
|
||||||
|
s.close();
|
||||||
|
cursorSessionRef.current = null;
|
||||||
|
};
|
||||||
|
}, [whiteboardId, session?.user, profile?.displayName]);
|
||||||
|
|
||||||
|
// Stale-cursor sweep: drop cursors that haven't been heard from in 2s. Cheap
|
||||||
|
// poll because the Map is tiny (at most one entry per active collaborator).
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteCursors.size === 0) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [k, v] of next) {
|
||||||
|
if (now - v.lastSeen > 2000) {
|
||||||
|
next.delete(k);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [remoteCursors.size]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cv = canvasRef.current;
|
const cv = canvasRef.current;
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
if (!draftRef.current) return;
|
|
||||||
const [x, y] = canvasPoint(e);
|
const [x, y] = canvasPoint(e);
|
||||||
|
cursorSessionRef.current?.send(x, y);
|
||||||
|
if (!draftRef.current) return;
|
||||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||||
forceTick((n) => n + 1);
|
forceTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
@@ -102,18 +159,53 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<div
|
||||||
ref={canvasRef}
|
className="relative"
|
||||||
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={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
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(
|
function renderStroke(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
s: Partial<WhiteboardStrokePayload>,
|
s: Partial<WhiteboardStrokePayload>,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
|||||||
color={color}
|
color={color}
|
||||||
width={width}
|
width={width}
|
||||||
onStroke={(payload) => void insertStroke(payload)}
|
onStroke={(payload) => void insertStroke(payload)}
|
||||||
|
whiteboardId={whiteboardId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ import {
|
|||||||
subscribeScreenShareVolumes,
|
subscribeScreenShareVolumes,
|
||||||
} from '../lib/screenShareVolumes';
|
} from '../lib/screenShareVolumes';
|
||||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
|
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
|
||||||
import { playEntry } from '../lib/soundboardPlayback';
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
import {
|
import {
|
||||||
getPrefs as getSoundboardPrefs,
|
getPrefs as getSoundboardPrefs,
|
||||||
@@ -2257,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Global soundboard hotkey registration — runs only while connected so the
|
// Global soundboard hotkey registration — always-on so the OS-level
|
||||||
// OS-level shortcuts don't fire when the user is outside of a call.
|
// shortcuts fire even outside a call (Stream-Deck-style local SFX). Inside
|
||||||
|
// a call we route through `playSoundboard` so peers hear; outside a call
|
||||||
|
// we fall back to `playSoundboardLocal` which plays through the system
|
||||||
|
// default output only.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT depend on `state.kind` in the effect dep array:
|
||||||
|
// every call state transition (idle → connecting → connected → reconnecting
|
||||||
|
// → ...) would trigger a full unregister+re-register cycle through IPC, and
|
||||||
|
// during the 1–50 ms gap the hotkeys are silently dead. Instead we read the
|
||||||
|
// current call state through a ref that's always kept in sync.
|
||||||
|
const callStateKindRef = useRef(state.kind);
|
||||||
|
callStateKindRef.current = state.kind;
|
||||||
|
const playSoundboardRef = useRef(playSoundboard);
|
||||||
|
playSoundboardRef.current = playSoundboard;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.kind !== 'connected') return;
|
|
||||||
const teardown = startSoundboardHotkeys((id) => {
|
const teardown = startSoundboardHotkeys((id) => {
|
||||||
void playSoundboard(id);
|
if (callStateKindRef.current === 'connected') {
|
||||||
|
void playSoundboardRef.current(id);
|
||||||
|
} else {
|
||||||
|
void (async () => {
|
||||||
|
const entries = await listSoundboard();
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (entry) await playSoundboardLocal(entry);
|
||||||
|
})();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return teardown;
|
return teardown;
|
||||||
}, [state.kind, playSoundboard]);
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ inputDeviceId: deviceId });
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
|
|||||||
@@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads
|
||||||
|
const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']);
|
||||||
|
|
||||||
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
throw new Error('only image files are accepted');
|
throw new Error('only image files are accepted');
|
||||||
}
|
}
|
||||||
|
// Animated formats bypass the canvas re-encode (which would strip
|
||||||
|
// animation by sampling the first frame). We still validate dimensions
|
||||||
|
// and size so a 40-MB animated WebP can't slip through.
|
||||||
|
if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) {
|
||||||
|
if (file.size > MAX_ANIMATED_BYTES) {
|
||||||
|
throw new Error('animated avatar too large (max 2 MB)');
|
||||||
|
}
|
||||||
|
const dims = await readDimensions(file);
|
||||||
|
if (dims.width > MAX_DIM || dims.height > MAX_DIM) {
|
||||||
|
throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')');
|
||||||
|
}
|
||||||
|
return uploadAvatarBlob(userId, file);
|
||||||
|
}
|
||||||
const blob = await resizeToSquare(file);
|
const blob = await resizeToSquare(file);
|
||||||
return uploadAvatarBlob(userId, blob);
|
return uploadAvatarBlob(userId, blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const i = new Image();
|
||||||
|
i.onload = () => resolve(i);
|
||||||
|
i.onerror = () => reject(new Error('image load failed'));
|
||||||
|
i.src = url;
|
||||||
|
});
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAnimated(file: File): Promise<boolean> {
|
||||||
|
// GIF: any GIF89a/GIF87a header is treated as potentially animated. The
|
||||||
|
// static-GIF case (one image-descriptor block) is rare enough that
|
||||||
|
// re-encoding wouldn't save much, so we accept the false-positives.
|
||||||
|
if (file.type === 'image/gif') return true;
|
||||||
|
// APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the
|
||||||
|
// first 64 KB — APNGs put acTL near the front, before IDAT.
|
||||||
|
if (file.type === 'image/apng' || file.type === 'image/png') {
|
||||||
|
const head = await file.slice(0, 65536).arrayBuffer();
|
||||||
|
return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL'
|
||||||
|
}
|
||||||
|
// Animated WebP: 'ANIM' chunk in the RIFF container.
|
||||||
|
if (file.type === 'image/webp') {
|
||||||
|
const head = await file.slice(0, 65536).arrayBuffer();
|
||||||
|
return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM'
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsBytes(buf: ArrayBuffer, needle: number[]): boolean {
|
||||||
|
const view = new Uint8Array(buf);
|
||||||
|
const len = view.length;
|
||||||
|
const nlen = needle.length;
|
||||||
|
outer: for (let i = 0; i + nlen <= len; i++) {
|
||||||
|
for (let j = 0; j < nlen; j++) {
|
||||||
|
if (view[i + j] !== needle[j]) continue outer;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
||||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||||
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
||||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
const ext =
|
||||||
|
blob.type === 'image/webp' ? 'webp' :
|
||||||
|
blob.type === 'image/gif' ? 'gif' :
|
||||||
|
blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' :
|
||||||
|
'jpg';
|
||||||
// Random filename so old uploads don't get overwritten before we update
|
// Random filename so old uploads don't get overwritten before we update
|
||||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||||
// also forces clients to fetch the new image.
|
// also forces clients to fetch the new image.
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// In-app changelog feed.
|
// In-app changelog feed.
|
||||||
//
|
//
|
||||||
// The release script (`scripts/release.mjs`) maintains a single
|
// 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
|
// The list is newest-first, capped at 200 entries server-side, and rewritten
|
||||||
// after every release.
|
// 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 {
|
export interface ChangelogEntry {
|
||||||
version: string;
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
|
||||||
import {
|
import {
|
||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
|
clearConvKeyCache,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
decryptMessages,
|
decryptMessages,
|
||||||
encryptAndUploadAttachment,
|
encryptAndUploadAttachment,
|
||||||
fetchConversationMessages,
|
fetchConversationMessages,
|
||||||
|
getOrCreateConvKey,
|
||||||
insertAttachmentRow,
|
insertAttachmentRow,
|
||||||
MAX_ATTACHMENT_BYTES,
|
MAX_ATTACHMENT_BYTES,
|
||||||
type MessageWithCipher,
|
type MessageWithCipher,
|
||||||
|
rotateConvKey,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
shareConvKeyToUser,
|
|
||||||
tryGetConvKey,
|
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -125,12 +125,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
});
|
});
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
// Proactive rewrap sweep: when a conversation opens, ensure the active
|
||||||
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
// conv-key has a `recipient_user_id` bundle for every accepted member.
|
||||||
// for them. Members who are missing one (typically peers who haven't yet
|
//
|
||||||
// migrated to the per-user key model) get a best-effort wrap from the
|
// If any peer is missing a bundle at the active version, the previous
|
||||||
// local conv-key handle. Closes the legacy migration gap so peer B can
|
// implementation called `shareConvKeyToUser` for each missing peer —
|
||||||
// read on first unlock without manual intervention from A.
|
// that helper reads from the module-level conv-key cache first, and if
|
||||||
|
// the cache held a STALE locally-generated key (from a buggy bootstrap
|
||||||
|
// race in an earlier app version), the stale key got propagated to the
|
||||||
|
// peer's row. Both sides then encrypt with mutually un-mergeable keys
|
||||||
|
// and every message is "Nachricht nicht lesbar" forever (incident:
|
||||||
|
// conv aae12d84).
|
||||||
|
//
|
||||||
|
// The replacement: when any peer is missing, call `rotateConvKey` once.
|
||||||
|
// Rotation generates a fresh symmetric key locally, fetches each member's
|
||||||
|
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
|
||||||
|
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
|
||||||
|
// serialises concurrent rotations). This bypasses the cache entirely:
|
||||||
|
// the new version's cache entry is the just-rotated key, and the stale
|
||||||
|
// entry at the old version is irrelevant because nobody reads it any more.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || !userId) return;
|
if (!conversationId || !userId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -154,56 +167,76 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||||
|
|
||||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
// First, make sure we have a usable handle for the active version
|
||||||
if (!handle || cancelled) return;
|
// (this auto-rotates if we're locked out of our own bundle — the
|
||||||
|
// recovery path added in v0.21.1/v0.21.2).
|
||||||
|
const handle = await getOrCreateConvKey(supabase, conversationId, {
|
||||||
|
userId,
|
||||||
|
privateKey: priv,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (handle.keyVersion > version) return; // already rotated by helper
|
||||||
|
|
||||||
|
// Check membership state on the server.
|
||||||
const { data: members, error: mErr } = await supabase
|
const { data: members, error: mErr } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
.select('user_id, accepted')
|
.select('user_id, accepted')
|
||||||
.eq('conversation_id', conversationId);
|
.eq('conversation_id', conversationId);
|
||||||
if (mErr || !members) return;
|
if (mErr || !members) return;
|
||||||
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||||
.filter((m) => m.accepted && m.user_id !== userId)
|
.filter((m) => m.accepted && m.user_id !== userId)
|
||||||
.map((m) => m.user_id);
|
.map((m) => m.user_id);
|
||||||
if (memberIds.length === 0) return;
|
if (peerIds.length === 0) return;
|
||||||
|
|
||||||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
// Count how many of the peers have a recipient_user_id bundle at
|
||||||
for (const peer of peers) {
|
// the active version. If any are missing, rotate to V+1 — the
|
||||||
if (cancelled) return;
|
// rotation will wrap a fresh key for every accepted member with a
|
||||||
const { count, error: cntErr } = await (
|
// user_keys row.
|
||||||
supabase as unknown as {
|
const { data: existingRows, error: rowsErr } = await (
|
||||||
from: (t: string) => {
|
supabase as unknown as {
|
||||||
select: (s: string, o?: object) => {
|
from: (t: string) => {
|
||||||
eq: (...a: unknown[]) => {
|
select: (s: string) => {
|
||||||
eq: (...a: unknown[]) => {
|
eq: (c: string, v: string) => {
|
||||||
eq: (
|
eq: (c: string, v: number) => {
|
||||||
...a: unknown[]
|
in: (c: string, v: string[]) => Promise<{
|
||||||
) => Promise<{ count: number | null; error: unknown }>;
|
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) {
|
} catch (err) {
|
||||||
console.warn('proactive rewrap sweep failed', err);
|
console.warn('proactive rewrap sweep failed', err);
|
||||||
@@ -474,11 +507,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
||||||
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
|
// refetches + decrypts. The first event in a quiet period fires
|
||||||
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
|
// `handleInsert` immediately so single incoming messages don't sit
|
||||||
// the last 100 in one query — cheaper and keeps order stable. For
|
// behind a debounce timer (previous behaviour: 250 ms blank between
|
||||||
// lone inserts the per-id path stays so latency is unchanged.
|
// notification-sound and message body). Subsequent events arriving
|
||||||
const BATCH_WINDOW_MS = 250;
|
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
|
||||||
|
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
|
||||||
|
// `refresh()` instead of N individual refetches.
|
||||||
|
const BATCH_WINDOW_MS = 80;
|
||||||
const BATCH_BURST_THRESHOLD = 3;
|
const BATCH_BURST_THRESHOLD = 3;
|
||||||
let burstBuffer: Array<Record<string, unknown>> = [];
|
let burstBuffer: Array<Record<string, unknown>> = [];
|
||||||
let burstTimer: number | null = null;
|
let burstTimer: number | null = null;
|
||||||
@@ -497,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const queueInsert = (row: Record<string, unknown>) => {
|
const queueInsert = (row: Record<string, unknown>) => {
|
||||||
|
if (burstBuffer.length === 0 && burstTimer === null) {
|
||||||
|
// First event in a quiet period — fire immediately so the user sees
|
||||||
|
// the message right when they hear the notification sound. Arm a
|
||||||
|
// short window in case a burst follows; follow-ups go through the
|
||||||
|
// buffer and may collapse into a refresh.
|
||||||
|
void handleInsert(row);
|
||||||
|
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
burstBuffer.push(row);
|
burstBuffer.push(row);
|
||||||
if (burstTimer === null) {
|
if (burstTimer === null) {
|
||||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||||
@@ -523,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
// Any conversation_keys change for this conv invalidates the cached
|
||||||
// registered a fresh device), re-decrypt the visible messages.
|
// conv-key for the affected version. The module-level cache in
|
||||||
|
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
|
||||||
|
// forever within a session — which is exactly what propagated the
|
||||||
|
// stale local bootstrap key in conv aae12d84, recreating divergent
|
||||||
|
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
|
||||||
|
// DELETE for the conv forces the next `getOrCreateConvKey` /
|
||||||
|
// `tryGetConvKey` call to re-fetch the canonical bundle from the
|
||||||
|
// server. Cheap (a single Map.delete), defensive, and avoids stale-
|
||||||
|
// cache propagation across all of {peer rotation, device wrap, admin
|
||||||
|
// cleanup}.
|
||||||
|
//
|
||||||
|
// We also keep the historical "device wrap → refresh" trigger so a
|
||||||
|
// freshly-registered device of our own re-decrypts in place.
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{
|
{
|
||||||
event: 'INSERT',
|
event: '*',
|
||||||
schema: 'public',
|
schema: 'public',
|
||||||
table: 'conversation_keys',
|
table: 'conversation_keys',
|
||||||
filter: 'conversation_id=eq.' + conversationId,
|
filter: 'conversation_id=eq.' + conversationId,
|
||||||
},
|
},
|
||||||
(payload: { new: { recipient_device_id?: string } }) => {
|
(payload: {
|
||||||
if (payload.new?.recipient_device_id === deviceId) {
|
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||||
|
new: { recipient_device_id?: string; key_version?: number };
|
||||||
|
old: { recipient_device_id?: string; key_version?: number };
|
||||||
|
}) => {
|
||||||
|
const v =
|
||||||
|
payload.eventType === 'DELETE'
|
||||||
|
? payload.old?.key_version
|
||||||
|
: payload.new?.key_version;
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
clearConvKeyCache(conversationId, v);
|
||||||
|
} else {
|
||||||
|
clearConvKeyCache(conversationId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.eventType === 'INSERT' &&
|
||||||
|
payload.new?.recipient_device_id === deviceId
|
||||||
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface UseMessageReactionsResult {
|
|||||||
byMessage: Map<string, AggregatedReaction[]>;
|
byMessage: Map<string, AggregatedReaction[]>;
|
||||||
toggle: (messageId: string, emoji: string) => Promise<void>;
|
toggle: (messageId: string, emoji: string) => Promise<void>;
|
||||||
voteExclusive: (messageId: string, emoji: string, exclusiveEmojis: 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
|
// Batch-fetches reactions for the given message ids + subscribes to the
|
||||||
@@ -29,10 +33,12 @@ export function useMessageReactions(
|
|||||||
): UseMessageReactionsResult {
|
): UseMessageReactionsResult {
|
||||||
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
||||||
const [rows, setRows] = useState<MessageReaction[]>([]);
|
const [rows, setRows] = useState<MessageReaction[]>([]);
|
||||||
|
const [readyKey, setReadyKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (messageIds.length === 0) {
|
if (messageIds.length === 0) {
|
||||||
setRows([]);
|
setRows([]);
|
||||||
|
setReadyKey(idsKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -40,6 +46,8 @@ export function useMessageReactions(
|
|||||||
setRows(data);
|
setRows(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('listReactionsForMessages failed', err);
|
console.error('listReactionsForMessages failed', err);
|
||||||
|
} finally {
|
||||||
|
setReadyKey(idsKey);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [idsKey]);
|
}, [idsKey]);
|
||||||
@@ -129,5 +137,7 @@ export function useMessageReactions(
|
|||||||
[byMessage, myId, refresh],
|
[byMessage, myId, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { byMessage, toggle, voteExclusive };
|
const ready = readyKey === idsKey;
|
||||||
|
|
||||||
|
return { byMessage, toggle, voteExclusive, ready };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Persists the preferred voice-message playback rate across sessions.
|
||||||
|
// localStorage is fine here — non-sensitive, single source of truth per
|
||||||
|
// device, no cross-device sync needed.
|
||||||
|
|
||||||
|
const KEY = 'chatapp:voice-speed';
|
||||||
|
const ALLOWED = [1, 1.5, 2] as const;
|
||||||
|
export type VoiceSpeed = (typeof ALLOWED)[number];
|
||||||
|
|
||||||
|
export function getVoiceSpeed(): VoiceSpeed {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) return 1;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (ALLOWED.includes(parsed as VoiceSpeed)) return parsed as VoiceSpeed;
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable */
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setVoiceSpeed(speed: VoiceSpeed): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, String(speed));
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable — best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VOICE_SPEEDS = ALLOWED;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Live-cursor pubsub for the multi-user whiteboard. Uses Supabase's
|
||||||
|
// `broadcast` channel rather than `presence` because we want fire-and-forget
|
||||||
|
// position updates (no need to track join/leave) and presence has higher
|
||||||
|
// minimum latency due to its diff-and-merge semantics.
|
||||||
|
//
|
||||||
|
// Throttled to ~30 fps so a continuous drag doesn't flood the channel.
|
||||||
|
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
const THROTTLE_MS = 33; // ~30 fps
|
||||||
|
|
||||||
|
export interface CursorEvent {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
// logical canvas coordinates (matches WhiteboardCanvas internal space)
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CursorSession {
|
||||||
|
send: (x: number, y: number) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCursorSession(
|
||||||
|
whiteboardId: string,
|
||||||
|
self: { userId: string; displayName: string },
|
||||||
|
onCursor: (ev: CursorEvent) => void,
|
||||||
|
): CursorSession {
|
||||||
|
const channel: RealtimeChannel = supabase.channel('wb-cursor:' + whiteboardId, {
|
||||||
|
config: { broadcast: { self: false } },
|
||||||
|
});
|
||||||
|
channel.on('broadcast', { event: 'cursor' }, (payload) => {
|
||||||
|
const ev = payload.payload as CursorEvent | undefined;
|
||||||
|
if (!ev || ev.userId === self.userId) return;
|
||||||
|
onCursor(ev);
|
||||||
|
});
|
||||||
|
channel.subscribe();
|
||||||
|
|
||||||
|
let lastSentAt = 0;
|
||||||
|
let pending: { x: number; y: number } | null = null;
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flush = (): void => {
|
||||||
|
flushTimer = null;
|
||||||
|
if (!pending) return;
|
||||||
|
const { x, y } = pending;
|
||||||
|
pending = null;
|
||||||
|
lastSentAt = Date.now();
|
||||||
|
void channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'cursor',
|
||||||
|
payload: { userId: self.userId, displayName: self.displayName, x, y } satisfies CursorEvent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = (x: number, y: number): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
const since = now - lastSentAt;
|
||||||
|
if (since >= THROTTLE_MS) {
|
||||||
|
pending = { x, y };
|
||||||
|
flush();
|
||||||
|
} else {
|
||||||
|
pending = { x, y };
|
||||||
|
if (flushTimer === null) {
|
||||||
|
flushTimer = setTimeout(flush, THROTTLE_MS - since);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
pending = null;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { send, close };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
import { MessageList, type MessageListHandle } from '../components/MessageList';
|
||||||
|
|
||||||
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
createWatchTogetherPayload,
|
createWatchTogetherPayload,
|
||||||
createGamePayload,
|
createGamePayload,
|
||||||
} from '../lib/conversationFeatures';
|
} from '../lib/conversationFeatures';
|
||||||
|
import { resolveInitialAnchor } from '../lib/scrollController';
|
||||||
const WhiteboardModal = lazy(() =>
|
const WhiteboardModal = lazy(() =>
|
||||||
import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
|
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 { usePeerPresence } from '../lib/usePeerPresence';
|
||||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||||
|
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
|
||||||
|
|
||||||
// Discriminated union for rows inside the virtualized message list. Keeping
|
// Discriminated union for rows inside the virtualized message list. Keeping
|
||||||
// pending bubbles and the "load older" tile inside the same Virtuoso
|
// pending bubbles and the "load older" tile inside the same Virtuoso
|
||||||
// instance means scroll-to-bottom / followOutput stay coherent across both
|
// instance means scroll-to-bottom / followOutput stay coherent across both
|
||||||
// (we don't need a sibling scroll container for pending items).
|
// (we don't need a sibling scroll container for pending items).
|
||||||
type VirtuosoRow =
|
export type VirtuosoRow =
|
||||||
| { kind: 'loader'; key: string }
|
| { kind: 'loader'; key: string }
|
||||||
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
|
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
|
||||||
| { kind: 'pending'; key: string; item: OutboxItem };
|
| { kind: 'pending'; key: string; item: OutboxItem };
|
||||||
@@ -144,6 +146,17 @@ export function ConversationPage() {
|
|||||||
voteExclusive: votePoll,
|
voteExclusive: votePoll,
|
||||||
} = useMessageReactions(messageIds, session?.user.id);
|
} = 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 myId = session?.user.id;
|
||||||
|
|
||||||
const ownMessageIds = useMemo(
|
const ownMessageIds = useMemo(
|
||||||
@@ -200,7 +213,10 @@ export function ConversationPage() {
|
|||||||
});
|
});
|
||||||
}, [id, messages, myId]);
|
}, [id, messages, myId]);
|
||||||
|
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState<string>(() => {
|
||||||
|
if (!id) return '';
|
||||||
|
return getDraftSync(id)?.text ?? '';
|
||||||
|
});
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
@@ -299,11 +315,26 @@ export function ConversationPage() {
|
|||||||
},
|
},
|
||||||
[send],
|
[send],
|
||||||
);
|
);
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
const listRef = useRef<MessageListHandle>(null);
|
||||||
const topmostIndexRef = useRef<number>(0);
|
const topmostIndexRef = useRef<number>(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (firstUnreadComputedRef.current) return;
|
if (firstUnreadComputedRef.current) return;
|
||||||
if (!id || messages.length === 0) return;
|
if (!id || messages.length === 0) return;
|
||||||
@@ -452,18 +483,14 @@ export function ConversationPage() {
|
|||||||
// previous visit to this chat AND the user wasn't sticking to the
|
// previous visit to this chat AND the user wasn't sticking to the
|
||||||
// bottom, restore the saved row index (clamped to the current row
|
// bottom, restore the saved row index (clamped to the current row
|
||||||
// count in case the cache was trimmed).
|
// count in case the cache was trimmed).
|
||||||
const initialTopMostIndex = useMemo(() => {
|
// Reuses the unit-tested resolveInitialAnchor so the "where do I open" rule
|
||||||
const saved = savedPositionRef.current;
|
// lives in one tested place. MessageList only reads this at reveal time (when
|
||||||
if (saved && !saved.stickToBottom) {
|
// rows are loaded), so depending on the row count clamps a stale saved index
|
||||||
return Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1));
|
// correctly without freezing a mount-time count of 0.
|
||||||
}
|
const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
|
||||||
return virtuosoRows.length - 1;
|
const anchor = resolveInitialAnchor(savedPositionRef.current, virtuosoRows.length);
|
||||||
// virtuosoRows.length changes when the conversation loads — that's the
|
return anchor.align === 'end' ? { type: 'bottom' } : { type: 'row', index: anchor.index };
|
||||||
// intentional trigger so a freshly-loaded chat anchors to the bottom
|
}, [virtuosoRows.length]);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
const jumpToMessage = useCallback(
|
const jumpToMessage = useCallback(
|
||||||
(targetId: string) => {
|
(targetId: string) => {
|
||||||
@@ -490,11 +517,7 @@ export function ConversationPage() {
|
|||||||
// resolve the target row. Without this, the scroll either no-ops or
|
// resolve the target row. Without this, the scroll either no-ops or
|
||||||
// lands on a stale row.
|
// lands on a stale row.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
|
||||||
index: rowIndex,
|
|
||||||
align: 'center',
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
setHighlightedId(targetId);
|
setHighlightedId(targetId);
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
@@ -690,23 +713,26 @@ export function ConversationPage() {
|
|||||||
const handleRangeChanged = useCallback(
|
const handleRangeChanged = useCallback(
|
||||||
(range: { startIndex: number; endIndex: number }) => {
|
(range: { startIndex: number; endIndex: number }) => {
|
||||||
topmostIndexRef.current = range.startIndex;
|
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);
|
const prev = scrollPositions.get(id);
|
||||||
scrollPositions.set(id, {
|
scrollPositions.set(id, {
|
||||||
topmostIndex: range.startIndex,
|
topmostIndex: prev?.topmostIndex ?? range.startIndex,
|
||||||
stickToBottom: prev?.stickToBottom ?? true,
|
stickToBottom: true,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
scrollPositions.set(id, { topmostIndex: range.startIndex, stickToBottom: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id],
|
[id, stickToBottom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jumpToBottom = useCallback(() => {
|
const jumpToBottom = useCallback(() => {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToBottom('smooth');
|
||||||
index: 'LAST',
|
|
||||||
align: 'end',
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setNewMessagesWhileAway(0);
|
setNewMessagesWhileAway(0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -726,15 +752,25 @@ export function ConversationPage() {
|
|||||||
const lastPendingCountRef = useRef(pending.length);
|
const lastPendingCountRef = useRef(pending.length);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pending.length > lastPendingCountRef.current) {
|
if (pending.length > lastPendingCountRef.current) {
|
||||||
virtuosoRef.current?.scrollToIndex({
|
listRef.current?.scrollToBottom('auto');
|
||||||
index: 'LAST',
|
|
||||||
align: 'end',
|
|
||||||
behavior: 'auto',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
lastPendingCountRef.current = pending.length;
|
lastPendingCountRef.current = pending.length;
|
||||||
}, [pending.length]);
|
}, [pending.length]);
|
||||||
|
|
||||||
|
// Snap the viewport back to the bottom after a send. The composer
|
||||||
|
// shrinks (cleared text, dismissed reply preview, dropped attachment
|
||||||
|
// thumbs) which lets the Virtuoso area grow vertically — leaving the
|
||||||
|
// just-sent bubble visibly above the new bottom for a frame.
|
||||||
|
// `requestAnimationFrame` defers the scroll until React has committed
|
||||||
|
// the composer-height change, so Virtuoso's ResizeObserver has
|
||||||
|
// already seen the new viewport and `index: 'LAST', align: 'end'`
|
||||||
|
// targets the correct bottom edge.
|
||||||
|
const snapToBottom = useCallback(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
listRef.current?.scrollToBottom('auto');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSend(e?: React.FormEvent) {
|
async function handleSend(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if ((!text.trim() && attachments.length === 0) || sending) return;
|
if ((!text.trim() && attachments.length === 0) || sending) return;
|
||||||
@@ -753,6 +789,8 @@ export function ConversationPage() {
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
|
if (id) clearDraft(id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
setSendError(
|
setSendError(
|
||||||
@@ -778,13 +816,14 @@ export function ConversationPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
||||||
} finally {
|
} finally {
|
||||||
setPollSending(false);
|
setPollSending(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[send, replyTo?.id, notifyStopTyping],
|
[send, replyTo?.id, notifyStopTyping, snapToBottom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateWhiteboard = useCallback(async () => {
|
const handleCreateWhiteboard = useCallback(async () => {
|
||||||
@@ -797,12 +836,13 @@ export function ConversationPage() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setOpenWhiteboardId(board.id);
|
setOpenWhiteboardId(board.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||||
} finally {
|
} finally {
|
||||||
setCreatingWhiteboard(false);
|
setCreatingWhiteboard(false);
|
||||||
}
|
}
|
||||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
const handleStartWatchTogether = useCallback(async () => {
|
const handleStartWatchTogether = useCallback(async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -822,12 +862,13 @@ export function ConversationPage() {
|
|||||||
setWatchDialogOpen(false);
|
setWatchDialogOpen(false);
|
||||||
setWatchUrl('');
|
setWatchUrl('');
|
||||||
setOpenWatchSessionId(ws.id);
|
setOpenWatchSessionId(ws.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
|
||||||
} finally {
|
} finally {
|
||||||
setWatchCreating(false);
|
setWatchCreating(false);
|
||||||
}
|
}
|
||||||
}, [id, watchUrl, send, replyTo?.id]);
|
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
const handleStartGame = useCallback(async (gameType: GameType) => {
|
const handleStartGame = useCallback(async (gameType: GameType) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -854,12 +895,13 @@ export function ConversationPage() {
|
|||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setGameDialogOpen(false);
|
setGameDialogOpen(false);
|
||||||
setOpenGameId(game.id);
|
setOpenGameId(game.id);
|
||||||
|
snapToBottom();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
|
||||||
} finally {
|
} finally {
|
||||||
setGameCreating(false);
|
setGameCreating(false);
|
||||||
}
|
}
|
||||||
}, [id, conversation, myId, send, replyTo?.id]);
|
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
|
||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
@@ -1010,31 +1052,24 @@ export function ConversationPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Virtuoso
|
<MessageList
|
||||||
ref={virtuosoRef}
|
ref={listRef}
|
||||||
className="flex-1"
|
rows={virtuosoRows}
|
||||||
style={{ height: '100%' }}
|
// Deferred reveal: the list stays hidden until messages + reactions
|
||||||
data={virtuosoRows}
|
// + the unread divider are loaded, then anchors and reveals — so the
|
||||||
computeItemKey={(_idx, row) => row.key}
|
// post-paint height cascade is never visible (no chat-switch flicker).
|
||||||
// Initial position: either restored from per-conv memory, or
|
ready={listReady}
|
||||||
// pinned to the bottom for fresh entry. Virtuoso applies this
|
computeKey={(row) => row.key}
|
||||||
// synchronously before its first paint so the user doesn't see
|
initialAnchor={initialAnchor}
|
||||||
// a "loaded at top, then jumped" flicker (matches the layout-
|
// 250px at-bottom tolerance — a tall appended row (image, voice note,
|
||||||
// effect behavior we used in the non-virtualized version).
|
// grouped attachments) shouldn't push the user out of the at-bottom zone.
|
||||||
initialTopMostItemIndex={initialTopMostIndex}
|
atBottomThreshold={250}
|
||||||
// followOutput auto-scrolls only when the user was already at
|
onReachTop={handleStartReached}
|
||||||
// the bottom; returning `false` from the callback when they're
|
onAtBottomChange={handleAtBottomStateChange}
|
||||||
// scrolled up preserves their reading position when realtime
|
onTopRowChange={(topIndex) =>
|
||||||
// messages arrive (critical UX: do NOT jerk the user).
|
handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
|
||||||
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
|
}
|
||||||
atBottomStateChange={handleAtBottomStateChange}
|
renderRow={(_index, row) => {
|
||||||
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) => {
|
|
||||||
if (row.kind === 'loader') {
|
if (row.kind === 'loader') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
|
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
@@ -0,0 +1,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 24–48 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 2–9 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).
|
||||||
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).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -28,7 +28,28 @@ export interface ConvKeyHandle {
|
|||||||
const cache = new Map<string, ConvKeyHandle>();
|
const cache = new Map<string, ConvKeyHandle>();
|
||||||
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
||||||
|
|
||||||
export function clearConvKeyCache(): void { cache.clear(); }
|
// Clear the in-memory conv-key cache. Three modes:
|
||||||
|
// * no args → clear everything (e.g. on logout)
|
||||||
|
// * convId only → clear all key-version entries for this conversation
|
||||||
|
// * convId + v → clear just the specific (conv, version) entry
|
||||||
|
//
|
||||||
|
// Callers that observe a peer rotation or a server-side conv-keys mutation
|
||||||
|
// MUST invalidate the affected entries so subsequent `getOrCreateConvKey` /
|
||||||
|
// `tryGetConvKey` calls re-fetch the canonical bundle from the server
|
||||||
|
// instead of returning a now-stale cached key.
|
||||||
|
export function clearConvKeyCache(conversationId?: string, keyVersion?: number): void {
|
||||||
|
if (conversationId === undefined) {
|
||||||
|
cache.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyVersion !== undefined) {
|
||||||
|
cache.delete(cacheKey(conversationId, keyVersion));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of Array.from(cache.keys())) {
|
||||||
|
if (key.startsWith(conversationId + '@')) cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function listMemberPublicKeys(
|
async function listMemberPublicKeys(
|
||||||
client: AppSupabaseClient,
|
client: AppSupabaseClient,
|
||||||
@@ -110,7 +131,26 @@ export async function bootstrapConvKey(
|
|||||||
p_bundles: bundles,
|
p_bundles: bundles,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const handle = { conversationId, keyVersion, key: convKey };
|
|
||||||
|
// `share_conv_keys` uses `ON CONFLICT (conv, recipient_user_id, key_version)
|
||||||
|
// DO NOTHING`. If a concurrent peer bootstrapped first at the same version,
|
||||||
|
// OUR INSERTs were silently skipped server-side and the row on the server
|
||||||
|
// holds THEIR conv-key, not ours. Trusting the locally-generated key here
|
||||||
|
// would leave both clients with mutually un-decryptable bundles (each
|
||||||
|
// encrypting/decrypting with its own key — exactly the bug that broke
|
||||||
|
// conv aae12d84). Re-fetch our own bundle and unwrap to get the CANONICAL
|
||||||
|
// server key. Whoever wrote first wins; the loser converges.
|
||||||
|
const ownBundle = await fetchKeyBundle(client, conversationId, own.userId, keyVersion);
|
||||||
|
if (!ownBundle) {
|
||||||
|
throw new Error('bootstrapConvKey: own bundle missing after share_conv_keys');
|
||||||
|
}
|
||||||
|
const canonicalKey = await unwrapConvKey(
|
||||||
|
ownBundle.encryptedKey,
|
||||||
|
ownBundle.nonce,
|
||||||
|
ownBundle.sender.senderPublicKey,
|
||||||
|
own.privateKey,
|
||||||
|
);
|
||||||
|
const handle = { conversationId, keyVersion, key: canonicalKey };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
@@ -125,12 +165,25 @@ export async function getOrCreateConvKey(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
const key = await unwrapConvKey(
|
try {
|
||||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
|
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);
|
const handle = { conversationId, keyVersion: version, key };
|
||||||
return handle;
|
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')
|
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||||
.select('recipient_user_id', { count: 'exact', head: true })
|
.select('recipient_user_id', { count: 'exact', head: true })
|
||||||
@@ -138,12 +191,13 @@ export async function getOrCreateConvKey(
|
|||||||
.eq('key_version', version);
|
.eq('key_version', version);
|
||||||
if (cntErr) throw cntErr;
|
if (cntErr) throw cntErr;
|
||||||
if ((count ?? 0) > 0) {
|
if ((count ?? 0) > 0) {
|
||||||
// Rows exist for this version, but none for me. Either I lost the device-key
|
// Rows exist for this version, but none usable for me. Either I lost the
|
||||||
// that originally received my bundle, or my own bundle was wiped by the
|
// device-key that originally received my bundle, my own bundle was wiped
|
||||||
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
|
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
|
||||||
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
|
// bundle is unwrappable (handled in the try/catch above). The only way
|
||||||
// unreadable for me; new ones flow.
|
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
|
||||||
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
|
// we can. Old messages stay unreadable for me; new ones flow.
|
||||||
|
console.info('[conv-key] no usable bundle for me at v' + version + ' — auto-rotating');
|
||||||
return rotateConvKey(client, conversationId, own);
|
return rotateConvKey(client, conversationId, own);
|
||||||
}
|
}
|
||||||
return bootstrapConvKey(client, conversationId, own, version);
|
return bootstrapConvKey(client, conversationId, own, version);
|
||||||
@@ -248,9 +302,24 @@ export async function tryGetConvKey(
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
||||||
if (!bundle) return null;
|
if (!bundle) return null;
|
||||||
const key = await unwrapConvKey(
|
let key: Uint8Array;
|
||||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
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 };
|
const handle = { conversationId, keyVersion, key };
|
||||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||||
return handle;
|
return handle;
|
||||||
|
|||||||
Generated
+20
-14
@@ -65,6 +65,9 @@ importers:
|
|||||||
'@supabase/supabase-js':
|
'@supabase/supabase-js':
|
||||||
specifier: ^2.46.0
|
specifier: ^2.46.0
|
||||||
version: 2.103.3
|
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:
|
better-sqlite3:
|
||||||
specifier: ^11.3.0
|
specifier: ^11.3.0
|
||||||
version: 11.10.0
|
version: 11.10.0
|
||||||
@@ -98,9 +101,6 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.0
|
specifier: ^6.28.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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:
|
zustand:
|
||||||
specifier: ^5.0.1
|
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))
|
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==}
|
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||||
engines: {node: '>=10'}
|
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':
|
'@testing-library/react-native@12.9.0':
|
||||||
resolution: {integrity: sha512-wIn/lB1FjV2N4Q7i9PWVRck3Ehwq5pkhAef5X5/bmQ78J/NoOsGbVY2/DG5Y9Lxw+RfE+GvSEh/fe5Tz6sKSvw==}
|
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.
|
deprecated: React Native Testing Library v12 is no longer maintained. Please upgrade to v13 or v14.
|
||||||
@@ -5479,12 +5488,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
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:
|
react@18.3.1:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -8760,6 +8763,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
defer-to-connect: 2.0.1
|
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)':
|
'@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:
|
dependencies:
|
||||||
jest-matcher-utils: 29.7.0
|
jest-matcher-utils: 29.7.0
|
||||||
@@ -12972,11 +12983,6 @@ snapshots:
|
|||||||
react-shallow-renderer: 16.15.0(react@18.3.1)
|
react-shallow-renderer: 16.15.0(react@18.3.1)
|
||||||
scheduler: 0.23.2
|
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:
|
react@18.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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).');
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Incident fix (2026-06-02): after the netralax.cloud -> netralax.de move, every
|
||||||
|
# storage object GET returned HTTP 500 with:
|
||||||
|
# { "code": "ENODATA", "errno": 61, "message": "The extended attribute does not exist." }
|
||||||
|
#
|
||||||
|
# Root cause: the OBJECT BYTES were copied to the new server, but Supabase
|
||||||
|
# Storage (supabase/storage-api:v1.48.26, file backend) keeps each object's
|
||||||
|
# response metadata in Linux extended attributes (xattrs) on the version file:
|
||||||
|
# user.supabase.content-type
|
||||||
|
# user.supabase.cache-control
|
||||||
|
# user.supabase.etag
|
||||||
|
# The migration copy did not preserve xattrs, so storage's getObject() throws
|
||||||
|
# ENODATA when it reads them. The OLD server is gone, so we cannot re-copy —
|
||||||
|
# but every value we need is still in the database column storage.objects.metadata
|
||||||
|
# (mimetype / cacheControl / eTag). This script reconstructs the missing xattrs
|
||||||
|
# from that column. It is idempotent and only ADDS metadata xattrs; it never
|
||||||
|
# touches object bytes.
|
||||||
|
#
|
||||||
|
# RUN ON THE NEW SERVER (needs /opt/supabase, docker, and root for setxattr):
|
||||||
|
# sudo python3 04-restore-storage-xattrs.py # all buckets
|
||||||
|
# sudo python3 04-restore-storage-xattrs.py --dry-run # show, change nothing
|
||||||
|
# sudo python3 04-restore-storage-xattrs.py --bucket profile-avatars
|
||||||
|
# After it finishes, no storage restart is needed (xattrs are read per request).
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SUPABASE_DIR = "/opt/supabase"
|
||||||
|
# Single-tenant self-hosted layout: <volume>/stub/stub/<bucket>/<name>/<version-file>
|
||||||
|
STORAGE_ROOT = os.path.join(SUPABASE_DIR, "volumes/storage/stub/stub")
|
||||||
|
|
||||||
|
# DB metadata field -> (xattr name, default when the field is absent)
|
||||||
|
XATTRS = [
|
||||||
|
("mimetype", "user.supabase.content-type", "application/octet-stream"),
|
||||||
|
("cacheControl", "user.supabase.cache-control", "no-cache"),
|
||||||
|
("eTag", "user.supabase.etag", None), # None default => skip if missing
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_objects():
|
||||||
|
"""Return [(bucket_id, name, metadata_dict), ...] from storage.objects."""
|
||||||
|
# Tab-separate so object names containing '|' can't break parsing.
|
||||||
|
query = (
|
||||||
|
"select bucket_id||chr(9)||name||chr(9)||coalesce(metadata::text,'{}') "
|
||||||
|
"from storage.objects"
|
||||||
|
)
|
||||||
|
raw = subprocess.check_output(
|
||||||
|
[
|
||||||
|
"docker", "compose", "exec", "-T", "db",
|
||||||
|
"psql", "-U", "postgres", "-d", "postgres", "-tAc", query,
|
||||||
|
],
|
||||||
|
cwd=SUPABASE_DIR,
|
||||||
|
).decode()
|
||||||
|
rows = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.rstrip("\r")
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split("\t", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
bucket, name, meta = parts
|
||||||
|
try:
|
||||||
|
md = json.loads(meta) if meta else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
md = {}
|
||||||
|
rows.append((bucket, name, md))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--bucket", help="only this bucket (e.g. profile-avatars)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="print, change nothing")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
objects = fetch_objects()
|
||||||
|
fixed = missing_dir = missing_file = 0
|
||||||
|
|
||||||
|
for bucket, name, md in objects:
|
||||||
|
if args.bucket and bucket != args.bucket:
|
||||||
|
continue
|
||||||
|
objdir = os.path.join(STORAGE_ROOT, bucket, name)
|
||||||
|
if not os.path.isdir(objdir):
|
||||||
|
print("NO_DIR ", bucket, name)
|
||||||
|
missing_dir += 1
|
||||||
|
continue
|
||||||
|
version_files = [
|
||||||
|
os.path.join(objdir, f)
|
||||||
|
for f in os.listdir(objdir)
|
||||||
|
if os.path.isfile(os.path.join(objdir, f))
|
||||||
|
]
|
||||||
|
if not version_files:
|
||||||
|
print("NO_FILE ", bucket, name)
|
||||||
|
missing_file += 1
|
||||||
|
continue
|
||||||
|
for path in version_files:
|
||||||
|
for field, xattr, default in XATTRS:
|
||||||
|
value = md.get(field, default)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
print(f" would set {xattr}={value!r} on {path}")
|
||||||
|
else:
|
||||||
|
os.setxattr(path, xattr, str(value).encode())
|
||||||
|
fixed += 1
|
||||||
|
print("OK ", bucket, name)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n{'DRY-RUN: would fix' if args.dry_run else 'fixed'} {fixed} file(s); "
|
||||||
|
f"{missing_dir} missing dir(s), {missing_file} empty object dir(s)."
|
||||||
|
)
|
||||||
|
if missing_dir or missing_file:
|
||||||
|
print(
|
||||||
|
"NOTE: objects with a missing dir/file have lost their bytes and "
|
||||||
|
"cannot be recovered from xattrs — those are genuinely gone."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if os.geteuid() != 0 and "--dry-run" not in sys.argv:
|
||||||
|
print("Re-run with sudo (setxattr needs root).", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
main()
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:
|
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-keygen -t ed25519 # only if you don't already have one
|
||||||
ssh-copy-id prox@46.225.156.249
|
# PROD now points at the netralax.de VPS (user "debian"; see config.sh).
|
||||||
ssh prox@46.225.156.249 'echo ok'
|
ssh-copy-id debian@141.95.34.204
|
||||||
|
ssh debian@141.95.34.204 'echo ok'
|
||||||
```
|
```
|
||||||
2. Make the scripts executable:
|
2. Make the scripts executable:
|
||||||
```
|
```
|
||||||
|
|||||||
+13
-5
@@ -5,16 +5,24 @@
|
|||||||
# Customize here when the server IP / domains change — all other scripts pick
|
# Customize here when the server IP / domains change — all other scripts pick
|
||||||
# the values up automatically.
|
# the values up automatically.
|
||||||
|
|
||||||
export PROD_SERVER="46.225.156.249"
|
# End state after the netralax.de migration. The old .cloud VPS was
|
||||||
export PROD_USER="prox"
|
# 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.
|
# Paths on the remote server.
|
||||||
export PROD_SUPABASE_DIR="/opt/supabase"
|
export PROD_SUPABASE_DIR="/opt/supabase"
|
||||||
export PROD_LIVEKIT_DIR="/opt/livekit"
|
export PROD_LIVEKIT_DIR="/opt/livekit"
|
||||||
|
|
||||||
# Public domains (served via Caddy on the same VPS).
|
# Public domains (served via Caddy on the new VPS). Caddy also keeps serving
|
||||||
export PROD_DOMAIN_SUPABASE="supabase.netralax.cloud"
|
# the legacy supabase.netralax.cloud / livekit.netralax.cloud vhosts (same
|
||||||
export PROD_DOMAIN_LIVEKIT="livekit.netralax.cloud"
|
# 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
|
# SSH helper: forwards the standard `-o StrictHostKeyChecking=accept-new` so
|
||||||
# first connections don't prompt. Override SSH_OPTS from the environment if
|
# first connections don't prompt. Override SSH_OPTS from the environment if
|
||||||
|
|||||||
Reference in New Issue
Block a user