Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 997252c4cb | |||
| 890d5dc2b7 | |||
| 759113b9ce | |||
| f981904e7f | |||
| 1fe196b839 | |||
| 1b4cf63e07 | |||
| 18197a9f95 | |||
| 383245ec90 | |||
| 5e59ee22f6 | |||
| be6f5b9c6f | |||
| 2243c646ac | |||
| 3df7cc01ea | |||
| f95858c703 | |||
| d29e2c1174 | |||
| 11a9c8b173 | |||
| d623a59c87 | |||
| 2a2e334f51 | |||
| 9228cc635c | |||
| 7056bfdd1c | |||
| e8a5d6dc5e | |||
| c5851195c7 | |||
| 258ad3511d | |||
| 2583613d7f | |||
| 04d9909e92 | |||
| e05f5445e7 | |||
| a4f7a16c90 | |||
| f6e0e4dd09 | |||
| d449e128a8 | |||
| 5b0aa24a8d | |||
| 540b506f91 | |||
| ede0d00f5b | |||
| ef1f9f45d8 | |||
| 95156a65eb | |||
| 915569db39 | |||
| 526f9d7bcc | |||
| cd79b77e6f | |||
| 7726498218 | |||
| f1d9602a76 | |||
| 45bcbb449c | |||
| ef98efb938 | |||
| 7355b343c8 | |||
| 1a39c1bb33 | |||
| 70209be1de | |||
| 3dbeabd268 | |||
| 6bff163f09 | |||
| ddeedeb71d | |||
| 7e85ffe548 | |||
| 0d30a462b3 | |||
| 745b8cd69d | |||
| 5ace6735d8 | |||
| ffaa6ceb70 | |||
| ccac822cb9 | |||
| 74a0ef6c32 | |||
| 9de2c368bf | |||
| 88345420e0 | |||
| 0354676d2f | |||
| cb7bd99fb4 | |||
| 3b55fcaf2c | |||
| 7ae1d5ba8c | |||
| 50bfb5b137 | |||
| 41359816f1 | |||
| 8ad212291a | |||
| 18a6365586 | |||
| 898b7469bb | |||
| a5eadef663 | |||
| e8074e7da0 | |||
| 88409aff5d | |||
| b7d7a85253 | |||
| 502c6af1b8 | |||
| adc9686035 | |||
| 472665b980 | |||
| c52c65faa9 | |||
| 79f1786ac6 | |||
| ca77214e2d | |||
| 8c27e8eafa | |||
| 69733168ed | |||
| bf87add18d | |||
| c871687fd2 | |||
| 61a1eb37ea | |||
| 9b9802ff6e | |||
| 2463081949 | |||
| 1eea80c529 | |||
| 830dac4cdd | |||
| cb46483f5d | |||
| 87fed820dc | |||
| fca5211008 | |||
| c430a590fc | |||
| 3b884b0415 | |||
| affff0b433 | |||
| a5c0889b0a | |||
| 90fa0a7e95 | |||
| 767db72847 | |||
| 0288d7a476 | |||
| 34b972ec2a | |||
| c55173800b | |||
| fc9f1ec143 | |||
| 12e4b597a0 | |||
| 5c05afb009 | |||
| a636a3c1c1 | |||
| c9fe4879e0 |
@@ -106,6 +106,19 @@ export const CHANNELS = {
|
||||
// Used by the call cinema mode to flip the host BrowserWindow into real
|
||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||
|
||||
// Wipe-on-close — main process pushes this to the renderer right before
|
||||
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
||||
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
||||
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
|
||||
// timeout, whichever comes first).
|
||||
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
|
||||
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
|
||||
|
||||
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
|
||||
// Used by AuthContext on first launch to populate the `devices` row with
|
||||
// a human-readable default device name. Returns null on failure.
|
||||
APP_HOSTNAME: 'app:hostname',
|
||||
} as const;
|
||||
|
||||
// ---- Screen sources ------------------------------------------------------
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
// the old Tauri devUrl so nothing in the renderer code needs to change)
|
||||
// and from the built dist in packaged mode.
|
||||
|
||||
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { CHANNELS } from './ipc-types';
|
||||
import { register as registerAudioLoopback } from './modules/audio-loopback';
|
||||
import { register as registerAppHostname } from './modules/app-hostname';
|
||||
import { register as registerAutostart } from './modules/autostart';
|
||||
import { register as registerFsScoped } from './modules/fs-scoped';
|
||||
import { register as registerNotifications } from './modules/notifications';
|
||||
@@ -253,6 +255,7 @@ if (!gotLock) {
|
||||
registerFsScoped();
|
||||
registerSql();
|
||||
registerAutostart();
|
||||
registerAppHostname();
|
||||
|
||||
// Window-dependent registrars — only call once mainWindow exists so
|
||||
// event emitters have somewhere to send.
|
||||
@@ -263,6 +266,32 @@ if (!gotLock) {
|
||||
registerAudioLoopback(mainWindow);
|
||||
});
|
||||
|
||||
// Wipe-on-close: when the user enables it in Settings, the renderer is given
|
||||
// a chance to clear all sensitive caches before the app process exits. If
|
||||
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
|
||||
// to lose the wipe than to hang the app shutdown.
|
||||
//
|
||||
// Note: there's a separate `before-quit` listener in modules/tray.ts that
|
||||
// tears down the Tray instance. Electron fires both; the tray listener is
|
||||
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
|
||||
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
|
||||
// when our own `app.quit()` below fires `before-quit` a second time.
|
||||
let wipeRequested = false;
|
||||
app.on('before-quit', (event) => {
|
||||
if (wipeRequested) return;
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
wipeRequested = true;
|
||||
event.preventDefault();
|
||||
|
||||
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
|
||||
const done = new Promise<void>((resolve) => {
|
||||
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
|
||||
});
|
||||
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
|
||||
// The renderer has no Node access (contextIsolation is ON), so it must ask
|
||||
// main for this value. Used by AuthContext on first launch to populate the
|
||||
// `devices` row with a human-readable default device name.
|
||||
|
||||
import os from 'node:os';
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { CHANNELS } from '../ipc-types';
|
||||
|
||||
export function register(): void {
|
||||
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
|
||||
try {
|
||||
const h = os.hostname();
|
||||
return typeof h === 'string' && h.length > 0 ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,17 @@ function resolveIconPathDev(): string {
|
||||
return path.join(app.getAppPath(), 'resources', 'icon.ico');
|
||||
}
|
||||
|
||||
// 16×16 grey square — last-ditch fallback when neither the packaged
|
||||
// nor dev icon file resolves. Tray constructor on Windows throws when
|
||||
// handed an empty NativeImage, which would tear down the whole
|
||||
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
|
||||
// taskbar overlay badge wired but the renderer's invoke rejecting
|
||||
// with "No handler registered". A non-empty placeholder keeps the
|
||||
// constructor happy so the IPC handler always gets registered.
|
||||
const FALLBACK_TRAY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
|
||||
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
|
||||
|
||||
function loadTrayIcon(): NativeImage {
|
||||
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
|
||||
try {
|
||||
@@ -39,7 +50,7 @@ function loadTrayIcon(): NativeImage {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return nativeImage.createEmpty();
|
||||
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
|
||||
}
|
||||
|
||||
function buildOverlay(): NativeImage {
|
||||
@@ -53,35 +64,49 @@ function buildOverlay(): NativeImage {
|
||||
}
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
const icon = loadTrayIcon();
|
||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
||||
trayRef.setToolTip('Netralax');
|
||||
// Tray icon is best-effort: if neither the packaged resource nor the
|
||||
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
|
||||
// in packaged builds — only the app icon goes into the .exe metadata),
|
||||
// we still want the taskbar overlay badge to work. setOverlayIcon is
|
||||
// a BrowserWindow method, so it functions even when the systray icon
|
||||
// creation fails.
|
||||
try {
|
||||
const icon = loadTrayIcon();
|
||||
if (!icon.isEmpty()) {
|
||||
trayRef = new Tray(icon);
|
||||
trayRef.setToolTip('Netralax');
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open',
|
||||
click: (): void => {
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open',
|
||||
click: (): void => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: (): void => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
trayRef.setContextMenu(menu);
|
||||
trayRef.on('click', (): void => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: (): void => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
trayRef.setContextMenu(menu);
|
||||
trayRef.on('click', (): void => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
if (mainWindow.isVisible()) mainWindow.focus();
|
||||
else mainWindow.show();
|
||||
});
|
||||
if (mainWindow.isVisible()) mainWindow.focus();
|
||||
else mainWindow.show();
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Swallow: the overlay badge below is the user-visible bit. A missing
|
||||
// systray icon is cosmetic and shouldn't take the unread handler with it.
|
||||
console.warn('[tray] systray init failed, overlay badge still active', err);
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
CHANNELS.TRAY_UNREAD,
|
||||
|
||||
+11
@@ -95,6 +95,17 @@ export interface ElectronAPI {
|
||||
setAutoStart: (enabled: boolean) => Promise<void>;
|
||||
|
||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||
|
||||
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||
* "Cache beim Schließen leeren" Settings toggle. */
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||
|
||||
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
|
||||
* AuthContext on first launch to populate the `devices` row with a
|
||||
* human-readable default device name. Optional: always feature-check
|
||||
* via `typeof window.electronAPI?.getHostname === 'function'` because
|
||||
* the web build has no preload bridge. */
|
||||
getHostname?: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -159,6 +159,27 @@ const api = {
|
||||
// Window fullscreen ------------------------------------------------------
|
||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||
|
||||
// OS hostname ------------------------------------------------------------
|
||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||
|
||||
// Wipe-on-close ----------------------------------------------------------
|
||||
// Subscribe to the main-process pre-quit notification. The renderer's
|
||||
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
|
||||
// unconditionally so main can finish quitting — better to lose the wipe
|
||||
// than to hang the app shutdown if the callback throws.
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
|
||||
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
|
||||
try {
|
||||
await cb();
|
||||
} catch (err) {
|
||||
console.warn('[wipe] renderer cb failed', err);
|
||||
}
|
||||
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
|
||||
};
|
||||
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||||
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ElectronAPI = typeof api;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.18.3",
|
||||
"version": "0.19.1",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
|
||||
import { SpinnerIcon } from './components/icons';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
@@ -168,6 +169,7 @@ export function App() {
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
<CrashToast />
|
||||
<RemoteRevokedScreen />
|
||||
</HashRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||
import { useMentionNotifications } from '../lib/useMentionNotifications';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { CallUI } from './CallUI';
|
||||
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||
@@ -13,6 +14,7 @@ import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session } = useAuth();
|
||||
useMentionNotifications(session?.user.id);
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
// re-asking if the user already responded this session.
|
||||
|
||||
@@ -5,9 +5,15 @@ import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
import { Lightbox } from './Lightbox';
|
||||
import { ViewOnceImage } from './ViewOnceImage';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
/** True iff the local user sent this message. View-once images use this
|
||||
* to suppress the burn (senders never burn their own attachment) and to
|
||||
* pick the right preview chrome. Defaults to false so existing callers
|
||||
* (e.g. MediaFilesDrawer) keep working without modification. */
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
// Max inline-preview dimension. Full-resolution stays available for the
|
||||
@@ -44,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export function AttachmentImage({ handle }: Props) {
|
||||
export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -116,6 +122,25 @@ export function AttachmentImage({ handle }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// View-once attachments swap the standard image preview for the ViewOnceImage
|
||||
// chrome (locked card → fullscreen lightbox + burn for recipient, normal
|
||||
// image with badge for sender). We still kick off the decrypt above so the
|
||||
// fullscreen lightbox has the decoded blob ready the moment the recipient
|
||||
// taps. `handle.viewedAt` is undefined in the current renderer (it lives on
|
||||
// the public attachment row, not in the encrypted payload) — the component
|
||||
// treats undefined as "not yet burned" and uses the RPC response to flip
|
||||
// the state locally after the recipient opens.
|
||||
if (handle.viewOnce) {
|
||||
return (
|
||||
<ViewOnceImage
|
||||
attachmentId={handle.id}
|
||||
viewedAt={handle.viewedAt ?? null}
|
||||
isSender={mine}
|
||||
src={blobUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import {
|
||||
CrownIcon,
|
||||
HeadphonesOffIcon,
|
||||
@@ -89,6 +90,7 @@ export interface ParticipantTileProps {
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
const {
|
||||
userId,
|
||||
displayName,
|
||||
me,
|
||||
muted,
|
||||
@@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
onContextMenu,
|
||||
} = props;
|
||||
|
||||
// Apply the per-viewer nickname override once at the top — each tile is
|
||||
// already per-participant, so a single hook call is fine. The resolved
|
||||
// name is forwarded into the avatar sub-components below so their initial
|
||||
// letter respects the nickname too.
|
||||
const resolvedName = useNickname(userId, displayName);
|
||||
const tileProps = { ...props, displayName: resolvedName };
|
||||
|
||||
const small = size === 'small';
|
||||
// Discord-style: full tile border switches to emerald the whole time the
|
||||
// user is speaking. Same treatment for audio + video tiles so the visual
|
||||
@@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
}
|
||||
>
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
<AudioContent {...tileProps} small={small} />
|
||||
)}
|
||||
|
||||
{speaking && (
|
||||
@@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{resolvedName}
|
||||
{me ? ' (du)' : ''}
|
||||
</span>
|
||||
{e2ee && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
import { PinnedMessagesPill } from './PinnedMessagesPill';
|
||||
|
||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
online: 'bg-emerald-500',
|
||||
@@ -29,6 +31,8 @@ interface Props {
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
pinnedCount?: number;
|
||||
onOpenPinned?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationHeader({
|
||||
@@ -38,6 +42,8 @@ export function ConversationHeader({
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
pinnedCount = 0,
|
||||
onOpenPinned,
|
||||
}: Props) {
|
||||
if (!conversation) {
|
||||
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
@@ -48,10 +54,12 @@ export function ConversationHeader({
|
||||
<HeaderBar
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
pinnedCount={pinnedCount}
|
||||
{...(onInfoClick ? { onInfoClick } : {})}
|
||||
{...(onMediaClick ? { onMediaClick } : {})}
|
||||
{...(onProfileClick ? { onProfileClick } : {})}
|
||||
{...(onSearchClick ? { onSearchClick } : {})}
|
||||
{...(onOpenPinned ? { onOpenPinned } : {})}
|
||||
/>
|
||||
{/* Voice-channel rail rendered separately in ConversationPage so it
|
||||
can sit between the message surface and the in-call dock. */}
|
||||
@@ -66,6 +74,8 @@ interface HeaderBarProps {
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
pinnedCount?: number;
|
||||
onOpenPinned?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({
|
||||
@@ -75,11 +85,19 @@ function HeaderBar({
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
pinnedCount = 0,
|
||||
onOpenPinned,
|
||||
}: HeaderBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
const isDm = conversation.type === 'dm';
|
||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||
// For DMs, route the peer's display name through the per-viewer nickname
|
||||
// override. Falls back to the real displayName when no nickname is set.
|
||||
const peerName = useNickname(
|
||||
conversation.peer?.userId,
|
||||
conversation.peer?.displayName ?? '?',
|
||||
);
|
||||
const title = isDm ? peerName : (conversation.name ?? '?');
|
||||
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||
const peerAvatar = isDm
|
||||
? (conversation.peer?.avatarUrl ?? null)
|
||||
@@ -133,6 +151,7 @@ function HeaderBar({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<PinnedMessagesPill count={pinnedCount} onClick={() => onOpenPinned?.()} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isDm ? (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
// Reusable empty-state placeholder: large icon + heading + description +
|
||||
// optional primary CTA. Used everywhere there's a meaningfully-empty list
|
||||
// (no chats, no friends, no search results, fresh conversation).
|
||||
export function EmptyState({ icon, title, description, action }: Props) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
|
||||
<p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
featuredGifs,
|
||||
getRecentGifs,
|
||||
type GifResult,
|
||||
rememberRecentGif,
|
||||
searchGifs,
|
||||
} from '../lib/tenor';
|
||||
import { SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (gif: GifResult) => void;
|
||||
}
|
||||
|
||||
type Tab = 'trending' | 'search' | 'recent';
|
||||
|
||||
export function GifPicker({ open, onClose, onPick }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('trending');
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<GifResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const recent = useMemo(() => getRecentGifs(), [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const run = async () => {
|
||||
try {
|
||||
const gifs = tab === 'search' && query.trim().length > 0
|
||||
? await searchGifs(query)
|
||||
: tab === 'trending'
|
||||
? await featuredGifs()
|
||||
: [];
|
||||
if (!cancelled) setResults(gifs);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, tab, query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const visible = tab === 'recent' ? recent : results;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
|
||||
<header className="flex items-center justify-between border-b border-line px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
|
||||
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
{tab === 'search' && (
|
||||
<div className="border-b border-line px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="GIFs suchen…"
|
||||
className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
|
||||
{loading && (
|
||||
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
|
||||
{error === 'giphy_api_key_missing'
|
||||
? 'GIFs sind nicht konfiguriert (VITE_GIPHY_API_KEY fehlt).'
|
||||
: 'GIFs gerade nicht verfügbar.'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && visible.length === 0 && (
|
||||
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
|
||||
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && visible.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
|
||||
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
|
||||
title={g.description}
|
||||
>
|
||||
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||
export type AnnotatorWidth = 2 | 4 | 8;
|
||||
|
||||
export interface AnnotatorOp {
|
||||
tool: AnnotatorTool;
|
||||
color: AnnotatorColor;
|
||||
width: AnnotatorWidth;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
from?: { x: number; y: number };
|
||||
to?: { x: number; y: number };
|
||||
text?: string;
|
||||
at?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onCancel: () => void;
|
||||
onSave: (next: File) => void;
|
||||
}
|
||||
|
||||
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
|
||||
// Defaults shown here are intentional so T2's pointer handlers work
|
||||
// immediately with sensible behavior before T3's UI is wired.
|
||||
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancel();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
if (draftRef.current) {
|
||||
renderOp(ctx, draftRef.current);
|
||||
}
|
||||
}, [imageLoaded, ops, draftTick]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const handleUndo = () => {
|
||||
setOps((cur) => {
|
||||
if (cur.length === 0) return cur;
|
||||
const next = cur.slice(0, -1);
|
||||
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
setRedoStack((r) => {
|
||||
if (r.length === 0) return r;
|
||||
const top = r[r.length - 1]!;
|
||||
setOps((cur) => [...cur, top]);
|
||||
return r.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setOps([]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
console.error('ImageAnnotator: toBlob returned null');
|
||||
return;
|
||||
}
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||
const next = new File([blob], baseName + '-annotated.png', {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
onSave(next);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const p = canvasPoint(e);
|
||||
|
||||
if (tool === 'text') {
|
||||
const value = window.prompt(
|
||||
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||
'',
|
||||
);
|
||||
if (value !== null && value.trim().length > 0) {
|
||||
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'pen' || tool === 'highlighter') {
|
||||
draftRef.current = { tool, color, width, points: [p] };
|
||||
} else {
|
||||
draftRef.current = { tool, color, width, from: p, to: p };
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const p = canvasPoint(e);
|
||||
const cur = draftRef.current;
|
||||
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||
cur.points = [...(cur.points ?? []), p];
|
||||
} else {
|
||||
cur.to = p;
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const cur = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!cur) return;
|
||||
const hasContent =
|
||||
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||
? (cur.points?.length ?? 0) >= 2
|
||||
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||
if (hasContent) {
|
||||
setOps((p) => [...p, cur]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{imageLoaded ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||
const label = t('app:annotator.tool.' + id, {
|
||||
defaultValue:
|
||||
id === 'pen' ? 'Stift'
|
||||
: id === 'highlighter' ? 'Marker'
|
||||
: id === 'arrow' ? 'Pfeil'
|
||||
: id === 'rect' ? 'Rechteck'
|
||||
: id === 'circle' ? 'Kreis'
|
||||
: 'Text',
|
||||
});
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{toolGlyph(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={ops.length === 0}
|
||||
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={redoStack.length === 0}
|
||||
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toolGlyph(t: AnnotatorTool): string {
|
||||
switch (t) {
|
||||
case 'pen': return '✎';
|
||||
case 'highlighter': return '🖍';
|
||||
case 'arrow': return '↗';
|
||||
case 'rect': return '▭';
|
||||
case 'circle': return '◯';
|
||||
case 'text': return 'T';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = op.color;
|
||||
ctx.fillStyle = op.color;
|
||||
ctx.lineWidth = op.width;
|
||||
|
||||
switch (op.tool) {
|
||||
case 'pen': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'highlighter': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = op.width * 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.strokeRect(
|
||||
Math.min(from.x, to.x),
|
||||
Math.min(from.y, to.y),
|
||||
Math.abs(to.x - from.x),
|
||||
Math.abs(to.y - from.y),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = (from.y + to.y) / 2;
|
||||
const rx = Math.abs(to.x - from.x) / 2;
|
||||
const ry = Math.abs(to.y - from.y) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'arrow': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const head = Math.max(12, op.width * 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const { at, text } = op;
|
||||
if (!at || !text) break;
|
||||
const fontSize = Math.max(14, op.width * 6);
|
||||
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(text, at.x, at.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -1542,8 +1542,8 @@ function FullscreenCall({
|
||||
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
type Member = ConversationSummary['members'][number];
|
||||
|
||||
interface Props {
|
||||
members: ConversationSummary['members'];
|
||||
query: string;
|
||||
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
|
||||
aria-label="Mitglieder"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
|
||||
>
|
||||
{matches.map((m, idx) => {
|
||||
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||
const handle = m.profile?.username ?? '';
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={m.userId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onClick={() => {
|
||||
if (m.profile?.username) onSelect(m.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={m.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{matches.map((m, idx) => (
|
||||
<MemberRow
|
||||
key={m.userId}
|
||||
member={m}
|
||||
isActive={idx === active}
|
||||
onActivate={() => setActive(idx)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Extracted per-row component so we can call `useNickname` once per member
|
||||
// at the top of THIS component (hooks can't run inside .map callbacks).
|
||||
function MemberRow({
|
||||
member,
|
||||
isActive,
|
||||
onActivate,
|
||||
onSelect,
|
||||
}: {
|
||||
member: Member;
|
||||
isActive: boolean;
|
||||
onActivate: () => void;
|
||||
onSelect: (username: string) => void;
|
||||
}) {
|
||||
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
|
||||
const name = useNickname(member.userId, fallback);
|
||||
const handle = member.profile?.username ?? '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={onActivate}
|
||||
onClick={() => {
|
||||
if (member.profile?.username) onSelect(member.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={member.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
PinIcon,
|
||||
ReplyIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
@@ -75,6 +77,10 @@ interface Props {
|
||||
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||
/** Highlighted state — set briefly after a jump. */
|
||||
highlighted?: boolean;
|
||||
/** True iff this message is currently pinned (parent maintains the set). */
|
||||
isPinned?: boolean;
|
||||
/** Right-click menu → toggle pin/unpin for this message id. */
|
||||
onTogglePin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -96,10 +102,18 @@ export function MessageBubble({
|
||||
onForward,
|
||||
onAvatarClick,
|
||||
highlighted = false,
|
||||
isPinned = false,
|
||||
onTogglePin,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
|
||||
// Per-viewer nickname override for the message sender. Fallback chain
|
||||
// keeps the existing behavior when no nickname is set. Skipped for the
|
||||
// quoted-sender snippet — that's a different user.
|
||||
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
|
||||
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
|
||||
|
||||
const parsed = parseMessagePayload(message.plaintext);
|
||||
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
||||
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
|
||||
@@ -281,7 +295,7 @@ export function MessageBubble({
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
displayName={resolvedSenderDisplayName}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
@@ -299,7 +313,7 @@ export function MessageBubble({
|
||||
parsed={parsed}
|
||||
mine={mine}
|
||||
time={time}
|
||||
senderDisplayName={senderDisplayName ?? null}
|
||||
senderDisplayName={resolvedSenderDisplayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -315,7 +329,7 @@ export function MessageBubble({
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
displayName={resolvedSenderDisplayName}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
@@ -416,11 +430,44 @@ export function MessageBubble({
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic text-fg-muted opacity-60">
|
||||
<span
|
||||
className={
|
||||
'italic ' +
|
||||
// Mine = blue/accent bubble → use accent-fg with reduced opacity
|
||||
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
|
||||
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
{t('app:chats.unreadable', {
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'whiteboard' ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-fg">Whiteboard</div>
|
||||
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chatapp:open-whiteboard', { detail: { id: parsed.whiteboardId } }),
|
||||
);
|
||||
}}
|
||||
disabled={!parsed.whiteboardId}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Öffnen
|
||||
</button>
|
||||
</div>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
@@ -444,7 +491,7 @@ export function MessageBubble({
|
||||
return <AttachmentAudio key={a.id} handle={a} />;
|
||||
}
|
||||
if (a.mimeType.startsWith('image/')) {
|
||||
return <AttachmentImage key={a.id} handle={a} />;
|
||||
return <AttachmentImage key={a.id} handle={a} mine={mine} />;
|
||||
}
|
||||
if (a.mimeType.startsWith('video/')) {
|
||||
return <AttachmentVideo key={a.id} handle={a} />;
|
||||
@@ -628,6 +675,15 @@ export function MessageBubble({
|
||||
setContextMenu(null);
|
||||
void handleDelete();
|
||||
}}
|
||||
isPinned={isPinned}
|
||||
{...(onTogglePin
|
||||
? {
|
||||
onTogglePin: () => {
|
||||
setContextMenu(null);
|
||||
onTogglePin(message.id);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -725,6 +781,8 @@ function MessageContextMenu({
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -739,6 +797,8 @@ function MessageContextMenu({
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -805,6 +865,13 @@ function MessageContextMenu({
|
||||
tone="danger"
|
||||
/>
|
||||
)}
|
||||
{onTogglePin && (
|
||||
<MenuItem
|
||||
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
|
||||
icon={<PinIcon className="h-4 w-4" />}
|
||||
onClick={onTogglePin}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getNickname, setNickname } from '../lib/friendNicknames';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue(getNickname(userId) ?? '');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const submit = (): void => {
|
||||
setNickname(userId, value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Spitzname setzen"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
maxLength={32}
|
||||
placeholder={displayName}
|
||||
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PinnedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import { PinIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
pins: PinnedMessage[];
|
||||
onClose: () => void;
|
||||
onJump: (messageId: string) => void;
|
||||
onUnpin: (messageId: string) => void;
|
||||
// Optional preview-renderer: parent resolves messageId → short text/snippet
|
||||
// since the panel itself doesn't decrypt. If absent, the panel just shows
|
||||
// the message-id stub.
|
||||
renderPreview?: (messageId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label="Angepinnte Nachrichten"
|
||||
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PinIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
{pins.length === 0 ? (
|
||||
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{pins.map((p) => (
|
||||
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(p.messageId)}
|
||||
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
|
||||
>
|
||||
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
|
||||
</button>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
|
||||
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnpin(p.messageId)}
|
||||
className="cursor-pointer text-rose-400 hover:underline"
|
||||
>
|
||||
Anheftung entfernen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PinIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Compact chip rendered in the conv header that opens the pinned panel.
|
||||
// Renders nothing when count is 0 so a fresh conv shows no clutter.
|
||||
export function PinnedMessagesPill({ count, onClick }: Props) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
title="Angepinnte Nachrichten anzeigen"
|
||||
>
|
||||
<PinIcon className="h-3 w-3 text-accent" />
|
||||
<span>{count} angepinnt</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export function RemoteRevokedScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { revokedRemotely, acknowledgeRevocation } = useAuth();
|
||||
|
||||
if (!revokedRemotely) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remote-revoked-title"
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
|
||||
>
|
||||
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
|
||||
<h2
|
||||
id="remote-revoked-title"
|
||||
className="mb-2 font-display text-xl font-semibold text-fg"
|
||||
>
|
||||
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-fg-muted">
|
||||
{t('app:auth.revoked_body', {
|
||||
defaultValue:
|
||||
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeRevocation}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||
import {
|
||||
changePin,
|
||||
type LegacyMigrationReport,
|
||||
@@ -19,6 +20,7 @@ export function SecurityCenter({ userId }: Props) {
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<string | null>(null);
|
||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
@@ -126,6 +128,27 @@ export function SecurityCenter({ userId }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Cache beim Schließen leeren
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
|
||||
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
|
||||
</p>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wipeOnClose}
|
||||
onChange={(e) => {
|
||||
setWipeOnClose(e.target.checked);
|
||||
setWipeOnCloseState(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>Aktivieren</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
|
||||
import { getPttSettings } from '../lib/pttSettings';
|
||||
import { codeToShortcut } from '../lib/globalShortcut';
|
||||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
subscribeSoundboardChanges,
|
||||
updateSound,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
|
||||
import { Modal } from './Modal';
|
||||
import {
|
||||
AlertIcon,
|
||||
@@ -46,6 +49,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const { badges } = useSoundboardSync();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -167,6 +171,11 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
}
|
||||
setBusyId(id);
|
||||
try {
|
||||
try {
|
||||
await deleteRemoteSound(supabase, id);
|
||||
} catch (err) {
|
||||
console.warn('remote sound delete failed (local delete proceeds)', err);
|
||||
}
|
||||
await deleteSound(id);
|
||||
invalidateSoundCache(id);
|
||||
if (previewingId === id) stopPreview();
|
||||
@@ -301,6 +310,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
entriesTotal={entries}
|
||||
busyId={busyId}
|
||||
previewingId={previewingId}
|
||||
badges={badges}
|
||||
onPatch={handlePatch}
|
||||
onDelete={handleDelete}
|
||||
onPreview={handlePreview}
|
||||
@@ -323,6 +333,7 @@ interface GroupProps {
|
||||
entriesTotal: SoundboardEntry[];
|
||||
busyId: string | null;
|
||||
previewingId: string | null;
|
||||
badges: Map<string, SyncBadge>;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
@@ -335,6 +346,7 @@ function SoundboardCategoryGroup({
|
||||
entriesTotal,
|
||||
busyId,
|
||||
previewingId,
|
||||
badges,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
@@ -370,6 +382,7 @@ function SoundboardCategoryGroup({
|
||||
isLast={idx === entries.length - 1}
|
||||
busy={busyId === entry.id}
|
||||
previewing={previewingId === entry.id}
|
||||
badge={badges.get(entry.id)}
|
||||
onPatch={onPatch}
|
||||
onDelete={onDelete}
|
||||
onPreview={onPreview}
|
||||
@@ -392,6 +405,7 @@ interface RowProps {
|
||||
isLast: boolean;
|
||||
busy: boolean;
|
||||
previewing: boolean;
|
||||
badge: SyncBadge | undefined;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
@@ -406,6 +420,7 @@ function SoundboardRow({
|
||||
isLast,
|
||||
busy,
|
||||
previewing,
|
||||
badge,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
@@ -503,6 +518,13 @@ function SoundboardRow({
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||
<span
|
||||
title={badgeTitle(badge)}
|
||||
aria-label={badgeTitle(badge)}
|
||||
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{badgeGlyph(badge)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -623,3 +645,25 @@ function SoundboardRow({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function badgeGlyph(b: SyncBadge | undefined): string {
|
||||
switch (b) {
|
||||
case 'uploading': return '↑';
|
||||
case 'downloading': return '↓';
|
||||
case 'error': return '⚠';
|
||||
case 'synced':
|
||||
default: return '☁';
|
||||
}
|
||||
}
|
||||
|
||||
function badgeTitle(b: SyncBadge | undefined): string {
|
||||
switch (b) {
|
||||
case 'uploading': return 'Hochladen…';
|
||||
case 'downloading': return 'Wird heruntergeladen…';
|
||||
case 'error': return 'Synchronisationsfehler';
|
||||
case 'synced':
|
||||
default: return 'Synchronisiert';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { EyeOffIcon, LockIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
attachmentId: string;
|
||||
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
|
||||
viewedAt: string | null;
|
||||
/** True iff the local user is the sender — they don't burn the view. */
|
||||
isSender: boolean;
|
||||
/** Decrypted image source; only fetched/displayed inside the lightbox. */
|
||||
src: string;
|
||||
}
|
||||
|
||||
// Three states:
|
||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
||||
// 2. viewedAt is set → tombstone "Angesehen am …".
|
||||
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
|
||||
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const burned = revealedAt !== null;
|
||||
|
||||
if (burned && !isSender) {
|
||||
return (
|
||||
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
||||
<EyeOffIcon className="mr-2 h-4 w-4" />
|
||||
Angesehen am {new Date(revealedAt).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSender) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<img src={src} alt="" className="max-h-72 rounded-lg" />
|
||||
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
|
||||
</span>
|
||||
{burned && (
|
||||
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
Angesehen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient, not yet viewed.
|
||||
const handleOpen = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||
} catch (err) {
|
||||
console.warn('mark-viewed failed', err);
|
||||
}
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpen()}
|
||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
||||
>
|
||||
<LockIcon className="h-6 w-6 text-accent" />
|
||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
||||
</button>
|
||||
{fullscreen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
||||
onClick={() => setFullscreen(false)}
|
||||
>
|
||||
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||
|
||||
export type WhiteboardTool = 'pen' | 'eraser';
|
||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||
export type WhiteboardWidth = 2 | 4 | 8;
|
||||
|
||||
export interface WhiteboardStrokePayload {
|
||||
tool: WhiteboardTool;
|
||||
color: WhiteboardColor;
|
||||
width: WhiteboardWidth;
|
||||
// [x, y, t-ms-since-stroke-start]
|
||||
points: Array<[number, number, number]>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
strokes: WhiteboardStroke[];
|
||||
tool: WhiteboardTool;
|
||||
color: WhiteboardColor;
|
||||
width: WhiteboardWidth;
|
||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||
logicalWidth?: number;
|
||||
logicalHeight?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGICAL_W = 1280;
|
||||
const DEFAULT_LOGICAL_H = 720;
|
||||
|
||||
export function WhiteboardCanvas({
|
||||
strokes,
|
||||
tool,
|
||||
color,
|
||||
width,
|
||||
onStroke,
|
||||
logicalWidth = DEFAULT_LOGICAL_W,
|
||||
logicalHeight = DEFAULT_LOGICAL_H,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||
const strokeStartRef = useRef<number>(0);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.width = logicalWidth;
|
||||
cv.height = logicalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
for (const s of strokes) {
|
||||
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
|
||||
if (payload) renderStroke(ctx, payload);
|
||||
}
|
||||
if (draftRef.current) renderStroke(ctx, draftRef.current);
|
||||
});
|
||||
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const [x, y] = canvasPoint(e);
|
||||
strokeStartRef.current = Date.now();
|
||||
draftRef.current = {
|
||||
tool,
|
||||
color,
|
||||
width,
|
||||
points: [[x, y, 0]],
|
||||
};
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const [x, y] = canvasPoint(e);
|
||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const draft = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!draft) return;
|
||||
if (draft.points.length < 2) {
|
||||
forceTick((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
onStroke(draft);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
s: Partial<WhiteboardStrokePayload>,
|
||||
): void {
|
||||
const points = Array.isArray(s.points) ? s.points : null;
|
||||
if (!points || points.length < 1) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
|
||||
if (s.tool === 'eraser') {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
|
||||
} else {
|
||||
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0]![0], points[0]![1]);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i]![0], points[i]![1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes';
|
||||
import { XIcon } from './icons';
|
||||
import {
|
||||
WhiteboardCanvas,
|
||||
type WhiteboardColor,
|
||||
type WhiteboardTool,
|
||||
type WhiteboardWidth,
|
||||
} from './WhiteboardCanvas';
|
||||
|
||||
interface Props {
|
||||
whiteboardId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7'];
|
||||
const WIDTHS: WhiteboardWidth[] = [2, 4, 8];
|
||||
|
||||
export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId);
|
||||
const [tool, setTool] = useState<WhiteboardTool>('pen');
|
||||
const [color, setColor] = useState<WhiteboardColor>('#000000');
|
||||
const [width, setWidth] = useState<WhiteboardWidth>(4);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleConfirmClear = async () => {
|
||||
setConfirmClear(false);
|
||||
try {
|
||||
await clearAll();
|
||||
} catch (err) {
|
||||
console.error('clearAll failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:whiteboard.close', { defaultValue: 'Schließen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{loading ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-rose-400">
|
||||
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
|
||||
</p>
|
||||
) : (
|
||||
<WhiteboardCanvas
|
||||
strokes={strokes}
|
||||
tool={tool}
|
||||
color={color}
|
||||
width={width}
|
||||
onStroke={(payload) => void insertStroke(payload)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
|
||||
const label = id === 'pen' ? 'Stift' : 'Radierer';
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{id === 'pen' ? '✎' : '⌫'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{COLORS.map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{WIDTHS.map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{confirmClear ? (
|
||||
<>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
||||
>
|
||||
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmClear()}
|
||||
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
|
||||
>
|
||||
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={strokes.length === 0}
|
||||
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOwnDevices } from '../../hooks/useOwnDevices';
|
||||
import { getDeviceRowId } from '../../lib/deviceRowId';
|
||||
import { SpinnerIcon } from '../icons';
|
||||
|
||||
function formatLastSeen(iso: string, locale: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function DeviceListTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { devices, loading, error, revoke } = useOwnDevices();
|
||||
const ownId = getDeviceRowId();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
setOpError(null);
|
||||
setBusy(id);
|
||||
try {
|
||||
await revoke(id);
|
||||
} catch (err) {
|
||||
setOpError(err instanceof Error ? err.message : 'revoke failed');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
|
||||
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{opError && (
|
||||
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
|
||||
{opError}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{devices.map((d) => {
|
||||
const isOwn = d.id === ownId;
|
||||
const isRevoked = d.revokedAt !== null;
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
|
||||
{isOwn && (
|
||||
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
|
||||
</span>
|
||||
)}
|
||||
{isRevoked && (
|
||||
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
|
||||
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
|
||||
{formatLastSeen(d.lastSeenAt, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
{isOwn ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
|
||||
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
|
||||
>
|
||||
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
|
||||
</button>
|
||||
) : isRevoked ? (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(d.id)}
|
||||
disabled={busy === d.id}
|
||||
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
{busy === d.id
|
||||
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
|
||||
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
fetchUserKeyBlob,
|
||||
getOwnProfile,
|
||||
listOwnDevices,
|
||||
registerDevice,
|
||||
signOut as supabaseSignOut,
|
||||
touchDeviceLastSeen,
|
||||
type Profile,
|
||||
updateOwnProfile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import {
|
||||
createContext,
|
||||
@@ -19,7 +23,10 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { wipeLocalState } from '../lib/memoryWipe';
|
||||
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
|
||||
@@ -48,6 +55,8 @@ interface AuthContextValue {
|
||||
refreshProfile: () => Promise<void>;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
revokedRemotely: boolean;
|
||||
acknowledgeRevocation: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -58,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const [revokedRemotely, setRevokedRemotely] = useState(false);
|
||||
const autoOnlineUserRef = useRef<string | null>(null);
|
||||
|
||||
// Initial session + auth subscription. We verify the cached JWT against the
|
||||
@@ -194,6 +204,70 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
void registerWebPush(installId);
|
||||
}, [session]);
|
||||
|
||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||
// pure session-list telemetry — it does not carry any cryptographic
|
||||
// material since the per-user-key refactor. We re-use the row across
|
||||
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
|
||||
// intentionally treated as "new install".
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const existing = getDeviceRowId();
|
||||
if (existing) {
|
||||
const rows = await listOwnDevices(supabase);
|
||||
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
|
||||
if (match) {
|
||||
await touchDeviceLastSeen(supabase, existing).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Row gone / revoked: drop the stale id and fall through to
|
||||
// registering a fresh one.
|
||||
clearDeviceRowId();
|
||||
}
|
||||
if (cancelled) return;
|
||||
const hostname =
|
||||
(typeof window.electronAPI?.getHostname === 'function'
|
||||
? await window.electronAPI.getHostname().catch(() => null)
|
||||
: null) ?? 'Desktop';
|
||||
// Map Node's process.platform values to the schema's device_platform
|
||||
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
|
||||
// to 'linux' for unknown/web-build cases — the field is only used
|
||||
// for the session-list UI icon.
|
||||
const osPlatform = window.electronAPI?.osPlatform;
|
||||
const platform: DevicePlatform =
|
||||
osPlatform === 'win32'
|
||||
? 'windows'
|
||||
: osPlatform === 'darwin'
|
||||
? 'macos'
|
||||
: 'linux';
|
||||
const created = await registerDevice(supabase, {
|
||||
name: hostname.slice(0, 64),
|
||||
platform,
|
||||
});
|
||||
if (!cancelled) setDeviceRowId(created.id);
|
||||
} catch (err) {
|
||||
console.warn('ensure device row failed', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
// Wipe-on-close: register a handler the main process pings on `before-quit`
|
||||
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
|
||||
// Electron (web build has no preload bridge) or when the toggle is off.
|
||||
useEffect(() => {
|
||||
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
|
||||
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
|
||||
if (!isWipeOnCloseEnabled()) return;
|
||||
await wipeLocalState(session?.user.id ?? null);
|
||||
});
|
||||
return unsub;
|
||||
}, [session?.user.id]);
|
||||
|
||||
// Auto online/offline transition.
|
||||
//
|
||||
// - On mount with a session whose last persisted state is `offline`, flip
|
||||
@@ -235,12 +309,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [session, profile, refreshProfile]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
const uid = session?.user.id ?? null;
|
||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||
console.warn('offline update before sign-out failed', err);
|
||||
});
|
||||
await supabaseSignOut(supabase);
|
||||
await wipeLocalState(uid);
|
||||
}, [session]);
|
||||
|
||||
const acknowledgeRevocation = useCallback(() => {
|
||||
setRevokedRemotely(false);
|
||||
}, []);
|
||||
|
||||
// Phase 3: listen for own-device revocations. The same channel also fires
|
||||
// when *another* of the user's installs is revoked — we ignore those (we
|
||||
// only force-sign-out when OUR row's revoked_at flips). The UI's device
|
||||
// list refetches independently via its own subscription in useOwnDevices.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const userId = session.user.id;
|
||||
const channel = supabase
|
||||
.channel('devices:self:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
(payload) => {
|
||||
const ownId = getDeviceRowId();
|
||||
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
|
||||
if (!row || !ownId) return;
|
||||
if (row.id !== ownId) return;
|
||||
if (row.revoked_at) {
|
||||
setRevokedRemotely(true);
|
||||
void signOut().catch((err) => {
|
||||
console.warn('forced signOut after revoke failed', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [session, signOut]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
@@ -250,8 +366,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
refreshProfile,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
}),
|
||||
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
|
||||
[
|
||||
session,
|
||||
profile,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshProfile,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1442,20 +1442,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// the same system audio twice and Chromium's version would
|
||||
// include our own renderer playback.
|
||||
audio: preferNativeLoopback ? false : wantAudio,
|
||||
// Preset dims are passed as `ideal` constraints (not fixed width/height)
|
||||
// so Chromium picks the closest match preserving the source's aspect
|
||||
// ratio instead of CROPPING non-matching monitors. Without this, a 16:10
|
||||
// monitor (1920×1200, 2560×1600) streamed under a 16:9 preset loses its
|
||||
// bottom strip — including the Windows taskbar.
|
||||
//
|
||||
// LiveKit's VideoResolution types declare width/height as `number`, but
|
||||
// Chromium's getDisplayMedia accepts the full MediaTrackConstraints shape
|
||||
// including { ideal: N } objects — we cast through `unknown` to satisfy tsc.
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
width: ssParams.dims.width,
|
||||
height: ssParams.dims.height,
|
||||
width: { ideal: ssParams.dims.width },
|
||||
height: { ideal: ssParams.dims.height },
|
||||
frameRate: fps,
|
||||
},
|
||||
} as unknown as { width: number; height: number; frameRate: number },
|
||||
}
|
||||
: {
|
||||
resolution: {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
frameRate: fps,
|
||||
},
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
} as unknown as { width: number; height: number; frameRate: number },
|
||||
}),
|
||||
...(displaySurface
|
||||
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
|
||||
@@ -2004,14 +2013,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const syncGlobalShortcuts = () => {
|
||||
if (!isTauriRuntime()) return;
|
||||
// Only register an OS-level shortcut if the user explicitly opted in.
|
||||
// Window-scoped firing happens via the `onKey` listener above and works
|
||||
// for every enabled binding regardless of the `global` flag.
|
||||
const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global;
|
||||
const desired: Record<HotkeyKind, string | null> = {
|
||||
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
|
||||
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
|
||||
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
|
||||
screenShare: settings.screenShare.enabled
|
||||
? bindingToTauriShortcut(settings.screenShare)
|
||||
: null,
|
||||
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
|
||||
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
|
||||
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
|
||||
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
|
||||
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
|
||||
video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null,
|
||||
};
|
||||
for (const kind of KINDS) {
|
||||
const want = desired[kind];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface State {
|
||||
devices: DeviceRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useOwnDevices(): {
|
||||
devices: DeviceRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
revoke: (deviceId: string) => Promise<void>;
|
||||
} {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? null;
|
||||
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
const list = await listOwnDevices(supabase);
|
||||
setState({ devices: list, loading: false, error: null });
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'failed to load devices',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setState({ devices: [], loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
const channel = supabase
|
||||
.channel('devices:list:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId, refresh]);
|
||||
|
||||
const revoke = useCallback(
|
||||
async (deviceId: string) => {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import {
|
||||
decryptSoundEnvelope,
|
||||
downloadSoundCiphertext,
|
||||
encryptSoundBlob,
|
||||
listOwnSounds,
|
||||
type RemoteSound,
|
||||
upsertSound,
|
||||
uploadSoundCiphertext,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
deleteRawStoredSound,
|
||||
getRawStoredSound,
|
||||
listSounds,
|
||||
putRawStoredSound,
|
||||
type SoundboardEntry,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
|
||||
export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error';
|
||||
|
||||
const PUSH_DEBOUNCE_MS = 500;
|
||||
const DELETE_GRACE_MS = 5000;
|
||||
|
||||
function storagePathFor(userId: string, soundId: string): string {
|
||||
return userId + '/' + soundId + '.bin';
|
||||
}
|
||||
|
||||
export function useSoundboardSync(): {
|
||||
badges: Map<string, SyncBadge>;
|
||||
initialPullDone: boolean;
|
||||
} {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? null;
|
||||
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
|
||||
const [initialPullDone, setInitialPullDone] = useState(false);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
function setBadge(id: string, badge: SyncBadge): void {
|
||||
setBadges((cur) => {
|
||||
const next = new Map(cur);
|
||||
next.set(id, badge);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setInitialPullDone(false);
|
||||
setBadges(new Map());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let teardown: (() => void) | null = null;
|
||||
|
||||
const init = async () => {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) return;
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
|
||||
try {
|
||||
await runDiff(userId, priv, pub);
|
||||
} catch (err) {
|
||||
console.error('soundboard initial diff failed', err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setInitialPullDone(true);
|
||||
|
||||
const unsubLocal = subscribeSoundboardChanges(() => {
|
||||
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void runDiff(userId, priv, pub).catch((err) => {
|
||||
console.error('soundboard push diff failed', err);
|
||||
});
|
||||
}, PUSH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('soundboards:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'user_soundboards',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
() => {
|
||||
void runDiff(userId, priv, pub).catch((err) => {
|
||||
console.error('soundboard realtime pull failed', err);
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
teardown = () => {
|
||||
unsubLocal();
|
||||
void supabase.removeChannel(channel);
|
||||
if (debounceRef.current !== null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
teardown?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise<void> {
|
||||
const [localList, remoteList] = await Promise.all([
|
||||
listSounds(),
|
||||
listOwnSounds(supabase),
|
||||
]);
|
||||
const remoteById = new Map<string, RemoteSound>();
|
||||
for (const r of remoteList) remoteById.set(r.id, r);
|
||||
const localById = new Map<string, SoundboardEntry>();
|
||||
for (const l of localList) localById.set(l.id, l);
|
||||
|
||||
// Push pass
|
||||
for (const local of localList) {
|
||||
const remote = remoteById.get(local.id);
|
||||
const localIso = new Date(local.updatedAt).toISOString();
|
||||
if (!remote) {
|
||||
await uploadAndUpsert(local, uid, priv, pub, localIso);
|
||||
} else {
|
||||
const remoteMs = Date.parse(remote.updatedAt);
|
||||
if (local.updatedAt > remoteMs) {
|
||||
await upsertMetadataOnly(local, remote, localIso);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull pass
|
||||
for (const remote of remoteList) {
|
||||
const local = localById.get(remote.id);
|
||||
const remoteMs = Date.parse(remote.updatedAt);
|
||||
if (!local) {
|
||||
await pullAndStore(remote, priv, pub);
|
||||
} else if (remoteMs > local.updatedAt) {
|
||||
const stored = await getRawStoredSound(remote.id);
|
||||
if (stored) {
|
||||
await putRawStoredSound({
|
||||
...stored,
|
||||
name: remote.name,
|
||||
mime: remote.mime,
|
||||
size: remote.size,
|
||||
category: remote.category,
|
||||
hotkey: remote.hotkey,
|
||||
gain: remote.gain,
|
||||
order: remote.sortOrder,
|
||||
updatedAt: remoteMs,
|
||||
});
|
||||
setBadge(remote.id, 'synced');
|
||||
}
|
||||
} else {
|
||||
setBadge(remote.id, 'synced');
|
||||
}
|
||||
}
|
||||
|
||||
// Remote-absence → local delete (with grace window for fresh adds).
|
||||
const now = Date.now();
|
||||
for (const local of localList) {
|
||||
if (!remoteById.has(local.id) && now - local.updatedAt > DELETE_GRACE_MS) {
|
||||
await deleteRawStoredSound(local.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAndUpsert(
|
||||
local: SoundboardEntry,
|
||||
uid: string,
|
||||
priv: Uint8Array,
|
||||
pub: Uint8Array,
|
||||
localIso: string,
|
||||
): Promise<void> {
|
||||
setBadge(local.id, 'uploading');
|
||||
try {
|
||||
const stored = await getRawStoredSound(local.id);
|
||||
if (!stored) return;
|
||||
const ciphertext = await encryptSoundBlob(stored.blob, pub, priv);
|
||||
const path = storagePathFor(uid, local.id);
|
||||
await uploadSoundCiphertext(supabase, path, ciphertext);
|
||||
await upsertSound(supabase, {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
mime: local.mime,
|
||||
size: local.size,
|
||||
category: local.category,
|
||||
hotkey: local.hotkey,
|
||||
gain: local.gain,
|
||||
sortOrder: local.order,
|
||||
storagePath: path,
|
||||
updatedAtIso: localIso,
|
||||
});
|
||||
setBadge(local.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard upload failed', err);
|
||||
setBadge(local.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertMetadataOnly(
|
||||
local: SoundboardEntry,
|
||||
remote: RemoteSound,
|
||||
localIso: string,
|
||||
): Promise<void> {
|
||||
setBadge(local.id, 'uploading');
|
||||
try {
|
||||
await upsertSound(supabase, {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
mime: local.mime,
|
||||
size: local.size,
|
||||
category: local.category,
|
||||
hotkey: local.hotkey,
|
||||
gain: local.gain,
|
||||
sortOrder: local.order,
|
||||
storagePath: remote.storagePath,
|
||||
updatedAtIso: localIso,
|
||||
});
|
||||
setBadge(local.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard metadata upload failed', err);
|
||||
setBadge(local.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function pullAndStore(
|
||||
remote: RemoteSound,
|
||||
priv: Uint8Array,
|
||||
pub: Uint8Array,
|
||||
): Promise<void> {
|
||||
setBadge(remote.id, 'downloading');
|
||||
try {
|
||||
const envelope = await downloadSoundCiphertext(supabase, remote.storagePath);
|
||||
const plain = await decryptSoundEnvelope(envelope, pub, priv);
|
||||
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants
|
||||
// (mirrors the pattern in @chat-app/shared/chat/attachments.ts).
|
||||
const copy = new Uint8Array(plain.byteLength);
|
||||
copy.set(plain);
|
||||
const blob = new Blob([copy.buffer], { type: remote.mime });
|
||||
const ms = Date.parse(remote.updatedAt);
|
||||
await putRawStoredSound({
|
||||
id: remote.id,
|
||||
name: remote.name,
|
||||
mime: remote.mime,
|
||||
size: remote.size,
|
||||
category: remote.category,
|
||||
hotkey: remote.hotkey,
|
||||
gain: remote.gain,
|
||||
order: remote.sortOrder,
|
||||
createdAt: Date.parse(remote.createdAt),
|
||||
updatedAt: ms,
|
||||
blob,
|
||||
});
|
||||
setBadge(remote.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard pull failed', err);
|
||||
setBadge(remote.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
return { badges, initialPullDone };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
clearWhiteboardStrokes,
|
||||
insertWhiteboardStroke,
|
||||
listWhiteboardStrokes,
|
||||
type WhiteboardStroke,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface State {
|
||||
strokes: WhiteboardStroke[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useWhiteboardStrokes(whiteboardId: string | null): {
|
||||
strokes: WhiteboardStroke[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
insertStroke: (strokeJson: unknown) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ strokes: [], loading: true, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!whiteboardId) {
|
||||
setState({ strokes: [], loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
const list = await listWhiteboardStrokes(supabase, whiteboardId);
|
||||
if (!cancelled) setState({ strokes: list, loading: false, error: null });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
strokes: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'failed to load strokes',
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
const channel = supabase
|
||||
.channel('whiteboard:' + whiteboardId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'whiteboard_strokes',
|
||||
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as {
|
||||
id?: string;
|
||||
whiteboard_id?: string;
|
||||
author_user_id?: string;
|
||||
stroke_json?: unknown;
|
||||
created_at?: string;
|
||||
} | null;
|
||||
if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return;
|
||||
const next: WhiteboardStroke = {
|
||||
id: row.id,
|
||||
whiteboardId: row.whiteboard_id,
|
||||
authorUserId: row.author_user_id,
|
||||
strokeJson: row.stroke_json,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
setState((s) => {
|
||||
if (s.strokes.some((x) => x.id === next.id)) return s;
|
||||
return { ...s, strokes: [...s.strokes, next] };
|
||||
});
|
||||
},
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'whiteboard_strokes',
|
||||
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||
},
|
||||
() => {
|
||||
// Bulk delete via "Clear all" — drop everything; future inserts
|
||||
// come back via the INSERT branch above.
|
||||
setState((s) => ({ ...s, strokes: [] }));
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [whiteboardId]);
|
||||
|
||||
const insertStroke = useCallback(
|
||||
async (strokeJson: unknown) => {
|
||||
if (!whiteboardId) return;
|
||||
try {
|
||||
await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson });
|
||||
// No optimistic append — realtime echoes the row back in <150ms.
|
||||
} catch (err) {
|
||||
console.error('insertWhiteboardStroke failed', err);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'stroke insert failed',
|
||||
}));
|
||||
}
|
||||
},
|
||||
[whiteboardId],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
if (!whiteboardId) return;
|
||||
await clearWhiteboardStrokes(supabase, whiteboardId);
|
||||
}, [whiteboardId]);
|
||||
|
||||
return {
|
||||
strokes: state.strokes,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
insertStroke,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
type PollOption,
|
||||
type WhiteboardPayload,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||
@@ -97,6 +98,15 @@ export function createPollPayload(question: string, optionTexts: string[]): stri
|
||||
});
|
||||
}
|
||||
|
||||
export function createWhiteboardPayload(whiteboardId: string): string {
|
||||
const payload: WhiteboardPayload = {
|
||||
v: 1,
|
||||
type: 'whiteboard',
|
||||
whiteboard_id: whiteboardId,
|
||||
};
|
||||
return serializeMessagePayload(payload);
|
||||
}
|
||||
|
||||
export function summarizePollVotes(
|
||||
options: PollOption[],
|
||||
reactions: ReactionSummaryInput[],
|
||||
|
||||
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
|
||||
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
|
||||
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
||||
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
|
||||
pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
},
|
||||
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
|
||||
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
|
||||
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// localStorage key for "the devices.id row that belongs to THIS install".
|
||||
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
|
||||
// fresh install, so registering a new row is correct.
|
||||
|
||||
const KEY = 'chatapp.deviceRowId.v1';
|
||||
|
||||
export function getDeviceRowId(): string | null {
|
||||
try {
|
||||
const v = window.localStorage.getItem(KEY);
|
||||
return v && v.length > 0 ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setDeviceRowId(id: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, id);
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeviceRowId(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
// Local-only friend nickname overrides. Stored in localStorage keyed by the
|
||||
// peer's user-id. Empty/missing value = use the real display name.
|
||||
// Local-only by design — friends never see your nickname for them.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
|
||||
|
||||
let cache: Record<string, string> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function load(): Record<string, string> {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) { cache = {}; return cache; }
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
cache = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
} catch { /* corrupted; fall through */ }
|
||||
cache = {};
|
||||
return cache;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
|
||||
catch { /* quota / private mode */ }
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function getNickname(userId: string): string | null {
|
||||
return load()[userId] ?? null;
|
||||
}
|
||||
|
||||
export function setNickname(userId: string, nickname: string | null): void {
|
||||
const store = load();
|
||||
const trimmed = nickname?.trim() ?? '';
|
||||
if (trimmed.length === 0) {
|
||||
if (!(userId in store)) return;
|
||||
delete store[userId];
|
||||
} else {
|
||||
if (store[userId] === trimmed) return;
|
||||
store[userId] = trimmed;
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
// Reactive hook: returns the current nickname for a user, or `fallback`
|
||||
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
|
||||
// the set is small).
|
||||
export function useNickname(userId: string | null | undefined, fallback: string): string {
|
||||
const subscribe = (cb: () => void) => {
|
||||
listeners.add(cb);
|
||||
return () => { listeners.delete(cb); };
|
||||
};
|
||||
const getSnapshot = () => (userId ? getNickname(userId) : null);
|
||||
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return nickname ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { clearConvKeyCache } from '@chat-app/shared/chat';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
|
||||
// Aggressively scrub local crypto + chat caches on sign-out (and on
|
||||
// app-close if the user opted in). Preserves things that aren't sensitive
|
||||
// and would be annoying to lose (theme, locale, install-id).
|
||||
//
|
||||
// We can't enumerate IndexedDB names without async + the indexedDB API,
|
||||
// so we list the ones we know about explicitly. Adding a new local store
|
||||
// later? Append to LOCAL_DBS.
|
||||
|
||||
const LOCAL_DBS = ['soundboard', 'message-cache', 'chatapp-attachments'];
|
||||
const PRESERVE_LOCAL_STORAGE = new Set([
|
||||
'chatapp.theme',
|
||||
'chatapp.locale',
|
||||
'chatapp.installId',
|
||||
'chatapp.wipeOnClose.v1',
|
||||
'i18nextLng',
|
||||
]);
|
||||
|
||||
export async function wipeLocalState(userId: string | null): Promise<void> {
|
||||
// 1. Per-conversation key cache (in-memory).
|
||||
try { clearConvKeyCache(); } catch { /* never throws but be defensive */ }
|
||||
|
||||
// 2. Stronghold / secret-store: drop the user-priv blob for this user.
|
||||
if (userId) {
|
||||
try { await devLocalSecretStore.removeSecret('chatapp.userpriv.' + userId); }
|
||||
catch (err) { console.warn('[wipe] userpriv remove failed', err); }
|
||||
}
|
||||
|
||||
// 3. localStorage — preserve only the explicit whitelist.
|
||||
try {
|
||||
const keysToDrop: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k && !PRESERVE_LOCAL_STORAGE.has(k)) keysToDrop.push(k);
|
||||
}
|
||||
for (const k of keysToDrop) window.localStorage.removeItem(k);
|
||||
} catch (err) { console.warn('[wipe] localStorage clear failed', err); }
|
||||
|
||||
// 4. sessionStorage — always full.
|
||||
try { window.sessionStorage.clear(); } catch { /* ignored */ }
|
||||
|
||||
// 5. IndexedDB — delete known databases. Resolves even when blocked so
|
||||
// we don't hang sign-out forever.
|
||||
await Promise.allSettled(LOCAL_DBS.map((name) => new Promise<void>((resolve) => {
|
||||
try {
|
||||
const req = window.indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
} catch { resolve(); }
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Persisted toggle for the "wipe local caches on app close" feature.
|
||||
// Read by SecurityCenter (UI) and AuthContext (renderer-side IPC handler).
|
||||
//
|
||||
// Stored in localStorage so the preference survives sessions without
|
||||
// touching the secret-store or SQLite. The key is on the preserve
|
||||
// whitelist in lib/memoryWipe.ts so a sign-out wipe (or a closing wipe)
|
||||
// doesn't blow away the user's own preference.
|
||||
const KEY = 'chatapp.wipeOnClose.v1';
|
||||
|
||||
export function isWipeOnCloseEnabled(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setWipeOnClose(enabled: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, enabled ? '1' : '0');
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Sync-engine bypass helpers ------------------------------------------
|
||||
// Used by useSoundboardSync to write/delete IndexedDB rows without firing
|
||||
// notifyChange — pulls and remote-driven deletes are not "edits". If they
|
||||
// triggered notifyChange the engine would loop:
|
||||
// push debounce → upsert → realtime → pull → notifyChange → push debounce → ...
|
||||
|
||||
export async function putRawStoredSound(stored: {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
blob: Blob;
|
||||
}): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
|
||||
}
|
||||
|
||||
export async function deleteRawStoredSound(id: string): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
export async function getRawStoredSound(id: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
blob: Blob;
|
||||
} | null> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readonly');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
const req = s.get(id);
|
||||
req.onsuccess = () => resolve((req.result as any) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// GIPHY v1 client — Google closed Tenor to new API clients in Jan 2026, so
|
||||
// the picker uses GIPHY's Developer API instead. Provision a free key at
|
||||
// https://developers.giphy.com/dashboard/ and expose it via the renderer
|
||||
// env var VITE_GIPHY_API_KEY (e.g. in apps/desktop/.env.local). When unset,
|
||||
// every call throws — the picker degrades to a friendly
|
||||
// "GIFs sind nicht konfiguriert" message via the consumer's catch.
|
||||
//
|
||||
// The file is still named `tenor.ts` for import-path stability; rename later
|
||||
// if it bothers anyone.
|
||||
|
||||
const ENDPOINT = 'https://api.giphy.com/v1/gifs';
|
||||
const RECENT_KEY = 'chatapp.gifRecent.v1';
|
||||
const RECENT_MAX = 24;
|
||||
const API_KEY = (
|
||||
(import.meta as unknown as { env?: { VITE_GIPHY_API_KEY?: string } }).env
|
||||
?.VITE_GIPHY_API_KEY ?? ''
|
||||
).trim();
|
||||
|
||||
export interface GifResult {
|
||||
id: string;
|
||||
// Animated full-size URL (typically <2 MB).
|
||||
url: string;
|
||||
// Small preview shown in the picker grid.
|
||||
previewUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface GiphyImage {
|
||||
url?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface GiphyResult {
|
||||
id: string;
|
||||
title?: string;
|
||||
images?: {
|
||||
original?: GiphyImage;
|
||||
fixed_height?: GiphyImage;
|
||||
fixed_height_small?: GiphyImage;
|
||||
preview_gif?: GiphyImage;
|
||||
};
|
||||
}
|
||||
|
||||
function mapResult(r: GiphyResult): GifResult | null {
|
||||
const full = r.images?.original ?? r.images?.fixed_height;
|
||||
const preview =
|
||||
r.images?.fixed_height_small ?? r.images?.preview_gif ?? r.images?.fixed_height ?? full;
|
||||
if (!full?.url || !preview?.url) return null;
|
||||
return {
|
||||
id: r.id,
|
||||
url: full.url,
|
||||
previewUrl: preview.url,
|
||||
width: full.width ? Number(full.width) : 0,
|
||||
height: full.height ? Number(full.height) : 0,
|
||||
description: r.title ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGiphy(path: string, params: Record<string, string>): Promise<GifResult[]> {
|
||||
if (!API_KEY) {
|
||||
throw new Error('giphy_api_key_missing');
|
||||
}
|
||||
const search = new URLSearchParams({ api_key: API_KEY, rating: 'pg-13', ...params });
|
||||
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
|
||||
if (!res.ok) throw new Error('giphy http ' + res.status);
|
||||
const json = (await res.json()) as { data?: GiphyResult[] };
|
||||
return (json.data ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
|
||||
}
|
||||
|
||||
export function isGifProviderConfigured(): boolean {
|
||||
return API_KEY.length > 0;
|
||||
}
|
||||
|
||||
// Back-compat alias for the original Tenor function name. New code should
|
||||
// use isGifProviderConfigured.
|
||||
export const isTenorConfigured = isGifProviderConfigured;
|
||||
|
||||
export async function searchGifs(query: string, locale: string = 'de'): Promise<GifResult[]> {
|
||||
if (!query.trim()) return featuredGifs(locale);
|
||||
return fetchGiphy('search', { q: query, limit: '30', lang: locale });
|
||||
}
|
||||
|
||||
export async function featuredGifs(_locale: string = 'de'): Promise<GifResult[]> {
|
||||
return fetchGiphy('trending', { limit: '30' });
|
||||
}
|
||||
|
||||
export function getRecentGifs(): GifResult[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(x): x is GifResult =>
|
||||
x != null &&
|
||||
typeof x === 'object' &&
|
||||
typeof (x as GifResult).url === 'string',
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberRecentGif(gif: GifResult): void {
|
||||
const current = getRecentGifs().filter((g) => g.id !== gif.id);
|
||||
const next = [gif, ...current].slice(0, RECENT_MAX);
|
||||
try {
|
||||
window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,12 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
}
|
||||
|
||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
||||
send: (
|
||||
text: string,
|
||||
images?: File[],
|
||||
replyToId?: string | null,
|
||||
opts?: { viewOnce?: boolean },
|
||||
) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
@@ -559,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||
async (
|
||||
text: string,
|
||||
images: File[] = [],
|
||||
replyToId: string | null = null,
|
||||
opts: { viewOnce?: boolean } = {},
|
||||
) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||
const priv = privateKeyRef.current;
|
||||
@@ -631,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||
});
|
||||
// Stamp the view-once flag on each handle the caller requested it
|
||||
// for. The flag rides inside the encrypted payload (so peers can
|
||||
// render the locked card without leaking who-sent-what to the
|
||||
// server) AND lands on the public message_attachments row via
|
||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
||||
if (opts.viewOnce) {
|
||||
res.handle.viewOnce = true;
|
||||
}
|
||||
handles.push(res.handle);
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { notify } from './osNotify';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
interface MentionRow {
|
||||
message_id: string;
|
||||
mentioned_user_id: string;
|
||||
conversation_id: string;
|
||||
}
|
||||
|
||||
// Subscribes to my own message_mentions inserts and fires an OS notification
|
||||
// for each one. Bypasses per-conv mute (mentions override mute by design).
|
||||
//
|
||||
// We don't decrypt the body here — the notification just says "Du wurdest
|
||||
// erwähnt". The conv list highlight + the in-app navigation reveal context.
|
||||
export function useMentionNotifications(userId: string | undefined): void {
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const channel = supabase
|
||||
.channel('mentions:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'message_mentions',
|
||||
filter: 'mentioned_user_id=eq.' + userId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as MentionRow | null;
|
||||
if (!row) return;
|
||||
void notify({
|
||||
title: 'Du wurdest erwähnt',
|
||||
body: 'Tippe um die Nachricht zu lesen.',
|
||||
force: true,
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Live list of pinned messages for one conversation. Subscribes to the
|
||||
// `pinned_messages` realtime channel for the conv so the header pill +
|
||||
// side-panel update without a refetch.
|
||||
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
|
||||
const [pins, setPins] = useState<PinnedMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setPins([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
void listPinnedMessages(supabase, conversationId).then((rows) => {
|
||||
if (!cancelled) setPins(rows);
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('pinned_messages:' + conversationId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'pinned_messages',
|
||||
filter: 'conversation_id=eq.' + conversationId,
|
||||
},
|
||||
() => {
|
||||
void listPinnedMessages(supabase, conversationId).then((rows) => {
|
||||
if (!cancelled) setPins(rows);
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
return pins;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
|
||||
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
||||
openUserKey, sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
@@ -50,7 +50,7 @@ export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult>
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<void> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) return;
|
||||
const pub = await derivePublicKey(priv);
|
||||
const pub = derivePublicKey(priv);
|
||||
await runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export async function changePin(params: {
|
||||
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: await derivePublicKey(cached),
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
@@ -122,7 +122,7 @@ export async function regenerateRecoveryCode(params: { userId: string }): Promis
|
||||
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: await derivePublicKey(cached),
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: blob.sealedPrivateKey,
|
||||
salt: blob.salt,
|
||||
kdfParams: blob.kdfParams,
|
||||
@@ -247,12 +247,10 @@ async function runLegacyMigration(
|
||||
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) throw new Error('user key not cached locally — re-login required');
|
||||
const pub = await derivePublicKey(priv);
|
||||
const pub = derivePublicKey(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
|
||||
const sodium = (await import('libsodium-wrappers-sumo')).default;
|
||||
await sodium.ready;
|
||||
return sodium.crypto_scalarmult_base(privateKey);
|
||||
function derivePublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return getCryptoBackend().scalarMultBase(privateKey);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface VoiceHotkeyBinding {
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* When true, the hotkey is registered as an OS-level shortcut and fires
|
||||
* even when the app isn't focused. When false (default) the binding only
|
||||
* fires from the window's keydown listener — so e.g. setting "M" as mute
|
||||
* doesn't break typing "m" everywhere else on the system.
|
||||
*/
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceHotkeys {
|
||||
@@ -36,46 +43,11 @@ export interface VoiceHotkeys {
|
||||
export type VoiceHotkeyKind = keyof VoiceHotkeys;
|
||||
|
||||
const DEFAULTS: VoiceHotkeys = {
|
||||
mute: {
|
||||
key: 'KeyM',
|
||||
keyLabel: 'Ctrl+Shift+M',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
deafen: {
|
||||
key: 'KeyD',
|
||||
keyLabel: 'Ctrl+Shift+D',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
hangup: {
|
||||
key: 'KeyH',
|
||||
keyLabel: 'Ctrl+Shift+H',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
screenShare: {
|
||||
key: 'KeyE',
|
||||
keyLabel: 'Ctrl+Shift+E',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
video: {
|
||||
key: 'KeyV',
|
||||
keyLabel: 'Ctrl+Shift+V',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
};
|
||||
|
||||
type Listener = (s: VoiceHotkeys) => void;
|
||||
@@ -93,6 +65,7 @@ function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotke
|
||||
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
|
||||
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
|
||||
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
|
||||
global: typeof b.global === 'boolean' ? b.global : fallback.global,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { acceptDm, type ConversationSummary, isConversationMuted } from '@chat-a
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationRowMenu } from '../components/ConversationRowMenu';
|
||||
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import {
|
||||
AddUserIcon,
|
||||
ArchiveIcon,
|
||||
@@ -119,6 +120,7 @@ function ConversationList({
|
||||
archivedCount,
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -223,27 +225,27 @@ function ConversationList({
|
||||
) : error ? (
|
||||
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
|
||||
{showArchived ? (
|
||||
<ArchiveIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ChatBubbleIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{showArchived
|
||||
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
|
||||
: t('app:chats.empty_title')}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted/80">
|
||||
{showArchived
|
||||
? t('app:chats.archived_empty_subtitle', {
|
||||
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
|
||||
})
|
||||
: t('app:chats.empty_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
showArchived ? (
|
||||
<EmptyState
|
||||
icon={<ArchiveIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })}
|
||||
description={t('app:chats.archived_empty_subtitle', {
|
||||
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<ChatBubbleIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.empty_title', { defaultValue: 'Noch keine Chats' })}
|
||||
description={t('app:chats.empty_desc', {
|
||||
defaultValue: 'Lade einen Freund ein und schreibe die erste Nachricht.',
|
||||
})}
|
||||
action={{
|
||||
label: t('app:chats.empty_cta', { defaultValue: 'Freunde verwalten' }),
|
||||
onClick: () => navigate('/friends'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<VirtualConversationList
|
||||
items={items}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseMessagePayload } from '@chat-app/shared/chat';
|
||||
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -6,27 +6,34 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { EmojiPicker } from '../components/EmojiPicker';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { ForwardDialog } from '../components/ForwardDialog';
|
||||
import { GifPicker } from '../components/GifPicker';
|
||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||
import {
|
||||
AlertIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
EyeOffIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
PollIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SendIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '../components/icons';
|
||||
import { ImageAnnotator } from '../components/ImageAnnotator';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
|
||||
import { PollComposerDialog } from '../components/PollComposerDialog';
|
||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
@@ -35,10 +42,18 @@ import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||
import {
|
||||
collectConversationAttachments,
|
||||
createPollPayload,
|
||||
createWhiteboardPayload,
|
||||
} from '../lib/conversationFeatures';
|
||||
import { WhiteboardModal } from '../components/WhiteboardModal';
|
||||
import { createWhiteboard } from '@chat-app/shared/chat';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { type GifResult } from '../lib/tenor';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||
@@ -46,6 +61,7 @@ import { useGroupReceipts } from '../lib/useGroupReceipts';
|
||||
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
|
||||
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
|
||||
const STICK_THRESHOLD = 80;
|
||||
@@ -146,10 +162,13 @@ export function ConversationPage() {
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
||||
const [pollSending, setPollSending] = useState(false);
|
||||
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
||||
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
||||
const [pollError, setPollError] = useState<string | null>(null);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||
@@ -175,6 +194,47 @@ export function ConversationPage() {
|
||||
} | null>(null);
|
||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
||||
// Sticky toggle: when on, the next image(s) sent are marked view-once.
|
||||
// Auto-clears on a successful send so the composer doesn't accidentally
|
||||
// burn the message-after-next.
|
||||
const [viewOnceNext, setViewOnceNext] = useState(false);
|
||||
const pins = usePinnedMessages(id);
|
||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
async (messageId: string) => {
|
||||
if (!id || !myId) return;
|
||||
try {
|
||||
if (pinnedIds.has(messageId)) {
|
||||
await unpinMessage(supabase, id, messageId);
|
||||
} else {
|
||||
await pinMessage(supabase, id, messageId, myId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('pin toggle failed', err);
|
||||
}
|
||||
},
|
||||
[id, myId, pinnedIds],
|
||||
);
|
||||
|
||||
const handleGifPick = useCallback(
|
||||
async (gif: GifResult) => {
|
||||
try {
|
||||
// Download the GIF bytes once and feed them into the existing
|
||||
// image-attachment pipeline so the result is end-to-end-encrypted
|
||||
// like any normal image.
|
||||
const res = await fetch(gif.url);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' });
|
||||
await send('', [file], null);
|
||||
} catch (err) {
|
||||
console.warn('GIF send failed', err);
|
||||
}
|
||||
},
|
||||
[send],
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -403,6 +463,15 @@ export function ConversationPage() {
|
||||
};
|
||||
}, [id, setActiveConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||
if (detail?.id) setOpenWhiteboardId(detail.id);
|
||||
};
|
||||
window.addEventListener('chatapp:open-whiteboard', onOpen);
|
||||
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && messages.length > 0) markRead(id);
|
||||
}, [id, messages.length, markRead]);
|
||||
@@ -492,10 +561,13 @@ export function ConversationPage() {
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
await send(text, attachments, replyTo?.id ?? null);
|
||||
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
|
||||
setText('');
|
||||
setAttachments([]);
|
||||
setReplyTo(null);
|
||||
// Reset the sticky view-once flag so it only applies to the message
|
||||
// the user explicitly armed it for — Snapchat / WhatsApp parity.
|
||||
setViewOnceNext(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
@@ -533,6 +605,23 @@ export function ConversationPage() {
|
||||
[send, replyTo?.id, notifyStopTyping],
|
||||
);
|
||||
|
||||
const handleCreateWhiteboard = useCallback(async () => {
|
||||
if (!id || creatingWhiteboard) return;
|
||||
setCreatingWhiteboard(true);
|
||||
try {
|
||||
const board = await createWhiteboard(supabase, id);
|
||||
const payload = createWhiteboardPayload(board.id);
|
||||
await send(payload, [], replyTo?.id ?? null);
|
||||
setReplyTo(null);
|
||||
setStickToBottom(true);
|
||||
setOpenWhiteboardId(board.id);
|
||||
} catch (err: unknown) {
|
||||
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||
} finally {
|
||||
setCreatingWhiteboard(false);
|
||||
}
|
||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
const compressed = await compressImages(files);
|
||||
const next: File[] = [];
|
||||
@@ -603,6 +692,8 @@ export function ConversationPage() {
|
||||
: {})}
|
||||
onSearchClick={() => setSearchOpen((v) => !v)}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
pinnedCount={pins.length}
|
||||
onOpenPinned={() => setPinnedPanelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -670,7 +761,13 @@ export function ConversationPage() {
|
||||
) : error ? (
|
||||
<Banner>{error}</Banner>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-center text-sm text-fg-muted">…</p>
|
||||
<EmptyState
|
||||
icon={<SendIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
||||
description={t('app:chats.conv_empty_desc', {
|
||||
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{displayCount < messages.length && (
|
||||
@@ -749,6 +846,8 @@ export function ConversationPage() {
|
||||
});
|
||||
}}
|
||||
highlighted={highlightedId === m.id}
|
||||
isPinned={pinnedIds.has(m.id)}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -855,6 +954,9 @@ export function ConversationPage() {
|
||||
key={idx}
|
||||
file={file}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
{...(file.type.startsWith('image/')
|
||||
? { onEdit: () => setAnnotatingIndex(idx) }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -913,6 +1015,16 @@ export function ConversationPage() {
|
||||
>
|
||||
<PollIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWhiteboard()}
|
||||
disabled={creatingWhiteboard}
|
||||
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
||||
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<WhiteboardIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -942,6 +1054,36 @@ export function ConversationPage() {
|
||||
onClose={() => setEmojiOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGifPickerOpen((v) => !v)}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
aria-label="GIF"
|
||||
title="GIF einfügen"
|
||||
>
|
||||
<span className="text-xs font-bold">GIF</span>
|
||||
</button>
|
||||
<GifPicker
|
||||
open={gifPickerOpen}
|
||||
onClose={() => setGifPickerOpen(false)}
|
||||
onPick={(gif) => void handleGifPick(gif)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewOnceNext((v) => !v)}
|
||||
aria-pressed={viewOnceNext}
|
||||
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
|
||||
className={
|
||||
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(viewOnceNext
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<VoiceRecorder
|
||||
disabled={sending}
|
||||
onComplete={async (file) => {
|
||||
@@ -1060,6 +1202,35 @@ export function ConversationPage() {
|
||||
onClose={() => setProfilePopover(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PinnedMessagesPanel
|
||||
open={pinnedPanelOpen}
|
||||
pins={pins}
|
||||
onClose={() => setPinnedPanelOpen(false)}
|
||||
onJump={(_messageId) => {
|
||||
// Future: scroll to message. For now just close the panel.
|
||||
setPinnedPanelOpen(false);
|
||||
}}
|
||||
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
||||
/>
|
||||
|
||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||
<ImageAnnotator
|
||||
file={attachments[annotatingIndex]!}
|
||||
onCancel={() => setAnnotatingIndex(null)}
|
||||
onSave={(next) => {
|
||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||
setAnnotatingIndex(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openWhiteboardId && (
|
||||
<WhiteboardModal
|
||||
whiteboardId={openWhiteboardId}
|
||||
onClose={() => setOpenWhiteboardId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1287,7 +1458,15 @@ function Banner({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
function AttachmentPreview({
|
||||
file,
|
||||
onRemove,
|
||||
onEdit,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -1297,7 +1476,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
{isImage && url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
@@ -1309,6 +1488,17 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
{isImage && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Bearbeiten"
|
||||
title="Bearbeiten"
|
||||
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
@@ -1320,3 +1510,13 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
sendFriendRequest,
|
||||
} from '@chat-app/shared/friends';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { NicknameDialog } from '../components/NicknameDialog';
|
||||
import {
|
||||
AddUserIcon,
|
||||
AlertIcon,
|
||||
ChatBubbleIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -39,6 +42,12 @@ export function FriendsPage() {
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const focusSearch = useCallback(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
|
||||
@@ -119,6 +128,7 @@ export function FriendsPage() {
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -160,24 +170,44 @@ export function FriendsPage() {
|
||||
{loading ? (
|
||||
<LoadingRow />
|
||||
) : tab === 'friends' ? (
|
||||
<FriendList
|
||||
items={accepted}
|
||||
emptyKey="app:friends.empty_friends"
|
||||
renderActions={(f) => (
|
||||
<FriendActions
|
||||
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
|
||||
onMessage={() =>
|
||||
performAction('msg-' + f.peer.userId, async () => {
|
||||
const id = await createDm(supabase, f.peer.userId);
|
||||
navigate('/chats/' + id);
|
||||
})
|
||||
}
|
||||
onUnfriend={() =>
|
||||
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
accepted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<AddUserIcon className="h-8 w-8" />}
|
||||
title={t('app:friends.empty_title', { defaultValue: 'Noch keine Freunde' })}
|
||||
description={t('app:friends.empty_desc', {
|
||||
defaultValue: 'Suche einen Friend per Username oder schicke eine Einladung.',
|
||||
})}
|
||||
action={{
|
||||
label: t('app:friends.empty_cta', { defaultValue: 'Friend hinzufügen' }),
|
||||
onClick: focusSearch,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FriendList
|
||||
items={accepted}
|
||||
emptyKey="app:friends.empty_friends"
|
||||
onRowContextMenu={(f) =>
|
||||
setNicknameDialog({
|
||||
userId: f.peer.userId,
|
||||
displayName: f.peer.displayName ?? f.peer.username ?? 'Freund',
|
||||
})
|
||||
}
|
||||
renderActions={(f) => (
|
||||
<FriendActions
|
||||
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
|
||||
onMessage={() =>
|
||||
performAction('msg-' + f.peer.userId, async () => {
|
||||
const id = await createDm(supabase, f.peer.userId);
|
||||
navigate('/chats/' + id);
|
||||
})
|
||||
}
|
||||
onUnfriend={() =>
|
||||
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : tab === 'pending' ? (
|
||||
<FriendList
|
||||
items={outgoing}
|
||||
@@ -211,6 +241,12 @@ export function FriendsPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NicknameDialog
|
||||
open={nicknameDialog !== null}
|
||||
userId={nicknameDialog?.userId ?? ''}
|
||||
displayName={nicknameDialog?.displayName ?? ''}
|
||||
onClose={() => setNicknameDialog(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -253,10 +289,12 @@ function FriendList({
|
||||
items,
|
||||
emptyKey,
|
||||
renderActions,
|
||||
onRowContextMenu,
|
||||
}: {
|
||||
items: Friendship[];
|
||||
emptyKey: string;
|
||||
renderActions: (f: Friendship) => React.ReactNode;
|
||||
onRowContextMenu?: (f: Friendship) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
if (items.length === 0) {
|
||||
@@ -274,16 +312,39 @@ function FriendList({
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f) => (
|
||||
<li key={f.peer.userId}>
|
||||
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
|
||||
<FriendRow
|
||||
profile={f.peer}
|
||||
{...(onRowContextMenu
|
||||
? {
|
||||
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onRowContextMenu(f);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{renderActions(f)}
|
||||
</FriendRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
|
||||
function FriendRow({
|
||||
profile,
|
||||
children,
|
||||
onContextMenu,
|
||||
}: {
|
||||
profile: ProfileBrief;
|
||||
children: React.ReactNode;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<div
|
||||
onContextMenu={onContextMenu}
|
||||
className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3"
|
||||
>
|
||||
<Avatar
|
||||
url={profile.avatarUrl}
|
||||
displayName={profile.displayName ?? profile.username}
|
||||
|
||||
@@ -8,9 +8,22 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import {
|
||||
AtIcon,
|
||||
BellIcon,
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MonitorShareIcon,
|
||||
MusicIcon,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
SunIcon,
|
||||
UsersIcon,
|
||||
} from '../components/icons';
|
||||
import { MicTestSection } from '../components/MicTestSection';
|
||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { DeviceListTab } from '../components/settings/DeviceListTab';
|
||||
import { SecurityCenter } from '../components/SecurityCenter';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -99,140 +112,279 @@ export function SettingsPage() {
|
||||
void patchProfile({ locale });
|
||||
}
|
||||
|
||||
// Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
|
||||
// section and only that section renders. activeTab is the single source of
|
||||
// truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
|
||||
// routing conflict with HashRouter.
|
||||
type TabId =
|
||||
| 'profile' | 'appearance' | 'privacy' | 'notifications'
|
||||
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
|
||||
|
||||
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
|
||||
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
|
||||
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
|
||||
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
|
||||
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
|
||||
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
|
||||
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
|
||||
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
|
||||
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
|
||||
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
|
||||
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('profile');
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('app:settings.section_account')}>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
|
||||
{/* Startup */}
|
||||
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
||||
<AutoStartControls />
|
||||
</Section>
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-8 space-y-1">
|
||||
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
<nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const active = activeTab === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={'settings-panel-' + id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent/15 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
</aside>
|
||||
|
||||
{/* Privacy */}
|
||||
<Section title={t('app:settings.section_privacy')}>
|
||||
<Toggle
|
||||
label={t('app:settings.show_read_receipts')}
|
||||
hint={t('app:settings.show_read_receipts_hint')}
|
||||
checked={profile?.showReadReceipts ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t('app:settings.allow_dms_strangers')}
|
||||
hint={t('app:settings.allow_dms_strangers_hint')}
|
||||
checked={profile?.allowDmsFromStrangers ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||
/>
|
||||
</Section>
|
||||
{/* Content panel — only the active tab renders */}
|
||||
<main className="min-w-0">
|
||||
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
|
||||
<div className="mb-6 space-y-3 lg:hidden">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
<select
|
||||
value={activeTab}
|
||||
onChange={(e) => setActiveTab(e.target.value as TabId)}
|
||||
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-label={t('app:settings.title')}
|
||||
>
|
||||
{tabs.map(({ id, label }) => (
|
||||
<option key={id} value={id}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Notification sound (new messages) */}
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</Section>
|
||||
<div
|
||||
id={'settings-panel-' + activeTab}
|
||||
role="tabpanel"
|
||||
aria-labelledby={'settings-tab-' + activeTab}
|
||||
>
|
||||
{activeTab === 'profile' && (
|
||||
<Section
|
||||
title={t('app:settings.section_account')}
|
||||
description={t('app:settings.section_account_hint', {
|
||||
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
|
||||
})}
|
||||
>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Ringtone (incoming custom) */}
|
||||
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
</Section>
|
||||
{activeTab === 'appearance' && (
|
||||
<Section
|
||||
title={t('app:settings.section_appearance')}
|
||||
description={t('app:settings.section_appearance_hint', {
|
||||
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
|
||||
})}
|
||||
>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SubGroup>
|
||||
<AutoStartControls />
|
||||
</SubGroup>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Soundboard */}
|
||||
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
|
||||
<SoundboardSettings />
|
||||
</Section>
|
||||
{activeTab === 'privacy' && (
|
||||
<Section
|
||||
title={t('app:settings.section_privacy')}
|
||||
description={t('app:settings.section_privacy_hint', {
|
||||
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
|
||||
})}
|
||||
>
|
||||
<Toggle
|
||||
label={t('app:settings.show_read_receipts')}
|
||||
hint={t('app:settings.show_read_receipts_hint')}
|
||||
checked={profile?.showReadReceipts ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t('app:settings.allow_dms_strangers')}
|
||||
hint={t('app:settings.allow_dms_strangers_hint')}
|
||||
checked={profile?.allowDmsFromStrangers ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioDeviceControls />
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<AudioQualityControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<PttControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="mute" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
</Section>
|
||||
{activeTab === 'notifications' && (
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
description={t('app:settings.section_notifications_hint', {
|
||||
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
|
||||
})}
|
||||
>
|
||||
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
</SubSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Screen-share quality */}
|
||||
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
{activeTab === 'voice' && (
|
||||
<Section
|
||||
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
|
||||
description={t('app:settings.section_voice_hint', {
|
||||
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
|
||||
})}
|
||||
>
|
||||
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
|
||||
<AudioDeviceControls />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||||
<AudioQualityControls />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
|
||||
<PttControls />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
|
||||
<div className="space-y-2">
|
||||
<VoiceHotkeyControls kind="mute" />
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
|
||||
<CallE2EEControls />
|
||||
</SubSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Security */}
|
||||
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
|
||||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||||
</Section>
|
||||
{activeTab === 'screen-share' && (
|
||||
<Section
|
||||
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
|
||||
description={t('app:settings.section_screen_share_hint', {
|
||||
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
|
||||
})}
|
||||
>
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Danger zone */}
|
||||
<Section title={t('app:settings.danger_zone')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
</Section>
|
||||
{activeTab === 'soundboard' && (
|
||||
<Section
|
||||
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
|
||||
description={t('app:settings.section_soundboard_hint', {
|
||||
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
|
||||
})}
|
||||
>
|
||||
<SoundboardSettings />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Section
|
||||
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
|
||||
description={t('app:settings.section_security_hint', {
|
||||
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
|
||||
})}
|
||||
>
|
||||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'devices' && (
|
||||
<Section
|
||||
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
|
||||
description={t('app:settings.section_devices_hint', {
|
||||
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
|
||||
})}
|
||||
>
|
||||
<DeviceListTab />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'account' && (
|
||||
<Section
|
||||
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
|
||||
description={t('app:settings.section_account_mgmt_hint', {
|
||||
defaultValue: 'Abmelden oder Konto-Aktionen.',
|
||||
})}
|
||||
tone="danger"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||
>
|
||||
<SignOutIcon className="h-4 w-4" />
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -436,22 +588,44 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||
<SettingRow
|
||||
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
className={
|
||||
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(capturing
|
||||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
? t('app:settings.hotkey_press_combo', {
|
||||
defaultValue: 'Kombination drücken…',
|
||||
})
|
||||
: binding.keyLabel}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
className={
|
||||
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(capturing
|
||||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
? t('app:settings.hotkey_press_combo', {
|
||||
defaultValue: 'Kombination drücken…',
|
||||
})
|
||||
: binding.keyLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
|
||||
disabled={!binding.enabled}
|
||||
aria-pressed={binding.global}
|
||||
title={
|
||||
binding.global
|
||||
? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
|
||||
: 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
|
||||
}
|
||||
className={
|
||||
'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
(binding.global
|
||||
? 'border-accent/40 bg-accent/15 text-accent'
|
||||
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
<span aria-hidden>🌐</span>
|
||||
<span>{binding.global ? 'Global' : 'Im Fenster'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
@@ -1138,21 +1312,80 @@ function formatBitrate(kbps: number): string {
|
||||
return kbps + ' kbps';
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
tone?: 'default' | 'danger';
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
<section
|
||||
className={
|
||||
'rounded-2xl border bg-surface-2 p-6 ' +
|
||||
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
|
||||
}
|
||||
>
|
||||
<header className="mb-5 border-b border-line pb-4">
|
||||
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-fg-muted">{description}</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
// Sub-heading inside a Section — used to chunk dense sections like Voice into
|
||||
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
|
||||
// No border: `--color-line` is already a semi-transparent token, and applying
|
||||
// the `/60` opacity modifier brightens it (Tailwind overrides the original
|
||||
// alpha) which made the sub-cards look harsher than the outer Section. Plain
|
||||
// background tint + caps heading carry the grouping signal on their own.
|
||||
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl bg-surface-3/50 p-4">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Lighter wrapper for a single related extra control inside a Section that
|
||||
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
|
||||
// Appearance).
|
||||
function SubGroup({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3 border-t border-line pt-4">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-fg-muted">{label}</dt>
|
||||
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
|
||||
{icon}
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
||||
|
||||
@@ -73,6 +73,23 @@ Copy `.env.example` to `.env.local` and fill in the same Supabase host +
|
||||
anon key the desktop uses. Expo bundles only `EXPO_PUBLIC_*`-prefixed
|
||||
vars into the JS, which is what the three required values use.
|
||||
|
||||
## EAS Builds and Environment Variables
|
||||
|
||||
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets —
|
||||
`.env.local` is only honoured by `expo start` locally. Without the
|
||||
secrets configured, an APK installs but `env.ts` throws on first read
|
||||
and `<BootError>` renders.
|
||||
|
||||
Required secrets (create once per project):
|
||||
|
||||
```bash
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
|
||||
```
|
||||
|
||||
Check with `npx eas-cli secret:list`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { Redirect, Stack } from 'expo-router';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function AppLayout() {
|
||||
const { session, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
const { ready, session, userKeyState } = useAuth();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!session) return <Redirect href="/" />;
|
||||
if (userKeyState.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (userKeyState.status === 'needs-setup') return <Redirect href="/(app)/setup" />;
|
||||
if (userKeyState.status === 'needs-unlock') return <Redirect href="/(app)/unlock" />;
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: true }}>
|
||||
<Stack screenOptions={{ headerStyle: { backgroundColor: colors.bg } }}>
|
||||
<Stack.Screen name="chats" />
|
||||
<Stack.Screen name="conversations/[id]" />
|
||||
<Stack.Screen
|
||||
name="call"
|
||||
options={{ headerShown: false, presentation: 'fullScreenModal' }}
|
||||
/>
|
||||
<Stack.Screen name="call" options={{ presentation: 'fullScreenModal' }} />
|
||||
<Stack.Screen name="settings" options={{ presentation: 'card' }} />
|
||||
<Stack.Screen name="setup" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="unlock" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Stack, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { ConversationRow } from '../../components/ConversationRow';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
@@ -23,7 +21,6 @@ import { colors } from '../../theme/colors';
|
||||
// is a Phase 1.5 follow-up. Tap → conversation detail.
|
||||
export default function Chats() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
const [list, setList] = useState<ConversationSummary[] | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -48,19 +45,6 @@ export default function Chats() {
|
||||
setRefreshing(false);
|
||||
}, [load]);
|
||||
|
||||
const confirmLogout = () => {
|
||||
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Abmelden',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void signOut();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen
|
||||
@@ -69,8 +53,8 @@ export default function Chats() {
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerRight: () => (
|
||||
<Pressable onPress={confirmLogout} hitSlop={10}>
|
||||
<Text style={styles.logoutLink}>Abmelden</Text>
|
||||
<Pressable onPress={() => router.push('/(app)/settings')} hitSlop={10}>
|
||||
<Text style={styles.logoutLink}>Einstellungen</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function ConversationDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { user, device, ownPrivateKey } = useAuth();
|
||||
const { user, userId, ownPrivateKey } = useAuth();
|
||||
const { state: callState, startCall } = useCall();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ConversationDetail() {
|
||||
const listRef = useRef<FlatList<DecryptedMessage>>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id || !device || !ownPrivateKey) return;
|
||||
if (!id || !userId || !ownPrivateKey) return;
|
||||
setError(null);
|
||||
try {
|
||||
const all = await chat.listConversations(supabase);
|
||||
@@ -63,7 +63,7 @@ export default function ConversationDetail() {
|
||||
const decrypted = await chat.decryptMessages({
|
||||
client: supabase,
|
||||
messages: ciphers,
|
||||
ownDeviceId: device.id,
|
||||
ownUserId: userId,
|
||||
ownPrivateKey,
|
||||
});
|
||||
setMessages(decrypted);
|
||||
@@ -81,7 +81,7 @@ export default function ConversationDetail() {
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
|
||||
}
|
||||
}, [id, device, ownPrivateKey]);
|
||||
}, [id, userId, ownPrivateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -103,7 +103,7 @@ export default function ConversationDetail() {
|
||||
: (conversation?.peer?.displayName ?? '…');
|
||||
|
||||
async function handleSendText() {
|
||||
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
|
||||
if (!text.trim() || !user || !ownPrivateKey || !id || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const replyToId = replyTo?.id;
|
||||
@@ -113,7 +113,6 @@ export default function ConversationDetail() {
|
||||
conversationId: id,
|
||||
plaintext: text.trim(),
|
||||
senderUserId: user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
@@ -128,7 +127,7 @@ export default function ConversationDetail() {
|
||||
}
|
||||
|
||||
async function sendImage(pick: PickedImage) {
|
||||
if (!user || !device || !ownPrivateKey || !id) return;
|
||||
if (!user || !ownPrivateKey || !id) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -148,7 +147,6 @@ export default function ConversationDetail() {
|
||||
conversationId: id,
|
||||
plaintext: '',
|
||||
senderUserId: user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
attachmentHandles: [result.handle],
|
||||
});
|
||||
@@ -300,7 +298,6 @@ export default function ConversationDetail() {
|
||||
})}
|
||||
reactions={reactions.get(item.id) ?? []}
|
||||
myUserId={user?.id ?? null}
|
||||
ownDeviceId={device?.id ?? null}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
onLongPress={() => setActiveMessage(item)}
|
||||
onToggleReaction={(emoji) => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerTintColor: colors.accent,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: 'Einstellungen' }} />
|
||||
<Stack.Screen name="security" options={{ title: 'Sicherheit' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useAuth } from '../../../lib/authContext';
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function SettingsIndex() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
function confirmLogout() {
|
||||
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{ text: 'Abmelden', style: 'destructive', onPress: () => void signOut() },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.row} onPress={() => router.push('/(app)/settings/security')}>
|
||||
<Text style={styles.rowTitle}>Sicherheit</Text>
|
||||
<Text style={styles.rowHint}>PIN ändern, Recovery-Code, Identität zurücksetzen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.row, styles.danger]} onPress={confirmLogout}>
|
||||
<Text style={[styles.rowTitle, styles.dangerText]}>Abmelden</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 },
|
||||
row: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 },
|
||||
danger: { borderColor: colors.danger },
|
||||
dangerText: { color: colors.danger },
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../../components/PinInput';
|
||||
import { useAuth } from '../../../lib/authContext';
|
||||
import {
|
||||
changePin,
|
||||
regenerateRecoveryCode,
|
||||
resetIdentity,
|
||||
retryLegacyMigration,
|
||||
type LegacyMigrationReport,
|
||||
} from '../../../lib/userIdentity';
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function SecuritySettings() {
|
||||
const { userId, refreshUserKeyState } = useAuth();
|
||||
const [oldPin, setOldPin] = useState('');
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [newRecovery, setNewRecovery] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [report, setReport] = useState<LegacyMigrationReport | null>(null);
|
||||
|
||||
async function handleChangePin() {
|
||||
if (!userId) return;
|
||||
if (oldPin.length !== 6 || newPin.length !== 6) {
|
||||
setError('Beide PINs müssen 6 Ziffern haben.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await changePin({ userId, oldPin, newPin });
|
||||
setOldPin('');
|
||||
setNewPin('');
|
||||
Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateRecovery() {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const code = await regenerateRecoveryCode({ userId });
|
||||
setNewRecovery(code);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (!userId) return;
|
||||
Alert.alert(
|
||||
'Identität zurücksetzen?',
|
||||
'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Zurücksetzen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await resetIdentity({ userId, pin: '000000' });
|
||||
await refreshUserKeyState();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRetryMigration() {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await retryLegacyMigration(userId);
|
||||
setReport(r);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.section}>PIN ändern</Text>
|
||||
<Text style={styles.label}>Alte PIN</Text>
|
||||
<PinInput value={oldPin} onChange={setOldPin} ariaLabel="Alte PIN" />
|
||||
<Text style={styles.label}>Neue PIN</Text>
|
||||
<PinInput value={newPin} onChange={setNewPin} ariaLabel="Neue PIN" />
|
||||
<Pressable style={styles.primary} onPress={handleChangePin} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>PIN aktualisieren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.section}>Recovery-Code</Text>
|
||||
<Pressable style={styles.primary} onPress={handleRegenerateRecovery} disabled={submitting}>
|
||||
<Text style={styles.primaryText}>Neuen Recovery-Code erzeugen</Text>
|
||||
</Pressable>
|
||||
{newRecovery && (
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeText} selectable>
|
||||
{newRecovery}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.section}>Migration</Text>
|
||||
<Pressable style={styles.primary} onPress={handleRetryMigration} disabled={submitting}>
|
||||
<Text style={styles.primaryText}>Migration erneut versuchen</Text>
|
||||
</Pressable>
|
||||
{report && (
|
||||
<View style={styles.report}>
|
||||
<Text style={styles.reportLine}>Geräte (Server): {report.serverDevices}</Text>
|
||||
<Text style={styles.reportLine}>
|
||||
Lokale Schlüssel im Vault:{' '}
|
||||
{report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan}
|
||||
</Text>
|
||||
<Text style={styles.reportLine}>
|
||||
Versucht: {report.attempted}, Erfolgreich: {report.migrated}
|
||||
</Text>
|
||||
<Text style={styles.reportLine}>
|
||||
Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '}
|
||||
{report.decryptFailed}, RPC-Fehler = {report.rpcFailed}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.section}>Gefahrenbereich</Text>
|
||||
<Pressable style={[styles.primary, styles.danger]} onPress={handleReset} disabled={submitting}>
|
||||
<Text style={[styles.primaryText, styles.dangerText]}>Identität zurücksetzen</Text>
|
||||
</Pressable>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 16, gap: 8, backgroundColor: colors.bg },
|
||||
section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 },
|
||||
label: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 },
|
||||
dangerText: { color: colors.danger },
|
||||
codeBox: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
codeText: {
|
||||
color: colors.text,
|
||||
fontFamily: 'Courier',
|
||||
fontSize: 16,
|
||||
letterSpacing: 1.2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
report: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
marginTop: 4,
|
||||
gap: 4,
|
||||
},
|
||||
reportLine: { color: colors.text, fontSize: 13 },
|
||||
error: { color: colors.danger, marginTop: 12 },
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../components/PinInput';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { setupNewUserIdentity } from '../../lib/userIdentity';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
|
||||
|
||||
export default function SetupScreen() {
|
||||
const router = useRouter();
|
||||
const { userId, refreshUserKeyState } = useAuth();
|
||||
const [step, setStep] = useState<Step>('pin');
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||||
const [withRecovery, setWithRecovery] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function runSetup(saveRecovery: boolean) {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
|
||||
setRecoveryCode(out.recoveryCode);
|
||||
if (out.recoveryCode) {
|
||||
setStep('recovery');
|
||||
} else {
|
||||
setStep('done');
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'pin') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>PIN festlegen</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
|
||||
</Text>
|
||||
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.primary, pin.length !== 6 && styles.disabled]}
|
||||
disabled={pin.length !== 6}
|
||||
onPress={() => setStep('confirm')}
|
||||
>
|
||||
<Text style={styles.primaryText}>Weiter</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>PIN bestätigen</Text>
|
||||
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
|
||||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
|
||||
disabled={confirm.length !== 6 || submitting}
|
||||
onPress={() => {
|
||||
if (pin !== confirm) {
|
||||
setError('PIN stimmt nicht überein.');
|
||||
setConfirm('');
|
||||
return;
|
||||
}
|
||||
void runSetup(withRecovery);
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>PIN speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
|
||||
<Text style={styles.secondaryText}>
|
||||
{withRecovery
|
||||
? 'Recovery-Code überspringen (riskant)'
|
||||
: 'Recovery-Code erzeugen (empfohlen)'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'recovery' && recoveryCode) {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>Recovery-Code</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
|
||||
vergisst. Wir zeigen ihn dir nur EINMAL.
|
||||
</Text>
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeText} selectable>
|
||||
{recoveryCode}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.primary}
|
||||
onPress={async () => {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondary}
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
'Sicher?',
|
||||
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Weiter ohne',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryText}>Überspringen</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
paddingTop: 64,
|
||||
backgroundColor: colors.bg,
|
||||
gap: 16,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
|
||||
error: { color: colors.danger, fontSize: 13 },
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
disabled: { opacity: 0.5 },
|
||||
secondary: { paddingVertical: 12, alignItems: 'center' },
|
||||
secondaryText: { color: colors.accent, fontWeight: '600' },
|
||||
codeBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Courier',
|
||||
letterSpacing: 1.5,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../components/PinInput';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
type Mode = 'pin' | 'recovery';
|
||||
|
||||
export default function UnlockScreen() {
|
||||
const router = useRouter();
|
||||
const { userId, userKeyState, refreshUserKeyState } = useAuth();
|
||||
const [mode, setMode] = useState<Mode>('pin');
|
||||
const [pin, setPin] = useState('');
|
||||
const [recovery, setRecovery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const locked =
|
||||
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
|
||||
const hasRecovery =
|
||||
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
|
||||
|
||||
async function attempt(value: string, isRecoveryCode: boolean) {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
|
||||
if (out.kind === 'unlocked') {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
return;
|
||||
}
|
||||
if (out.kind === 'locked') {
|
||||
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
|
||||
await refreshUserKeyState();
|
||||
return;
|
||||
}
|
||||
setError('Kein Identitäts-Datensatz auf dem Server.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
|
||||
if (isRecoveryCode) setRecovery('');
|
||||
else setPin('');
|
||||
await refreshUserKeyState();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resetIdentity({ userId, pin: '000000' });
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/setup');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Netralax entsperren</Text>
|
||||
<View style={styles.tabs}>
|
||||
<Pressable
|
||||
style={[styles.tab, mode === 'pin' && styles.tabActive]}
|
||||
onPress={() => setMode('pin')}
|
||||
>
|
||||
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
|
||||
disabled={!hasRecovery}
|
||||
onPress={() => setMode('recovery')}
|
||||
>
|
||||
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
|
||||
Recovery-Code
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{mode === 'pin' ? (
|
||||
<>
|
||||
<PinInput
|
||||
value={pin}
|
||||
onChange={setPin}
|
||||
ariaLabel="PIN eingeben"
|
||||
autoFocus
|
||||
disabled={locked || submitting}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
|
||||
disabled={pin.length !== 6 || locked || submitting}
|
||||
onPress={() => attempt(pin, false)}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>Entsperren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
|
||||
<Text style={styles.recoveryDisplay} selectable>
|
||||
{recovery}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={[styles.primary, recovery.length === 0 && styles.disabled]}
|
||||
disabled={recovery.length === 0 || submitting}
|
||||
onPress={() => attempt(recovery, true)}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{locked && (
|
||||
<Pressable style={styles.danger} onPress={handleReset}>
|
||||
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
paddingTop: 64,
|
||||
paddingHorizontal: 24,
|
||||
gap: 16,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 13 },
|
||||
tabs: { flexDirection: 'row', gap: 8 },
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tabActive: { borderBottomColor: colors.accent },
|
||||
tabText: { color: colors.textMuted, fontWeight: '600' },
|
||||
tabTextActive: { color: colors.text },
|
||||
recoveryDisplay: {
|
||||
color: colors.text,
|
||||
fontFamily: 'Courier',
|
||||
fontSize: 18,
|
||||
letterSpacing: 1.5,
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
disabled: { opacity: 0.5 },
|
||||
danger: {
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderColor: colors.danger,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
},
|
||||
dangerText: { color: colors.danger, fontWeight: '700' },
|
||||
error: { color: colors.danger, fontSize: 13 },
|
||||
});
|
||||
+16
-17
@@ -1,34 +1,33 @@
|
||||
import { crypto } from '@chat-app/shared';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import { AppBootstrap } from '../components/AppBootstrap';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
import { IncomingCallModal } from '../components/IncomingCallModal';
|
||||
import { AuthProvider } from '../lib/authContext';
|
||||
import { CallProvider } from '../lib/callContext';
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<CallProvider>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
<Stack.Screen name="auth/callback" />
|
||||
</Stack>
|
||||
<IncomingCallModal />
|
||||
</CallProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
<AppBootstrap>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<CallProvider>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
<Stack.Screen name="auth/callback" />
|
||||
</Stack>
|
||||
<IncomingCallModal />
|
||||
</CallProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</AppBootstrap>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -21,13 +21,13 @@ import { colors } from '../theme/colors';
|
||||
// they tap the email link and Expo Router routes the deep link to
|
||||
// app/auth/callback.tsx.
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth();
|
||||
const { session, ready } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (loading) return null;
|
||||
if (!ready) return null;
|
||||
if (session) return <Redirect href="/(app)/chats" />;
|
||||
|
||||
async function handleSubmit() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { crypto } from '@chat-app/shared';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
import { BootError } from './BootError';
|
||||
import { BootSplash } from './BootSplash';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// React Native exposes ErrorUtils on the global. The types ship with RN but
|
||||
// we cast defensively because the renderer used by Vitest does not.
|
||||
interface RNErrorUtils {
|
||||
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
|
||||
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
|
||||
}
|
||||
|
||||
// Initialises the crypto backend inside a useEffect (not at module-eval) so
|
||||
// any failure surfaces in the React tree. Also installs a global JS error
|
||||
// handler that routes unhandled throws to BootError; this catches errors
|
||||
// thrown during render (e.g. the lazy env proxy reading a missing var) that
|
||||
// would otherwise escape every per-screen ErrorBoundary.
|
||||
export function AppBootstrap({ children }: Props) {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
setReady(true);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
|
||||
if (!eu) return;
|
||||
const prev = eu.getGlobalHandler();
|
||||
eu.setGlobalHandler((err, isFatal) => {
|
||||
prev?.(err, isFatal);
|
||||
setError(err);
|
||||
});
|
||||
return () => eu.setGlobalHandler(prev);
|
||||
}, []);
|
||||
|
||||
if (error) return <BootError error={error} />;
|
||||
if (!ready) return <BootSplash />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
ownDeviceId: string;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
// Decrypts an encrypted image attachment on first mount and renders it
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
|
||||
// when the global JS error handler catches an unhandled exception. The
|
||||
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
|
||||
// triageable from a single screenshot.
|
||||
export function BootError({ error }: Props) {
|
||||
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
|
||||
<Text style={styles.message}>{error.message}</Text>
|
||||
<View style={styles.diagnostic}>
|
||||
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
|
||||
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
|
||||
{envOk ? 'env-ok' : 'env-missing'}
|
||||
</Text>
|
||||
</View>
|
||||
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
paddingTop: 64,
|
||||
gap: 12,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
|
||||
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
|
||||
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
|
||||
diagnosticValue: { fontSize: 12, fontWeight: '700' },
|
||||
ok: { color: colors.success },
|
||||
bad: { color: colors.danger },
|
||||
stack: {
|
||||
color: colors.textDim,
|
||||
fontSize: 11,
|
||||
fontFamily: 'Courier',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Shown while AppBootstrap is initialising the crypto backend. Identical
|
||||
// background to the Expo splash so the handoff is invisible to the user.
|
||||
export function BootSplash() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,6 @@ interface Props {
|
||||
parentSenderName: string;
|
||||
reactions: MessageReaction[];
|
||||
myUserId: string | null;
|
||||
ownDeviceId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
onLongPress: () => void;
|
||||
onToggleReaction: (emoji: string) => void;
|
||||
@@ -36,7 +35,6 @@ export function MessageBubble({
|
||||
parentSenderName,
|
||||
reactions,
|
||||
myUserId,
|
||||
ownDeviceId,
|
||||
ownPrivateKey,
|
||||
onLongPress,
|
||||
onToggleReaction,
|
||||
@@ -72,12 +70,8 @@ export function MessageBubble({
|
||||
) : (
|
||||
<>
|
||||
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
|
||||
{firstImage && ownDeviceId && ownPrivateKey && (
|
||||
<AttachmentImage
|
||||
handle={firstImage}
|
||||
ownDeviceId={ownDeviceId}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
/>
|
||||
{firstImage && ownPrivateKey && (
|
||||
<AttachmentImage handle={firstImage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fireEvent, render } from '@testing-library/react-native';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PinInput } from './PinInput';
|
||||
|
||||
describe('<PinInput>', () => {
|
||||
it('appends digits to the underlying value and stops at length', () => {
|
||||
const onChange = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
|
||||
);
|
||||
fireEvent.changeText(getByTestId('pin-input'), '1234567890');
|
||||
expect(onChange).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('strips non-digits', () => {
|
||||
const onChange = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
|
||||
);
|
||||
fireEvent.changeText(getByTestId('pin-input'), '1a2b3c');
|
||||
expect(onChange).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('renders one bullet per filled slot', () => {
|
||||
const { getAllByText } = render(
|
||||
<PinInput value="123" onChange={() => {}} length={6} ariaLabel="PIN" />,
|
||||
);
|
||||
expect(getAllByText('•').length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type TextInput as TextInputType,
|
||||
} from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
length?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
// Six-slot numeric PIN entry. The actual input is an invisible TextInput
|
||||
// that captures the numeric keyboard; visible slots render bullets when
|
||||
// filled. Tapping anywhere on the row re-focuses the input.
|
||||
export function PinInput({
|
||||
value,
|
||||
onChange,
|
||||
length = 6,
|
||||
autoFocus,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const ref = useRef<TextInputType | null>(null);
|
||||
useEffect(() => {
|
||||
if (autoFocus) ref.current?.focus();
|
||||
}, [autoFocus]);
|
||||
return (
|
||||
<Pressable onPress={() => ref.current?.focus()} style={styles.row}>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
testID="pin-input"
|
||||
accessibilityLabel={ariaLabel}
|
||||
keyboardType="numeric"
|
||||
textContentType="oneTimeCode"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={length}
|
||||
editable={!disabled}
|
||||
value={value}
|
||||
onChangeText={(t) => onChange(t.replace(/\D/g, '').slice(0, length))}
|
||||
onSubmitEditing={() => {
|
||||
if (value.length === length) onSubmit?.();
|
||||
}}
|
||||
style={styles.hidden}
|
||||
/>
|
||||
<View style={styles.slots}>
|
||||
{Array.from({ length }).map((_, i) => {
|
||||
const filled = i < value.length;
|
||||
return (
|
||||
<View key={i} style={[styles.slot, filled && styles.slotFilled]}>
|
||||
{filled && <Text style={styles.bullet}>•</Text>}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { alignItems: 'center' },
|
||||
hidden: {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
slots: { flexDirection: 'row', gap: 8 },
|
||||
slot: {
|
||||
width: 40,
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
slotFilled: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
bullet: { color: colors.text, fontSize: 22 },
|
||||
});
|
||||
@@ -1,23 +1,31 @@
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { auth, crypto } from '@chat-app/shared';
|
||||
import type { DeviceRecord } from '@chat-app/shared/auth';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { secretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
|
||||
|
||||
// Locally-stored secrets keyed by stable names. Mirrors the desktop
|
||||
// convention so the migration tests (later) can compare snapshots.
|
||||
const KEY_DEVICE_ID = 'device.id';
|
||||
const KEY_DEVICE_PRIVKEY = 'device.privateKey';
|
||||
export type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
device: DeviceRecord | null;
|
||||
userId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
loading: boolean;
|
||||
userKeyState: UserKeyState;
|
||||
ready: boolean;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue {
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Resolve or create the device record for this install given an active
|
||||
// session. Stores the private key in expo-secure-store on first run.
|
||||
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => {
|
||||
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
|
||||
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
|
||||
|
||||
if (savedDeviceId && savedPrivKey) {
|
||||
const devices = await auth.listOwnDevices(supabase);
|
||||
const deviceIdStr = new TextDecoder().decode(savedDeviceId);
|
||||
const match = devices.find((d) => d.id === deviceIdStr);
|
||||
if (match) {
|
||||
setDevice(match);
|
||||
setOwnPrivateKey(savedPrivKey);
|
||||
return;
|
||||
}
|
||||
// Stored id no longer matches any device on the server (revoked,
|
||||
// wiped). Fall through to register a fresh one.
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
const s = session;
|
||||
if (!s) {
|
||||
setUserKeyState({ status: 'loading' });
|
||||
setOwnPrivateKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = crypto.getCryptoBackend();
|
||||
const kp = backend.generateKeyPair();
|
||||
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
|
||||
const record = await auth.registerDevice(supabase, {
|
||||
name: `Netralax Mobile (${Platform.OS})`,
|
||||
platform,
|
||||
publicKey: kp.publicKey,
|
||||
setUserKeyState({ status: 'loading' });
|
||||
const cached = await cachedUserKey(s.user.id);
|
||||
if (cached) {
|
||||
setOwnPrivateKey(cached);
|
||||
setUserKeyState({ status: 'unlocked' });
|
||||
void ensureLegacyMigrated(s.user.id).catch((err) => {
|
||||
console.warn('legacy migration on auth-resume failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = await fetchUserKeyBlob(supabase, s.user.id);
|
||||
if (!blob || !blob.exists) {
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
return;
|
||||
}
|
||||
if (blob.locked) {
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: blob.lockedUntil,
|
||||
hasRecovery: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: null,
|
||||
hasRecovery: blob.recoverySealedPrivateKey !== null,
|
||||
});
|
||||
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
|
||||
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
|
||||
setDevice(record);
|
||||
setOwnPrivateKey(kp.privateKey);
|
||||
}, []);
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (cancelled) return;
|
||||
setSession(data.session);
|
||||
if (data.session) {
|
||||
try {
|
||||
await ensureDevice(data.session);
|
||||
} catch (err) {
|
||||
console.warn('[auth] ensureDevice failed', err);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setReady(true);
|
||||
})();
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession);
|
||||
setReady(true);
|
||||
if (!nextSession) {
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
} else {
|
||||
void ensureDevice(nextSession).catch((err) =>
|
||||
console.warn('[auth] ensureDevice (state change) failed', err),
|
||||
);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, [ensureDevice]);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async (): Promise<void> => {
|
||||
useEffect(() => {
|
||||
void refreshUserKeyState().catch((err) => {
|
||||
console.warn('refreshUserKeyState failed', err);
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
});
|
||||
}, [session, refreshUserKeyState]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabase.auth.signOut();
|
||||
await secretStore.removeSecret(KEY_DEVICE_ID);
|
||||
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
device,
|
||||
userId: session?.user.id ?? null,
|
||||
ownPrivateKey,
|
||||
loading,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
};
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -24,5 +24,13 @@ export function createLibsodiumBackend(): CryptoBackend {
|
||||
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
|
||||
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
||||
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
|
||||
pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
},
|
||||
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
|
||||
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
|
||||
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('mobile env (lazy proxy)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
|
||||
});
|
||||
|
||||
it('importing the module does NOT throw when required vars are missing', async () => {
|
||||
await expect(import('./env')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('reading a property with no env set throws a clear error', async () => {
|
||||
const mod = await import('./env');
|
||||
expect(() => mod.env.supabaseUrl).toThrowError(
|
||||
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
|
||||
);
|
||||
});
|
||||
|
||||
it('reading a property after setting env returns the value and memoises', async () => {
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
|
||||
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
|
||||
const mod = await import('./env');
|
||||
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
|
||||
expect(mod.env.supabaseAnonKey).toBe('anon-123');
|
||||
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
|
||||
});
|
||||
});
|
||||
+31
-9
@@ -1,19 +1,41 @@
|
||||
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin.
|
||||
// We pull them through a single typed module so a missing var is a loud
|
||||
// startup error rather than a confusing Supabase 401 later.
|
||||
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
|
||||
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
|
||||
// We pull them through a Proxy so missing vars throw on first READ, not at
|
||||
// module-eval time. That keeps the throw inside the React tree where the
|
||||
// <BootError> boundary can render it as a readable screen instead of a blank
|
||||
// white window.
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error(
|
||||
'Missing required env var ' + name + '. Set it in apps/mobile/.env.local — see .env.example.',
|
||||
'Missing required env var ' + name +
|
||||
'. Set it via `eas secret:create --scope project --name ' + name +
|
||||
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
} as const;
|
||||
interface EnvShape {
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
authRedirectUrl: string;
|
||||
}
|
||||
|
||||
function readEnv(): EnvShape {
|
||||
return {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: EnvShape | null = null;
|
||||
|
||||
export const env: EnvShape = new Proxy({} as EnvShape, {
|
||||
get(_target, key: string | symbol): unknown {
|
||||
cached ??= readEnv();
|
||||
return cached[key as keyof EnvShape];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { secretStore } from './secretStore';
|
||||
|
||||
// During the migration window the orchestrator probes SecureStore for legacy
|
||||
// per-device private keys. We keep this read-only — never write — so a future
|
||||
// reset can safely wipe the new chatapp.userpriv.* slot without affecting
|
||||
// pre-existing legacy entries.
|
||||
export function legacyDeviceKey(userId: string, deviceId: string): Promise<Uint8Array | null> {
|
||||
return secretStore.getSecret('chatapp.priv.' + userId + '.' + deviceId);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { crypto } from '@chat-app/shared';
|
||||
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// We mock react-native modules so this Vitest file can run in Node without
|
||||
// loading native code. The mocks live next to the test for clarity.
|
||||
vi.mock('expo-secure-store', () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null),
|
||||
setItemAsync: vi.fn(async (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
}),
|
||||
deleteItemAsync: vi.fn(async (k: string) => {
|
||||
store.delete(k);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const rpcImpl = vi.fn();
|
||||
vi.mock('./supabase', () => ({
|
||||
supabase: {
|
||||
rpc: (name: string, params: unknown) => rpcImpl(name, params),
|
||||
from: () => ({
|
||||
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
crypto.setCryptoBackend(await makeWasmTestBackend());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rpcImpl.mockReset();
|
||||
});
|
||||
|
||||
describe('mobile userIdentity', () => {
|
||||
it('setupNewUserIdentity uploads + caches', async () => {
|
||||
rpcImpl.mockResolvedValue({ data: 0, error: null });
|
||||
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
|
||||
const out = await setupNewUserIdentity({
|
||||
userId: 'user-1',
|
||||
pin: '123456',
|
||||
withRecovery: true,
|
||||
});
|
||||
expect(out.publicKey.length).toBe(32);
|
||||
expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/);
|
||||
const cached = await cachedUserKey('user-1');
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached!.length).toBe(32);
|
||||
expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object));
|
||||
});
|
||||
|
||||
it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => {
|
||||
rpcImpl.mockResolvedValue({ data: { exists: false }, error: null });
|
||||
const { loadOrUnlockUserKey } = await import('./userIdentity');
|
||||
const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' });
|
||||
expect(out.kind).toBe('missing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
import {
|
||||
fetchUserKeyBlob,
|
||||
listOwnDevices,
|
||||
recordPinAttempt,
|
||||
resetUserKey,
|
||||
tryUnlockUserKey,
|
||||
uploadUserKeyBlob,
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode,
|
||||
generateUserKeyPair,
|
||||
getCryptoBackend,
|
||||
normalizeRecoveryCode,
|
||||
openUserKey,
|
||||
sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
|
||||
import { legacyDeviceKey } from './legacyDeviceVault';
|
||||
import { secretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const cacheKey = (userId: string) => 'chatapp.userpriv.' + userId;
|
||||
|
||||
export interface SetupParams {
|
||||
userId: string;
|
||||
pin: string;
|
||||
withRecovery: boolean;
|
||||
}
|
||||
export interface SetupResult {
|
||||
publicKey: Uint8Array;
|
||||
recoveryCode: string | null;
|
||||
}
|
||||
|
||||
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
|
||||
const kp = await generateUserKeyPair();
|
||||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
|
||||
let recoveryCode: string | null = null;
|
||||
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
|
||||
if (p.withRecovery) {
|
||||
recoveryCode = await generateRecoveryCode();
|
||||
const r = await sealUserKey({
|
||||
privateKey: kp.privateKey,
|
||||
pin: normalizeRecoveryCode(recoveryCode),
|
||||
});
|
||||
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
|
||||
}
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: p.userId,
|
||||
publicKey: kp.publicKey,
|
||||
sealedPrivateKey: sealed.sealedPrivateKey,
|
||||
salt: sealed.salt,
|
||||
kdfParams: sealed.kdfParams,
|
||||
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
|
||||
recoverySalt: recoverySealed?.salt ?? null,
|
||||
});
|
||||
await secretStore.setSecret(cacheKey(p.userId), kp.privateKey);
|
||||
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||
console.warn('legacy conv-key migration failed', err);
|
||||
});
|
||||
return { publicKey: kp.publicKey, recoveryCode };
|
||||
}
|
||||
|
||||
export interface UnlockParams {
|
||||
userId: string;
|
||||
pin: string;
|
||||
isRecoveryCode?: boolean;
|
||||
}
|
||||
|
||||
export type UnlockOutcome =
|
||||
| { kind: 'unlocked' }
|
||||
| { kind: 'locked'; lockedUntil: string }
|
||||
| { kind: 'missing' };
|
||||
|
||||
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
|
||||
const remote = await tryUnlockUserKey(supabase, p.userId);
|
||||
if (!remote.exists) return { kind: 'missing' };
|
||||
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
|
||||
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
|
||||
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
|
||||
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
|
||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||
let priv: Uint8Array;
|
||||
try {
|
||||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
} catch (err) {
|
||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
|
||||
await secretStore.setSecret(cacheKey(p.userId), priv);
|
||||
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||
console.warn('legacy conv-key migration failed', err);
|
||||
});
|
||||
return { kind: 'unlocked' };
|
||||
}
|
||||
|
||||
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
|
||||
return secretStore.getSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||||
await secretStore.removeSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
|
||||
const blob = await fetchUserKeyBlob(supabase, userId);
|
||||
return blob !== null;
|
||||
}
|
||||
|
||||
export async function changePin(params: {
|
||||
userId: string;
|
||||
oldPin: string;
|
||||
newPin: string;
|
||||
}): Promise<void> {
|
||||
const cached = await cachedUserKey(params.userId);
|
||||
if (!cached) throw new Error('user key not cached locally — re-login required');
|
||||
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: getCryptoBackend().scalarMultBase(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
});
|
||||
void params.oldPin; // cached key already proves old PIN was correct
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
|
||||
const cached = await cachedUserKey(params.userId);
|
||||
if (!cached) throw new Error('user key not cached locally');
|
||||
const blob = await fetchUserKeyBlob(supabase, params.userId);
|
||||
if (!blob || !blob.exists || blob.locked) {
|
||||
throw new Error('cannot regenerate recovery while locked');
|
||||
}
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const sealed = await sealUserKey({
|
||||
privateKey: cached,
|
||||
pin: normalizeRecoveryCode(recoveryCode),
|
||||
});
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: getCryptoBackend().scalarMultBase(cached),
|
||||
sealedPrivateKey: blob.sealedPrivateKey,
|
||||
salt: blob.salt,
|
||||
kdfParams: blob.kdfParams,
|
||||
recoverySealedPrivateKey: sealed.sealedPrivateKey,
|
||||
recoverySalt: sealed.salt,
|
||||
});
|
||||
return recoveryCode;
|
||||
}
|
||||
|
||||
export async function resetIdentity(params: {
|
||||
userId: string;
|
||||
pin: string;
|
||||
}): Promise<string> {
|
||||
await clearUserKeyCache(params.userId);
|
||||
await resetUserKey(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: new Uint8Array(32),
|
||||
sealedPrivateKey: new Uint8Array(40),
|
||||
salt: new Uint8Array(16),
|
||||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
|
||||
});
|
||||
const setup = await setupNewUserIdentity({
|
||||
userId: params.userId,
|
||||
pin: params.pin,
|
||||
withRecovery: true,
|
||||
});
|
||||
return setup.recoveryCode ?? '';
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReport {
|
||||
serverDevices: number;
|
||||
strongholdKeysFromServerDevices: number;
|
||||
strongholdKeysFromBundleScan: number;
|
||||
attempted: number;
|
||||
migrated: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<LegacyMigrationReport | null> {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) return null;
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) throw new Error('user key not cached locally — re-login required');
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
async function runLegacyMigration(
|
||||
userId: string,
|
||||
ownNewPriv: Uint8Array,
|
||||
ownNewPub: Uint8Array,
|
||||
): Promise<LegacyMigrationReport> {
|
||||
const report: LegacyMigrationReport = {
|
||||
serverDevices: 0,
|
||||
strongholdKeysFromServerDevices: 0,
|
||||
strongholdKeysFromBundleScan: 0,
|
||||
attempted: 0,
|
||||
migrated: 0,
|
||||
noStrongholdKey: 0,
|
||||
decryptFailed: 0,
|
||||
rpcFailed: 0,
|
||||
};
|
||||
|
||||
const devices = await listOwnDevices(supabase);
|
||||
report.serverDevices = devices.length;
|
||||
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const d of devices) {
|
||||
const k = await legacyDeviceKey(userId, d.id);
|
||||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||||
}
|
||||
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: scanRowsRaw } = await (supabase as any)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.is('recipient_user_id', null)
|
||||
.not('recipient_device_id', 'is', null);
|
||||
const scanIds = Array.from(
|
||||
new Set(
|
||||
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
|
||||
.map((r) => r.recipient_device_id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
);
|
||||
for (const id of scanIds) {
|
||||
if (ownLegacyDevicePrivateKeys[id]) continue;
|
||||
const k = await legacyDeviceKey(userId, id);
|
||||
if (k) {
|
||||
ownLegacyDevicePrivateKeys[id] = k;
|
||||
report.strongholdKeysFromBundleScan += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const ids = Object.keys(ownLegacyDevicePrivateKeys);
|
||||
if (ids.length === 0) {
|
||||
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
|
||||
return report;
|
||||
}
|
||||
|
||||
const result = await migrateOwnLegacyBundles({
|
||||
client: supabase,
|
||||
ownUserId: userId,
|
||||
ownNewPublicKey: ownNewPub,
|
||||
ownNewPrivateKey: ownNewPriv,
|
||||
ownLegacyDeviceIds: ids,
|
||||
ownLegacyDevicePrivateKeys,
|
||||
});
|
||||
report.attempted = result.attempted;
|
||||
report.migrated = result.migratedConversations;
|
||||
report.noStrongholdKey = result.noStrongholdKey;
|
||||
report.decryptFailed = result.decryptFailed;
|
||||
report.rpcFailed = result.rpcFailed;
|
||||
return report;
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo"
|
||||
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo",
|
||||
"check:env": "node scripts/check-env.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -48,6 +49,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
"@types/react": "~18.3.12"
|
||||
"@testing-library/react-native": "^12.9.0",
|
||||
"@types/react": "~18.3.12",
|
||||
"jsdom": "^29.1.1",
|
||||
"react-test-renderer": "18.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys
|
||||
// so an onboarding dev doesn't ship a build that white-screens.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const mobileRoot = resolve(here, '..');
|
||||
const examplePath = resolve(mobileRoot, '.env.example');
|
||||
const localPath = resolve(mobileRoot, '.env.local');
|
||||
|
||||
if (!existsSync(localPath)) {
|
||||
console.error('No .env.local found at ' + localPath);
|
||||
console.error('Copy .env.example to .env.local and fill in values.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function keysOf(path) {
|
||||
return new Set(
|
||||
readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.map((line) => line.split('=', 1)[0]),
|
||||
);
|
||||
}
|
||||
|
||||
const exampleKeys = keysOf(examplePath);
|
||||
const localKeys = keysOf(localPath);
|
||||
const missing = [...exampleKeys].filter((k) => !localKeys.has(k));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('Missing keys in .env.local: ' + missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('env-ok: all .env.example keys present in .env.local');
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const shimPath = fileURLToPath(new URL('./vitest.rn-shim.ts', import.meta.url));
|
||||
|
||||
// Vitest cannot transform React Native's Flow-typed source. We redirect
|
||||
// every `react-native` reference to a minimal host-component shim
|
||||
// (`vitest.rn-shim.ts`) so @testing-library/react-native v12 + jsdom can
|
||||
// render the few primitives PinInput uses.
|
||||
//
|
||||
// Two interception layers are needed:
|
||||
// 1. resolve.alias — covers ESM imports vite sees during transformation
|
||||
// of inlined dependencies (e.g. when @testing-library/react-native
|
||||
// goes through vite's pipeline because of `server.deps.inline`).
|
||||
// 2. setupFiles patches Module._resolveFilename so any pure-CJS
|
||||
// `require("react-native")` that escapes vite also lands on the shim.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@testing-library\/react-native/],
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^react-native$/, replacement: shimPath },
|
||||
{ find: /^react-native\/(.*)$/, replacement: shimPath },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Minimal `react-native` surface used by vitest tests. Each visible
|
||||
// primitive is a forwardRef component that renders a lower-case React
|
||||
// element so react-test-renderer treats it as a regular host node, and
|
||||
// @testing-library/react-native's queries can traverse the tree.
|
||||
// The shim is loaded by Module._resolveFilename in vitest.setup.ts.
|
||||
import * as React from 'react';
|
||||
|
||||
function makeHost(name: string) {
|
||||
const C: any = React.forwardRef(function HostComponent(
|
||||
props: Record<string, unknown>,
|
||||
ref: unknown,
|
||||
) {
|
||||
return React.createElement(name, { ...props, ref });
|
||||
});
|
||||
C.displayName = name;
|
||||
return C;
|
||||
}
|
||||
|
||||
export const View = makeHost('View');
|
||||
export const Text = makeHost('Text');
|
||||
export const TextInput = makeHost('TextInput');
|
||||
export const Pressable = makeHost('Pressable');
|
||||
export const Image = makeHost('Image');
|
||||
export const ScrollView = makeHost('ScrollView');
|
||||
export const Modal = makeHost('Modal');
|
||||
export const Switch = makeHost('Switch');
|
||||
|
||||
export const Platform = { OS: 'test', select: (m: any) => m.default ?? m.test ?? null };
|
||||
|
||||
export const StyleSheet = {
|
||||
create<T extends Record<string, unknown>>(s: T): T {
|
||||
return s;
|
||||
},
|
||||
flatten(s: unknown): unknown {
|
||||
if (Array.isArray(s)) {
|
||||
const flat: Record<string, unknown> = {};
|
||||
for (const part of s) Object.assign(flat, part ?? {});
|
||||
return flat;
|
||||
}
|
||||
return s ?? {};
|
||||
},
|
||||
hairlineWidth: 1,
|
||||
absoluteFill: {},
|
||||
absoluteFillObject: {},
|
||||
};
|
||||
|
||||
export const Dimensions = { get: () => ({ width: 360, height: 640 }) };
|
||||
export const NativeModules: Record<string, unknown> = {};
|
||||
export const Animated = { View, Text, Image, createAnimatedComponent: (c: unknown) => c };
|
||||
export const Linking = { openURL: async () => undefined };
|
||||
export const Alert = { alert: () => undefined };
|
||||
export const Appearance = { getColorScheme: () => 'dark' };
|
||||
export const AccessibilityInfo = {
|
||||
isScreenReaderEnabled: async () => false,
|
||||
addEventListener: () => ({ remove: () => undefined }),
|
||||
};
|
||||
export const PixelRatio = { get: () => 2 };
|
||||
export const Keyboard = { dismiss: () => undefined };
|
||||
export const useWindowDimensions = () => ({ width: 360, height: 640 });
|
||||
|
||||
const defaultExport = {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Image,
|
||||
ScrollView,
|
||||
Modal,
|
||||
Switch,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
NativeModules,
|
||||
Animated,
|
||||
Linking,
|
||||
Alert,
|
||||
Appearance,
|
||||
AccessibilityInfo,
|
||||
PixelRatio,
|
||||
Keyboard,
|
||||
useWindowDimensions,
|
||||
};
|
||||
export default defaultExport;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Vitest cannot transform React Native's Flow-typed source. We monkey-patch
|
||||
// Node's CommonJS resolver so every bare `react-native` request returns our
|
||||
// minimal host-component shim. This file is referenced by `setupFiles` in
|
||||
// vitest.config.ts and runs before any test file imports execute.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Module = require('node:module');
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const shimPath = path.join(here, 'vitest.rn-shim.ts');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalResolve = (Module as any)._resolveFilename;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Module as any)._resolveFilename = function patched(
|
||||
request: string,
|
||||
parent: unknown,
|
||||
...rest: unknown[]
|
||||
): string {
|
||||
if (request === 'react-native' || request.startsWith('react-native/')) {
|
||||
return shimPath;
|
||||
}
|
||||
return originalResolve.call(this, request, parent, ...rest);
|
||||
};
|
||||
@@ -0,0 +1,779 @@
|
||||
# Android White-Screen RCA — 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:** Identify and fix the cause of the Android white-screen-after-install on the mobile build, and ship defense-in-depth so the next boot-time failure renders a readable error screen instead of a blank window.
|
||||
|
||||
**Architecture:** Wire EXPO_PUBLIC_* into EAS builds via EAS Secrets (the leading hypothesis). Make `env.ts` lazy so missing variables throw inside React. Add an `<AppBootstrap>` boundary inside `_layout.tsx` that initialises the crypto backend in a `useEffect`, renders a splash while loading, and routes any error to a `<BootError>` view. Install a global JS error handler as the last-resort net. Then run the diagnostic playbook against a fresh APK to confirm which hypothesis actually fired.
|
||||
|
||||
**Tech Stack:** Expo SDK 52, React Native 0.76, TypeScript, EAS Build, expo-secure-store, react-native-libsodium.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-16-android-whitescreen-rca-design.md`
|
||||
|
||||
**Decisions inherited from spec review gate:**
|
||||
- EAS Secrets (not `eas.json env`) for `EXPO_PUBLIC_*` values.
|
||||
- `newArchEnabled: false` is acceptable as a temporary rollback if H2 confirms.
|
||||
|
||||
---
|
||||
|
||||
## File Overview
|
||||
|
||||
**New files (mobile app):**
|
||||
- `apps/mobile/components/BootError.tsx` — full-screen error fallback with env diagnostic
|
||||
- `apps/mobile/components/BootSplash.tsx` — minimal splash shown while the crypto backend warms up
|
||||
- `apps/mobile/components/AppBootstrap.tsx` — boundary that initialises the crypto backend in a `useEffect` and routes errors to `BootError`
|
||||
- `apps/mobile/lib/env.test.ts` — lazy proxy + missing-var coverage
|
||||
- `apps/mobile/scripts/check-env.mjs` — optional lint comparing `.env.example` against `.env.local`
|
||||
|
||||
**Modified files (mobile app):**
|
||||
- `apps/mobile/lib/env.ts` — convert to lazy proxy
|
||||
- `apps/mobile/app/_layout.tsx` — remove the module-eval crypto init; mount `<AppBootstrap>` at the top of the tree
|
||||
- `apps/mobile/README.md` — add an "EAS env" section
|
||||
- `apps/mobile/package.json` — add `check:env` script
|
||||
|
||||
**No files deleted.** No native code changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Wire env into EAS builds (the leading hypothesis fix)
|
||||
|
||||
### Task 1: Create EAS Secrets and document the contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/README.md`
|
||||
|
||||
- [ ] **Step 1: Confirm EAS CLI is installed and authenticated**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npx eas-cli --version
|
||||
npx eas-cli whoami
|
||||
```
|
||||
|
||||
Expected: a version string ≥ 13.0.0, and the `whoami` output shows the `bygalax` owner (matches `app.json` `expo.owner`). If `whoami` errors, run `npx eas-cli login` interactively in a terminal (this plan cannot be executed in a sandboxed shell).
|
||||
|
||||
- [ ] **Step 2: Create the three project-scoped secrets**
|
||||
|
||||
Run, substituting the real Supabase project URL and `sb_publishable_…` anon key (look them up in `apps/mobile/.env.local`):
|
||||
|
||||
```bash
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
|
||||
```
|
||||
|
||||
Expected: each command prints `✔ Created a new secret EXPO_PUBLIC_…`. List to confirm:
|
||||
|
||||
```bash
|
||||
npx eas-cli secret:list
|
||||
```
|
||||
|
||||
Expected: all three names present, type `STRING`, scope `PROJECT`.
|
||||
|
||||
- [ ] **Step 3: Document the env contract in README**
|
||||
|
||||
Append to `apps/mobile/README.md`:
|
||||
|
||||
````markdown
|
||||
## EAS Builds and Environment Variables
|
||||
|
||||
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets — `.env.local` is only honoured by `expo start` locally.
|
||||
|
||||
Required secrets (create once per project):
|
||||
|
||||
```bash
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
|
||||
```
|
||||
|
||||
Check with `npx eas-cli secret:list`. Missing values cause `env.ts` to throw at the first read, which the `<BootError>` view renders.
|
||||
````
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/README.md
|
||||
git commit -m "docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_*"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Lazy env proxy (TDD)
|
||||
|
||||
### Task 2: Failing test for missing env var
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/lib/env.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `apps/mobile/lib/env.test.ts`:
|
||||
|
||||
```ts
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('mobile env (lazy proxy)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
|
||||
});
|
||||
|
||||
it('importing the module does NOT throw when required vars are missing', async () => {
|
||||
await expect(import('./env')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('reading a property with no env set throws a clear error', async () => {
|
||||
const mod = await import('./env');
|
||||
expect(() => mod.env.supabaseUrl).toThrowError(
|
||||
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
|
||||
);
|
||||
});
|
||||
|
||||
it('reading a property after setting env returns the value and memoises', async () => {
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
|
||||
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
|
||||
const mod = await import('./env');
|
||||
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
|
||||
expect(mod.env.supabaseAnonKey).toBe('anon-123');
|
||||
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test, confirm it fails for the right reason**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile test -- env.test
|
||||
```
|
||||
|
||||
Expected: `importing the module does NOT throw when required vars are missing` FAILS because the current `env.ts` evaluates `required(...)` at module-eval time. This proves the test is wired correctly. Do NOT proceed if a different test fails first — fix the wiring before editing source.
|
||||
|
||||
### Task 3: Convert `env.ts` to a lazy proxy
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/lib/env.ts`
|
||||
|
||||
- [ ] **Step 1: Replace the module body with the lazy proxy**
|
||||
|
||||
Overwrite `apps/mobile/lib/env.ts` with:
|
||||
|
||||
```ts
|
||||
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
|
||||
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
|
||||
// We pull them through a Proxy so missing vars throw on first READ, not at
|
||||
// module-eval time. That keeps the throw inside the React tree where the
|
||||
// <BootError> boundary can render it as a readable screen instead of a blank
|
||||
// white window.
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error(
|
||||
'Missing required env var ' + name +
|
||||
'. Set it via `eas secret:create --scope project --name ' + name +
|
||||
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
interface EnvShape {
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
authRedirectUrl: string;
|
||||
}
|
||||
|
||||
function readEnv(): EnvShape {
|
||||
return {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: EnvShape | null = null;
|
||||
|
||||
export const env: EnvShape = new Proxy({} as EnvShape, {
|
||||
get(_target, key: string | symbol): unknown {
|
||||
cached ??= readEnv();
|
||||
return cached[key as keyof EnvShape];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Re-run the env tests**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile test -- env.test
|
||||
```
|
||||
|
||||
Expected: all three tests PASS.
|
||||
|
||||
- [ ] **Step 3: Run the full mobile test suite**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile test
|
||||
```
|
||||
|
||||
Expected: no regressions. If a test depended on the old eager-throw behaviour, update it inline to expect a lazy throw.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/lib/env.ts apps/mobile/lib/env.test.ts
|
||||
git commit -m "fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Bootstrap boundary + global handler
|
||||
|
||||
### Task 4: Create `BootSplash` and `BootError`
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/components/BootSplash.tsx`
|
||||
- Create: `apps/mobile/components/BootError.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the splash**
|
||||
|
||||
`apps/mobile/components/BootSplash.tsx`:
|
||||
|
||||
```tsx
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Shown while AppBootstrap is initialising the crypto backend. Identical
|
||||
// background to the Expo splash so the handoff is invisible to the user.
|
||||
export function BootSplash() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the error view**
|
||||
|
||||
`apps/mobile/components/BootError.tsx`:
|
||||
|
||||
```tsx
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
|
||||
// when the global JS error handler catches an unhandled exception. The
|
||||
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
|
||||
// triageable from a single screenshot.
|
||||
export function BootError({ error }: Props) {
|
||||
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
|
||||
<Text style={styles.message}>{error.message}</Text>
|
||||
<View style={styles.diagnostic}>
|
||||
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
|
||||
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
|
||||
{envOk ? 'env-ok' : 'env-missing'}
|
||||
</Text>
|
||||
</View>
|
||||
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
paddingTop: 64,
|
||||
gap: 12,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
|
||||
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
|
||||
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
|
||||
diagnosticValue: { fontSize: 12, fontWeight: '700' },
|
||||
ok: { color: colors.success },
|
||||
bad: { color: colors.danger },
|
||||
stack: {
|
||||
color: colors.textDim,
|
||||
fontSize: 11,
|
||||
fontFamily: 'Courier',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit (no test yet — these are render-only components)**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/components/BootSplash.tsx apps/mobile/components/BootError.tsx
|
||||
git commit -m "feat(mobile): BootSplash + BootError fallback views for AppBootstrap"
|
||||
```
|
||||
|
||||
### Task 5: Create `AppBootstrap` boundary
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/components/AppBootstrap.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the boundary**
|
||||
|
||||
`apps/mobile/components/AppBootstrap.tsx`:
|
||||
|
||||
```tsx
|
||||
import { crypto } from '@chat-app/shared';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
import { BootError } from './BootError';
|
||||
import { BootSplash } from './BootSplash';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// React Native exposes ErrorUtils on the global. The types ship with RN but
|
||||
// we cast defensively because the renderer used by Vitest does not.
|
||||
interface RNErrorUtils {
|
||||
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
|
||||
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
|
||||
}
|
||||
|
||||
// Initialises the crypto backend inside a useEffect (not at module-eval) so
|
||||
// any failure surfaces in the React tree. Also installs a global JS error
|
||||
// handler that routes unhandled throws to BootError; this catches errors
|
||||
// thrown during render (e.g. the lazy env proxy reading a missing var) that
|
||||
// would otherwise escape every per-screen ErrorBoundary.
|
||||
export function AppBootstrap({ children }: Props) {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
setReady(true);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
|
||||
if (!eu) return;
|
||||
const prev = eu.getGlobalHandler();
|
||||
eu.setGlobalHandler((err, isFatal) => {
|
||||
prev?.(err, isFatal);
|
||||
setError(err);
|
||||
});
|
||||
return () => eu.setGlobalHandler(prev);
|
||||
}, []);
|
||||
|
||||
if (error) return <BootError error={error} />;
|
||||
if (!ready) return <BootSplash />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/components/AppBootstrap.tsx
|
||||
git commit -m "feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws"
|
||||
```
|
||||
|
||||
### Task 6: Rewire `_layout.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app/_layout.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace the module-eval crypto init with `<AppBootstrap>`**
|
||||
|
||||
Overwrite `apps/mobile/app/_layout.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import { AppBootstrap } from '../components/AppBootstrap';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
import { IncomingCallModal } from '../components/IncomingCallModal';
|
||||
import { AuthProvider } from '../lib/authContext';
|
||||
import { CallProvider } from '../lib/callContext';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<AppBootstrap>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<CallProvider>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
<Stack.Screen name="auth/callback" />
|
||||
</Stack>
|
||||
<IncomingCallModal />
|
||||
</CallProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</AppBootstrap>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Two structural changes vs. the prior version:
|
||||
|
||||
1. Removed the top-level `crypto.setCryptoBackend(createLibsodiumBackend())` call — it now runs inside `AppBootstrap`'s `useEffect`.
|
||||
2. `<AppBootstrap>` sits OUTSIDE `<ErrorBoundary>` so a boot failure renders `<BootError>` instead of trying (and failing) to hit `ErrorBoundary`'s consumer-tree path.
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: no errors. The previously-direct `crypto` + `createLibsodiumBackend` imports are now gone from `_layout.tsx`; if either is reported as unused, remove the stale import.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/app/_layout.tsx
|
||||
git commit -m "fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Local validation (sanity before remote build)
|
||||
|
||||
### Task 7: Local smoke (Expo Go / dev client) — env-missing path
|
||||
|
||||
**Files:**
|
||||
- None (local sanity, no edits)
|
||||
|
||||
- [ ] **Step 1: Temporarily clear local env**
|
||||
|
||||
```bash
|
||||
mv apps/mobile/.env.local apps/mobile/.env.local.bak
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Start the dev server**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile dev
|
||||
```
|
||||
|
||||
In a connected Android emulator / device, open the dev client.
|
||||
|
||||
Expected: app reaches `<BootError>` with the message `Missing required env var EXPO_PUBLIC_SUPABASE_URL ...` and the diagnostic line `EXPO_PUBLIC_SUPABASE_URL: env-missing`. **No white screen.**
|
||||
|
||||
- [ ] **Step 3: Restore env**
|
||||
|
||||
```bash
|
||||
mv apps/mobile/.env.local.bak apps/mobile/.env.local
|
||||
```
|
||||
|
||||
Reload the dev client.
|
||||
|
||||
Expected: app boots normally to the login screen.
|
||||
|
||||
- [ ] **Step 4: No commit (validation only)**
|
||||
|
||||
No-op.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Remote build validation (the actual RCA)
|
||||
|
||||
### Task 8: Run the diagnostic playbook against a real APK
|
||||
|
||||
**Files:**
|
||||
- None (investigative; outcome determines whether Phase 5 fixes are needed)
|
||||
|
||||
- [ ] **Step 1: Build the preview APK with EAS Secrets present**
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npx eas-cli build --profile preview --platform android
|
||||
```
|
||||
|
||||
Expected: build succeeds. Note the APK URL.
|
||||
|
||||
- [ ] **Step 2: Install on a connected Android device**
|
||||
|
||||
```bash
|
||||
adb install -r <downloaded-apk>.apk
|
||||
```
|
||||
|
||||
Expected: install succeeds.
|
||||
|
||||
- [ ] **Step 3: Capture logs while launching**
|
||||
|
||||
```bash
|
||||
adb logcat -c
|
||||
adb logcat *:E ReactNative:V ReactNativeJS:V &
|
||||
# tap the launcher icon for the app
|
||||
```
|
||||
|
||||
Triage the first 50 lines for the first `Error`, `Exception`, or `FATAL` after the app starts.
|
||||
|
||||
- [ ] **Step 4: Match the trace against a hypothesis**
|
||||
|
||||
| Trace pattern | Hypothesis | Next action |
|
||||
|---|---|---|
|
||||
| `Missing required env var EXPO_PUBLIC_…` rendered to BootError (no red box) | H1 — fix already applied | Skip to Phase 6 |
|
||||
| `Native module … not found` / `RNLibsodium not found` | H3 — libsodium native autolink missing | Phase 5 Task 9 |
|
||||
| `JNI DETECTED ERROR` / `Fatal signal 11 (SIGSEGV)` before any RN log | H2 — new arch + incompatible lib | Phase 5 Task 10 |
|
||||
| `libsodium-wrappers-sumo` or `WebAssembly` in the trace | H4 — covered by the mobile-encryption-port plan | Note the trace; merge that plan next |
|
||||
| App reaches login screen | H1 was the root cause; nothing more to do | Skip to Phase 6 |
|
||||
|
||||
- [ ] **Step 5: Write a one-paragraph note in the PR description**
|
||||
|
||||
Capture which hypothesis confirmed, log lines, and which Phase 5 task (if any) was needed. This becomes the regression record.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Hypothesis-specific fixes (conditional)
|
||||
|
||||
Only run the tasks that the Step 4 triage selected. If H1 alone resolves it, skip Phase 5 entirely.
|
||||
|
||||
### Task 9 (conditional, H3): Force re-link `react-native-libsodium`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app.json` (only if `expo prebuild` adds a plugin entry — see below)
|
||||
|
||||
- [ ] **Step 1: Run `expo prebuild` to regenerate native projects**
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npx expo prebuild --clean --platform android
|
||||
```
|
||||
|
||||
Expected: an `android/` directory is created (or refreshed), and `app.json` may gain a `plugins` entry for `react-native-libsodium` if the lib ships a config plugin.
|
||||
|
||||
- [ ] **Step 2: Rebuild and re-test**
|
||||
|
||||
```bash
|
||||
npx eas-cli build --profile preview --platform android
|
||||
```
|
||||
|
||||
Install, repeat Phase 4 Step 3-4.
|
||||
|
||||
Expected: native module is now found. If still missing, escalate to the lib's GitHub issues — it likely needs a manual Gradle entry in `android/app/build.gradle`.
|
||||
|
||||
- [ ] **Step 3: Commit any generated config**
|
||||
|
||||
If `app.json` changed, commit the diff:
|
||||
|
||||
```bash
|
||||
git add apps/mobile/app.json
|
||||
git commit -m "fix(mobile): re-link react-native-libsodium via expo prebuild"
|
||||
```
|
||||
|
||||
If `android/` is ignored (Expo managed flow), document the prebuild step in the README under the "EAS env" section instead.
|
||||
|
||||
### Task 10 (conditional, H2): Temporarily disable `newArchEnabled`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app.json`
|
||||
|
||||
- [ ] **Step 1: Toggle the flag**
|
||||
|
||||
Edit `apps/mobile/app.json`, change `"newArchEnabled": true` → `"newArchEnabled": false`. Note in the commit message which library is suspected and the upstream issue link.
|
||||
|
||||
- [ ] **Step 2: Rebuild and re-test**
|
||||
|
||||
```bash
|
||||
npx eas-cli build --profile preview --platform android
|
||||
```
|
||||
|
||||
Install, repeat Phase 4 Step 3-4.
|
||||
|
||||
Expected: app boots to login.
|
||||
|
||||
- [ ] **Step 3: Open a follow-up issue**
|
||||
|
||||
Create a tracking issue in the repo titled `mobile: re-enable newArchEnabled once <lib> is Fabric-ready` with the trace from Phase 4 attached. Link to the affected lib's tracker.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/app.json
|
||||
git commit -m "fix(mobile): temporarily disable newArchEnabled (white-screen on Android)
|
||||
|
||||
Suspected incompatibility: <library@version>. Tracked in #<issue>."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Optional hardening
|
||||
|
||||
### Task 11: `check:env` lint script
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/scripts/check-env.mjs`
|
||||
- Modify: `apps/mobile/package.json`
|
||||
|
||||
- [ ] **Step 1: Add the script**
|
||||
|
||||
`apps/mobile/scripts/check-env.mjs`:
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys
|
||||
// so an onboarding dev doesn't ship a build that white-screens.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const mobileRoot = resolve(here, '..');
|
||||
const examplePath = resolve(mobileRoot, '.env.example');
|
||||
const localPath = resolve(mobileRoot, '.env.local');
|
||||
|
||||
if (!existsSync(localPath)) {
|
||||
console.error('No .env.local found at ' + localPath);
|
||||
console.error('Copy .env.example to .env.local and fill in values.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function keysOf(path) {
|
||||
return new Set(
|
||||
readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.map((line) => line.split('=', 1)[0]),
|
||||
);
|
||||
}
|
||||
|
||||
const exampleKeys = keysOf(examplePath);
|
||||
const localKeys = keysOf(localPath);
|
||||
const missing = [...exampleKeys].filter((k) => !localKeys.has(k));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('Missing keys in .env.local: ' + missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('env-ok: all .env.example keys present in .env.local');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the script into `package.json`**
|
||||
|
||||
In `apps/mobile/package.json`, under `scripts`, add:
|
||||
|
||||
```json
|
||||
"check:env": "node scripts/check-env.mjs"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile run check:env
|
||||
```
|
||||
|
||||
Expected: `env-ok: all .env.example keys present in .env.local`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/scripts/check-env.mjs apps/mobile/package.json
|
||||
git commit -m "chore(mobile): check:env script lints .env.local against .env.example"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Wrap-up
|
||||
|
||||
### Task 12: PR + post-mortem note
|
||||
|
||||
- [ ] **Step 1: Push branch + open PR**
|
||||
|
||||
```bash
|
||||
git push -u origin <branch-name>
|
||||
gh pr create --title "fix(mobile): Android white-screen RCA + defense-in-depth" --body "..."
|
||||
```
|
||||
|
||||
PR body must contain:
|
||||
|
||||
```
|
||||
## Summary
|
||||
|
||||
- Lazy env proxy so missing EXPO_PUBLIC_* throws inside React.
|
||||
- AppBootstrap boundary mounts before AuthProvider; renders BootError on init failure.
|
||||
- Global ErrorUtils handler routes unhandled throws to BootError.
|
||||
- EAS Secrets documented in README; eas.json untouched.
|
||||
|
||||
## RCA outcome
|
||||
|
||||
<paste Phase 4 Step 5 paragraph>
|
||||
|
||||
## Test plan
|
||||
|
||||
- [x] Local: env-cleared dev client shows BootError, not white screen.
|
||||
- [x] Remote: preview APK installed on Android device; <result>.
|
||||
- [x] check:env script passes.
|
||||
- [x] All mobile unit tests green.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mark plan complete**
|
||||
|
||||
This plan is done when the Android preview build opens to the login screen and `<BootError>` renders correctly with env intentionally cleared.
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage check
|
||||
|
||||
- Hypothesis H1 — EAS Secrets (Task 1), lazy env (Task 3), env test (Task 2). ✅
|
||||
- Hypothesis H2 — Diagnostic (Task 8), conditional Task 10 fix. ✅
|
||||
- Hypothesis H3 — Diagnostic (Task 8), conditional Task 9 fix. ✅
|
||||
- Hypothesis H4 — Diagnostic only (Task 8); structural fix belongs to the mobile-encryption-port plan. ✅
|
||||
- Hypothesis H5 — Documented in Task 8 Step 4 table; cost of an asset eyeball is zero, no separate task needed. ✅
|
||||
- Defense-in-depth 1 (lazy env) — Tasks 2-3. ✅
|
||||
- Defense-in-depth 2 (AppBootstrap) — Tasks 4-6. ✅
|
||||
- Defense-in-depth 3 (global handler) — Task 5. ✅
|
||||
- Defense-in-depth 4 (SecureStore probe) — deferred (see Out of Scope). ✅
|
||||
- Defense-in-depth 5 (`check:env`) — Task 11. ✅
|
||||
|
||||
## Out of scope for this plan (in spec, but deferred)
|
||||
|
||||
- SecureStore availability probe — only relevant on rare Android factory-test profiles. Add later if Phase 4 surfaces a SecureStore symptom.
|
||||
- Sentry / Bugsnag integration — spec lists this under future work.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,927 @@
|
||||
# Phase 3 — Security & Devices: Session/Device List + Revoke
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the user see every install currently signed into their account, revoke any one remotely, and force-sign-out any device whose row is flipped to `revoked_at != null` (either by another install or — in the future — by an admin).
|
||||
|
||||
**Architecture:**
|
||||
- Server: extend the existing telemetry-only `devices` table with a `revoked_at` column + a `revoke_device(p_device_id uuid)` SECURITY DEFINER RPC. Publish `devices` to `supabase_realtime` so every signed-in client gets postgres_changes events for its own rows.
|
||||
- Client: on each post-sign-in startup, "ensure" exactly one `devices` row exists for this install (registered the first time, then re-used). Subscribe to own `devices` for revoked_at flips → force `signOut()` (which already chains through `wipeLocalState`). A new "Geräte" tab in Settings lists rows + offers a Revoke button per row (own row's button is disabled, label "Nutze Sign-out").
|
||||
- A new full-screen `RemoteRevokedScreen` is rendered as a top-level overlay (inside `AuthProvider`) whenever `revokedRemotely` is true, so the user sees "Du wurdest remote abgemeldet" *before* the router bounces them to `/auth`.
|
||||
|
||||
**Tech Stack:** Postgres + RLS + RPC + Supabase Realtime (postgres_changes); React 18 + HashRouter; Electron preload IPC for OS hostname; existing `wipeLocalState` and `signOut` chain in `AuthContext`.
|
||||
|
||||
**Non-goals:**
|
||||
- No "rename device" UI (out of scope for Phase 3 spec).
|
||||
- No "log out all other devices" bulk action.
|
||||
- No admin-side revoke; spec only requires user-driven revoke of own installs.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **Verify current working directory and clean status**
|
||||
|
||||
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||
Expected: branch `main`, no uncommitted changes (except possibly `apps/desktop/.env.local` which is gitignored).
|
||||
|
||||
- [ ] **Confirm tooling is green before starting**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test --run`
|
||||
Expected: all green. If anything is red, STOP and report — don't start on a broken baseline.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: SQL migration — devices.revoked_at + revoke_device RPC + realtime publication
|
||||
|
||||
**Why:** The whole feature pivots on a server-side authoritative "is this install still allowed in" flag plus a way for any of the user's own installs to flip it.
|
||||
|
||||
**Files:**
|
||||
- Create: `supabase/migrations/20260516000005_device_revocation.sql`
|
||||
|
||||
**Migration must be idempotent** — pre-Phase-2 we re-ran migrations against prod by hand, and pushing again must not error. Use `add column if not exists`, `create or replace function`, `do $$ ... if not exists ... $$` for publication adds.
|
||||
|
||||
- [ ] **Step 1: Write the SQL migration**
|
||||
|
||||
Create `supabase/migrations/20260516000005_device_revocation.sql`:
|
||||
|
||||
```sql
|
||||
-- Phase 3: per-device revocation. Adds a revoked_at flag, an RPC that lets
|
||||
-- the device's owner flip it, and publishes the devices table to realtime
|
||||
-- so every signed-in install can react to its own row being revoked.
|
||||
|
||||
alter table public.devices
|
||||
add column if not exists revoked_at timestamptz null;
|
||||
|
||||
create index if not exists devices_user_revoked_idx
|
||||
on public.devices(user_id, revoked_at);
|
||||
|
||||
-- RLS: existing policy on devices already gates by user_id; an UPDATE done
|
||||
-- via the RPC runs with SECURITY DEFINER so we don't widen the RLS surface.
|
||||
|
||||
create or replace function public.revoke_device(p_device_id uuid)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_owner uuid;
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'not authenticated' using errcode = '28000';
|
||||
end if;
|
||||
|
||||
select user_id into v_owner
|
||||
from public.devices
|
||||
where id = p_device_id;
|
||||
|
||||
if v_owner is null then
|
||||
raise exception 'device not found' using errcode = 'P0002';
|
||||
end if;
|
||||
|
||||
if v_owner <> auth.uid() then
|
||||
raise exception 'not authorized' using errcode = '42501';
|
||||
end if;
|
||||
|
||||
update public.devices
|
||||
set revoked_at = now()
|
||||
where id = p_device_id
|
||||
and revoked_at is null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.revoke_device(uuid) from public;
|
||||
grant execute on function public.revoke_device(uuid) to authenticated;
|
||||
|
||||
-- Replica identity full so UPDATE events deliver the full new row (including
|
||||
-- revoked_at) to subscribers; default REPLICA IDENTITY DEFAULT only sends
|
||||
-- the primary key columns, which would force a refetch on every event.
|
||||
alter table public.devices replica identity full;
|
||||
|
||||
-- Add devices to the supabase_realtime publication if not already present.
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_publication_tables
|
||||
where pubname = 'supabase_realtime'
|
||||
and schemaname = 'public'
|
||||
and tablename = 'devices'
|
||||
) then
|
||||
execute 'alter publication supabase_realtime add table public.devices';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit the migration**
|
||||
|
||||
```bash
|
||||
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||
git add supabase/migrations/20260516000005_device_revocation.sql
|
||||
git commit -m "feat(P3.T1): devices.revoked_at + revoke_device RPC + realtime publication"
|
||||
```
|
||||
|
||||
Do NOT push to prod yet — `bash scripts/prod/push-migrations.sh <filter>` is only run when explicitly authorized by the user. This task is local-commit-only.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Shared wrapper — DeviceRecord.revokedAt + revokeDevice helper + listOwnDevices update
|
||||
|
||||
**Why:** The desktop renderer only ever talks to Supabase through `@chat-app/shared` wrappers. Adding `revokedAt` to `DeviceRecord` and a `revokeDevice` helper keeps the contract consistent across packages and gives us a typed RPC client.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/auth/device.ts`
|
||||
- Test: `packages/shared/src/auth/device.test.ts` (CREATE — file doesn't exist today)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `packages/shared/src/auth/device.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listOwnDevices, revokeDevice } from './device';
|
||||
|
||||
function makeClient(overrides: {
|
||||
user?: { id: string } | null;
|
||||
selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>;
|
||||
rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
|
||||
}): any {
|
||||
const builder: any = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }),
|
||||
};
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) },
|
||||
from: vi.fn().mockReturnValue(builder),
|
||||
rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))),
|
||||
};
|
||||
}
|
||||
|
||||
describe('listOwnDevices', () => {
|
||||
it('maps revoked_at into revokedAt', async () => {
|
||||
const client = makeClient({
|
||||
selectData: [
|
||||
{ id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null },
|
||||
{ id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' },
|
||||
],
|
||||
});
|
||||
const out = await listOwnDevices(client);
|
||||
expect(out).toEqual([
|
||||
{ id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null },
|
||||
{ id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeDevice', () => {
|
||||
it('invokes the revoke_device RPC with the device id', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
|
||||
const client = makeClient({ rpcImpl: rpc });
|
||||
await revokeDevice(client, 'd-42');
|
||||
expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' });
|
||||
});
|
||||
|
||||
it('throws when the RPC returns an error', async () => {
|
||||
const client = makeClient({
|
||||
rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }),
|
||||
});
|
||||
await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared test --run device`
|
||||
Expected: FAIL — `revokeDevice` not exported, `revokedAt` missing from mapped row.
|
||||
|
||||
- [ ] **Step 3: Implement revokedAt and revokeDevice in the wrapper**
|
||||
|
||||
Edit `packages/shared/src/auth/device.ts`:
|
||||
|
||||
- Extend `DeviceRecord`:
|
||||
```ts
|
||||
export interface DeviceRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: DevicePlatform;
|
||||
lastSeenAt: string;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
```
|
||||
- Update `registerDevice`'s select + return-mapper to include `revoked_at` / `revokedAt: null`:
|
||||
```ts
|
||||
.select('id, name, platform, last_seen_at, revoked_at')
|
||||
```
|
||||
```ts
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
platform: data.platform,
|
||||
lastSeenAt: data.last_seen_at,
|
||||
revokedAt: data.revoked_at,
|
||||
};
|
||||
```
|
||||
- Update `listOwnDevices` to select+map revoked_at:
|
||||
```ts
|
||||
.select('id, name, platform, last_seen_at, revoked_at')
|
||||
```
|
||||
```ts
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
platform: row.platform,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
revokedAt: row.revoked_at,
|
||||
}));
|
||||
```
|
||||
- Add the new helper at the end of the file (above the re-exports):
|
||||
```ts
|
||||
export async function revokeDevice(
|
||||
client: AppSupabaseClient,
|
||||
deviceId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await client.rpc('revoke_device', { p_device_id: deviceId });
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared test --run device`
|
||||
Expected: PASS — 3 tests in `device.test.ts` pass.
|
||||
|
||||
- [ ] **Step 5: Run full shared typecheck + test suite**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/shared test --run`
|
||||
Expected: all green. If a downstream file uses `DeviceRecord` and breaks on the missing field, fix the call site to handle `revokedAt: null` (don't suppress with `as any`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/auth/device.ts packages/shared/src/auth/device.test.ts
|
||||
git commit -m "feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Preload IPC — `app:hostname` for the device-name default
|
||||
|
||||
**Why:** The first time we register a `devices` row for this install we need a human-readable name. Renderer has no Node access so we expose a tiny IPC that returns `os.hostname()`. If the call ever fails (e.g. web build), the caller falls back to a static "Desktop".
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/electron/ipc-types.ts` — add channel constant
|
||||
- Modify: `apps/desktop/electron/main.ts` — register handler
|
||||
- Modify: `apps/desktop/electron/preload.ts` — expose method
|
||||
- Modify: `apps/desktop/electron/preload-types.d.ts` — extend renderer typing
|
||||
|
||||
- [ ] **Step 1: Find the existing CHANNELS constant and add the new channel**
|
||||
|
||||
Run: `grep -n "CHANNELS =" apps/desktop/electron/ipc-types.ts`
|
||||
|
||||
Add a new entry next to the other `app:*` channels (or at the end if there are none):
|
||||
```ts
|
||||
export const CHANNELS = {
|
||||
// … existing …
|
||||
appHostname: 'app:hostname',
|
||||
} as const;
|
||||
```
|
||||
(Pick a name that matches the existing convention in that file — if other entries use snake_case keys, match them.)
|
||||
|
||||
- [ ] **Step 2: Register the handler in main.ts**
|
||||
|
||||
Locate the place in `apps/desktop/electron/main.ts` where other `ipcMain.handle(...)` calls live (search for `ipcMain.handle`). Add:
|
||||
```ts
|
||||
import os from 'node:os';
|
||||
// …
|
||||
ipcMain.handle(CHANNELS.appHostname, () => {
|
||||
try {
|
||||
const h = os.hostname();
|
||||
return typeof h === 'string' && h.length > 0 ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Expose the method on the preload bridge**
|
||||
|
||||
Edit `apps/desktop/electron/preload.ts` — in the `contextBridge.exposeInMainWorld('electronAPI', { ... })` object, add:
|
||||
```ts
|
||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.appHostname),
|
||||
```
|
||||
(Match the formatting/style of nearby entries.)
|
||||
|
||||
- [ ] **Step 4: Extend the renderer typing**
|
||||
|
||||
Edit `apps/desktop/electron/preload-types.d.ts` to add `getHostname?: () => Promise<string | null>` to the `ElectronAPI` interface (make it optional so the renderer code that consumes it must always `if (typeof window.electronAPI?.getHostname === 'function')`).
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/electron/ipc-types.ts apps/desktop/electron/main.ts apps/desktop/electron/preload.ts apps/desktop/electron/preload-types.d.ts
|
||||
git commit -m "feat(P3.T3): app:hostname IPC for device-name default"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: AuthContext — ensure device row + revocation realtime + revokedRemotely flag
|
||||
|
||||
**Why:** Every install needs (a) a `devices` row on the server so it shows up in the Geräte tab and (b) a live subscription that triggers a forced sign-out when its row gets revoked.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/lib/deviceRowId.ts` — tiny localStorage helper
|
||||
- Modify: `apps/desktop/src/context/AuthContext.tsx` — ensure-row effect + realtime subscription + `revokedRemotely` state
|
||||
|
||||
- [ ] **Step 1: Create the deviceRowId helper**
|
||||
|
||||
Create `apps/desktop/src/lib/deviceRowId.ts`:
|
||||
|
||||
```ts
|
||||
// localStorage key for "the devices.id row that belongs to THIS install".
|
||||
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
|
||||
// fresh install, so registering a new row is correct.
|
||||
|
||||
const KEY = 'chatapp.deviceRowId.v1';
|
||||
|
||||
export function getDeviceRowId(): string | null {
|
||||
try {
|
||||
const v = window.localStorage.getItem(KEY);
|
||||
return v && v.length > 0 ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setDeviceRowId(id: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, id);
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeviceRowId(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `revokedRemotely` to the AuthContext value + wire ensure-device effect**
|
||||
|
||||
Edit `apps/desktop/src/context/AuthContext.tsx`:
|
||||
|
||||
1. Extend the `AuthContextValue` interface with:
|
||||
```ts
|
||||
revokedRemotely: boolean;
|
||||
acknowledgeRevocation: () => void;
|
||||
```
|
||||
|
||||
2. Import the new helper + shared wrappers + supabase client (most of these are already imported; verify):
|
||||
```ts
|
||||
import { listOwnDevices, registerDevice, touchDeviceLastSeen } from '@chat-app/shared/auth';
|
||||
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
|
||||
```
|
||||
|
||||
3. Add a `const [revokedRemotely, setRevokedRemotely] = useState(false);` next to the other `useState`s in `AuthProvider`.
|
||||
|
||||
4. Add `acknowledgeRevocation` as a `useCallback` that just calls `setRevokedRemotely(false)`. Include it in the `value` memo + the deps array.
|
||||
|
||||
5. After the existing `void registerWebPush(installId);` effect, add an "ensure device row" effect:
|
||||
```ts
|
||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||
// pure session-list telemetry — it does not carry any cryptographic
|
||||
// material since the per-user-key refactor. We re-use the row across
|
||||
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
|
||||
// intentionally treated as "new install".
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const existing = getDeviceRowId();
|
||||
if (existing) {
|
||||
const rows = await listOwnDevices(supabase);
|
||||
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
|
||||
if (match) {
|
||||
await touchDeviceLastSeen(supabase, existing).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Row gone / revoked: drop the stale id and fall through to
|
||||
// registering a fresh one.
|
||||
clearDeviceRowId();
|
||||
}
|
||||
if (cancelled) return;
|
||||
const hostname =
|
||||
(typeof window.electronAPI?.getHostname === 'function'
|
||||
? await window.electronAPI.getHostname().catch(() => null)
|
||||
: null) ?? 'Desktop';
|
||||
const created = await registerDevice(supabase, {
|
||||
name: hostname.slice(0, 64),
|
||||
platform: 'desktop',
|
||||
});
|
||||
if (!cancelled) setDeviceRowId(created.id);
|
||||
} catch (err) {
|
||||
console.warn('ensure device row failed', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session]);
|
||||
```
|
||||
|
||||
6. Add a "revocation realtime" effect AFTER the ensure-device effect:
|
||||
```ts
|
||||
// Phase 3: listen for own-device revocations. The same channel also fires
|
||||
// when *another* of the user's installs is revoked — we ignore those (we
|
||||
// only force-sign-out when OUR row's revoked_at flips). The UI's device
|
||||
// list refetches independently via its own subscription in useOwnDevices.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const userId = session.user.id;
|
||||
const channel = supabase
|
||||
.channel('devices:self:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
(payload) => {
|
||||
const ownId = getDeviceRowId();
|
||||
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
|
||||
if (!row || !ownId) return;
|
||||
if (row.id !== ownId) return;
|
||||
if (row.revoked_at) {
|
||||
setRevokedRemotely(true);
|
||||
// Force-sign-out chain. signOut already wipes localStorage.
|
||||
void signOut().catch((err) => {
|
||||
console.warn('forced signOut after revoke failed', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [session, signOut]);
|
||||
```
|
||||
|
||||
Place this AFTER `signOut` is defined (which is the `useCallback` at line ~251). If a hoisting/order issue makes that awkward, define a `signOutRef = useRef<() => Promise<void>>()` updated by an effect and call `signOutRef.current?.()` inside the realtime callback — but try the direct approach first.
|
||||
|
||||
7. Add `revokedRemotely` + `acknowledgeRevocation` to the `value` object + deps array of its `useMemo`.
|
||||
|
||||
8. Do NOT reset `revokedRemotely` in `signOut` itself — the flag is only ever set by the realtime callback, and `acknowledgeRevocation` is the explicit reset path.
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS. If a complaint about the supabase channel filter syntax appears, double-check it matches existing realtime subscriptions in the codebase (search `postgres_changes` for an example).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/lib/deviceRowId.ts apps/desktop/src/context/AuthContext.tsx
|
||||
git commit -m "feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `useOwnDevices` hook — initial list + realtime refresh
|
||||
|
||||
**Why:** The Geräte tab needs a live, mutable list. Pulling once on mount means a freshly-revoked row stays visible until manual refresh; subscribing keeps the list in sync with reality.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/hooks/useOwnDevices.ts`
|
||||
|
||||
- [ ] **Step 1: Write the hook**
|
||||
|
||||
Create `apps/desktop/src/hooks/useOwnDevices.ts`:
|
||||
|
||||
```ts
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface State {
|
||||
devices: DeviceRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useOwnDevices(): {
|
||||
devices: DeviceRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
revoke: (deviceId: string) => Promise<void>;
|
||||
} {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? null;
|
||||
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
const list = await listOwnDevices(supabase);
|
||||
setState({ devices: list, loading: false, error: null });
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'failed to load devices',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setState({ devices: [], loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
const channel = supabase
|
||||
.channel('devices:list:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'devices', filter: 'user_id=eq.' + userId },
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId, refresh]);
|
||||
|
||||
const revoke = useCallback(async (deviceId: string) => {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||
}
|
||||
```
|
||||
|
||||
If `../lib/supabase` doesn't export the client under the name `supabase`, follow the convention used by neighboring hooks (e.g. `usePinnedMessages.ts`, `useMentionNotifications.ts`) for both the import and any path differences.
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/hooks/useOwnDevices.ts
|
||||
git commit -m "feat(P3.T5): useOwnDevices hook with realtime refresh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: SettingsPage — new "Geräte" tab between Sicherheit and Konto
|
||||
|
||||
**Why:** This is the user-facing surface — a sortable list of installs with a Revoke button per row, the own install marked with a badge and a disabled button.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/components/settings/DeviceListTab.tsx`
|
||||
- Modify: `apps/desktop/src/pages/SettingsPage.tsx` (TabId union, tabs array, content panel)
|
||||
|
||||
If the directory `apps/desktop/src/components/settings/` does not already exist, create it. Check first with `ls apps/desktop/src/components/`.
|
||||
|
||||
- [ ] **Step 1: Build the tab component**
|
||||
|
||||
Create `apps/desktop/src/components/settings/DeviceListTab.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOwnDevices } from '../../hooks/useOwnDevices';
|
||||
import { getDeviceRowId } from '../../lib/deviceRowId';
|
||||
import { SpinnerIcon } from '../icons';
|
||||
|
||||
function formatLastSeen(iso: string, locale: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function DeviceListTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { devices, loading, error, revoke } = useOwnDevices();
|
||||
const ownId = getDeviceRowId();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
setOpError(null);
|
||||
setBusy(id);
|
||||
try {
|
||||
await revoke(id);
|
||||
} catch (err) {
|
||||
setOpError(err instanceof Error ? err.message : 'revoke failed');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
|
||||
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{opError && (
|
||||
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
|
||||
{opError}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{devices.map((d) => {
|
||||
const isOwn = d.id === ownId;
|
||||
const isRevoked = d.revokedAt !== null;
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
|
||||
{isOwn && (
|
||||
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
|
||||
</span>
|
||||
)}
|
||||
{isRevoked && (
|
||||
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
|
||||
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
|
||||
{formatLastSeen(d.lastSeenAt, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
{isOwn ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
|
||||
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
|
||||
>
|
||||
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
|
||||
</button>
|
||||
) : isRevoked ? (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(d.id)}
|
||||
disabled={busy === d.id}
|
||||
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
{busy === d.id
|
||||
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
|
||||
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the tab into SettingsPage**
|
||||
|
||||
In `apps/desktop/src/pages/SettingsPage.tsx`:
|
||||
|
||||
1. At the top with other imports, add:
|
||||
```ts
|
||||
import { DeviceListTab } from '../components/settings/DeviceListTab';
|
||||
```
|
||||
(Match the path style of nearby `../components/...` imports.)
|
||||
|
||||
2. Around line 118–120, extend the `TabId` union to include `'devices'`:
|
||||
```ts
|
||||
type TabId =
|
||||
| 'profile' | 'appearance' | 'privacy' | 'notifications'
|
||||
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
|
||||
```
|
||||
|
||||
3. In the `tabs` array around line 122–132, insert a new entry between `'security'` and `'account'`:
|
||||
```ts
|
||||
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
|
||||
```
|
||||
`MonitorShareIcon` is already imported (line 128 uses it for screen-share). If that feels wrong stylistically, use an existing alternative such as `LockIcon` or whichever icon set the file uses — but do NOT add a new icon import; pick from what's already imported.
|
||||
|
||||
4. In the content panel (after the `activeTab === 'security'` block at line ~344 and before the `activeTab === 'account'` block at line ~355), add:
|
||||
```tsx
|
||||
{activeTab === 'devices' && (
|
||||
<Section
|
||||
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
|
||||
description={t('app:settings.section_devices_hint', {
|
||||
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
|
||||
})}
|
||||
>
|
||||
<DeviceListTab />
|
||||
</Section>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/settings/DeviceListTab.tsx apps/desktop/src/pages/SettingsPage.tsx
|
||||
git commit -m "feat(P3.T6): Settings 'Geräte' tab with revoke + this-device badge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: RemoteRevokedScreen — overlay shown when revokedRemotely flips to true
|
||||
|
||||
**Why:** When the user (or another of their installs) revokes THIS install, we force `signOut()` which races against the realtime delivery + the route navigation. The user should see a clear "Du wurdest remote abgemeldet" screen — not just be silently bounced to /auth.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/components/RemoteRevokedScreen.tsx`
|
||||
- Modify: `apps/desktop/src/App.tsx` — render the overlay inside `AuthProvider`
|
||||
|
||||
- [ ] **Step 1: Build the overlay**
|
||||
|
||||
Create `apps/desktop/src/components/RemoteRevokedScreen.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export function RemoteRevokedScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { revokedRemotely, acknowledgeRevocation } = useAuth();
|
||||
|
||||
if (!revokedRemotely) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remote-revoked-title"
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
|
||||
>
|
||||
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
|
||||
<h2
|
||||
id="remote-revoked-title"
|
||||
className="mb-2 font-display text-xl font-semibold text-fg"
|
||||
>
|
||||
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-fg-muted">
|
||||
{t('app:auth.revoked_body', {
|
||||
defaultValue:
|
||||
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeRevocation}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-contrast transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mount the overlay in App.tsx**
|
||||
|
||||
In `apps/desktop/src/App.tsx`:
|
||||
|
||||
1. Add the import at the top with other component imports:
|
||||
```ts
|
||||
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
|
||||
```
|
||||
|
||||
2. Inside the `<HashRouter>` block, alongside `<UpdateToast />` and `<CrashToast />` (right before `</HashRouter>`), add:
|
||||
```tsx
|
||||
<RemoteRevokedScreen />
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/RemoteRevokedScreen.tsx apps/desktop/src/App.tsx
|
||||
git commit -m "feat(P3.T7): RemoteRevokedScreen overlay for own-device revocation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final gate
|
||||
|
||||
- [ ] **Step 1: Run full typecheck across both packages**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 2: Run full shared test suite**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared test --run`
|
||||
Expected: PASS — at least the previous 30 tests + the 3 new device tests added in T2 = 33 tests passing.
|
||||
|
||||
- [ ] **Step 3: Verify no uncommitted changes**
|
||||
|
||||
Run: `git status`
|
||||
Expected: clean working tree on `main` (`apps/desktop/.env.local` is gitignored and may show in `git status --ignored`, that's fine).
|
||||
|
||||
- [ ] **Step 4: Report to user**
|
||||
|
||||
Report: "Phase 3 code-complete on `main`. New migration `20260516000005_device_revocation.sql` is committed locally but NOT yet pushed to prod — say the word and I run `bash scripts/prod/push-migrations.sh device_revocation` (filter pattern matches the new file). After the prod push, smoke test in dev: Settings → Geräte should list this install with the 'Dieses Gerät' badge. To test revoke, sign into a second install (or use prod CLI), revoke this install from the other one → this client should pop the 'Du wurdest remote abgemeldet' overlay and bounce to /auth. **No release** — version stays 0.18.8 until all 5 phases done."
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (resolved inline)
|
||||
|
||||
1. **Spec coverage:**
|
||||
- existing devices table → T1 reuses it
|
||||
- Geräte tab between Sicherheit and Konto → T6
|
||||
- name · platform · last_seen → T6 row layout
|
||||
- "Dieses Gerät" badge → T6 isOwn branch
|
||||
- revoked_at column → T1
|
||||
- revoke_device RPC validates user_id = auth.uid() → T1
|
||||
- realtime subscription on own devices → T4
|
||||
- forced signOut + Memory-Wipe on own revoke → T4 (signOut already chains wipeLocalState)
|
||||
- "Du wurdest remote abgemeldet" screen → T7
|
||||
- own device's revoke button disabled, label "Nutze Sign-out" → T6 isOwn branch
|
||||
|
||||
2. **Placeholders:** none — every step has concrete code.
|
||||
|
||||
3. **Type consistency:**
|
||||
- `DeviceRecord.revokedAt: string | null` introduced in T2; consumed by T5 (hook) and T6 (UI) consistently
|
||||
- `revokeDevice(client, deviceId): Promise<void>` defined T2, used T5 (via hook) and indirectly T6 (via hook's `revoke`)
|
||||
- `getDeviceRowId(): string | null` defined T4, used in T4 (AuthContext) + T6 (DeviceListTab)
|
||||
- `revokedRemotely` + `acknowledgeRevocation` added to AuthContext value in T4, consumed by T7
|
||||
|
||||
4. **One ambiguity surfaced + resolved:** the spec says Realtime → forced signOut. We also need the *revoking* device (when revoking ANOTHER device) to NOT receive any extra signOut for itself — handled by T4's `row.id !== ownId` early return.
|
||||
|
||||
5. **Memory-wipe interaction:** the `chatapp.deviceRowId.v1` key is intentionally NOT added to `PRESERVE_LOCAL_STORAGE`, so a memory-wipe gets a fresh device row on next sign-in. That matches the spirit of memory-wipe = fresh install.
|
||||
@@ -0,0 +1,849 @@
|
||||
# Phase 4A — Image Annotation vor Send
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the user mark up an image attachment (pen, arrow, rectangle, circle, text, highlighter) BEFORE it's sent. The annotated copy replaces the original `File` in the composer's attachments[] state; the recipient sees the flattened PNG with the annotation baked in (no separate metadata, no decoding work on receive).
|
||||
|
||||
**Architecture:**
|
||||
- New self-contained component `ImageAnnotator.tsx` (~400 LOC, no external deps): a full-screen modal with a single `<canvas>` rendering the original image plus an in-memory "ops stack" of drawing commands. Every tool stroke is one op; undo pops from the stack into a redo buffer; redo moves it back. The canvas re-renders the whole stack on every change — simple, debuggable, fast at typical image sizes.
|
||||
- New `AttachmentPreview` prop `onEdit?: () => void`. When the preview is an image and `onEdit` is wired, a `✏` button overlays the thumb. Clicking it opens `ImageAnnotator`; on Save the modal calls `onSave(newFile)` and `ConversationPage` swaps the entry in `attachments[]`.
|
||||
- Save flow: `canvas.toBlob({ type: 'image/png' })` → wrap in `new File([blob], original.name.replace(/\.\w+$/, '') + '-annotated.png', { type: 'image/png', lastModified: Date.now() })` → return through `onSave`.
|
||||
|
||||
**Tech Stack:** React 18 + TypeScript + HTML5 Canvas. Reuses Tailwind classes from the existing modal/toolbar code in the app. No new dependencies.
|
||||
|
||||
**Non-goals:**
|
||||
- No image-only crop / rotate / filter (just annotation).
|
||||
- No persistence of in-progress annotations between modal opens.
|
||||
- No collaboration / sharing of the op-stack (recipient sees flattened PNG only).
|
||||
- No SVG / vector output; PNG raster only.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **Verify clean working tree on `main`**
|
||||
|
||||
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||
Expected: clean (ignored `.env.local` is fine).
|
||||
|
||||
- [ ] **Confirm tooling is green**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS. If it's red, STOP and report — don't start on a broken baseline.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ImageAnnotator component skeleton + canvas mount + op-stack types
|
||||
|
||||
**Why:** Get the modal rendering with the image visible inside the canvas before adding any drawing logic. Establishes the file's structure (state, refs, types) that the next tasks fill in.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/components/ImageAnnotator.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the file**
|
||||
|
||||
Create `apps/desktop/src/components/ImageAnnotator.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||
export type AnnotatorWidth = 2 | 4 | 8;
|
||||
|
||||
export interface AnnotatorOp {
|
||||
tool: AnnotatorTool;
|
||||
color: AnnotatorColor;
|
||||
width: AnnotatorWidth;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
from?: { x: number; y: number };
|
||||
to?: { x: number; y: number };
|
||||
text?: string;
|
||||
at?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onCancel: () => void;
|
||||
onSave: (next: File) => void;
|
||||
}
|
||||
|
||||
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancel();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
}, [imageLoaded, ops]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const handleUndo = () => {
|
||||
setOps((cur) => {
|
||||
if (cur.length === 0) return cur;
|
||||
const next = cur.slice(0, -1);
|
||||
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
setRedoStack((r) => {
|
||||
if (r.length === 0) return r;
|
||||
const top = r[r.length - 1]!;
|
||||
setOps((cur) => [...cur, top]);
|
||||
return r.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setOps([]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
console.error('ImageAnnotator: toBlob returned null');
|
||||
return;
|
||||
}
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||
const next = new File([blob], baseName + '-annotated.png', {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
onSave(next);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{imageLoaded ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full cursor-crosshair rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="text-xs text-fg-muted">
|
||||
{ops.length === 0
|
||||
? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
|
||||
: ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// renderOp is filled in by Task 2. Stub for now so the canvas-replay loop
|
||||
// in the main useEffect compiles cleanly.
|
||||
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
|
||||
// implemented in Task 2
|
||||
}
|
||||
```
|
||||
|
||||
If `text-accent-fg` doesn't exist in this project's Tailwind, use the same class the existing accent buttons use — grep `Grep -n "bg-accent " apps/desktop/src/components/RemoteRevokedScreen.tsx` (P3.T7 wrote this very recently) to find the conventional pair.
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||
git commit -m "feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Drawing implementation — render all 6 tools + capture pointer events
|
||||
|
||||
**Why:** This is the drawing engine. After this task the user can free-hand-draw + shapes on the canvas with the defaults (pen / red / width 4). Tool/color/width pickers come in Task 3.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the `renderOp` stub + add pointer handlers + draft state)
|
||||
|
||||
- [ ] **Step 1: Replace the `renderOp` stub with the real renderer**
|
||||
|
||||
At the bottom of the file, replace the stub with:
|
||||
|
||||
```ts
|
||||
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = op.color;
|
||||
ctx.fillStyle = op.color;
|
||||
ctx.lineWidth = op.width;
|
||||
|
||||
switch (op.tool) {
|
||||
case 'pen': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'highlighter': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = op.width * 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.strokeRect(
|
||||
Math.min(from.x, to.x),
|
||||
Math.min(from.y, to.y),
|
||||
Math.abs(to.x - from.x),
|
||||
Math.abs(to.y - from.y),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = (from.y + to.y) / 2;
|
||||
const rx = Math.abs(to.x - from.x) / 2;
|
||||
const ry = Math.abs(to.y - from.y) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'arrow': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const head = Math.max(12, op.width * 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const { at, text } = op;
|
||||
if (!at || !text) break;
|
||||
const fontSize = Math.max(14, op.width * 6);
|
||||
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(text, at.x, at.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add pointer-event capture + draft state**
|
||||
|
||||
Inside the component, near the other refs, add:
|
||||
|
||||
```ts
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
```
|
||||
|
||||
Replace the existing main render `useEffect` body so it ALSO draws the in-progress draft (live preview during pointer-down):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
if (draftRef.current) {
|
||||
renderOp(ctx, draftRef.current);
|
||||
}
|
||||
}, [imageLoaded, ops, draftTick]);
|
||||
```
|
||||
|
||||
Add this helper right above the `return` (to convert client coords to internal canvas coords — important because the canvas is scaled to fit):
|
||||
|
||||
```ts
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Add these handlers (also above the `return`):
|
||||
|
||||
```ts
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const p = canvasPoint(e);
|
||||
|
||||
if (tool === 'text') {
|
||||
const value = window.prompt(
|
||||
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||
'',
|
||||
);
|
||||
if (value !== null && value.trim().length > 0) {
|
||||
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'pen' || tool === 'highlighter') {
|
||||
draftRef.current = { tool, color, width, points: [p] };
|
||||
} else {
|
||||
draftRef.current = { tool, color, width, from: p, to: p };
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const p = canvasPoint(e);
|
||||
const cur = draftRef.current;
|
||||
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||
cur.points = [...(cur.points ?? []), p];
|
||||
} else {
|
||||
cur.to = p;
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const cur = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!cur) return;
|
||||
const hasContent =
|
||||
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||
? (cur.points?.length ?? 0) >= 2
|
||||
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||
if (hasContent) {
|
||||
setOps((p) => [...p, cur]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
```
|
||||
|
||||
Wire the handlers onto the `<canvas>` element — replace the existing `<canvas ref={canvasRef} className="..." />` JSX with:
|
||||
|
||||
```tsx
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||
git commit -m "feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Toolbar — tool picker, color swatches, width swatches, undo/redo
|
||||
|
||||
**Why:** The user can already DRAW (default pen + red + width 4) — but can't change tool/color/width or visibly undo. This task adds the controls.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the footer)
|
||||
|
||||
- [ ] **Step 1: Add the tool/color/width palette to the footer**
|
||||
|
||||
Replace the existing `<footer>` element with:
|
||||
|
||||
```tsx
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||
const label = t('app:annotator.tool.' + id, {
|
||||
defaultValue:
|
||||
id === 'pen' ? 'Stift'
|
||||
: id === 'highlighter' ? 'Marker'
|
||||
: id === 'arrow' ? 'Pfeil'
|
||||
: id === 'rect' ? 'Rechteck'
|
||||
: id === 'circle' ? 'Kreis'
|
||||
: 'Text',
|
||||
});
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{toolGlyph(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={ops.length === 0}
|
||||
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={redoStack.length === 0}
|
||||
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
```
|
||||
|
||||
And add this helper at the bottom of the file (after `renderOp`):
|
||||
|
||||
```ts
|
||||
function toolGlyph(t: AnnotatorTool): string {
|
||||
switch (t) {
|
||||
case 'pen': return '✎';
|
||||
case 'highlighter': return '🖍';
|
||||
case 'arrow': return '↗';
|
||||
case 'rect': return '▭';
|
||||
case 'circle': return '◯';
|
||||
case 'text': return 'T';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||
git commit -m "feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire annotator into the composer — "✏" overlay on image previews + Save round-trip
|
||||
|
||||
**Why:** The annotator is fully functional but unreachable from the UI. This task adds the entry point.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (the `AttachmentPreview` function at line ~1393 + the call site at line ~915 + add ImageAnnotator import/state)
|
||||
|
||||
- [ ] **Step 1: Extend `AttachmentPreview` with an `onEdit` prop**
|
||||
|
||||
Replace the existing `AttachmentPreview` function (line ~1393) with:
|
||||
|
||||
```tsx
|
||||
function AttachmentPreview({
|
||||
file,
|
||||
onRemove,
|
||||
onEdit,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isImage) return;
|
||||
const u = URL.createObjectURL(file);
|
||||
setUrl(u);
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
{isImage && url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
|
||||
<span className="truncate font-semibold text-fg" title={file.name}>
|
||||
{file.name || 'Datei'}
|
||||
</span>
|
||||
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
|
||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
{isImage && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Bearbeiten"
|
||||
title="Bearbeiten"
|
||||
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Entfernen"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If `PencilIcon` isn't already exported from `./components/icons` (grep first: `Grep -n "PencilIcon\|EditIcon" apps/desktop/src/components/icons*`), inline this small SVG below `AttachmentPreview`:
|
||||
|
||||
```tsx
|
||||
function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add ImageAnnotator state + import to ConversationPage**
|
||||
|
||||
In the import block at the top of the file (alongside other `../components/...` imports), add:
|
||||
|
||||
```ts
|
||||
import { ImageAnnotator } from '../components/ImageAnnotator';
|
||||
```
|
||||
|
||||
Near the top of `ConversationPage` where other `useState`s live (e.g. near `attachments`), add:
|
||||
|
||||
```ts
|
||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass `onEdit` to AttachmentPreview**
|
||||
|
||||
In the `attachments.map(...)` at line ~915, change the JSX to:
|
||||
|
||||
```tsx
|
||||
{attachments.map((file, idx) => (
|
||||
<AttachmentPreview
|
||||
key={idx}
|
||||
file={file}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
onEdit={
|
||||
file.type.startsWith('image/')
|
||||
? () => setAnnotatingIndex(idx)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render the annotator at the page root when `annotatingIndex !== null`**
|
||||
|
||||
Place this near the end of the JSX return — just before the closing `</div>` of the page root:
|
||||
|
||||
```tsx
|
||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||
<ImageAnnotator
|
||||
file={attachments[annotatingIndex]!}
|
||||
onCancel={() => setAnnotatingIndex(null)}
|
||||
onSave={(next) => {
|
||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||
setAnnotatingIndex(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||
git commit -m "feat(P4A.T4): wire ImageAnnotator into composer attachment preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final gate
|
||||
|
||||
- [ ] **Step 1: Typecheck both packages**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 2: Run shared tests (sanity — these changes don't touch shared)**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared test -- --run`
|
||||
Expected: PASS — same count as Phase 3 baseline (33 tests).
|
||||
|
||||
- [ ] **Step 3: Verify no uncommitted changes**
|
||||
|
||||
Run: `git status`
|
||||
Expected: clean working tree on `main`.
|
||||
|
||||
- [ ] **Step 4: Report**
|
||||
|
||||
Report: "Phase 4A (Image Annotation) code-complete on `main`. Restart dev, attach an image in any chat, hover the preview → ✏ button appears → click → annotator opens. Draw, choose tools/colors/widths, undo/redo, Save → preview updates with the annotated PNG, ready to send. No migration. Released? defer to user."
|
||||
|
||||
---
|
||||
|
||||
## Self-review (resolved inline)
|
||||
|
||||
1. **Spec coverage** (against `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 97-101):
|
||||
- "Attachment-picker for images shows a new ✏ Bearbeiten button before send" → T4 adds the overlay button (image-only via `file.type.startsWith('image/')`).
|
||||
- "Opens `<ImageAnnotator>` modal: canvas overlay on the image" → T1 mounts canvas at image's natural size.
|
||||
- "Tools: pen, arrow, rectangle, circle, text, highlighter" → T2 implements all 6 in `renderOp`.
|
||||
- "6 colors, 3 stroke widths" → T3 swatches use the exact AnnotatorColor/AnnotatorWidth tuples from T1.
|
||||
- "Undo/Redo stack, Reset, Save" → T1 callbacks + T3 toolbar buttons; Ctrl+Z / Ctrl+Y bindings in T1 keydown effect.
|
||||
- "On Save: canvas.toBlob({type: 'image/png'}) flattens" → T1's `handleSave` + T4's `setAttachments(... map ... idx === annotatingIndex ? next : f)`.
|
||||
|
||||
2. **Placeholders:** none. Every step has concrete code.
|
||||
|
||||
3. **Type consistency:**
|
||||
- `AnnotatorTool`/`AnnotatorColor`/`AnnotatorWidth` defined in T1, consumed everywhere downstream.
|
||||
- `AnnotatorOp` fields match `renderOp` switch arms in T2.
|
||||
- `ImageAnnotator` props shape (`file`/`onCancel`/`onSave`) used identically in T4's call site.
|
||||
- `AttachmentPreview` extended with optional `onEdit?: () => void` in T4; existing callers passing only `file`/`onRemove` continue to compile because it's optional.
|
||||
|
||||
4. **One ambiguity surfaced + resolved:** the spec doesn't say whether to overwrite the original File or keep a side-by-side copy. Plan replaces the slot with a `<basename>-annotated.png` File so the recipient sees the marked-up version with a clear filename hint.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
# Screen-Share UI Fixes (Hotfix)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix two visible bugs in the Discord-style screen-share viewer:
|
||||
1. **Taskbar abgeschnitten** — when a sender shares their entire monitor, the receiver sees the bottom of the screen (Windows taskbar area) cut off.
|
||||
2. **Top-right icon overflows its frame** — the small button at the top-right corner of the in-share strip (`StripToggleIcon` / participants-toggle) renders its 2-people SVG too large for its container, with no top/bottom padding.
|
||||
|
||||
**Architecture / cause analysis:**
|
||||
|
||||
**Bug 1** has TWO possible root causes that both need to be ruled in or out:
|
||||
|
||||
- *Sender side (most likely)*: `apps/desktop/src/lib/screenShareSettings.ts` defines fixed `dims` for every non-`auto` preset (e.g. `1080p30` = 1920×1080). `CallContext.tsx:1445-1459` passes those dims as `resolution: { width, height, frameRate }` to LiveKit's `setScreenShareEnabled`. LiveKit forwards them to Chromium's `getDisplayMedia` as exact constraints. When the sender's primary monitor isn't 16:9 (e.g. a common 1920×1200 laptop, a 2560×1600 16:10 panel, a vertical secondary monitor, or a HiDPI scaled display), Chromium *crops* the frame to match — chopping the bottom strip where the Windows taskbar lives. The `auto` preset (`dims: null`) is unaffected because it omits the constraints.
|
||||
- *Receiver side*: `apps/desktop/src/components/InCallPanel.tsx:1326-1330` reserves `pb-28` (112px) at the bottom of the cinema-mode content area for the floating controls bar, and `ScreenShareViewer.tsx:99-113` uses `object-contain` on the `<video>`. The math already protects against overlap, but worth a visual sanity check during the smoke test.
|
||||
|
||||
**Bug 2** is in `apps/desktop/src/components/InCallPanel.tsx:1497-1524` — the `StripToggleIcon` button is `h-9 w-9` (36×36) wrapping an `<svg width="18" height="18">`. The SVG's `viewBox` is `0 0 24 24` but several of its paths actually reach beyond y=24 (`M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2` puts the bottom edge at y=23 and the top at y=11, but combined with `M16 3.13a4 4 0 0 1 0 7.75` the icon extends roughly y=3.13 to y=23 — almost the full viewBox, leaving ~1px padding). At 18×18 that translates to <1px visible breathing room. Combined with any container line-height or rounding, the icon appears to touch the frame edge. Fix: drop the explicit `width="18" height="18"` and let it inherit a Tailwind size class, OR shrink the icon to fit cleanly inside the 36px button.
|
||||
|
||||
**Tech Stack:** React 18 + TailwindCSS + LiveKit client. No backend changes.
|
||||
|
||||
**Non-goals:**
|
||||
- Not redesigning the cinema-mode controls layout.
|
||||
- Not adding a new "fit-to-monitor" preset option (could be a follow-up if cropping is widespread).
|
||||
- Not changing audio-share behavior.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **Verify clean working tree on `main`**
|
||||
|
||||
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||
Expected: only Phase 3 commits ahead of last good baseline; no unrelated uncommitted changes.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Diagnose — confirm sender-side cropping vs receiver-side overlay for Bug 1
|
||||
|
||||
**Why:** Before changing the preset behavior we want to confirm the symptom matches the hypothesis. The user's screenshot shows the Windows taskbar visible-but-truncated; if the truncation is exactly the height difference between 1080 and the monitor's actual height, the sender-side hypothesis is confirmed.
|
||||
|
||||
**Files (read-only):**
|
||||
- `apps/desktop/src/context/CallContext.tsx:1395-1470` — where the preset dims are passed to LiveKit
|
||||
- `apps/desktop/src/lib/screenShareSettings.ts` — preset dims table
|
||||
- `apps/desktop/src/components/InCallPanel.tsx:1320-1410` — cinema-mode layout for receiver
|
||||
|
||||
- [ ] **Step 1: Inspect the sender's current preset (in dev / chat with user)**
|
||||
|
||||
Ask the user: "Which screen-share preset is set on the sender's machine (Settings → Bildschirmfreigabe → Voreinstellung)? And what's their primary monitor resolution (right-click desktop → Anzeigeeinstellungen → Auflösung)?"
|
||||
|
||||
If preset is `auto` → sender-side hypothesis is FALSE; jump to Step 3 (receiver-side investigation).
|
||||
If preset is anything else (e.g. `1080p30`) AND monitor resolution doesn't match the preset's aspect ratio → sender-side hypothesis is CONFIRMED; proceed to Task 2.
|
||||
|
||||
- [ ] **Step 2: Reproduce locally if access exists**
|
||||
|
||||
Set a known non-matching configuration:
|
||||
- Settings → Bildschirmfreigabe → Voreinstellung = `1080p · 30 fps`
|
||||
- On a monitor with native 1920×1200 (or any non-16:9) resolution
|
||||
- Start a share with the user's account in a second window
|
||||
- Observe: bottom of receiver's view should be the cropped band where the taskbar would be
|
||||
|
||||
Document the observed monitor resolution + preset in the commit message of Task 2.
|
||||
|
||||
- [ ] **Step 3: Inspect receiver-side cinema layout for any overlap regression**
|
||||
|
||||
Read `apps/desktop/src/components/InCallPanel.tsx` lines 1324-1485. Verify that:
|
||||
- The content area has `pb-28` (line 1331 today)
|
||||
- The control bar's wrapping div has `absolute bottom-4` (line 1528 today)
|
||||
- `4 + ~80 (controls height) ≈ 84 < 112 (pb-28)` → no overlap
|
||||
|
||||
If the math still holds, the receiver-side path is clean and Bug 1 is purely sender-side.
|
||||
|
||||
**No commit for this task** — diagnosis only. Findings inform Task 2.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Fix — preset dims act as aspect-preserving maxima, not hard crops (Bug 1)
|
||||
|
||||
**Why:** The user expects "1080p" to mean *quality cap*, not *forced crop*. A non-16:9 monitor should still stream its entire surface, just scaled to fit within the preset's pixel budget.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/context/CallContext.tsx` (the `setScreenShareEnabled` call around line 1438)
|
||||
|
||||
Two viable approaches — Task 2 picks Option A; Option B is a fallback if A doesn't work in LiveKit.
|
||||
|
||||
### Option A (preferred): drop fixed dims, rely on bitrate cap only
|
||||
|
||||
LiveKit's screen-share publish accepts a `videoSimulcastLayers` / `screenShareEncoding.maxBitrate` knob independently of resolution. By only constraining frame rate + bitrate, we let the capture run at native resolution and downscale via encoder.
|
||||
|
||||
- [ ] **Step 1: Find the LiveKit screen-share publish call**
|
||||
|
||||
Read `apps/desktop/src/context/CallContext.tsx` around line 1438-1465 to confirm the current shape of the `setScreenShareEnabled` options object.
|
||||
|
||||
- [ ] **Step 2: Replace the `resolution.{width,height}` block with an aspect-preserving variant**
|
||||
|
||||
The current block (lines ~1445-1459):
|
||||
```ts
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
width: ssParams.dims.width,
|
||||
height: ssParams.dims.height,
|
||||
frameRate: fps,
|
||||
},
|
||||
}
|
||||
: {
|
||||
resolution: {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
frameRate: fps,
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
Change to (verify LiveKit option shapes against the current installed version with `pnpm list livekit-client` first — if `screenShareEncoding` isn't supported, fall back to Option B):
|
||||
```ts
|
||||
// Preset dims become a max-height cap that the encoder honours via the
|
||||
// videoEncoding.maxBitrate + frameRate combo. We deliberately do NOT pass
|
||||
// resolution.width/height to getDisplayMedia — Chromium treats those as
|
||||
// exact constraints and CROPS non-matching monitors (e.g. 16:10 panels
|
||||
// lose their bottom strip, including the Windows taskbar).
|
||||
//
|
||||
// Quality is still scoped to the preset via the bitrate ceiling configured
|
||||
// elsewhere in screenShareSettings; we just stop forcing the geometry.
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
frameRate: fps,
|
||||
},
|
||||
}
|
||||
: {
|
||||
resolution: {
|
||||
frameRate: fps,
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
If `resolution` requires at least one of `width`/`height` in the installed LiveKit type, pass only the height as `ideal` (not the width) — Chromium will then scale width to preserve aspect:
|
||||
```ts
|
||||
resolution: {
|
||||
height: { ideal: ssParams.dims.height } as unknown as number,
|
||||
frameRate: fps,
|
||||
},
|
||||
```
|
||||
(The `as unknown as number` cast handles a LiveKit type that expects a plain number; the runtime accepts the MediaTrackConstraints object form because it's forwarded to `getDisplayMedia`.)
|
||||
|
||||
- [ ] **Step 3: Typecheck + smoke test plan**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS. If LiveKit's TS types reject the change, fall through to Option B below.
|
||||
|
||||
Smoke test sequence (user-driven; don't need to verify in code):
|
||||
1. Set preset to `1080p · 30 fps`
|
||||
2. Share entire primary monitor
|
||||
3. Receiver sees the full monitor including the taskbar — no crop
|
||||
4. Switch to a Windows window source → still shares fully
|
||||
5. Switch preset to `auto` → still works
|
||||
6. Switch preset to `720p · 30 fps` → bitrate drops, but no crop
|
||||
|
||||
### Option B (fallback): keep dims, switch to `ideal` constraints
|
||||
|
||||
If Option A doesn't typecheck, replace the resolution block with:
|
||||
```ts
|
||||
// `ideal` lets Chromium pick the closest match without cropping when the
|
||||
// source's native resolution doesn't fit exactly. Using fixed width/height
|
||||
// causes Chromium to crop non-matching monitors (e.g. 16:10 panels lose
|
||||
// the bottom strip with the taskbar).
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
width: { ideal: ssParams.dims.width } as unknown as number,
|
||||
height: { ideal: ssParams.dims.height } as unknown as number,
|
||||
frameRate: fps,
|
||||
},
|
||||
}
|
||||
: {
|
||||
resolution: {
|
||||
frameRate: fps,
|
||||
},
|
||||
}),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit (whichever option compiled)**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/context/CallContext.tsx
|
||||
git commit -m "fix(screen-share): preset dims no longer crop non-16:9 monitors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fix — StripToggleIcon padding inside its frame (Bug 2)
|
||||
|
||||
**Why:** The participants strip-toggle button (`absolute right-5 top-5` in cinema mode) is `h-9 w-9` (36×36) with an 18×18 SVG whose path data fills nearly the entire viewBox, leaving no visible padding. The icon appears glued to the frame edges.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` (the `StripToggleIcon` function around line 1542-1561)
|
||||
|
||||
- [ ] **Step 1: Shrink the icon's actual rendered size**
|
||||
|
||||
Read `apps/desktop/src/components/InCallPanel.tsx` lines 1542-1561 to confirm the current SVG shape.
|
||||
|
||||
Change the SVG element from:
|
||||
```tsx
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
...
|
||||
>
|
||||
```
|
||||
to:
|
||||
```tsx
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
Rationale: 14px inside a 36px button leaves 11px of padding on each side (36 − 14 = 22, /2 = 11), matching the visual rhythm of other icon buttons in the same file (e.g. the `FullscreenIcon` in ScreenShareViewer uses `h-3.5 w-3.5` = 14px inside `h-6 w-6` = 24px = ~5px padding, but the cinema strip-toggle is on a larger 36px button and benefits from a similar ratio).
|
||||
|
||||
- [ ] **Step 2: Verify visually adjacent icon-buttons match the new ratio**
|
||||
|
||||
Skim the file for other `h-9 w-9` buttons and confirm their inner-icon sizes (`grep -n "h-9 w-9" apps/desktop/src/components/InCallPanel.tsx`). If most use ~14px icons, the change is in line; if they use 18px, leave 18 alone and instead fix the SVG's viewBox padding by widening it to `viewBox="-2 -2 28 28"` so the visible content sits in the middle:
|
||||
|
||||
```tsx
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="-2 -2 28 28"
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
Pick whichever approach the file's existing aesthetic favours. Default to **Step 1's shrink-to-14** unless Step 2's grep proves 18 is the house norm.
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS (this is a pure JSX change).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "fix(call): strip-toggle icon no longer touches its frame edges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final gate
|
||||
|
||||
- [ ] **Step 1: Typecheck both packages**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 2: Run shared tests (sanity — these changes don't touch shared, but confirm no flakes)**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared test --run`
|
||||
Expected: PASS — same count as before this hotfix.
|
||||
|
||||
- [ ] **Step 3: Smoke test in dev (user-driven)**
|
||||
|
||||
User starts `pnpm dev`, runs a screen-share to a second account, and verifies:
|
||||
- Taskbar visible at bottom of the streamed monitor (any preset)
|
||||
- Strip-toggle button in cinema mode shows the 2-people icon with comfortable padding inside its 36px frame
|
||||
|
||||
Report back: "Hotfix code-complete. Restart dev, share your monitor at any preset — taskbar should now stream fully. Cinema-mode strip-toggle button should have visible padding around the icon. **No release** — version stays 0.18.8."
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
1. **Bug coverage:**
|
||||
- Bug 1 (taskbar crop) → Task 2 (sender-side preset constraint change)
|
||||
- Bug 2 (icon overflow) → Task 3 (SVG size adjustment)
|
||||
- Diagnosis Task 1 confirms the root cause before applying fixes
|
||||
|
||||
2. **Placeholders:** none.
|
||||
|
||||
3. **Risk:** Option A in Task 2 changes the screen-share quality model — bitrate cap remains, but visual fidelity may improve OR encoder may push more bandwidth than before for the same preset name. Acceptable: the previous behaviour cropped content, which is strictly worse. The user can fall back to `720p` if bandwidth becomes an issue.
|
||||
|
||||
4. **Aspect-ratio interaction with low-bitrate presets:** `720p · 30 fps` previously enforced a 1280×720 max — a 4K monitor streamed under this preset now passes native 3840×2160 frames to the encoder, capped only by bitrate. The encoder will downscale to stay under the budget, but the receiver renders at the source resolution × encoder scale-factor. If this becomes a CPU/quality concern, re-introduce a height cap via Option B's `ideal` constraints (which Chromium honours preferring aspect ratio over exact match).
|
||||
@@ -0,0 +1,304 @@
|
||||
# Android White-Screen RCA — Design
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Scope:** Diagnose and fix the white-screen-after-install symptom reported on Android for the `apps/mobile` (Expo SDK 52 / RN 0.76 / new architecture) build. Includes both an ordered diagnostic playbook and defense-in-depth changes we ship regardless of which hypothesis turns out to be the root cause, so a future regression of the same shape lands inside the ErrorBoundary rather than leaving users at a blank window.
|
||||
**Status:** Approved by user (verbal, sections covered in brainstorming).
|
||||
**Related:** [`2026-05-16-mobile-encryption-ux-port-design.md`](./2026-05-16-mobile-encryption-ux-port-design.md) — depends on the env-fix in this spec landing first.
|
||||
|
||||
## Problem
|
||||
|
||||
User-reported symptom: installing the Android build (likely a `preview` or `production` EAS build) produces a fully white screen after the launcher icon is tapped. No native crash, no recoverable error UI, no logs visible to the user. The desktop and dev builds work, so the failure is bound to Android release-mode bundling, the New Architecture toggle, or a module-eval throw before the React tree mounts.
|
||||
|
||||
The current `ErrorBoundary` (`apps/mobile/components/ErrorBoundary.tsx`) is mounted inside `_layout.tsx`. Any throw *before* `_layout.tsx`'s default export runs — including throws from `import` side effects — bypasses it entirely. The empty splash screen lingers, then React mounts nothing, leaving a white window.
|
||||
|
||||
## Goals
|
||||
|
||||
- Identify the root cause empirically by running an ordered diagnostic playbook on a real Android build.
|
||||
- Land a defense-in-depth patch that ensures future boot-time errors are visible to the user, not silent.
|
||||
- Restore Android installability of `preview` and `production` profile builds.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding native error reporting (Sentry, Bugsnag). Tracked separately.
|
||||
- A general refactor of bootstrap order. Touches stay minimal and surgical.
|
||||
- Diagnosing iOS bootstrap issues (no symptom reported there; the hardening here helps iOS regardless).
|
||||
|
||||
## Hypotheses (ordered by likelihood)
|
||||
|
||||
Confidence is judged from static evidence: file content, package.json, eas.json, manifest, and the boot-order of module imports.
|
||||
|
||||
### H1 — Missing `EXPO_PUBLIC_*` env vars in the built APK *(highest confidence)*
|
||||
|
||||
`apps/mobile/lib/env.ts`:
|
||||
|
||||
```ts
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error('Missing required env var ' + name + '. ...');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
The `export const env = { ... }` evaluates the moment any importer reaches this module. `_layout.tsx` → `AuthProvider` → `supabase.ts` → `env.ts`. So this runs at app boot, **before** the React tree mounts, **before** the `ErrorBoundary` exists.
|
||||
|
||||
Expo only inlines `EXPO_PUBLIC_*` from `.env` files when bundling locally with `expo start`. EAS Build does **not** read `.env.local`. The contract is that the project's `eas.json` either declares an `env` block per profile or relies on EAS Secrets created via `eas secret:create`. Today's `apps/mobile/eas.json` has no `env` keys in any profile.
|
||||
|
||||
→ The APK is shipped with `process.env.EXPO_PUBLIC_SUPABASE_URL === undefined` → `required()` throws → module-eval failure → React never mounts → white screen.
|
||||
|
||||
The Android-only framing is incidental: the user simply hasn't tested iOS in this configuration yet; iOS would white-screen too with the same APK contents.
|
||||
|
||||
### H2 — `newArchEnabled: true` + an incompatible native lib *(medium-high confidence)*
|
||||
|
||||
`app.json` sets `"newArchEnabled": true`. Several native deps installed are not yet uniformly bridgeless / Fabric-ready as of SDK 52:
|
||||
|
||||
- `react-native-libsodium` ^1.3.0
|
||||
- `@livekit/react-native-webrtc` ^144.0.0
|
||||
- `@config-plugins/react-native-webrtc` ^10.0.0
|
||||
- `@livekit/react-native` ^2.10.3
|
||||
- `react-native-gesture-handler` ^2.20.2 (generally fine but historically a culprit on Android Fabric)
|
||||
|
||||
If any of these crashes at JNI link time, the JS bundle never runs — fully white window because the Android shell waits for the JS thread to send its first frame.
|
||||
|
||||
### H3 — `crypto.setCryptoBackend(createLibsodiumBackend())` at module top-level *(medium confidence)*
|
||||
|
||||
`apps/mobile/app/_layout.tsx` line 13 calls `crypto.setCryptoBackend(createLibsodiumBackend())` at module-eval. `createLibsodiumBackend` reads constants like `s.crypto_box_NONCEBYTES`. If `react-native-libsodium`'s native module isn't autolinked (pnpm symlinking + prebuild without an explicit pod / Gradle entry occasionally produces this), `s.crypto_box_NONCEBYTES` is `undefined`. That alone doesn't throw, but the backend object then carries `nonceLength: undefined`. A later code path that reads `nonceLength` and calls `randomBytes(undefined)` throws asynchronously and either produces a red box (dev) or a silent failure (release).
|
||||
|
||||
Weaker hypothesis on its own — usually masked by H1 or H2 — but worth ruling out.
|
||||
|
||||
### H4 — Module-eval side effects in `@chat-app/shared` *(low-medium confidence)*
|
||||
|
||||
`packages/shared/src/crypto/userKey.ts` does `import sodium from 'libsodium-wrappers-sumo'`. If anything in the mobile entrypoint reaches into the shared `crypto` index, libsodium-wrappers-sumo's module body runs in Hermes. Sumo is the "compatibility build" and is known to fail to instantiate on Hermes; even on success its global side effects (`globalThis.crypto`) can collide.
|
||||
|
||||
Resolved as part of the mobile-encryption spec (the `CryptoBackend` extension removes the direct `libsodium-wrappers-sumo` dependency from shared). Diagnostic-only entry here.
|
||||
|
||||
### H5 — Asset / splash path or manifest issue *(low confidence)*
|
||||
|
||||
`app.json` references `./assets/icon.png`, `./assets/adaptive-icon.png`, `./assets/splash.png`. If any of these were lost during a `git mv` or rename, EAS Build would still succeed but the Android launcher could choke. Symptom would more likely be "cannot install" or a missing icon, not pure white, so this is the lowest-likelihood branch.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Diagnostic playbook (the actual investigation)
|
||||
|
||||
Order matters — each step rules out a hypothesis with a minimum-cost action.
|
||||
|
||||
**Step 0 — Capture logs.** With the user-reported APK on a connected Android device:
|
||||
|
||||
```bash
|
||||
adb logcat -c
|
||||
adb logcat *:E ReactNative:V ReactNativeJS:V
|
||||
# launch the app
|
||||
```
|
||||
|
||||
Triage the first 50 lines for `Error`, `Exception`, `FATAL`. The matching hypothesis determines which fix below to apply first.
|
||||
|
||||
**Step 1 — Validate H1.** Even without logs, this is mechanically falsifiable:
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npx expo export --platform android --dev false --output-dir /tmp/expo-android-export
|
||||
grep -r "Missing required env var" /tmp/expo-android-export/_expo/static/js || true
|
||||
grep -r "EXPO_PUBLIC_SUPABASE_URL" /tmp/expo-android-export/_expo/static/js || true
|
||||
```
|
||||
|
||||
If "Missing required env var" appears as a literal in the bundle (it will, because it's a thrown Error string), and a Supabase URL string does not appear, H1 is confirmed.
|
||||
|
||||
**Step 2 — Validate H2.** Toggle `newArchEnabled: false` in `app.json`, `eas build --profile preview --platform android`, install, retest. If the white-screen disappears, H2 holds. (Do not ship with new-arch off; the fix is to upgrade or replace the incompatible lib, not to permanently disable new-arch.)
|
||||
|
||||
**Step 3 — Validate H3.** With the env fix in place (or temporarily hardcoded values), wrap the `crypto.setCryptoBackend(...)` call in `try/catch` that surfaces to a fallback `<View>` (see "Defense-in-depth"). If the fallback now renders, H3 was real.
|
||||
|
||||
**Step 4 — Validate H4.** Only after H1 / H2 / H3 are eliminated. Run the bundle through `metro` with verbose logging; look for `libsodium-wrappers-sumo` in the trace. The mobile-encryption port spec removes this risk structurally.
|
||||
|
||||
**Step 5 — H5 sweep.** `ls apps/mobile/assets` — confirm every path in `app.json` resolves to an actual file.
|
||||
|
||||
### Fixes per hypothesis
|
||||
|
||||
**Fix H1 — Wire env vars into EAS builds (REQUIRED — ship regardless of which RCA hypothesis confirms).**
|
||||
|
||||
Two acceptable paths:
|
||||
|
||||
1. **EAS Secrets (recommended for production).**
|
||||
|
||||
```bash
|
||||
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value 'https://<project>.supabase.co'
|
||||
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value 'sb_publishable_...'
|
||||
eas secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
|
||||
```
|
||||
|
||||
No `eas.json` change needed; secrets propagate automatically to all profiles. **This is the path chosen for this release.**
|
||||
|
||||
2. **`eas.json` `env` block (rejected — kept here only as a reference for future profiles where Secrets are not yet provisioned).**
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_SUPABASE_URL": "https://<project>.supabase.co",
|
||||
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "sb_publishable_...",
|
||||
"EXPO_PUBLIC_AUTH_REDIRECT_URL": "netralax://auth/callback"
|
||||
},
|
||||
"ios": { "simulator": true },
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"preview": { "...": "same env block" },
|
||||
"production": { "...": "same env block" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pick one consistently across profiles.
|
||||
|
||||
**Fix H2 — newArchEnabled gating.** Identify the incompatible lib. Probable suspect order:
|
||||
1. `@livekit/react-native-webrtc` — verify against the lib's CHANGELOG that the installed major version declares Fabric support.
|
||||
2. `react-native-libsodium` — same check.
|
||||
|
||||
If a lib is not yet new-arch-ready, the temporary fix is `"newArchEnabled": false` in `app.json` and to file an issue upstream. The permanent fix is an upgrade or replacement (`react-native-sodium-jsi`, `op-sqlite`-style native modules).
|
||||
|
||||
**Fix H3 — Defer crypto backend init to React lifecycle.** Move the `crypto.setCryptoBackend(createLibsodiumBackend())` call out of module top-level into a `useEffect` inside an `<AppBootstrap>` boundary. While the backend is initialising, render an `ActivityIndicator`; on failure, render a fallback `<View>` with the error text. This makes any constants-undefined failure user-visible rather than silent. (Also fulfilled by the mobile-encryption spec.)
|
||||
|
||||
**Fix H4 — Backend extension (covered by mobile-encryption spec).** Once shared no longer imports `libsodium-wrappers-sumo` at module level, this risk disappears.
|
||||
|
||||
**Fix H5 — Repair asset paths.** If a file is missing, `git mv` it back to the path declared in `app.json` or update `app.json` to match.
|
||||
|
||||
### Defense-in-depth (ship regardless of root cause)
|
||||
|
||||
These changes ship as part of this spec because they harden bootup against any future boot-time throw of the same shape. None of them is a workaround for the actual RCA — they ensure the next failure produces a readable screen, not a white one.
|
||||
|
||||
1. **Lazy `env`.** Convert `apps/mobile/lib/env.ts` from `export const env = {...}` to a lazy proxy:
|
||||
|
||||
```ts
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error('Missing required env var ' + name + '. Set it via EAS Secret or eas.json env block.');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
type EnvShape = {
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
authRedirectUrl: string;
|
||||
};
|
||||
|
||||
function readEnv(): EnvShape {
|
||||
return {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: EnvShape | null = null;
|
||||
export const env = new Proxy({} as EnvShape, {
|
||||
get(_t, key) {
|
||||
cached ??= readEnv();
|
||||
return cached[key as keyof EnvShape];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Effect: missing env vars throw the first time someone reads `env.supabaseUrl`, which happens inside React, where `<ErrorBoundary>` is mounted and can render the message.
|
||||
|
||||
2. **Bootstrap boundary.** `apps/mobile/app/_layout.tsx` introduces `<AppBootstrap>`:
|
||||
|
||||
```tsx
|
||||
function AppBootstrap({ children }: { children: ReactNode }) {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
try {
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
setReady(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}, []);
|
||||
if (error) return <BootError error={error} />;
|
||||
if (!ready) return <BootSplash />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
`BootError` is a minimal `<View>` with the message + a dump of `process.env.EXPO_PUBLIC_SUPABASE_URL ? 'env-ok' : 'env-missing'` so future white-screen reports can be triaged in one screenshot.
|
||||
|
||||
3. **Global JS error handler.** Inside the same boundary, register a fallback for unhandled errors that escape every React-level boundary:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
const prev = ErrorUtils.getGlobalHandler();
|
||||
ErrorUtils.setGlobalHandler((err, isFatal) => {
|
||||
prev?.(err, isFatal);
|
||||
setError(err);
|
||||
});
|
||||
return () => ErrorUtils.setGlobalHandler(prev);
|
||||
}, []);
|
||||
```
|
||||
|
||||
This catches throws that happen during e.g. lazy `env` reads in render paths and surfaces them. Negligible runtime overhead.
|
||||
|
||||
4. **`SecureStore` smoke probe.** Optional, cheap: a `useEffect` that calls `SecureStore.isAvailableAsync()` and reports failure in the same `BootError` path. Helps catch the rare Android profile where secure storage is disabled.
|
||||
|
||||
5. **`.env.local` parity check (lint).** Add a `npm run check:env` script that compares `.env.example` against `.env.local` to surface missing keys in dev. Cheap insurance against the same class of bug recurring during onboarding.
|
||||
|
||||
## Data Flow
|
||||
|
||||
This spec changes no data flow. The runtime data flow remains: env vars in → React mounts → AuthProvider → screens. The only structural change is *when* `env` is read (lazily, inside React) and *where* crypto backend init runs (inside a React effect).
|
||||
|
||||
## Components Touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/mobile/eas.json` | Add `env` blocks (or document EAS Secret names) per profile. Ship as part of this spec. |
|
||||
| `apps/mobile/lib/env.ts` | Convert to lazy proxy; preserves the public surface. |
|
||||
| `apps/mobile/app/_layout.tsx` | Introduce `<AppBootstrap>` + `<BootError>` + `<BootSplash>`; move `setCryptoBackend` call into the bootstrap effect; install global JS error handler. |
|
||||
| `apps/mobile/components/BootError.tsx` | NEW. Minimal fallback that renders the error message + env-diagnostic line. |
|
||||
| `apps/mobile/components/ErrorBoundary.tsx` | No change in behaviour; remains the per-screen boundary. |
|
||||
| `apps/mobile/README.md` | Add an "EAS env" section pointing at `eas secret:create` / `eas.json env`. |
|
||||
| `apps/mobile/package.json` | Optional `check:env` script. |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Missing `EXPO_PUBLIC_*` at runtime | `BootError` renders with the specific variable name and the env-missing diagnostic. |
|
||||
| Crypto backend constants undefined | `BootError` renders; user sees "Crypto-Backend konnte nicht geladen werden" + the underlying message. |
|
||||
| `SecureStore` unavailable | `BootError` with explicit hint; app does not boot further. |
|
||||
| Global unhandled JS error | Global handler routes to `BootError` (or whatever screen is currently mounted, via the per-screen `ErrorBoundary`). |
|
||||
| All H-fixes applied; new bug appears | The defense-in-depth path catches it; we get a stack instead of a white screen. |
|
||||
|
||||
## Testing
|
||||
|
||||
**Manual (the real validation — pre-merge):**
|
||||
|
||||
1. With env block / EAS Secret in place: `eas build --profile preview --platform android`. Install. App opens to login screen.
|
||||
2. With env block intentionally removed locally: `npx expo run:android --no-bundler-reload` — the `BootError` view must render with "Missing required env var EXPO_PUBLIC_SUPABASE_URL". No white screen.
|
||||
3. Crypto backend simulated failure: temporarily stub `createLibsodiumBackend` to throw; verify `BootError` shows the underlying message.
|
||||
4. Global handler smoke: place a `throw new Error('boom')` inside `setTimeout(..., 100)` in `_layout.tsx`; verify it surfaces.
|
||||
|
||||
**Automated (cheap, ship with):**
|
||||
|
||||
- `apps/mobile/lib/env.test.ts` — lazy proxy returns env var when set; throws on first read when missing; subsequent reads memoise.
|
||||
- `apps/mobile/components/BootError.test.tsx` — renders message + env diagnostic.
|
||||
|
||||
## Decisions (resolved at brainstorming user-review gate)
|
||||
|
||||
- **EAS Secret over `eas.json env`.** All `EXPO_PUBLIC_*` variables ship via `eas secret:create --scope project`. `eas.json` is not touched for env wiring. Rationale: keeps `eas.json` future-proof for non-public values; avoids accidental commits.
|
||||
- **newArchEnabled rollback acceptable.** If H2 confirms, we ship with `"newArchEnabled": false` while the offending lib is upgraded or replaced. Re-enable in a follow-up release.
|
||||
|
||||
## Out of Scope (future work)
|
||||
|
||||
- Sentry / Bugsnag integration so future regressions are auto-reported.
|
||||
- Migrating off `@livekit/react-native-webrtc` if it turns out to be the new-arch blocker.
|
||||
- iOS-specific bootstrap hardening (no symptom reported there; the changes here help iOS anyway).
|
||||
- A `doctor`-style CLI command that lints `.env.local` against `.env.example` and `eas.json`.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Fifteen-Features Initiative — Design
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Scope:** Desktop only (Electron). Mobile is out of scope.
|
||||
**Status:** Approved by user (sections 1–6).
|
||||
|
||||
## Problem
|
||||
|
||||
The desktop app has reached a stable point (encryption rolled out, Settings refactored). The user identified fifteen feature gaps to close as one coordinated initiative — quality fixes, messaging power, security UI, creative tools, and shared activities. Releasing each piecemeal would mean nine bumps and nine test cycles; instead this initiative ships as one `0.19.0` after a single end-to-end smoke pass.
|
||||
|
||||
## Goals
|
||||
|
||||
- Close all fifteen features without a release until the user signs off.
|
||||
- Fix the global-shortcut capture bug that currently breaks every keystroke after a hotkey is set.
|
||||
- No surprise releases — every commit is `git commit` only, no `pnpm release`. Version stays on `0.18.8` throughout, bumps to `0.19.0` once.
|
||||
- Each phase ends with a clean typecheck + green shared tests.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Mobile / React Native port of any of these features.
|
||||
- Twitch in Watch-Together (YouTube only for the MVP).
|
||||
- Chess or other mini-games beyond Tic-Tac-Toe + Vier-Gewinnt.
|
||||
- Operational Transform / CRDT for the Whiteboard (snapshot model only).
|
||||
- Live video preview or speaking indicators in the existing CallPreviewPanel.
|
||||
- Hot-patch releases between phases.
|
||||
|
||||
## Phasing
|
||||
|
||||
| Phase | Theme | Features | Rough Effort |
|
||||
|-------|-------|----------|--------------|
|
||||
| 1 | Quality & Fixes | Hotkey-Bug Fix, Tray-Badge Audit, Empty-States, Friend-Nicknames, Memory-Wipe | 3–4 days |
|
||||
| 2 | Messaging | Pinned Messages, Mentions-Notifications, GIF-Picker, View-Once Media | ~1 week |
|
||||
| 3 | Security & Devices | Session/Device List + Revoke | 2–3 days |
|
||||
| 4 | Creative Tools | Image-Annotation, Soundboard Cloud-Sync, Whiteboard (Snapshot-Sync) | ~1.5 weeks |
|
||||
| 5 | Activities | Watch-Together (YouTube), Mini-Games (Tic-Tac-Toe + 4-in-a-Row) | ~1.5 weeks |
|
||||
|
||||
Phases run sequentially. Within a phase, features are independent and can be implemented in any order.
|
||||
|
||||
## Architecture (per feature)
|
||||
|
||||
### Phase 1 — Quality & Fixes
|
||||
|
||||
**Hotkey-Bug Fix.** The current code (`apps/desktop/src/lib/globalShortcut.ts`) registers every hotkey through Electron's `globalShortcut` API, which captures system-wide. Setting "M" as Mute means "M" can't be typed anywhere on the OS while the app is running. Fix: split into two registration modes.
|
||||
|
||||
- **Window-scoped (default):** DOM `keydown` listener on `window`, only fires while the app has focus. Used for mute, deafen, hangup, screen-share, video toggle.
|
||||
- **Global (opt-in):** Keep `globalShortcut.register()` for PTT and any hotkey the user explicitly toggles "Global" on.
|
||||
|
||||
UI change: every hotkey row in Settings → Voice → Hotkeys gets a `🌐 Global`-toggle next to the key-capture button. Default off.
|
||||
|
||||
**Tray-Badge Audit.** Tray code already exists (`electron/modules/tray.ts`, `lib/trayBadge.ts`, called from `ConversationsContext`). Verify the count is actually pushed and the badge renders on Windows. Likely a wiring or icon-loading bug; spec-time fix scope unknown until investigated.
|
||||
|
||||
**Empty-States.** Four locations: empty chat list, empty friends list, empty conversation (just-created with no messages), empty search results. Each gets an illustration (SVG, light/dark variant), a heading, a description, and a primary action button (e.g., "Friend einladen", "Erste Nachricht schreiben"). Reuses existing icon palette.
|
||||
|
||||
**Friend-Nicknames.** Local-only override stored in `localStorage` keyed by `userId`. Surfaces wherever a profile name is shown: chat header, message bubble sender, friends list, mention autocomplete, call participant tile. UI: right-click friend in friends list → "Spitzname setzen" → input modal. Empty = use real display name.
|
||||
|
||||
**Memory-Wipe.**
|
||||
- *Always on sign-out:* clear Stronghold (`chatapp.userpriv.*`), IndexedDB (soundboard, message-cache), SQLite cache, conv-key in-memory cache, `localStorage` except theme + locale + installId.
|
||||
- *Configurable on app-close (Settings → Security → "Cache beim Schließen leeren"):* hooks `before-quit` in main process → IPC to renderer → wipe → quit. Next start requires PIN.
|
||||
|
||||
### Phase 2 — Messaging
|
||||
|
||||
**Pinned Messages.**
|
||||
- DB: `pinned_messages (conversation_id uuid, message_id uuid, pinned_by uuid, pinned_at timestamptz, PK(conversation_id, message_id))`. CHECK via trigger: max 5 pins per conversation.
|
||||
- RLS: any accepted conv-member can `SELECT` / `INSERT` / `DELETE`.
|
||||
- UI: right-click message → "Anpinnen" / "Anheftung aufheben". Conv header shows compact "📌 N angepinnt"-pill; click opens a right-side panel with the pinned list; clicking an item scrolls to + flashes the original message.
|
||||
- Realtime: subscribe `pinned_messages:<conv_id>` channel for live updates.
|
||||
|
||||
**Mentions-Notifications.**
|
||||
- `sendEncryptedMessage` parses plaintext for `@<username>` tokens before encrypting. Username → user_id lookup against `conversation_members` joined with `profiles`. Bulk-insert into new `message_mentions (message_id, mentioned_user_id, PK both)`.
|
||||
- RLS: mentioned user (and message author) can SELECT.
|
||||
- Realtime: each client subscribes `message_mentions WHERE mentioned_user_id = me`. On insert → fire web/desktop notification with `[@] <sender>: <decrypted snippet>` and the configured mention sound. Bypasses per-conv mute.
|
||||
- Per-conv setting "Nur bei @Mentions benachrichtigen" (stored alongside the existing mute setting).
|
||||
|
||||
**GIF-Picker.**
|
||||
- Provider: Tenor v2 (`https://tenor.googleapis.com/v2/search`, no per-user key — public key passable). Trending + Search + Recent (localStorage of last 24 URLs).
|
||||
- New component `<GifPicker>` Popover anchored to the composer's GIF button (next to attachment plus).
|
||||
- On click: resolved GIF URL is fetched once, uploaded as a normal attachment (mime `image/gif`) so it lives in the same per-conv attachment storage and is end-to-end-encrypted like any other image. Trade-off: a sent GIF doesn't stay free — it counts as an attachment (~few MB).
|
||||
|
||||
**View-Once Media.**
|
||||
- Per-attachment flag `view_once boolean` (new column on `message_attachments`).
|
||||
- Sender UI: attachment-picker toggle "👁 Einmal ansehen".
|
||||
- Recipient UI: bubble shows blurred lock-overlay; tap → fullscreen view; close → bubble flips to "👁 Angesehen, von <time>". Storage object is deleted server-side via RPC `mark_attachment_viewed(p_attachment_id)` triggered atomically with the view.
|
||||
- Auto-purge: Supabase cron deletes view-once attachments untouched after 7 days.
|
||||
- Sender sees the same "Angesehen"-bubble update (so they know it was opened).
|
||||
|
||||
### Phase 3 — Security & Devices
|
||||
|
||||
**Session/Device List + Revoke.**
|
||||
- Uses the existing `devices` table (telemetry only since the 0.18 encryption refactor).
|
||||
- New SettingsPage tab "Geräte" between Sicherheit and Konto. Lists `name · platform · last_seen` per device, "Dieses Gerät"-badge for the local install.
|
||||
- New column `devices.revoked_at timestamptz NULL`. Revoke RPC `revoke_device(p_device_id uuid)`: requires `device.user_id = auth.uid()`, sets `revoked_at = now()`.
|
||||
- Realtime: every client subscribes `devices:user_id=me`. On its own `revoked_at` change → forced `signOut()` + Memory-Wipe + screen "Du wurdest remote abgemeldet".
|
||||
- The own device's revoke button is disabled in the list (label: "Nutze Sign-out").
|
||||
|
||||
### Phase 4 — Creative Tools
|
||||
|
||||
**Image-Annotation vor Send.**
|
||||
- Attachment-picker for images shows a new "✏ Bearbeiten" button before send.
|
||||
- Opens `<ImageAnnotator>` modal: canvas overlay on the image. Tools: pen, arrow, rectangle, circle, text, highlighter. 6 colors, 3 stroke widths. Undo/Redo stack, Reset, Save.
|
||||
- On Save: `canvas.toBlob({type: 'image/png'})` flattens the annotation into the original, replaces the upload-queue entry.
|
||||
- Custom implementation (~400 LOC). No external dep (tldraw etc. would be overkill).
|
||||
|
||||
**Soundboard Cloud-Sync.**
|
||||
- New table `user_soundboards (id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at)`.
|
||||
- Storage: bucket `soundboards/<user_id>/<sound_id>.bin`. Audio payload is encrypted client-side with the user's existing per-user X25519 key (reuses `crypto/box`). Server only sees ciphertext.
|
||||
- Local IndexedDB stays the working store; sync is bidirectional.
|
||||
- Sync engine: on app start + on every local edit (debounced 500ms), diff IndexedDB ↔ remote → upload new/changed, download remote-newer. Realtime subscription on own `user_soundboards` rows triggers pulls when another device of the user edits.
|
||||
- Conflict resolution: last-write-wins by `updated_at`. UI shows "☁ Sync OK / ↑ Hochladen…/ ⚠ Konflikt" badge per sound.
|
||||
|
||||
**Whiteboard (Snapshot-Sync).**
|
||||
- New tables `conversation_whiteboards (id, conversation_id, owner_user_id, created_at)` and `whiteboard_strokes (id, whiteboard_id, author_user_id, stroke_json jsonb, created_at)`.
|
||||
- UI: composer menu → "✏ Whiteboard" → starts a new whiteboard inline in the chat as a bubble (preview + author + "Öffnen"-button). Click → fullscreen modal with canvas.
|
||||
- Tools: pen, eraser, 6 colors, 3 widths. "Clear all" with confirm dialog.
|
||||
- Each pen stroke (on pointerup) inserts a row into `whiteboard_strokes`. Realtime subscription on `whiteboard_id` renders incoming strokes from other authors. Strokes are JSON: `{tool, color, width, points: [[x, y, t], …]}`.
|
||||
- Whiteboard is permanent — bubble in the chat stays, history is preserved, anyone in the conv can reopen it later.
|
||||
|
||||
### Phase 5 — Activities
|
||||
|
||||
**Watch-Together (YouTube).**
|
||||
- New table `conversation_watch_sessions (id, conversation_id, owner_user_id, video_id text, started_at, ended_at NULL, current_state jsonb)`. `current_state = { playing: bool, position_seconds: number, updated_at_ms: number }`.
|
||||
- Composer → "📺 Watch Together" → modal with URL input → extract video-id (regex on YouTube URL formats) → INSERT row → inline bubble "Anna hat Watch-Together gestartet · [Beitreten]".
|
||||
- Click Beitreten → fullscreen modal with YouTube IFrame Player API. Controls (play/pause/seek) by owner are broadcast via the session row's `current_state` update. Other clients reconcile their local player when drift > 2 seconds.
|
||||
- IFrame API loaded lazily via `<script src="https://www.youtube.com/iframe_api">`. No build-time dep.
|
||||
- Auto-end after 12h inactivity (cron). Owner-leave sets `ended_at`. Non-owner leave keeps session open for re-join.
|
||||
|
||||
**Mini-Games (Tic-Tac-Toe + Vier-Gewinnt).**
|
||||
- Shared infrastructure for both:
|
||||
- New table `conversation_games (id, conversation_id, game_type text, state jsonb, players jsonb, current_turn_user_id, winner_user_id NULL, created_at, finished_at NULL)`.
|
||||
- Move RPC `game_make_move(p_game_id uuid, p_move jsonb)`. Server-authoritative state machine validates and applies the move (no client trust). Returns updated state.
|
||||
- Realtime subscription on the game row renders state.
|
||||
- Tic-Tac-Toe: 3×3 board, cell index 0–8, server validates cell-is-empty + correct turn + win line detection (8 winning lines).
|
||||
- Vier-Gewinnt: 7×6 board, column 0–6 with gravity, server validates column-not-full + correct turn + 4-in-row detection (horizontal/vertical/2 diagonals).
|
||||
- UI: composer → "🎮 Spiel starten" → submenu (Tic-Tac-Toe / Vier-Gewinnt) → inline invite bubble. Recipient sees "Annehmen"-button. Both then see a ~250×250 board in their bubble. Click a valid cell/column = RPC move. Turn indicator + winner reveal with confetti animation (`canvas-confetti`).
|
||||
- Scope: 1v1 only (2-player games). Group games out of scope.
|
||||
|
||||
## Data Flow Highlights
|
||||
|
||||
- **No race conditions on mini-game moves**: server RPC is atomic via `BEGIN; SELECT ... FOR UPDATE; validate; UPDATE; COMMIT;`. Last-write loses.
|
||||
- **No race on watch-together state**: owner-only writes. Other clients always reconcile from `current_state.updated_at_ms`.
|
||||
- **No race on whiteboard**: strokes are append-only; concurrent strokes from multiple authors interleave naturally.
|
||||
- **Mentions on edit**: when a message is edited, the `message_mentions` rows for that message are recomputed (delete-then-insert in the same RPC).
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| Hotkey conflict (same accelerator double-bound) | Capture-modal shows "Bereits belegt von <Funktion>". Save disabled until user picks something else. |
|
||||
| Tray-badge fails to render on Linux | Silent fallback to text-only tooltip; only Windows has overlay-icon API. |
|
||||
| Friend has no nickname | Falls back to display_name. |
|
||||
| Memory-wipe IPC times out at quit | Force-exit anyway after 2s timeout; user accepts ephemeral data may remain in OS swap. |
|
||||
| Pinned-message limit reached (5) | "Anpinnen" action shows toast "Max 5 angepinnt — heften Sie eine andere ab". |
|
||||
| Mention notification fires for a muted device | Always shows (mention overrides mute). |
|
||||
| Tenor API down | GIF-Picker shows "GIFs gerade nicht verfügbar". |
|
||||
| View-once attachment opened concurrently by two devices of recipient | First view RPC wins; second sees "Bereits angesehen". |
|
||||
| Watch-together: video deleted from YouTube | Player surfaces YouTube's own error UI inline. |
|
||||
| Mini-game: opponent disconnects mid-game | Game stays open. On reconnect (refresh) the latest state is fetched. After 24h auto-finishes as draw. |
|
||||
| Whiteboard stroke insert fails | Stroke stays local; UI shows a "↻ Retry"-badge. |
|
||||
|
||||
## Testing
|
||||
|
||||
**Per phase:** typecheck (`pnpm --filter @chat-app/shared typecheck` + `pnpm --filter @chat-app/desktop typecheck`) and shared tests (`pnpm --filter @chat-app/shared test`) must be green.
|
||||
|
||||
**Final smoke list (user runs after Phase 5 completes):**
|
||||
|
||||
1. Hotkey "M" as Mute, default (window-scoped). Type "m" in Composer → letter appears, mute doesn't fire.
|
||||
2. Hotkey "M" toggled to Global → "m" outside the app triggers mute.
|
||||
3. Friend sends DM → tray-badge shows 1; click opens app focused.
|
||||
4. Empty chat list → "Friend einladen"-button visible + functional.
|
||||
5. Right-click friend → set nickname "Bestie" → chat header shows "Bestie".
|
||||
6. Sign-out → Storage Inspector shows no Stronghold key, no user-priv, no conv-key cache.
|
||||
7. Memory-Wipe toggle on → close app, reopen → PIN required.
|
||||
8. Pin a message → conv header shows "📌 1 angepinnt" → click → side panel lists it.
|
||||
9. Friend writes "@mich" → notification with [@] prefix + mention sound, even on muted conv.
|
||||
10. GIF-Picker → search "cat" → click → GIF sent in chat.
|
||||
11. View-once image sent → recipient opens once → bubble shows "Angesehen", image is gone.
|
||||
12. Settings → Geräte → revoke another device → that device is force-signed-out.
|
||||
13. Send image → "Bearbeiten" → draw arrow → send → arrow is in the final image.
|
||||
14. Add soundboard sound on device A → device B (same user, different install) → sound appears.
|
||||
15. Open whiteboard → friend draws → strokes appear within 1s.
|
||||
16. Start Watch-Together with YouTube URL → friend joins → owner pauses → friend's player pauses.
|
||||
17. Start Tic-Tac-Toe → friend accepts → alternate moves → win animation.
|
||||
18. Start Vier-Gewinnt → play → 4-in-row detection correct.
|
||||
|
||||
## Release Policy
|
||||
|
||||
- **No `pnpm release` calls** during Phases 1–5. Only `git commit` and `git tag` (if any) — none of which trigger upload.
|
||||
- Version stays on `0.18.8` throughout. The migration files and feature commits accumulate on `main`.
|
||||
- After the user signs off on the smoke list, a single `pnpm release 0.19.0 "<combined notes>"` bumps and ships everything.
|
||||
|
||||
## Out of Scope (future)
|
||||
|
||||
- Mobile equivalents of any of these.
|
||||
- Twitch, Vimeo, or self-hosted video in Watch-Together.
|
||||
- Chess, Pong, or other mini-games beyond the two.
|
||||
- Operational-transform / CRDT for Whiteboard (post-MVP if collaboration friction shows).
|
||||
- Cross-conversation pinning / global pin list.
|
||||
- Mention auto-suggest improvements (existing `MentionAutocomplete` is fine).
|
||||
- Skin-tone reaction picker.
|
||||
- Voice-message transcription.
|
||||
@@ -0,0 +1,285 @@
|
||||
# Mobile Encryption-UX Port — Design
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Scope:** Bring `apps/mobile` (Expo / React Native) to feature parity with the desktop user-key + PIN model shipped in v0.18.x. Touches the mobile app, the shared crypto-backend abstraction, and the mobile build config. Server schema and RPCs are unchanged — the desktop spec already migrated them.
|
||||
**Status:** Approved by user (verbal, sections covered in brainstorming).
|
||||
**Related:** [`2026-05-15-encryption-ux-simplification-design.md`](./2026-05-15-encryption-ux-simplification-design.md) — desktop spec this port mirrors. [`2026-05-16-android-whitescreen-rca-design.md`](./2026-05-16-android-whitescreen-rca-design.md) — must land first; the env-fix it specifies is a prerequisite for this port.
|
||||
|
||||
## Problem
|
||||
|
||||
Mobile is still on the legacy per-device identity model:
|
||||
|
||||
- `apps/mobile/lib/authContext.tsx` generates an X25519 keypair on first sign-in, registers a `devices` row, stores `device.id` + `device.privateKey` in `expo-secure-store`, and exposes `device`/`ownPrivateKey`.
|
||||
- All call sites (`conversations/[id].tsx`, `MessageBubble`, `AttachmentImage`) pass `senderDeviceId` / `ownDeviceId` to the shared chat helpers.
|
||||
- There is no PIN, no `user_keys` row, no recovery code, no migration of legacy bundles.
|
||||
|
||||
Consequences:
|
||||
|
||||
1. Mobile users locked out after re-install — the new install creates a fresh device-key that no peer has wrapped any conv-key for. Desktop peers that have upgraded to ≥ v0.18 only wrap for `recipient_user_id`, so mobile won't receive a bundle.
|
||||
2. Friends-list interop is broken when mixing mobile (device-keyed) and desktop (user-keyed) on the same account.
|
||||
3. The "one PIN, single secret" UX promise from the desktop spec doesn't hold cross-platform.
|
||||
|
||||
## Goals
|
||||
|
||||
- Mobile uses the same `user_keys`-based identity as desktop. `userId` + PIN-sealed private key, optional 24-char recovery code. No `devices.public_key` reliance for messaging crypto.
|
||||
- Sign-in on a fresh mobile install is one PIN entry away from full read + write access; no peer dependency.
|
||||
- Existing legacy conv-key bundles for the user are migrated transparently on first PIN unlock (same `migrateOwnLegacyBundles` helper that desktop uses).
|
||||
- Shared crypto code stops importing `libsodium-wrappers-sumo` at module level — Argon2id KDF and `crypto_scalarmult_base` route through the `CryptoBackend` interface so React Native's Hermes runtime never has to load WASM.
|
||||
- Single source of truth: every behaviour already in `apps/desktop/src/lib/userIdentity.ts` is reused, not re-implemented from scratch. Mobile gets a thin platform shell.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Re-deriving the desktop spec's threat model or migration cutoff — both inherited unchanged.
|
||||
- Replacing `react-native-libsodium`. We extend it through the backend abstraction.
|
||||
- Multi-account on a single device.
|
||||
- Per-device fingerprint UI. Same trade-off as desktop: identity rotation is the only revocation path.
|
||||
- Biometric (Face ID / fingerprint) unlock as a PIN alternative — tracked separately.
|
||||
- Cross-device pairing via QR. Out of scope.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The libsodium-wrappers-sumo problem (blocker — must land first)
|
||||
|
||||
`packages/shared/src/crypto/userKey.ts` currently does:
|
||||
|
||||
```ts
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
// ...
|
||||
await sodium.ready;
|
||||
return sodium.crypto_pwhash(..., sodium.crypto_pwhash_ALG_ARGON2ID13);
|
||||
```
|
||||
|
||||
And `apps/desktop/src/lib/userIdentity.ts` does a similar direct call to `sodium.crypto_scalarmult_base` in `derivePublicKey`.
|
||||
|
||||
`libsodium-wrappers-sumo` is JS + WASM. On Hermes (React Native) it either fails to instantiate or is prohibitively slow / large. Even if it worked, shipping WASM through Metro requires a custom transformer.
|
||||
|
||||
**Fix:** Extend the `CryptoBackend` contract so the shared user-key code never references `libsodium-wrappers-sumo` directly. Test code (`testBackend.ts`) keeps its WASM import — it only runs in Node / Vitest.
|
||||
|
||||
Backend additions (`packages/shared/src/crypto/backend.ts`):
|
||||
|
||||
```ts
|
||||
export interface CryptoBackend {
|
||||
// ... existing members ...
|
||||
readonly pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: number;
|
||||
MEMLIMIT_MODERATE: number;
|
||||
ALG_ARGON2ID13: number;
|
||||
};
|
||||
pwhash(
|
||||
outLen: number,
|
||||
password: string,
|
||||
salt: Uint8Array,
|
||||
opslimit: number,
|
||||
memlimit: number,
|
||||
alg: number,
|
||||
): Uint8Array;
|
||||
scalarMultBase(privateKey: Uint8Array): Uint8Array;
|
||||
}
|
||||
```
|
||||
|
||||
`packages/shared/src/crypto/userKey.ts` is refactored so `deriveKek` and `defaultKdfParams()` pull from `getCryptoBackend()` instead of `sodium`. `apps/desktop/src/lib/userIdentity.ts` swaps the inline `await import('libsodium-wrappers-sumo')` block in `derivePublicKey` for `getCryptoBackend().scalarMultBase(priv)`.
|
||||
|
||||
Desktop backend adapter (`apps/desktop/src/lib/cryptoBackend.ts`) routes the new methods to `libsodium-wrappers`. Mobile backend adapter (`apps/mobile/lib/cryptoBackend.ts`) routes them to `react-native-libsodium`, which already exposes `crypto_pwhash`, `crypto_scalarmult_base`, and the `crypto_pwhash_*` constants (verified in `react-native-libsodium/lib/typescript/lib.d.ts`).
|
||||
|
||||
Naming note: TypeScript forbids a property and a method with the same name. `pwhashConsts` (object) + `pwhash` (function) keeps both addressable; one rename is the only deviation from the libsodium naming.
|
||||
|
||||
### Mobile identity orchestrator
|
||||
|
||||
`apps/mobile/lib/userIdentity.ts` — new file, mirrors `apps/desktop/src/lib/userIdentity.ts` 1:1. Public API:
|
||||
|
||||
| Function | Behaviour |
|
||||
|----------|-----------|
|
||||
| `setupNewUserIdentity({ userId, pin, withRecovery })` | Generate keypair, seal with PIN, optionally seal with recovery code, UPSERT `user_keys` via `uploadUserKeyBlob`, cache cleartext key in `SecureStore` under `chatapp.userpriv.<userId>`, fire-and-forget `ensureLegacyMigrated`. |
|
||||
| `loadOrUnlockUserKey({ userId, pin, isRecoveryCode })` | RPC `try_unlock_user_key`, derive KEK, open sealed blob. On success: `record_pin_attempt(true)`, cache key, fire-and-forget legacy migration. On failure: `record_pin_attempt(false)`, throw. Returns `{ kind: 'unlocked' \| 'locked' \| 'missing' }`. |
|
||||
| `cachedUserKey(userId)` | Returns `Uint8Array \| null` from SecureStore. |
|
||||
| `clearUserKeyCache(userId)` | SecureStore remove. |
|
||||
| `changePin({ userId, oldPin, newPin })` | Cached key proves old PIN; reseal with new PIN; UPSERT. |
|
||||
| `regenerateRecoveryCode({ userId })` | Generate new 24-char code, seal cached key with it, UPSERT recovery-only fields. |
|
||||
| `resetIdentity({ userId, pin })` | `resetUserKey` + `setupNewUserIdentity`; returns recovery code. |
|
||||
| `retryLegacyMigration(userId)` | Runs the migration helper from `@chat-app/shared/chat` for the SecurityCenter "retry" affordance; returns the structured `LegacyMigrationReport`. |
|
||||
| `userKeyExistsRemotely(userId)` | Thin wrapper around `fetchUserKeyBlob`. |
|
||||
|
||||
The desktop file already factors `runLegacyMigration` cleanly. Mobile uses an identical implementation; the only platform difference is the SecureStore adapter for legacy device-key probing (`chatapp.priv.<userId>.<deviceId>`).
|
||||
|
||||
### AuthProvider rewrite
|
||||
|
||||
`apps/mobile/lib/authContext.tsx` is rewritten to mirror `apps/desktop/src/context/AuthContext.tsx`:
|
||||
|
||||
```ts
|
||||
type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
userId: string | null;
|
||||
ownPrivateKey: Uint8Array | null; // cached cleartext user key when 'unlocked'
|
||||
userKeyState: UserKeyState;
|
||||
ready: boolean;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
- On session resume: `cachedUserKey(userId)` first. Hit → `unlocked` and key is exposed to consumers. Miss → fetch blob, decide `needs-setup` vs `needs-unlock`.
|
||||
- `device` / `ensureDevice` disappear. The `devices` table is no longer used for messaging crypto; mobile can still call `auth.registerDevice` for telemetry / push routing in a follow-up, but it is not on the boot path.
|
||||
- Legacy migration: when transitioning to `unlocked` via either setup or unlock, `ensureLegacyMigrated` runs in the background.
|
||||
|
||||
### Routing / screen graph
|
||||
|
||||
`apps/mobile/app/_layout.tsx` removes the module-eval `crypto.setCryptoBackend(...)` call (see white-screen spec) and replaces it with a guarded `useEffect` inside an `<AppBootstrap>` component that:
|
||||
|
||||
1. Initialises the crypto backend.
|
||||
2. Renders a small splash with `ActivityIndicator` while `ready === false`.
|
||||
3. Mounts the rest of the tree only after backend init succeeds.
|
||||
|
||||
`apps/mobile/app/(app)/_layout.tsx` becomes the gate:
|
||||
|
||||
- `userKeyState.status === 'loading'` → `<ActivityIndicator/>`
|
||||
- `userKeyState.status === 'needs-setup'` → `<Redirect href="/(app)/setup" />`
|
||||
- `userKeyState.status === 'needs-unlock'` → `<Redirect href="/(app)/unlock" />`
|
||||
- `userKeyState.status === 'unlocked'` → render `<Stack>` with `chats`, `conversations/[id]`, `call`, `settings/*`.
|
||||
|
||||
New screens:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/mobile/app/(app)/setup.tsx` | First-time PIN setup. Two PIN entries, confirm, "Recovery-Code anzeigen", "Habe ich gespeichert" / "Überspringen (riskant)". On success: navigates to `/(app)/chats`. |
|
||||
| `apps/mobile/app/(app)/unlock.tsx` | PIN entry. Shows remaining attempts + lockout countdown. "Recovery-Code verwenden" tab. On success: navigates to `/(app)/chats`. |
|
||||
| `apps/mobile/app/(app)/settings/index.tsx` | New settings hub. Profile placeholder + section cards (Sicherheit, Abmelden). |
|
||||
| `apps/mobile/app/(app)/settings/security.tsx` | PIN ändern, Recovery-Code neu erzeugen, Identität zurücksetzen, "Migration erneut versuchen" with structured report. |
|
||||
|
||||
`apps/mobile/components/PinInput.tsx` — React-Native pendant of the desktop `PinInput.tsx`. Six bullet slots + invisible `TextInput` with `keyboardType="numeric"`, `textContentType="oneTimeCode"`, autoFocus, max-length 6. Numpad bring-up is handled by the OS keyboard.
|
||||
|
||||
### Call-site updates
|
||||
|
||||
Every shared-chat call must move from device-keyed args to user-keyed args. The shared helpers already expect `senderUserId` / `ownUserId` after v0.18 (`decryptMessages` takes `ownUserId`; `sendEncryptedMessage` takes `senderUserId` and treats `senderDeviceId` as deprecated telemetry). Concrete edits:
|
||||
|
||||
| File | Edit |
|
||||
|------|------|
|
||||
| `apps/mobile/app/(app)/conversations/[id].tsx` | Replace `device.id` references with `userId`. `decryptMessages` already takes `ownUserId`; remove the dead `ownDeviceId: device.id` line. `sendEncryptedMessage` already takes `senderUserId`; drop `senderDeviceId`. Source `userId` + `ownPrivateKey` from `useAuth()`. |
|
||||
| `apps/mobile/components/MessageBubble.tsx` | Drop the `ownDeviceId` prop. `AttachmentImage` no longer reads it. |
|
||||
| `apps/mobile/components/AttachmentImage.tsx` | Drop the `ownDeviceId` prop entirely. |
|
||||
| `apps/mobile/app/(app)/chats.tsx` | No crypto changes; only the type of `useAuth()` shifts (no `device` field). |
|
||||
|
||||
### Mobile crypto-backend extension
|
||||
|
||||
`apps/mobile/lib/cryptoBackend.ts` adds the new members:
|
||||
|
||||
```ts
|
||||
pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
},
|
||||
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
|
||||
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
|
||||
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
|
||||
```
|
||||
|
||||
Desktop adapter mirrors the structure against `libsodium-wrappers`.
|
||||
|
||||
### What we delete
|
||||
|
||||
- `apps/mobile/lib/authContext.tsx` — device-id constants `KEY_DEVICE_ID`, `KEY_DEVICE_PRIVKEY`, `ensureDevice`, the device-list lookup. The file is rewritten, not patched.
|
||||
- `device`/`ownDeviceId` props through the component tree.
|
||||
|
||||
### What stays
|
||||
|
||||
- `apps/mobile/lib/secretStore.ts` (still used; new key is `chatapp.userpriv.<userId>`).
|
||||
- `apps/mobile/lib/cryptoBackend.ts` (extended, not replaced).
|
||||
- `react-native-libsodium`, `expo-secure-store`, all supabase wiring.
|
||||
- All chat / call / attachment / reactions UI components — they consume `ownPrivateKey` and a user-id, both of which are still available, just sourced differently.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### First sign-in (no `user_keys` row server-side)
|
||||
|
||||
1. User completes magic-link sign-in. `AuthProvider` lands `session`.
|
||||
2. `refreshUserKeyState`: `cachedUserKey` returns null; `fetchUserKeyBlob` returns null → `userKeyState = 'needs-setup'`.
|
||||
3. `(app)` layout redirects to `/(app)/setup`.
|
||||
4. User picks PIN (6 digits), confirms. Optionally taps "Recovery-Code anzeigen", confirms.
|
||||
5. `setupNewUserIdentity` runs: generate keypair → seal with PIN → optionally seal with recovery code → UPSERT `user_keys` → cache cleartext key.
|
||||
6. `ensureLegacyMigrated` runs in background (no-op on fresh accounts; rewraps legacy bundles when a desktop install has existing chats).
|
||||
7. `refreshUserKeyState` re-runs → `unlocked` → `(app)` mounts the chats stack.
|
||||
|
||||
### Re-install / fresh device, account already has `user_keys`
|
||||
|
||||
1. Sign in. `cachedUserKey` returns null (fresh SecureStore).
|
||||
2. `fetchUserKeyBlob` returns the row → `needs-unlock` (or `needs-unlock` with lockout if `locked_until > now`).
|
||||
3. Routed to `/(app)/unlock`. User enters PIN.
|
||||
4. `loadOrUnlockUserKey` → RPC → derive KEK → open → cache → success.
|
||||
5. `ensureLegacyMigrated` triggers in background; reinstall users have nothing to migrate locally (no old SecureStore entries), so the helper exits with `noStrongholdKey > 0` and the UI is unaffected.
|
||||
6. UI navigates to `/(app)/chats`.
|
||||
|
||||
### Sending a message
|
||||
|
||||
Identical to desktop. `useAuth().ownPrivateKey` (now the user-key) + `session.user.id` → `chat.sendEncryptedMessage`. `getOrCreateConvKey` finds the bundle by `recipient_user_id = me` and works on the very first send without peer involvement.
|
||||
|
||||
### PIN change / recovery regenerate / identity reset
|
||||
|
||||
Reuses `changePin`, `regenerateRecoveryCode`, `resetIdentity` exactly as on desktop. UI in `settings/security.tsx`.
|
||||
|
||||
### Forgot PIN, no recovery
|
||||
|
||||
`unlock.tsx` exposes "Identität zurücksetzen" after the user has been locked. Triggers `resetIdentity` which generates a new keypair, replaces the `user_keys` row, deletes own `conversation_keys` bundles, and re-runs setup. User loses access to old chats — same trade-off as desktop.
|
||||
|
||||
### Upgrade-in-place migration (existing v0.1.x mobile users)
|
||||
|
||||
A user who already has the app v0.1.x installed has a `chatapp.priv.<userId>.<deviceId>` entry in SecureStore. On first launch of the upgraded build:
|
||||
|
||||
1. Setup or unlock runs as above and produces a fresh `chatapp.userpriv.<userId>`.
|
||||
2. `ensureLegacyMigrated` reads the old SecureStore entries (the orchestrator probes both `listOwnDevices` and scans `conversation_keys` for `recipient_device_id`s, identical to desktop).
|
||||
3. Re-wraps and uploads via `migrate_user_key_recipients`.
|
||||
4. Old SecureStore entries are left in place (no destructive cleanup until a follow-up release confirms the migration succeeded — same as desktop).
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Wrong PIN | `record_pin_attempt(false)` increments. UI shows remaining attempts. Same backoff schedule as desktop. |
|
||||
| Lockout | `try_unlock_user_key` returns `locked: true`. UI surfaces lockout time + "Recovery-Code verwenden" tab if `hasRecovery`. |
|
||||
| Recovery missing + lockout | UI shows hard warning + "Identität zurücksetzen" path. |
|
||||
| SecureStore unavailable (rare; Android factory-test profiles) | Boot screen surfaces error from the global handler. No silent fallback — losing the local cache means re-entering PIN every launch, which is acceptable; we do not fall back to AsyncStorage cleartext storage. |
|
||||
| Migration failure | Background only; never blocks unlock. `SecurityCenter` exposes structured report + "Migration erneut versuchen" button. |
|
||||
| `crypto_pwhash` rejects (input length, memory limit) | Treated as identical to wrong-PIN (record attempt, surface generic "PIN falsch"). Logged with `console.warn` for diagnostics. |
|
||||
| `react-native-libsodium` constants undefined at boot | `setCryptoBackend` throws inside the `<AppBootstrap>` `useEffect`; the error is caught and surfaced in a fallback `<View>` instead of a white screen. |
|
||||
| Server unreachable during unlock | `try_unlock_user_key` rejects → unlock screen shows "Server nicht erreichbar" + Retry button; cached key (if any) is unaffected. |
|
||||
|
||||
## Testing
|
||||
|
||||
**Shared (`packages/shared`):**
|
||||
- `crypto/userKey.test.ts` — already exists, must stay green after the refactor. Run against the WASM test backend AND a stub backend that records calls (to assert `pwhash` is only invoked with the `MODERATE` preset).
|
||||
- `crypto/backend.contract.test.ts` — NEW. Defines a backend contract test that any adapter must pass: pwhash determinism, `scalarMultBase` produces the public key matching `generateKeyPair()` private→public mapping.
|
||||
|
||||
**Mobile unit (`apps/mobile`):**
|
||||
- `lib/userIdentity.test.ts` — setup/unlock/changePin/regenerateRecovery/reset roundtrip with a mocked Supabase RPC and the test crypto backend.
|
||||
- `lib/cryptoBackend.test.ts` — pwhash determinism + length assertions. Skipped on Node when `react-native-libsodium` isn't loadable; runs in `expo-test`/Device-Farm context.
|
||||
- `components/PinInput.test.tsx` — typing accumulates, max length, submit on full input.
|
||||
|
||||
**Manual smoke list (pre-merge):**
|
||||
1. Fresh install Android. Sign in. Set PIN with recovery. Send message. Sign out. Sign in. Enter PIN. Old + new chats work.
|
||||
2. Fresh install iOS. Same flow.
|
||||
3. Reinstall scenario: nuke app data, reinstall, sign in, enter PIN. No peer needs to be online.
|
||||
4. Cross-platform: send from desktop, receive on mobile that was set up on a different device. Decrypts.
|
||||
5. Wrong PIN 5×, 10× → lockout banner + recovery tab.
|
||||
6. Recovery code unlock works.
|
||||
7. PIN change in Settings → sign out → sign in with new PIN.
|
||||
8. Identity reset → re-setup → fresh recovery code → old chats unreadable (expected), new chats work.
|
||||
9. Upgrade-in-place from v0.1.0 with existing chats: legacy migration rewraps; "Migration erneut versuchen" shows non-zero `migrated`.
|
||||
|
||||
## Migration Sequencing
|
||||
|
||||
Two-stage rollout to keep server compatibility.
|
||||
|
||||
1. **v0.2.0 (silent upgrade):** New mobile build with user-keys + PIN. Falls back gracefully when peers are still on legacy desktop (uses `recipient_device_id` for legacy peers via existing `rotateConvKey` legacy branch).
|
||||
2. **v0.3.0 (cleanup):** Drop the device-key probing path once telemetry confirms ≥95% of mobile installs have a `user_keys` row. Aligns with the desktop spec's migration cutoff.
|
||||
|
||||
## Out of Scope (future work)
|
||||
|
||||
- Biometric unlock (Face ID / fingerprint) as an alternative to PIN entry.
|
||||
- Cross-device pairing flow (QR / Bluetooth handshake) for a no-PIN re-install path.
|
||||
- Push-notification routing via per-install device tokens (separate from crypto identity).
|
||||
- Device-list management UI on mobile.
|
||||
@@ -119,6 +119,7 @@ export type Database = {
|
||||
name: string
|
||||
platform: Database["public"]["Enums"]["device_platform"]
|
||||
public_key: string | null
|
||||
revoked_at: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
@@ -128,6 +129,7 @@ export type Database = {
|
||||
name: string
|
||||
platform: Database["public"]["Enums"]["device_platform"]
|
||||
public_key?: string | null
|
||||
revoked_at?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
@@ -137,6 +139,7 @@ export type Database = {
|
||||
name?: string
|
||||
platform?: Database["public"]["Enums"]["device_platform"]
|
||||
public_key?: string | null
|
||||
revoked_at?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
@@ -474,6 +477,111 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
conversation_whiteboards: {
|
||||
Row: {
|
||||
id: string
|
||||
conversation_id: string
|
||||
owner_user_id: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
conversation_id: string
|
||||
owner_user_id: string
|
||||
created_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
conversation_id?: string
|
||||
owner_user_id?: string
|
||||
created_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "conversation_whiteboards_conversation_id_fkey"
|
||||
columns: ["conversation_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "conversations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
whiteboard_strokes: {
|
||||
Row: {
|
||||
id: string
|
||||
whiteboard_id: string
|
||||
author_user_id: string
|
||||
stroke_json: Json
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
whiteboard_id: string
|
||||
author_user_id: string
|
||||
stroke_json: Json
|
||||
created_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
whiteboard_id?: string
|
||||
author_user_id?: string
|
||||
stroke_json?: Json
|
||||
created_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "whiteboard_strokes_whiteboard_id_fkey"
|
||||
columns: ["whiteboard_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "conversation_whiteboards"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
user_soundboards: {
|
||||
Row: {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
category: string | null
|
||||
hotkey: string | null
|
||||
gain: number
|
||||
sort_order: number
|
||||
storage_path: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
user_id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
category?: string | null
|
||||
hotkey?: string | null
|
||||
gain?: number
|
||||
sort_order?: number
|
||||
storage_path: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
user_id?: string
|
||||
name?: string
|
||||
mime?: string
|
||||
size?: number
|
||||
category?: string | null
|
||||
hotkey?: string | null
|
||||
gain?: number
|
||||
sort_order?: number
|
||||
storage_path?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
@@ -481,6 +589,7 @@ export type Database = {
|
||||
Functions: {
|
||||
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
||||
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
||||
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
||||
attachment_object_conv_id: {
|
||||
Args: { object_name: string }
|
||||
Returns: string
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listOwnDevices, revokeDevice } from './device';
|
||||
|
||||
function makeClient(overrides: {
|
||||
user?: { id: string } | null;
|
||||
selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>;
|
||||
rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
|
||||
}): any {
|
||||
const builder: any = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }),
|
||||
};
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) },
|
||||
from: vi.fn().mockReturnValue(builder),
|
||||
rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))),
|
||||
};
|
||||
}
|
||||
|
||||
describe('listOwnDevices', () => {
|
||||
it('maps revoked_at into revokedAt', async () => {
|
||||
const client = makeClient({
|
||||
selectData: [
|
||||
{ id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null },
|
||||
{ id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' },
|
||||
],
|
||||
});
|
||||
const out = await listOwnDevices(client);
|
||||
expect(out).toEqual([
|
||||
{ id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null },
|
||||
{ id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeDevice', () => {
|
||||
it('invokes the revoke_device RPC with the device id', async () => {
|
||||
const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
|
||||
const client = makeClient({ rpcImpl: rpc });
|
||||
await revokeDevice(client, 'd-42');
|
||||
expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' });
|
||||
});
|
||||
|
||||
it('throws when the RPC returns an error', async () => {
|
||||
const client = makeClient({
|
||||
rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }),
|
||||
});
|
||||
await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export interface DeviceRecord {
|
||||
name: string;
|
||||
platform: DevicePlatform;
|
||||
lastSeenAt: string;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
export async function registerDevice(
|
||||
@@ -32,7 +33,7 @@ export async function registerDevice(
|
||||
name: params.name,
|
||||
platform: params.platform,
|
||||
})
|
||||
.select('id, name, platform, last_seen_at')
|
||||
.select('id, name, platform, last_seen_at, revoked_at')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
@@ -41,6 +42,7 @@ export async function registerDevice(
|
||||
name: data.name,
|
||||
platform: data.platform,
|
||||
lastSeenAt: data.last_seen_at,
|
||||
revokedAt: data.revoked_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +52,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
|
||||
|
||||
const { data, error } = await client
|
||||
.from('devices')
|
||||
.select('id, name, platform, last_seen_at')
|
||||
.select('id, name, platform, last_seen_at, revoked_at')
|
||||
.eq('user_id', session.user.id)
|
||||
.order('last_seen_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
@@ -60,6 +62,7 @@ export async function listOwnDevices(client: AppSupabaseClient): Promise<DeviceR
|
||||
name: row.name,
|
||||
platform: row.platform,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
revokedAt: row.revoked_at,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -74,6 +77,14 @@ export async function touchDeviceLastSeen(
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function revokeDevice(
|
||||
client: AppSupabaseClient,
|
||||
deviceId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await client.rpc('revoke_device', { p_device_id: deviceId });
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
// Intentional re-exports so app layers only need @chat-app/shared/auth.
|
||||
export type { SecretStore } from './secure-storage';
|
||||
export { toBase64 as base64FromBytes, fromBase64 as bytesFromBase64 };
|
||||
|
||||
@@ -25,6 +25,16 @@ export interface AttachmentHandle {
|
||||
// base64-encoded — only readable via per-device envelope decrypt.
|
||||
keyB64: string;
|
||||
nonceB64: string;
|
||||
/** View-once flag — set by sender on the encrypted envelope and mirrored
|
||||
* to `message_attachments.view_once` so the renderer can show a "tap to
|
||||
* view" tombstone and call the mark-viewed RPC on first open. */
|
||||
viewOnce?: boolean;
|
||||
/** ISO timestamp the first non-sender opened the attachment. Populated by
|
||||
* the mark-viewed RPC (server-authoritative); undefined until burnt. */
|
||||
viewedAt?: string | null;
|
||||
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
|
||||
* by the mark-viewed RPC so the renderer can render attribution. */
|
||||
viewedBy?: string | null;
|
||||
}
|
||||
|
||||
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
||||
@@ -66,7 +76,17 @@ export interface PollPayload {
|
||||
options: PollOption[];
|
||||
}
|
||||
|
||||
export type MessagePayload = TextMessagePayload | CallEventPayload | PollPayload;
|
||||
export interface WhiteboardPayload {
|
||||
v: 1;
|
||||
type: 'whiteboard';
|
||||
whiteboard_id: string;
|
||||
}
|
||||
|
||||
export type MessagePayload =
|
||||
| TextMessagePayload
|
||||
| CallEventPayload
|
||||
| PollPayload
|
||||
| WhiteboardPayload;
|
||||
|
||||
export type ParsedMessagePayload =
|
||||
| {
|
||||
@@ -85,6 +105,10 @@ export type ParsedMessagePayload =
|
||||
kind: 'poll';
|
||||
question: string;
|
||||
options: PollOption[];
|
||||
}
|
||||
| {
|
||||
kind: 'whiteboard';
|
||||
whiteboardId: string;
|
||||
};
|
||||
|
||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||
@@ -141,6 +165,13 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
||||
options,
|
||||
};
|
||||
}
|
||||
if (obj.type === 'whiteboard') {
|
||||
const p = obj as Partial<WhiteboardPayload>;
|
||||
const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0
|
||||
? p.whiteboard_id
|
||||
: '';
|
||||
return { kind: 'whiteboard', whiteboardId: id };
|
||||
}
|
||||
const t = obj as TextMessagePayload;
|
||||
return {
|
||||
kind: 'text',
|
||||
@@ -256,6 +287,7 @@ export async function insertAttachmentRow(
|
||||
nonce: blobNonceHex,
|
||||
mime_type: handle.mimeType,
|
||||
size_bytes: handle.sizeBytes,
|
||||
view_once: handle.viewOnce ?? false,
|
||||
};
|
||||
if (handle.width !== undefined) row.width = handle.width;
|
||||
if (handle.height !== undefined) row.height = handle.height;
|
||||
|
||||
@@ -7,6 +7,11 @@ export * from './groups';
|
||||
export * from './messages';
|
||||
export * from './types';
|
||||
export * from './userKeyMigration';
|
||||
export * from './pinnedMessages';
|
||||
export * from './mentions';
|
||||
export * from './viewOnceAttachments';
|
||||
export * from './whiteboards';
|
||||
export * from './soundboards';
|
||||
|
||||
// ----- RPC wrappers ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseMentionUsernames } from './mentions';
|
||||
|
||||
describe('parseMentionUsernames', () => {
|
||||
it('extracts a leading mention', () => {
|
||||
expect(parseMentionUsernames('@anna hi')).toEqual(['anna']);
|
||||
});
|
||||
it('extracts mid-sentence', () => {
|
||||
expect(parseMentionUsernames('hey @ben_c what do you think')).toEqual(['ben_c']);
|
||||
});
|
||||
it('lowercases usernames', () => {
|
||||
expect(parseMentionUsernames('hi @Anna')).toEqual(['anna']);
|
||||
});
|
||||
it('deduplicates', () => {
|
||||
expect(parseMentionUsernames('@xx and @xx again')).toEqual(['xx']);
|
||||
});
|
||||
it('ignores emails (no preceding boundary)', () => {
|
||||
expect(parseMentionUsernames('mail me at foo@bar.com')).toEqual([]);
|
||||
});
|
||||
it('rejects 1-char names', () => {
|
||||
expect(parseMentionUsernames('@a')).toEqual([]);
|
||||
});
|
||||
it('handles multiple in one message', () => {
|
||||
expect(parseMentionUsernames('@anna, @ben and @cara')).toEqual(['anna', 'ben', 'cara']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AppSupabaseClient } from '../supabase/client';
|
||||
|
||||
// `@anna_b` style — letters, digits, underscore, dot, dash, 2-32 chars.
|
||||
// Conservative on purpose: false negatives (a real username we don't match)
|
||||
// are recoverable (no notification fires); false positives (matching a
|
||||
// non-username) just become an INSERT that the FK check rejects.
|
||||
const MENTION_RE = /(?:^|[\s,;:!?(])@([a-zA-Z0-9_.-]{2,32})/g;
|
||||
|
||||
export function parseMentionUsernames(plaintext: string): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const m of plaintext.matchAll(MENTION_RE)) {
|
||||
if (m[1]) out.add(m[1].toLowerCase());
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export interface MentionResolver {
|
||||
// Resolves an array of @usernames in this conversation to user-ids.
|
||||
// Returns only memberships that exist + are accepted.
|
||||
resolveUsernames(conversationId: string, usernames: string[]): Promise<Map<string, string>>;
|
||||
}
|
||||
|
||||
export function makeMentionResolver(client: AppSupabaseClient): MentionResolver {
|
||||
return {
|
||||
async resolveUsernames(conversationId, usernames) {
|
||||
if (usernames.length === 0) return new Map();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data, error } = await (client as any)
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted, profiles!inner(username)')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('accepted', true)
|
||||
.in('profiles.username', usernames);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, string>();
|
||||
for (const row of (data ?? []) as Array<{ user_id: string; profiles: { username: string } }>) {
|
||||
out.set(row.profiles.username.toLowerCase(), row.user_id);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function insertMentions(
|
||||
client: AppSupabaseClient,
|
||||
messageId: string,
|
||||
conversationId: string,
|
||||
mentionedUserIds: string[],
|
||||
): Promise<void> {
|
||||
if (mentionedUserIds.length === 0) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { error } = await (client as any).from('message_mentions').insert(
|
||||
mentionedUserIds.map((uid) => ({
|
||||
message_id: messageId,
|
||||
mentioned_user_id: uid,
|
||||
conversation_id: conversationId,
|
||||
})),
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type OwnUserCtx,
|
||||
tryGetConvKey,
|
||||
} from './convKeys';
|
||||
import { insertMentions, makeMentionResolver, parseMentionUsernames } from './mentions';
|
||||
import type { ChatMessage, DecryptedMessage } from './types';
|
||||
|
||||
const MESSAGE_COLS =
|
||||
@@ -91,7 +92,11 @@ export interface SendMessageParams {
|
||||
conversationId: string;
|
||||
plaintext: string;
|
||||
senderUserId: string;
|
||||
senderDeviceId: string;
|
||||
// Optional now: post-conv-keys this is pure telemetry. The 0.18 builds
|
||||
// started passing a localStorage UUID that doesn't exist in the devices
|
||||
// table; messages.sender_device_id RLS then 403'd every insert. Senders
|
||||
// pass null (or an actually-registered device id, if they have one).
|
||||
senderDeviceId?: string | null;
|
||||
senderPrivateKey: Uint8Array;
|
||||
replyToId?: string;
|
||||
// Optional encrypted attachments — their handles are already materialised
|
||||
@@ -124,7 +129,12 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
||||
const insertPayload: Record<string, unknown> = {
|
||||
conversation_id: params.conversationId,
|
||||
sender_id: params.senderUserId,
|
||||
sender_device_id: params.senderDeviceId,
|
||||
// ALWAYS null until we re-introduce a real per-install devices row.
|
||||
// Desktop callers currently pass a localStorage UUID (ensureInstallId)
|
||||
// which doesn't exist in the devices table; the messages_insert_member
|
||||
// RLS policy then 403s because the id can't be proven to belong to the
|
||||
// caller. NULL satisfies the policy ("sender_device_id IS NULL OR …").
|
||||
sender_device_id: null,
|
||||
ciphertext: bytesToPgHex(cipher.ciphertext),
|
||||
nonce: bytesToPgHex(cipher.nonce),
|
||||
key_version: handle.keyVersion,
|
||||
@@ -137,6 +147,28 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
||||
.select(MESSAGE_COLS)
|
||||
.single();
|
||||
if (insertErr) throw insertErr;
|
||||
|
||||
// Mention rows are inserted on a best-effort basis after the message itself
|
||||
// lands. If parsing or resolution fails the message still goes through —
|
||||
// worst case the mentioned user doesn't get a notification.
|
||||
const usernames = parseMentionUsernames(params.plaintext);
|
||||
if (usernames.length > 0) {
|
||||
try {
|
||||
const resolver = makeMentionResolver(params.client);
|
||||
const resolved = await resolver.resolveUsernames(params.conversationId, usernames);
|
||||
if (resolved.size > 0) {
|
||||
await insertMentions(
|
||||
params.client,
|
||||
(messageRow as unknown as { id: string }).id,
|
||||
params.conversationId,
|
||||
[...resolved.values()],
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('mention insert failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
return mapMessage(messageRow as unknown as MessageRow);
|
||||
}
|
||||
|
||||
@@ -174,7 +206,7 @@ export interface EditMessageParams {
|
||||
// Re-encrypts the message body with the conv-key and updates the row.
|
||||
// Server-side trigger enforces 24h window + sender-only rule.
|
||||
export async function editEncryptedMessage(
|
||||
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
||||
params: EditMessageParams & { senderUserId: string; senderDeviceId?: string | null },
|
||||
): Promise<void> {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: params.senderUserId,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user