Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a636a3c1c1 | |||
| c9fe4879e0 | |||
| 61462516d2 | |||
| d39a0fb6dc | |||
| 9207f473cd | |||
| 5367544b59 | |||
| e6b698bf14 | |||
| 6caa674c19 | |||
| d9377fc52a | |||
| 1c18078b1f | |||
| 0e05a2cd85 | |||
| b55ccf899f | |||
| b2eb214d9f | |||
| 010a810485 | |||
| 6a9a0bb804 | |||
| f7c60945d0 | |||
| 15ef9ece66 | |||
| b789f4b10d | |||
| 02e1af4517 | |||
| e292df82f0 | |||
| b3804d805b | |||
| 02c4bb1c9b | |||
| 20216b37c6 | |||
| 2c586351fc | |||
| b89a7e2617 | |||
| 8d69329763 | |||
| 2fdbe9ee3a | |||
| 0d1d4496c3 | |||
| baf9c2e054 | |||
| b54fe0b56d | |||
| 89eb8d97c6 | |||
| 32bff77fd0 | |||
| d10b0fb5c3 | |||
| 1370f8794b | |||
| eef884c782 | |||
| d9f3c6c562 | |||
| b61f929cf7 | |||
| b0967dd2c5 | |||
| f8859b4601 | |||
| 4068e8c7b6 | |||
| cb8e729624 | |||
| 1e9395fdc2 | |||
| 9b3ef8c24f | |||
| ba8ba75490 | |||
| 122191276e | |||
| 5a3dc15704 | |||
| 10d3e91bbb | |||
| 04da0f211d | |||
| 4d787b68b4 | |||
| c2df741055 | |||
| b81c63a2d3 | |||
| 098e4f4c1d | |||
| e83c23d698 | |||
| 68cd4108dd | |||
| ed7cb72ebe | |||
| 177a4c059f | |||
| 1440b11740 | |||
| 286836f539 | |||
| 3b62fbc243 | |||
| 8d7ed592b8 | |||
| 153e40bdcb | |||
| 415fa10ed0 | |||
| 8b7b6a3b0f | |||
| 05a17b7c1c | |||
| dd6ae63491 | |||
| b1cdc4d184 | |||
| 263d2455a8 | |||
| 3755a927c3 | |||
| 0b3a6280fc | |||
| 3313dd84f3 | |||
| 2a5f836b86 | |||
| bf02380c19 | |||
| 375d28efa5 | |||
| 1e88941796 | |||
| 7ac93a95e6 | |||
| a275703ba6 | |||
| 035b5f078d | |||
| 13c6ad68f0 | |||
| 84a9f35ed8 | |||
| 7eb8224bb0 | |||
| 574436ab71 | |||
| 5e483368ec | |||
| b4457b74f5 | |||
| bb7aeec345 | |||
| 1bf4f4bb83 | |||
| 3049a0e39d | |||
| 2003a26c23 | |||
| 1e0cf67335 | |||
| 5f753412a6 | |||
| a560f24c45 | |||
| 87c2e45bb4 | |||
| b65a3994f3 | |||
| aa609389fa | |||
| cb3cbd8827 | |||
| d1f38ce313 | |||
| 0d65a134fd | |||
| 12c66d676a | |||
| 0dde1dd1a3 | |||
| 81d3587a91 | |||
| 8be1105333 | |||
| f9e1d2f073 | |||
| 341f5f227d | |||
| 8baac2fd1e | |||
| 05870ef8fa | |||
| f9e340dbec | |||
| 91eedc0e8e | |||
| e56533918e | |||
| c87d4e82d3 | |||
| 187f8dc95a | |||
| f612c1bb50 | |||
| a9d5e2d430 | |||
| a065cc0a2c | |||
| 005aebd60b | |||
| 68bc1f76f6 | |||
| f1c7501807 | |||
| e16b248366 | |||
| 9b764053c4 | |||
| 950ef5b706 |
@@ -0,0 +1,11 @@
|
||||
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
|
||||
# where ALL transitive deps are reachable from any package. pnpm's strict
|
||||
# isolation breaks that — Metro hits a fresh missing-module error for every
|
||||
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
|
||||
#
|
||||
# shamefully-hoist=true hoists everything to the workspace-root node_modules
|
||||
# (like npm/yarn) so all transitive deps are reachable via standard Node
|
||||
# resolution. This is the official Expo monorepo workaround and is the
|
||||
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
|
||||
# bundles via Vite, so the flatter layout doesn't affect it.
|
||||
shamefully-hoist=true
|
||||
@@ -37,6 +37,51 @@ const __dirnameSafe = path.dirname(__filenameSafe);
|
||||
const DEV_URL = 'http://localhost:1420';
|
||||
const WINDOW_STATE_FILE = 'window-state.json';
|
||||
|
||||
// Pin userData FIRST — before any other Electron call that might cache a
|
||||
// productName-derived path. The 0.17.0 release saw users get logged out
|
||||
// after upgrading from 0.16.x: the most likely culprit was an internal
|
||||
// path resolution kicking off the moment `setName('Netralax')` ran, so
|
||||
// 0.17.1 swaps the order so the explicit override wins regardless of
|
||||
// what setName triggers internally. The literal 'ChatApp' here is the
|
||||
// pre-rename product folder — installed users' SQLite, secrets, sounds,
|
||||
// IndexedDB all live there and we never want to leave them stranded by
|
||||
// a future rebrand.
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
|
||||
|
||||
// App branding. productName in package.json drives the packaged exe name
|
||||
// (Netralax.exe) and electron-builder installer title. setName + the
|
||||
// AppUserModelId cover the live process: window title fallback, Windows
|
||||
// taskbar grouping, notification source attribution.
|
||||
app.setName('Netralax');
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('cloud.netralax.desktop');
|
||||
}
|
||||
|
||||
// Run dev side-by-side with the installed packaged build by isolating the
|
||||
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
|
||||
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
|
||||
// the single-instance lock fires, and `pnpm dev` exits immediately while
|
||||
// the installed prod app holds the lock. Must run BEFORE the lock check
|
||||
// below + before any other module reads `app.getPath('userData')`.
|
||||
if (!app.isPackaged) {
|
||||
app.setPath('userData', app.getPath('userData') + '-Dev');
|
||||
}
|
||||
|
||||
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
|
||||
// because we had no record of the actual resolved paths. With this log
|
||||
// any future user can paste their main-process output and we can tell
|
||||
// at a glance whether userData ended up where we intended.
|
||||
console.log(
|
||||
'[main] resolved paths',
|
||||
JSON.stringify({
|
||||
appName: app.getName(),
|
||||
appData: app.getPath('appData'),
|
||||
userData: app.getPath('userData'),
|
||||
isPackaged: app.isPackaged,
|
||||
platform: process.platform,
|
||||
}),
|
||||
);
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function resolvePreloadPath(): string {
|
||||
@@ -64,7 +109,7 @@ async function createWindow(): Promise<BrowserWindow> {
|
||||
const state = await loadState(WINDOW_STATE_FILE);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: 'ChatApp',
|
||||
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
...(state.x !== undefined ? { x: state.x } : {}),
|
||||
|
||||
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
|
||||
}
|
||||
|
||||
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
|
||||
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
|
||||
// is correct) from "file exists but unreadable" (corruption / DPAPI
|
||||
// breakage — we MUST NOT let the next write overwrite those bytes,
|
||||
// because the original ciphertext is the only path back to the user's
|
||||
// device keys if a future build can fix the read path).
|
||||
let rawBuf: Buffer | null = null;
|
||||
let rawStr: string | null = null;
|
||||
try {
|
||||
if (encrypted) {
|
||||
const buf = await fs.readFile(filePath);
|
||||
const json = safeStorage.decryptString(buf);
|
||||
rawBuf = await fs.readFile(filePath);
|
||||
} else {
|
||||
rawStr = await fs.readFile(filePath, 'utf8');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (code === 'ENOENT') return new Map();
|
||||
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
|
||||
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
|
||||
// the file might be transiently locked. Empty map + future writes
|
||||
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
|
||||
// these rarer cases.
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Parse / decrypt step.
|
||||
try {
|
||||
if (encrypted && rawBuf) {
|
||||
const json = safeStorage.decryptString(rawBuf);
|
||||
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
} else {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
|
||||
}
|
||||
if (rawStr) {
|
||||
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
}
|
||||
} catch {
|
||||
// Missing file or malformed contents — start fresh. The next write
|
||||
// will overwrite with a fresh blob.
|
||||
return new Map();
|
||||
} catch (err: unknown) {
|
||||
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
|
||||
// pre-0.17.1 build we silently started fresh — the next set() then
|
||||
// scheduledSave() over-wrote the original ciphertext, destroying the
|
||||
// user's device keys forever. Now we rename the original to
|
||||
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
|
||||
// write goes to a new file and the original bytes survive for
|
||||
// forensics or a future decrypt-recovery path.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const brokenPath = `${filePath}.broken-${ts}`;
|
||||
try {
|
||||
await fs.rename(filePath, brokenPath);
|
||||
console.error(
|
||||
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
|
||||
err,
|
||||
);
|
||||
} catch (renameErr: unknown) {
|
||||
// Even rename failed — fall back to the old behaviour (silent empty
|
||||
// map) but log loudly so it's visible in the main-process output.
|
||||
console.error(
|
||||
'[secure-store] rename of broken file failed; original may be overwritten on next save',
|
||||
renameErr,
|
||||
'original decrypt error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ function buildOverlay(): NativeImage {
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
const icon = loadTrayIcon();
|
||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
||||
trayRef.setToolTip('ChatApp');
|
||||
trayRef.setToolTip('Netralax');
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
@@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void {
|
||||
else mainWindow.show();
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (trayRef && !trayRef.isDestroyed()) {
|
||||
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
|
||||
}
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (process.platform === 'win32') {
|
||||
if (n > 0) {
|
||||
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
|
||||
} else {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
ipcMain.handle(
|
||||
CHANNELS.TRAY_UNREAD,
|
||||
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (trayRef && !trayRef.isDestroyed()) {
|
||||
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
|
||||
}
|
||||
}
|
||||
});
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (process.platform !== 'win32') return;
|
||||
if (n <= 0) {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
return;
|
||||
}
|
||||
// Discord-style: prefer the renderer-painted badge (red circle with
|
||||
// the actual unread number). Fall back to the static red dot only if
|
||||
// the renderer didn't supply one or decoding failed — keeps the
|
||||
// visual indicator alive even when the canvas pipeline is unavailable.
|
||||
let overlay: NativeImage | null = null;
|
||||
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
|
||||
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
|
||||
if (!decoded.isEmpty()) overlay = decoded;
|
||||
}
|
||||
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
|
||||
},
|
||||
);
|
||||
|
||||
app.on('before-quit', () => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { CHANNELS } from '../ipc-types';
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
// Per-window maximize-before-fullscreen memo. We have to drop the
|
||||
// maximized flag on Windows before setFullScreen so DWM recomposes
|
||||
// cleanly (taskbar quirk), but Electron doesn't remember that the
|
||||
// window WAS maximized — exiting fullscreen would leave it as a small
|
||||
// floating window. Track it ourselves keyed by window-id so a future
|
||||
// multi-window setup doesn't cross-pollute state.
|
||||
const wasMaximized = new Map<number, boolean>();
|
||||
|
||||
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
|
||||
try {
|
||||
// Prefer the BrowserWindow that issued the IPC so multi-window setups
|
||||
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
|
||||
// registered against (matches autostart.ts's app-singleton shape).
|
||||
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.setFullScreen(!!enabled);
|
||||
const id = win.id;
|
||||
if (enabled) {
|
||||
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
|
||||
// taskbar drawn on top of the window because DWM keeps the
|
||||
// maximized work-area constraints. Drop the maximize flag first
|
||||
// so setFullScreen covers the whole monitor cleanly. Remember the
|
||||
// pre-fullscreen state so the exit path can restore it.
|
||||
if (process.platform === 'win32') {
|
||||
const was = win.isMaximized();
|
||||
wasMaximized.set(id, was);
|
||||
if (was) win.unmaximize();
|
||||
}
|
||||
win.setFullScreen(true);
|
||||
} else {
|
||||
win.setFullScreen(false);
|
||||
// Restore maximize if we dropped it on entry. setFullScreen(false)
|
||||
// emits 'leave-full-screen' asynchronously; maximize() needs to
|
||||
// wait until the window is back in normal mode or it silently
|
||||
// no-ops. The event fires same-tick in Electron 33, but we listen
|
||||
// for it once just to be safe across versions.
|
||||
if (process.platform === 'win32' && wasMaximized.get(id)) {
|
||||
wasMaximized.delete(id);
|
||||
const restore = (): void => {
|
||||
if (!win.isDestroyed()) win.maximize();
|
||||
};
|
||||
if (win.isFullScreen()) {
|
||||
win.once('leave-full-screen', restore);
|
||||
} else {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('window setFullscreen failed', err);
|
||||
throw err;
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export interface ElectronAPI {
|
||||
notify: (args: NotifyArgs) => Promise<void>;
|
||||
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
|
||||
|
||||
setTrayUnread: (count: number) => Promise<void>;
|
||||
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
|
||||
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
|
||||
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
type UpdateCheckResult,
|
||||
type UpdateProgress,
|
||||
} from './ipc-types';
|
||||
// Static import of the desktop package.json so the bundler inlines the
|
||||
// version string at build time. The previous `process.env.npm_package_-
|
||||
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||||
// fell back to '0.0.0' in packaged builds — every installed user got
|
||||
// flagged as "Update verfügbar" against their own actually-current
|
||||
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||||
import pkg from '../package.json';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||||
const api = {
|
||||
platform: ELECTRON_RUNTIME_MARKER,
|
||||
osPlatform: process.platform as NodeJS.Platform,
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
appVersion: pkg.version,
|
||||
|
||||
// Screen sources ---------------------------------------------------------
|
||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||
@@ -94,7 +101,12 @@ const api = {
|
||||
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||||
|
||||
// Tray -------------------------------------------------------------------
|
||||
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
|
||||
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
|
||||
// that main applies as the Windows taskbar overlay icon. We render in the
|
||||
// renderer because main has no Canvas2D; passing a finished image avoids
|
||||
// bundling a native canvas backend just for a 32×32 badge.
|
||||
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
|
||||
|
||||
// Secure store -----------------------------------------------------------
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.16.1",
|
||||
"version": "0.18.4",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.meinname.chatapp",
|
||||
"productName": "ChatApp",
|
||||
"productName": "Netralax",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "resources"
|
||||
|
||||
@@ -3,13 +3,16 @@ import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { CallUI } from './CallUI';
|
||||
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
// re-asking if the user already responded this session.
|
||||
@@ -17,9 +20,25 @@ export function AppShell() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
return startConversationKeySync(session.user.id, device.id);
|
||||
}, [session?.user.id, device?.id]);
|
||||
if (!session?.user.id) return;
|
||||
const userId = session.user.id;
|
||||
// Per-install id replaces the per-device record now that crypto is
|
||||
// user-keyed. The legacy conv-key-sync and device-approval subsystems
|
||||
// still take a "deviceId" argument for telemetry / row identity; passing
|
||||
// the install id keeps those wires intact until they are themselves
|
||||
// reworked in later tasks (Task 16/17/19 territory).
|
||||
const installId = ensureInstallId();
|
||||
const stopKeySync = startConversationKeySync(userId, installId);
|
||||
const stopApproval = startDeviceApprovalListener({
|
||||
ownUserId: userId,
|
||||
ownDeviceId: installId,
|
||||
getPriv: () => cachedUserKey(userId),
|
||||
});
|
||||
return () => {
|
||||
stopKeySync();
|
||||
stopApproval();
|
||||
};
|
||||
}, [session?.user.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||
@@ -33,7 +52,7 @@ export function AppShell() {
|
||||
</main>
|
||||
</div>
|
||||
<CallUI />
|
||||
<BackupPromptBanner />
|
||||
<DeviceApprovalBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
import { Lightbox } from './Lightbox';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
@@ -136,42 +137,5 @@ export function AttachmentImage({ handle }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bildansicht"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={url}
|
||||
alt="attachment full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
||||
// any future surface can reuse the same dialog without duplication.
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
|
||||
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Exports the device's private key + identity into a passphrase-protected
|
||||
// portable string. The user can store this string anywhere (password manager,
|
||||
// printed paper, encrypted file on a USB stick). Without it, losing local
|
||||
// storage on this install means losing all past conversation keys.
|
||||
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||
|
||||
const canGenerate = useMemo(() => {
|
||||
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||
}, [passphrase, confirm, busy]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPassphrase('');
|
||||
setConfirm('');
|
||||
setBundle(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setCopiedRecovery(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
onClose();
|
||||
}, [reset, onClose]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!canGenerate) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const b = await exportDeviceBackupWithRecovery({
|
||||
userId,
|
||||
deviceId,
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
setBundle(b);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||
|
||||
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (marker === 'main') {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
setCopiedRecovery(true);
|
||||
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||
}
|
||||
} catch {
|
||||
/* fall back — user can select manually */
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!bundle) return;
|
||||
const text =
|
||||
'=== Passphrase backup ===\n' +
|
||||
bundle.passphraseBackup +
|
||||
'\n\n=== Recovery code ===\n' +
|
||||
bundle.recoveryCode +
|
||||
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||
bundle.recoveryBackup +
|
||||
'\n';
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [bundle, deviceId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{!bundle ? (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:backup.export_explainer', {
|
||||
defaultValue:
|
||||
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
minLength={8}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="min. 8 Zeichen"
|
||||
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
{confirm.length > 0 && confirm !== passphrase && (
|
||||
<p className="text-xs text-rose-500 dark:text-rose-300">
|
||||
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<span>
|
||||
{t('app:backup.export_warning', {
|
||||
defaultValue:
|
||||
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
{t('app:backup.export_success', {
|
||||
defaultValue:
|
||||
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.passphraseBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
Als Datei speichern (alles)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||
Recovery-Code (Passphrase vergessen?)
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||
ohne Passphrase wiederherstellen.
|
||||
</p>
|
||||
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||
{bundle.recoveryCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Recovery-Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.recoveryBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
{!bundle ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={!canGenerate}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
{t('app:backup.done', { defaultValue: 'Fertig' })}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { BackupExportDialog } from './BackupExportDialog';
|
||||
import { ShieldIcon, XIcon } from './icons';
|
||||
|
||||
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
|
||||
const SESSION_KEY = 'chatapp.backup.prompt';
|
||||
|
||||
// Post-registration nudge: right after a fresh device provision we set
|
||||
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
|
||||
// shows a floating "mach jetzt ein Backup" banner until the user either
|
||||
// creates one or explicitly dismisses (persisted in localStorage so we stop
|
||||
// nagging across reloads).
|
||||
export function BackupPromptBanner() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
|
||||
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
|
||||
setVisible(true);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((persist: boolean) => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.sessionStorage.removeItem(SESSION_KEY);
|
||||
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDialog = useCallback(async () => {
|
||||
if (!profile?.userId || !device?.id) return;
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) return;
|
||||
setPrivateKey(priv);
|
||||
setDialogOpen(true);
|
||||
}, [profile, device]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setPrivateKey(null);
|
||||
// After the user interacts with the dialog, drop the banner regardless
|
||||
// of whether they actually completed the backup — they're aware now.
|
||||
dismiss(true);
|
||||
}, [privateKey, dismiss]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
|
||||
>
|
||||
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">
|
||||
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
|
||||
{t('app:backup.prompt_body', {
|
||||
defaultValue:
|
||||
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDialog()}
|
||||
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||
>
|
||||
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(true)}
|
||||
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
|
||||
>
|
||||
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(false)}
|
||||
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
|
||||
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogOpen && profile?.userId && device?.id && privateKey && (
|
||||
<BackupExportDialog
|
||||
open={dialogOpen}
|
||||
userId={profile.userId}
|
||||
deviceId={device.id}
|
||||
privateKey={privateKey}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DeviceRestore } from './DeviceRestore';
|
||||
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||
// successful restore swaps the local device-identity for the one embedded
|
||||
// in the backup string — the app then hard-reloads so every hook
|
||||
// re-initialises against the restored keys (simpler than invalidating
|
||||
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||
// individually).
|
||||
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Backup wiederherstellen"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Gerät aus Backup wiederherstellen
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<p className="min-w-0 flex-1">
|
||||
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||
Device-ID wrappen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeviceRestore
|
||||
userId={userId}
|
||||
onRestored={() => {
|
||||
// Hard reload — cleanest way to reset every hook, supabase
|
||||
// realtime channel, stronghold handle, and cached state.
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export interface ParticipantTileProps {
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
@@ -102,6 +103,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
} = props;
|
||||
|
||||
@@ -120,6 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
|
||||
@@ -129,7 +132,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
}
|
||||
>
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
@@ -303,7 +306,8 @@ function VideoStub({
|
||||
videoTrack,
|
||||
me,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
fit,
|
||||
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
@@ -330,7 +334,8 @@ function VideoStub({
|
||||
playsInline
|
||||
muted
|
||||
className={
|
||||
'h-full w-full object-cover ' +
|
||||
'h-full w-full ' +
|
||||
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
|
||||
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_TILES = 7;
|
||||
|
||||
/**
|
||||
* Discord-DM-style call preview panel. Renders only while peers are in the
|
||||
* conversation's active call and the local user is NOT in it. Provides large
|
||||
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
|
||||
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
|
||||
* component never initiates — only joins.
|
||||
*/
|
||||
export function CallPreviewPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
if (iAmIn || others.length === 0) return null;
|
||||
|
||||
const visibleTiles = others.slice(0, MAX_TILES);
|
||||
const overflow = Math.max(0, others.length - MAX_TILES);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-line bg-surface-3/70">
|
||||
<div className="flex items-center gap-2 px-5 py-2 text-xs">
|
||||
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Laufender Anruf · {{count}} im Raum',
|
||||
count: others.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
|
||||
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
|
||||
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{visibleTiles.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={name}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
|
||||
>
|
||||
<div className="h-16 w-16 overflow-hidden rounded-full">
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={name}
|
||||
className="h-full w-full text-base"
|
||||
/>
|
||||
</div>
|
||||
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
|
||||
<span className="text-xl font-semibold">+{overflow}</span>
|
||||
<span className="text-xs">weitere</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJoin}
|
||||
disabled={busy}
|
||||
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
approveDevice,
|
||||
denyAllPending,
|
||||
denyDevice,
|
||||
type PendingApproval,
|
||||
subscribePendingApprovals,
|
||||
} from '../lib/deviceApproval';
|
||||
import { LockIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
// Sticky bottom-right banner stack (Discord-style). One tile per pending
|
||||
// device-approval request. Visual language deliberately mirrors
|
||||
// `BackupPromptBanner` — fixed positioning, rounded panel, subtle border
|
||||
// accent — but uses the brand/accent palette to distinguish "security
|
||||
// decision" from the amber "you should make a backup" nudge.
|
||||
//
|
||||
// Data flow:
|
||||
// 1. `startDeviceApprovalListener` (mounted from AppShell) seeds the
|
||||
// pending list on connect + on realtime INSERTs.
|
||||
// 2. This component subscribes to that module and re-renders.
|
||||
// 3. On Genehmigen: calls `approveDevice` which re-uses the
|
||||
// `wrapForOneDevice` helper from conversationKeySync to write conv-key
|
||||
// bundles for the new device across every shared conversation.
|
||||
// 4. On Ablehnen: persists the deviceId in localStorage so it doesn't
|
||||
// re-surface on app reload.
|
||||
export function DeviceApprovalBanner() {
|
||||
const { t, i18n } = useTranslation(['app']);
|
||||
const [pending, setPending] = useState<PendingApproval[]>([]);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [errorId, setErrorId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => subscribePendingApprovals(setPending), []);
|
||||
|
||||
const onApprove = useCallback(async (req: PendingApproval) => {
|
||||
setErrorId(null);
|
||||
setBusyId(req.deviceId);
|
||||
try {
|
||||
await approveDevice(req);
|
||||
} catch (err) {
|
||||
console.warn('deviceApproval: approve failed', err);
|
||||
setErrorId(req.deviceId);
|
||||
} finally {
|
||||
setBusyId((curr) => (curr === req.deviceId ? null : curr));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDeny = useCallback((deviceId: string) => {
|
||||
denyDevice(deviceId);
|
||||
}, []);
|
||||
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex w-[min(92vw,420px)] flex-col gap-3">
|
||||
{pending.length > 1 && (
|
||||
<div className="pointer-events-auto flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-3/80 px-4 py-2 text-xs text-fg-muted backdrop-blur-md">
|
||||
<span>
|
||||
{t('app:device_approval.bulk_count', {
|
||||
count: pending.length,
|
||||
defaultValue: '{{count}} Geräte warten auf Bestätigung',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => denyAllPending()}
|
||||
disabled={busyId !== null}
|
||||
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{t('app:device_approval.deny_all', { defaultValue: 'Alle ablehnen' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{pending.map((req) => {
|
||||
const busy = busyId === req.deviceId;
|
||||
const errored = errorId === req.deviceId;
|
||||
return (
|
||||
<div
|
||||
key={req.deviceId}
|
||||
role="alertdialog"
|
||||
aria-labelledby={`device-approval-${req.deviceId}-title`}
|
||||
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-line bg-surface-3 p-4 text-sm text-fg shadow-xl backdrop-blur-md"
|
||||
>
|
||||
<LockIcon className="mt-0.5 h-5 w-5 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
id={`device-approval-${req.deviceId}-title`}
|
||||
className="font-semibold"
|
||||
>
|
||||
{t('app:device_approval.title', {
|
||||
defaultValue: 'Neues Gerät registriert',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{formatDeviceLabel(req)} ·{' '}
|
||||
{formatRelativeTime(req.createdAt, i18n.language)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:device_approval.question', {
|
||||
defaultValue: 'War das du?',
|
||||
})}
|
||||
</p>
|
||||
{errored && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
{t('app:device_approval.error', {
|
||||
defaultValue:
|
||||
'Genehmigung fehlgeschlagen. Versuch es nochmal.',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void onApprove(req)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t('app:device_approval.approve', {
|
||||
defaultValue: 'Genehmigen',
|
||||
})}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDeny(req.deviceId)}
|
||||
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{t('app:device_approval.deny', {
|
||||
defaultValue: 'Ablehnen',
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDeny(req.deviceId)}
|
||||
aria-label={t('app:device_approval.dismiss', {
|
||||
defaultValue: 'Schließen',
|
||||
})}
|
||||
className="cursor-pointer text-fg-muted transition hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDeviceLabel(req: PendingApproval): string {
|
||||
const platform = humanPlatform(req.platform);
|
||||
const name = (req.name ?? '').trim();
|
||||
if (name && platform) return `${platform} · ${name}`;
|
||||
if (name) return name;
|
||||
if (platform) return platform;
|
||||
return 'Unbekanntes Gerät';
|
||||
}
|
||||
|
||||
function humanPlatform(p: string): string {
|
||||
switch (p) {
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case 'ios':
|
||||
return 'iOS';
|
||||
case 'android':
|
||||
return 'Android';
|
||||
default:
|
||||
return p.length > 0 ? p.charAt(0).toUpperCase() + p.slice(1) : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort relative-time formatter using Intl.RelativeTimeFormat.
|
||||
// Falls back to absolute timestamp if anything goes sideways.
|
||||
function formatRelativeTime(iso: string, locale: string): string {
|
||||
try {
|
||||
const ts = Date.parse(iso);
|
||||
if (Number.isNaN(ts)) return iso;
|
||||
const diffSec = Math.round((ts - Date.now()) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
const rtf = new Intl.RelativeTimeFormat(locale || 'de', { numeric: 'auto' });
|
||||
if (abs < 60) return rtf.format(diffSec, 'second');
|
||||
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
|
||||
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
|
||||
return rtf.format(Math.round(diffSec / 86400), 'day');
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { DeviceRecord } from '@chat-app/shared/auth';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
|
||||
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
defaultName: string;
|
||||
onRegistered: (device: DeviceRecord) => void;
|
||||
}
|
||||
|
||||
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
|
||||
const { t } = useTranslation(['auth', 'errors']);
|
||||
const nameId = useId();
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const device = await registerCurrentDevice({ userId, name: trimmed });
|
||||
onRegistered(device);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
if (code) {
|
||||
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(t('errors:generic'));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[name, userId, onRegistered, t],
|
||||
);
|
||||
|
||||
const platform = detectDesktopPlatform();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
|
||||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
|
||||
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-1.5">
|
||||
<label
|
||||
htmlFor={nameId}
|
||||
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
|
||||
>
|
||||
{t('auth:device.name_label')}
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('auth:device.name_placeholder')}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || name.trim().length === 0}
|
||||
aria-busy={busy}
|
||||
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>{t('auth:device.cta_loading')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('auth:device.cta')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-xs text-neutral-500">
|
||||
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
restoreDeviceFromServerRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
decodePrivateKeyFromBackup,
|
||||
importDeviceBackup,
|
||||
normalizeRecoveryCode,
|
||||
} from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { writeLocalDeviceId } from '../lib/device';
|
||||
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
onRestored: (device: DeviceRecord) => void;
|
||||
}
|
||||
|
||||
// Restores a device from a user-provided backup string. The backup embeds
|
||||
// userId + deviceId + X25519 private key; we verify userId matches the current
|
||||
// session, confirm the device row still exists server-side, and then re-seed
|
||||
// the local vault + cached deviceId so the app treats this install as the
|
||||
// original device (conv-key bundles stay valid, no "awaiting" state).
|
||||
export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const [backup, setBackup] = useState('');
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!backup.trim() || passphrase.length < 1 || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let privateKey: Uint8Array | null = null;
|
||||
try {
|
||||
const secret =
|
||||
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||
privateKey = decodePrivateKeyFromBackup(payload);
|
||||
|
||||
const device = await restoreDeviceFromServerRecord({
|
||||
client: supabase,
|
||||
secretStore: devLocalSecretStore,
|
||||
userId: payload.userId,
|
||||
deviceId: payload.deviceId,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
// Cache deviceId locally so findExistingDevice picks it up on next load.
|
||||
writeLocalDeviceId(userId, device.id);
|
||||
|
||||
onRestored(device);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[backup, passphrase, mode, userId, onRestored, busy],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
|
||||
<ShieldIcon className="h-5 w-5 text-emerald-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-white">
|
||||
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{t('app:backup.restore_subtitle', {
|
||||
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
value={backup}
|
||||
onChange={(e) => setBackup(e.target.value)}
|
||||
placeholder="chatapp-backup-v1…"
|
||||
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||
setPassphrase('');
|
||||
setError(null);
|
||||
}}
|
||||
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'passphrase'
|
||||
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||
: 'Stattdessen Passphrase eingeben'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||
required
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||
spellCheck={false}
|
||||
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||
className={
|
||||
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
|
||||
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||
}
|
||||
/>
|
||||
{mode === 'recovery' && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{t('app:backup.restore_hint', {
|
||||
defaultValue:
|
||||
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
|
||||
aria-busy={busy}
|
||||
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
@@ -15,8 +14,9 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
// preview hints at the dropped attachment.
|
||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -84,13 +84,13 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!session?.user.id || !device?.id || !message) return;
|
||||
if (!session?.user.id || !message) return;
|
||||
if (selected.size === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) throw new Error('user key not unlocked');
|
||||
|
||||
const hasAttachments = sourceAttachments.length > 0;
|
||||
const text = preview || (hasAttachments ? '' : '');
|
||||
@@ -138,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-label={t('app:group.info_title')}
|
||||
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
|
||||
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||
import { RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||
}, [soundboardCount]);
|
||||
|
||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
||||
// rather than "exactly one speaker" — matches Discord more closely and
|
||||
// handles the case where two people talk briefly without the focus
|
||||
// collapsing to nobody.
|
||||
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
||||
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
for (const id of activeSpeakers) {
|
||||
if (!prevActiveSpeakersRef.current.has(id)) {
|
||||
setLastStartedSpeakerId(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
||||
}, [activeSpeakers]);
|
||||
|
||||
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||
@@ -310,12 +294,35 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
// Discord-style precedence:
|
||||
// 1. focusedId set → 'focus', that tile is the stage.
|
||||
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||
// 4. no shares, no pin → 'equal-grid'.
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
|
||||
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||
const stageLayout: StageLayout = (() => {
|
||||
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||
return { kind: 'focus', bigTileId: focusedId };
|
||||
}
|
||||
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||
if (shareIds.length === 1 && shareIds[0]) {
|
||||
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||
}
|
||||
return { kind: 'equal-grid' };
|
||||
})();
|
||||
|
||||
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||
// stays consistent with the layout decision.
|
||||
const bigTile =
|
||||
stageLayout.kind === 'focus'
|
||||
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||
: undefined;
|
||||
|
||||
const controls = (
|
||||
<CallControls
|
||||
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
}));
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||
// the person who most recently started speaking (tracked in
|
||||
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
||||
// trigger; they stay as equal-size grid tiles until the user clicks
|
||||
// one. "Most recent speaker" beats "exactly one currently speaking"
|
||||
// because two people briefly overlapping shouldn't kick us out of
|
||||
// auto-focus.
|
||||
const autoSpeaker =
|
||||
focusedId === null && lastStartedSpeakerId !== null
|
||||
? tiles.find(
|
||||
(t) =>
|
||||
t.kind === 'user' &&
|
||||
!t.self &&
|
||||
t.userId === lastStartedSpeakerId,
|
||||
)
|
||||
: undefined;
|
||||
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||
return (
|
||||
<>
|
||||
{micError && (
|
||||
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
)}
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={effectiveSpeaker}
|
||||
speaker={bigTile}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
|
||||
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
|
||||
const sectionClass =
|
||||
callMode === 'focus'
|
||||
stageLayout.kind === 'focus'
|
||||
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
|
||||
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
|
||||
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
|
||||
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
mode={callMode}
|
||||
speaker={bigTile}
|
||||
stageLayout={stageLayout}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile
|
||||
// collapses back to grid; clicking another tile swaps focus;
|
||||
// clicking any tile in grid mode focuses it.
|
||||
if (callMode === 'focus' && focusedId === id) {
|
||||
setFocusedId(null);
|
||||
setCallMode('grid');
|
||||
return;
|
||||
}
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
onTileContextMenu={openTileContextMenu}
|
||||
compact
|
||||
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
const isPinned = focusedId === volumeMenu.tileId;
|
||||
setFocusedId(isPinned ? null : volumeMenu.tileId);
|
||||
if (!isPinned && callMode === 'grid') setCallMode('focus');
|
||||
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
|
||||
}}
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
@@ -869,9 +847,6 @@ function ModeToggles({
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={mode === 'fullscreen'}
|
||||
onClick={() => onChange('fullscreen')}
|
||||
@@ -916,7 +891,12 @@ function ModeButton({
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
mode: CallMode;
|
||||
/** Discriminated layout decision driven by InCallPanel's StageLayout
|
||||
* selector. Drives the bento-vs-grid-vs-focus render branch. */
|
||||
stageLayout:
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
@@ -941,7 +921,9 @@ function TileRender({
|
||||
conversationMembers,
|
||||
size,
|
||||
focused,
|
||||
cinema,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
@@ -951,7 +933,12 @@ function TileRender({
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
/** True when rendered inside FullscreenCall's big-tile slot. Drives
|
||||
* chrome-suppression on the inner ScreenShareViewer so its toggle
|
||||
* doesn't visually collide with the cinema-mode strip-hidden button. */
|
||||
cinema?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
@@ -964,6 +951,7 @@ function TileRender({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||
>
|
||||
@@ -971,6 +959,7 @@ function TileRender({
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||
hideFullscreenToggle={cinema === true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -994,6 +983,7 @@ function TileRender({
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
@@ -1002,7 +992,7 @@ function TileRender({
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
mode,
|
||||
stageLayout,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
@@ -1011,7 +1001,7 @@ function CallStage({
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
if (stageLayout.kind === 'focus' && speaker) {
|
||||
const others = tiles.filter((p) => p.id !== speaker.id);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
@@ -1022,6 +1012,7 @@ function CallStage({
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
@@ -1032,7 +1023,11 @@ function CallStage({
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
||||
// Docked strip: thumbs grow to share the row width evenly
|
||||
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
|
||||
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
|
||||
// with the share above; min-w keeps them readable when many.
|
||||
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -1054,13 +1049,91 @@ function CallStage({
|
||||
);
|
||||
}
|
||||
|
||||
// Grid
|
||||
if (stageLayout.kind === 'bento') {
|
||||
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
|
||||
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
|
||||
const bentoCols = gridColsFor(shares.length);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
|
||||
}
|
||||
>
|
||||
{shares.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{webcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||
{webcams.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
// Same docked-strip sizing as the focus branch above.
|
||||
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid (equal-grid fallthrough)
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
// aspect-video on every cell pushed the row past the section height on
|
||||
// wide chat panels — a single cell at full width forced height = width
|
||||
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
|
||||
// below. Let cells fill grid tracks normally for n>=2, and only enforce
|
||||
// a 16:9 silhouette (capped width, centred) for the solo-user case.
|
||||
const isSolo = tiles.length === 1;
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||
gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||
<div
|
||||
key={p.id}
|
||||
className={
|
||||
isSolo
|
||||
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||
}
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -1086,6 +1159,7 @@ function FocusedTile({
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
@@ -1093,6 +1167,7 @@ function FocusedTile({
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
@@ -1104,6 +1179,7 @@ function FocusedTile({
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1112,17 +1188,16 @@ function FocusedTile({
|
||||
const GRID_PAGE_SIZE = 12;
|
||||
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
// content, and a video element's intrinsic size blows the tile past the
|
||||
// container bounds (overlapping the toolbar below).
|
||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||
return 'grid-cols-4 grid-rows-3';
|
||||
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||
// height follows from their width, and the container centers them
|
||||
// vertically when the row stack is shorter than the available area.
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2';
|
||||
if (n <= 6) return 'grid-cols-3';
|
||||
if (n <= 9) return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
}
|
||||
|
||||
// Promote self + active speakers to the front of the tile list. Stable
|
||||
@@ -1216,12 +1291,25 @@ function FullscreenCall({
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
|
||||
const fsShareIds = tiles
|
||||
.filter((t) => t.kind === 'screen')
|
||||
.map((t) => t.id);
|
||||
const bentoMode = !hasFocus && fsShareIds.length >= 2;
|
||||
const bentoShares = bentoMode
|
||||
? tiles.filter((t) => fsShareIds.includes(t.id))
|
||||
: [];
|
||||
const bentoWebcams = bentoMode
|
||||
? tiles.filter((t) => !fsShareIds.includes(t.id))
|
||||
: [];
|
||||
|
||||
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||
// exist, slice them into pages. Reset to page 0 if the page count drops
|
||||
// below the current page (someone left).
|
||||
// exist, slice them into pages and prioritize active speakers onto page 1.
|
||||
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
|
||||
// whenever someone speaks.
|
||||
const needsPagination = tiles.length > GRID_PAGE_SIZE;
|
||||
const sortedGridTiles = useMemo(
|
||||
() => prioritizeTiles(tiles, activeSpeakers),
|
||||
[tiles, activeSpeakers],
|
||||
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
|
||||
[tiles, activeSpeakers, needsPagination],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
@@ -1235,14 +1323,18 @@ function FullscreenCall({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||
{/* Content area. pb-24 reserves ~96px space at the bottom for the
|
||||
floating controls bar so tiles never sit behind it. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
|
||||
{/* Content area. pb-28 reserves ~112px space at the bottom for the
|
||||
floating controls bar plus extra clearance so the tiles' bottom
|
||||
name-chip (positioned `bottom-2` inside each tile) doesn't sit
|
||||
directly underneath the controls — pb-24 was tight enough that
|
||||
on wide screens with audio-only avatars the chip got eclipsed. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
|
||||
{hasFocus ? (
|
||||
<>
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onClick={() => onFocusTile(speaker!.id)}
|
||||
onDoubleClick={() => onFocusTile(speaker!.id)}
|
||||
title="Zurück zur Übersicht"
|
||||
>
|
||||
<TileRender
|
||||
@@ -1252,6 +1344,7 @@ function FullscreenCall({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
cinema
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||
: {})}
|
||||
@@ -1262,7 +1355,7 @@ function FullscreenCall({
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -1281,24 +1374,86 @@ function FullscreenCall({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : bentoMode ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
|
||||
{bentoShares.map((s) => (
|
||||
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{bentoWebcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2 overflow-x-auto">
|
||||
{bentoWebcams.map((w) => (
|
||||
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{visibleTiles.map((p) => (
|
||||
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||
gridClass
|
||||
}
|
||||
>
|
||||
{visibleTiles.map((p) => {
|
||||
const isSolo = visibleTiles.length === 1;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
// Same fix as docked equal-grid: aspect-video w-full on
|
||||
// wide screens pushed cell height to ~ width × 9/16,
|
||||
// which dragged the tile's bottom name-chip down behind
|
||||
// the floating controls bar. For n>=2 fill the grid
|
||||
// tracks normally; for solo, cap width + centre.
|
||||
className={
|
||||
isSolo
|
||||
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||
}
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
|
||||
// inside AttachmentImage.tsx as a file-private component; extracted here
|
||||
// so other surfaces (settings avatar preview, future profile popover,
|
||||
// etc.) can reuse the exact same dialog without duplicating the chrome.
|
||||
//
|
||||
// The image itself stops click propagation so a click on the picture
|
||||
// keeps the lightbox open — only the backdrop or the explicit close
|
||||
// button dismisses.
|
||||
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bildansicht"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
|
||||
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type DecryptedMessage,
|
||||
editEncryptedMessage,
|
||||
@@ -11,8 +10,9 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { summarizePollVotes } from '../lib/conversationFeatures';
|
||||
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||
@@ -98,7 +98,7 @@ export function MessageBubble({
|
||||
highlighted = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
|
||||
const parsed = parseMessagePayload(message.plaintext);
|
||||
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
||||
@@ -191,7 +191,7 @@ export function MessageBubble({
|
||||
}, [pickerOpen]);
|
||||
|
||||
const handleEditSave = useCallback(async () => {
|
||||
if (!session || !device) return;
|
||||
if (!session) return;
|
||||
const trimmed = editText.trim();
|
||||
if (!trimmed || trimmed === bodyText) {
|
||||
setEditing(false);
|
||||
@@ -201,15 +201,15 @@ export function MessageBubble({
|
||||
setBusy(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) throw new Error('user key not unlocked');
|
||||
await editEncryptedMessage({
|
||||
client: supabase,
|
||||
messageId: message.id,
|
||||
conversationId,
|
||||
newPlaintext: trimmed,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
setEditing(false);
|
||||
@@ -225,7 +225,7 @@ export function MessageBubble({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
|
||||
}, [editText, message.id, message.plaintext, conversationId, session, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -294,7 +294,14 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
if (parsed.kind === 'call_event') {
|
||||
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
|
||||
return (
|
||||
<CallEventRow
|
||||
parsed={parsed}
|
||||
mine={mine}
|
||||
time={time}
|
||||
senderDisplayName={senderDisplayName ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -409,7 +416,11 @@ export function MessageBubble({
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
<span className="italic text-fg-muted opacity-60">
|
||||
{t('app:chats.unreadable', {
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
@@ -866,10 +877,15 @@ function CallEventRow({
|
||||
parsed,
|
||||
mine,
|
||||
time,
|
||||
senderDisplayName,
|
||||
}: {
|
||||
parsed: { status: string; mediaKind: string; durationSec: number };
|
||||
mine: boolean;
|
||||
time: string;
|
||||
/** Discord-parity: in group chats the system pill should say WHO
|
||||
* started/missed the call. Null means we don't know (fall back to the
|
||||
* legacy generic labels). */
|
||||
senderDisplayName: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const status = parsed.status;
|
||||
@@ -880,16 +896,36 @@ function CallEventRow({
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||
|
||||
// For non-own events we prefer the name-aware label so group chats
|
||||
// make it clear who triggered the call event. Own events stay generic
|
||||
// ("Outgoing call" / "No answer") since the user already knows they
|
||||
// were the initiator.
|
||||
const hasName = !mine && !!senderDisplayName;
|
||||
const label =
|
||||
status === 'ended'
|
||||
? mine
|
||||
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
||||
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
||||
: hasName
|
||||
? t('app:chats.call_started_by', {
|
||||
name: senderDisplayName,
|
||||
defaultValue: '{{name}} hat einen Anruf gestartet',
|
||||
})
|
||||
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
||||
: status === 'missed'
|
||||
? mine
|
||||
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
||||
: hasName
|
||||
? t('app:chats.call_missed_by', {
|
||||
name: senderDisplayName,
|
||||
defaultValue: 'Verpasster Anruf von {{name}}',
|
||||
})
|
||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
||||
: hasName
|
||||
? t('app:chats.call_declined_by', {
|
||||
name: senderDisplayName,
|
||||
defaultValue: 'Anruf von {{name}} abgelehnt',
|
||||
})
|
||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
||||
|
||||
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
length?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
|
||||
// Index of the next empty slot the next keystroke will fill. When the user
|
||||
// has typed all `length` digits, no slot is "active" — the form should
|
||||
// submit instead of pretending one is still focused.
|
||||
const activeIndex = value.length < length ? value.length : -1;
|
||||
return (
|
||||
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="\d*"
|
||||
maxLength={length}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length }).map((_, i) => {
|
||||
const filled = i < value.length;
|
||||
const active = focused && i === activeIndex;
|
||||
let classes =
|
||||
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
|
||||
if (filled) {
|
||||
classes += 'border-brand-400 bg-brand-500/10 text-white';
|
||||
} else if (active) {
|
||||
// Brand-coloured ring + glow so the user immediately sees where
|
||||
// the next keystroke lands. The animated cursor inside reinforces
|
||||
// the "input is alive" feeling.
|
||||
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
|
||||
} else {
|
||||
classes += 'border-white/10 bg-ink-800 text-neutral-500';
|
||||
}
|
||||
return (
|
||||
<span key={i} className={classes}>
|
||||
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,13 +26,65 @@ const TABS = [
|
||||
];
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
||||
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
|
||||
// Resolution and framerate are picked independently. The preset table
|
||||
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
|
||||
// caps, so we map (res, fps) → existing preset and rely on
|
||||
// `framerateOverride` for the non-default framerate combinations
|
||||
// (e.g. 1440p · 30, 4K · 30).
|
||||
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
|
||||
type FpsChoice = 30 | 60;
|
||||
|
||||
const RES_PILLS: { id: ResChoice; label: string }[] = [
|
||||
{ id: 'auto', label: 'Auto' },
|
||||
{ id: '720p60', label: '720p · 60' },
|
||||
{ id: '1080p60', label: '1080p · 60' },
|
||||
{ id: '1440p60', label: '1440p · 60' },
|
||||
{ id: '720p', label: '720p' },
|
||||
{ id: '1080p', label: '1080p' },
|
||||
{ id: '1440p', label: '1440p' },
|
||||
{ id: '4k', label: '4K' },
|
||||
];
|
||||
|
||||
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
|
||||
{ id: 30, label: '30 fps' },
|
||||
{ id: 60, label: '60 fps' },
|
||||
];
|
||||
|
||||
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
|
||||
switch (res) {
|
||||
case 'auto':
|
||||
return 'auto';
|
||||
case '720p':
|
||||
return fps === 60 ? '720p60' : '720p30';
|
||||
case '1080p':
|
||||
return fps === 60 ? '1080p60' : '1080p30';
|
||||
case '1440p':
|
||||
return '1440p60';
|
||||
case '4k':
|
||||
return '4k60';
|
||||
}
|
||||
}
|
||||
|
||||
function decomposePreset(
|
||||
p: ScreenSharePreset,
|
||||
framerateOverride: number | null,
|
||||
): { res: ResChoice; fps: FpsChoice } {
|
||||
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
|
||||
switch (p) {
|
||||
case 'auto':
|
||||
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
|
||||
case '720p30':
|
||||
return { res: '720p', fps: 30 };
|
||||
case '720p60':
|
||||
return { res: '720p', fps: 60 };
|
||||
case '1080p30':
|
||||
return { res: '1080p', fps: 30 };
|
||||
case '1080p60':
|
||||
return { res: '1080p', fps: 60 };
|
||||
case '1440p60':
|
||||
return { res: '1440p', fps: fallback };
|
||||
case '4k60':
|
||||
return { res: '4k', fps: fallback };
|
||||
}
|
||||
}
|
||||
|
||||
const THUMBNAIL_REFRESH_MS = 3500;
|
||||
|
||||
export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(
|
||||
() => getScreenShareSettings().preset,
|
||||
);
|
||||
const [res, setRes] = useState<ResChoice>(() => {
|
||||
const s = getScreenShareSettings();
|
||||
return decomposePreset(s.preset, s.framerateOverride).res;
|
||||
});
|
||||
const [fps, setFps] = useState<FpsChoice>(() => {
|
||||
const s = getScreenShareSettings();
|
||||
return decomposePreset(s.preset, s.framerateOverride).fps;
|
||||
});
|
||||
const [audio, setAudio] = useState<boolean>(
|
||||
() => getScreenShareSettings().includeSystemAudio,
|
||||
);
|
||||
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// Persist quality + audio toggle. Also force-clear any stale duck
|
||||
// setting users may have inherited from earlier builds — the
|
||||
// native loopback addon excludes the app's own audio at OS level
|
||||
// now, so JS-side ducking (which muted the user's incoming peer
|
||||
// audio) is no longer needed and was causing "I can't hear anyone".
|
||||
const preset = presetForResFps(res, fps);
|
||||
// Persist resolution + fps + audio. The duck flag is force-cleared
|
||||
// because the native loopback addon now excludes the app's own audio
|
||||
// at OS level; JS-side ducking (which also muted incoming peer audio)
|
||||
// was causing "I can't hear anyone" on earlier builds.
|
||||
updateScreenShareSettings({
|
||||
preset,
|
||||
framerateOverride: fps,
|
||||
includeSystemAudio: audio,
|
||||
duckRemoteAudioWhileSharing: false,
|
||||
});
|
||||
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
await startScreenShare({
|
||||
preset,
|
||||
displaySurface: tab === 'screen' ? 'monitor' : 'window',
|
||||
framerate: null,
|
||||
framerate: fps,
|
||||
// Forward the picked source id so the native loopback path can
|
||||
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
|
||||
// (parses HWND from `window:<HWND>:0`). For screen-shares this
|
||||
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
Qualität
|
||||
Auflösung
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{QUALITY_PILLS.map((q) => {
|
||||
const active = preset === q.id;
|
||||
{RES_PILLS.map((q) => {
|
||||
const active = res === q.id;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
onClick={() => setPreset(q.id)}
|
||||
onClick={() => setRes(q.id)}
|
||||
className={
|
||||
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
||||
(active
|
||||
? 'border-accent bg-accent/10 text-fg'
|
||||
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
FPS
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{FPS_PILLS.map((q) => {
|
||||
const active = fps === q.id;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
onClick={() => setFps(q.id)}
|
||||
className={
|
||||
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
||||
(active
|
||||
|
||||
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
/** Suppress the in-share fullscreen toggle button. Used inside cinema
|
||||
* mode where (a) the window is already OS-fullscreen and (b) the
|
||||
* toggle collides visually with FullscreenCall's strip-hidden button
|
||||
* at the same top-right corner. */
|
||||
hideFullscreenToggle?: boolean;
|
||||
}
|
||||
|
||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
|
||||
share,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
hideFullscreenToggle = false,
|
||||
}: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
{watching && !hideFullscreenToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
|
||||
type="button"
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
changePin,
|
||||
type LegacyMigrationReport,
|
||||
regenerateRecoveryCode,
|
||||
resetIdentity,
|
||||
retryLegacyMigration,
|
||||
} from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props { userId: string }
|
||||
|
||||
export function SecurityCenter({ userId }: Props) {
|
||||
const [pinOld, setPinOld] = useState('');
|
||||
const [pinNew, setPinNew] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<string | null>(null);
|
||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
try {
|
||||
const report = await retryLegacyMigration(userId);
|
||||
setMigration(report);
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleChangePin() {
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
|
||||
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleRegenerateRecovery() {
|
||||
setBusy(true); setMsg(null);
|
||||
try { setRecovery(await regenerateRecoveryCode({ userId })); }
|
||||
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
|
||||
setRecovery(code);
|
||||
setMsg('Identität zurückgesetzt.');
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-sm font-semibold">Sicherheit</h2>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
|
||||
<div className="space-y-2">
|
||||
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
|
||||
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
|
||||
<button type="button"
|
||||
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
|
||||
onClick={() => void handleChangePin()}
|
||||
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>Neuen Recovery-Code erzeugen</button>
|
||||
{recovery && (
|
||||
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
|
||||
{recovery}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
|
||||
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
|
||||
</p>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
|
||||
</button>
|
||||
{migration && (
|
||||
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
|
||||
<div>Geräte (Server): {migration.serverDevices}</div>
|
||||
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
|
||||
{migration.strongholdKeysFromBundleScan > 0 && (
|
||||
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
|
||||
)}
|
||||
</div>
|
||||
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
|
||||
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
|
||||
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
|
||||
<div>Server-Fehler: {migration.rpcFailed}</div>
|
||||
{migration.attempted > 0 && migration.migrated === 0 && (
|
||||
<p className="mt-2 text-rose-300">
|
||||
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
|
||||
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<button type="button" disabled={busy} onClick={() => void handleReset()}
|
||||
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
|
||||
>Identität zurücksetzen</button>
|
||||
</section>
|
||||
|
||||
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { setupNewUserIdentity } from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props { userId: string; onComplete: () => void }
|
||||
type Step = 'enter' | 'recovery';
|
||||
|
||||
export function UserKeySetup({ userId, onComplete }: Props) {
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [withRecovery, setWithRecovery] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<Step>('enter');
|
||||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (pin.length !== 6 || confirm.length !== 6) {
|
||||
setError('PIN muss 6 Ziffern haben.'); return;
|
||||
}
|
||||
if (pin !== confirm) {
|
||||
setError('PINs stimmen nicht überein.'); return;
|
||||
}
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const r = await setupNewUserIdentity({ userId, pin, withRecovery });
|
||||
setRecoveryCode(r.recoveryCode);
|
||||
if (withRecovery) setStep('recovery');
|
||||
else onComplete();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}, [pin, confirm, withRecovery, userId, onComplete]);
|
||||
|
||||
if (step === 'recovery') {
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
|
||||
<div className="flex items-center gap-2 text-amber-300">
|
||||
<ShieldIcon className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Recovery-Code</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-300">
|
||||
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
|
||||
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
|
||||
</p>
|
||||
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
|
||||
{recoveryCode}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
|
||||
onClick={onComplete}
|
||||
>Überspringen</button>
|
||||
<button type="button"
|
||||
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
|
||||
onClick={onComplete}
|
||||
>Habe ich gespeichert</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
|
||||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-neutral-300">
|
||||
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
|
||||
Recovery-Code erstellen (empfohlen)
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
Weiter
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { loadOrUnlockUserKey } from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { LockIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
hasRecovery: boolean;
|
||||
lockedUntil?: string | null;
|
||||
onUnlocked: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'pin' | 'recovery';
|
||||
|
||||
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
|
||||
const [mode, setMode] = useState<Mode>('pin');
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await loadOrUnlockUserKey({
|
||||
userId, pin: value, isRecoveryCode: mode === 'recovery',
|
||||
});
|
||||
if (res.kind === 'unlocked') onUnlocked();
|
||||
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
|
||||
else setError('Schlüssel auf dem Server nicht gefunden.');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}, [userId, value, mode, onUnlocked]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||
<h2 className="text-lg font-semibold">Account entsperren</h2>
|
||||
</div>
|
||||
{lockedUntil && (
|
||||
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
|
||||
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
|
||||
</p>
|
||||
)}
|
||||
{mode === 'pin' ? (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
|
||||
<input
|
||||
id="recovery-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasRecovery && (
|
||||
<button type="button"
|
||||
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
|
||||
className="text-xs text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
Entsperren
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
/**
|
||||
* Discord-style persistent voice-channel band rendered at the top of the
|
||||
* message surface. Always visible for groups so any member can pop in
|
||||
* without an invite-ring; for 1:1 conversations only when someone's already
|
||||
* in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall
|
||||
* path which sends no invite signals.
|
||||
*/
|
||||
export function VoiceChannelRail({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
const isGroup = conversation.type === 'group';
|
||||
|
||||
// For groups: persistent channel feel — show even when empty.
|
||||
// For DMs: only when somebody is already in (matches Discord-DM behaviour).
|
||||
const visible = !iAmIn && (isGroup || others.length > 0);
|
||||
if (!visible) return null;
|
||||
|
||||
const showAvatars = others.slice(0, MAX_AVATARS);
|
||||
const overflow = Math.max(0, others.length - MAX_AVATARS);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleJoin}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleJoin();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(others.length > 0
|
||||
? 'bg-emerald-500/10 hover:bg-emerald-500/15'
|
||||
: 'bg-surface-3/70 hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
|
||||
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
|
||||
}
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg">
|
||||
{others.length === 0
|
||||
? t('app:call.voice_empty', {
|
||||
defaultValue: 'Niemand drin — sei der Erste.',
|
||||
})
|
||||
: t('app:call.voice_count', {
|
||||
defaultValue: '{{count}} im Channel',
|
||||
count: others.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{showAvatars.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{showAvatars.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={member?.profile?.displayName ?? '?'}
|
||||
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
|
||||
>
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? '?'}
|
||||
className="h-full w-full text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleJoin();
|
||||
}}
|
||||
disabled={busy}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PhoneIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{others.length === 0
|
||||
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
|
||||
: t('app:call.join', { defaultValue: 'Beitreten' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,14 @@ export function RequireAuth() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Forces a registered device on this install. Sends to /device otherwise.
|
||||
// Forces a usable per-user encrypted key blob on this install. Sends to
|
||||
// /device (the setup/unlock page) when the blob is missing or locked.
|
||||
export function RequireDevice() {
|
||||
const { device, deviceLookupDone } = useAuth();
|
||||
if (!deviceLookupDone) return <FullScreenSpinner />;
|
||||
if (!device) return <Navigate to="/device" replace />;
|
||||
const { userKeyState } = useAuth();
|
||||
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
|
||||
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
|
||||
return <Navigate to="/device" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
fetchUserKeyBlob,
|
||||
getOwnProfile,
|
||||
signOut as supabaseSignOut,
|
||||
type Profile,
|
||||
touchDeviceLastSeen,
|
||||
updateOwnProfile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
@@ -20,23 +19,34 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
|
||||
import { registerWebPush } from '../lib/webPush';
|
||||
|
||||
// Discriminated union describing the per-user encrypted key blob lifecycle:
|
||||
//
|
||||
// loading — initial state, or refresh in flight
|
||||
// needs-setup — no row exists on Supabase; user must pick a PIN
|
||||
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
|
||||
// required. `lockedUntil` non-null means the server-side
|
||||
// rate limiter is currently rejecting attempts.
|
||||
// unlocked — private key is in the local secret store and ready to use
|
||||
export type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
device: DeviceRecord | null;
|
||||
userKeyState: UserKeyState;
|
||||
// null while we're still resolving the very first auth state.
|
||||
ready: boolean;
|
||||
// null until a device lookup has finished for the current session.
|
||||
deviceLookupDone: boolean;
|
||||
refreshProfile: () => Promise<void>;
|
||||
refreshDevice: () => Promise<void>;
|
||||
setDevice: (device: DeviceRecord | null) => void;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -47,8 +57,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const autoOnlineUserRef = useRef<string | null>(null);
|
||||
|
||||
// Initial session + auth subscription. We verify the cached JWT against the
|
||||
@@ -112,41 +121,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [session, i18n.resolvedLanguage]);
|
||||
|
||||
const refreshDevice = useCallback(async () => {
|
||||
// Resolves the current state of the per-user encrypted key blob:
|
||||
// 1. local cache hit → 'unlocked'
|
||||
// 2. no remote row → 'needs-setup'
|
||||
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
|
||||
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
|
||||
// recovery-code blob is present so the UI can
|
||||
// conditionally offer the recovery affordance
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
if (!session) {
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
setDeviceLookupDone(false);
|
||||
const found = await findExistingDevice(session.user.id);
|
||||
setDevice(found);
|
||||
setDeviceLookupDone(true);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
const cached = await cachedUserKey(session.user.id);
|
||||
if (cached) {
|
||||
setUserKeyState({ status: 'unlocked' });
|
||||
// Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC
|
||||
// uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0
|
||||
// where the migration query had a `.eq(null)` bug that made it a no-op.
|
||||
void ensureLegacyMigrated(session.user.id).catch((err) => {
|
||||
console.warn('legacy conv-key migration on auth-resume failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = await fetchUserKeyBlob(supabase, session.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,
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
// Re-pull profile + device whenever session flips.
|
||||
// Re-pull profile + user-key state whenever session flips.
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setProfile(null);
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
void refreshProfile().catch((err: unknown) => {
|
||||
console.error('refreshProfile failed', err);
|
||||
});
|
||||
void refreshDevice().catch((err: unknown) => {
|
||||
console.error('refreshDevice failed', err);
|
||||
setDeviceLookupDone(true);
|
||||
void refreshUserKeyState().catch((err: unknown) => {
|
||||
console.error('refreshUserKeyState failed', err);
|
||||
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
|
||||
// least drives the user toward the setup/unlock page rather than
|
||||
// hanging forever on the spinner.
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
});
|
||||
}, [session, refreshProfile, refreshDevice]);
|
||||
}, [session, refreshProfile, refreshUserKeyState]);
|
||||
|
||||
// Best-effort web-push registration once we know the device id. No-op on
|
||||
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
// Best-effort web-push registration once we have a session. Keyed by an
|
||||
// install-id (localStorage UUID) since there's no longer a per-device
|
||||
// crypto record to key by. No-op on Tauri (uses native notifications) or
|
||||
// when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
useEffect(() => {
|
||||
if (!device?.id) return;
|
||||
void registerWebPush(device.id);
|
||||
}, [device?.id]);
|
||||
if (!session) return;
|
||||
const installId = ensureInstallId();
|
||||
void registerWebPush(installId);
|
||||
}, [session]);
|
||||
|
||||
// Auto online/offline transition.
|
||||
//
|
||||
@@ -188,32 +234,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [session, profile, refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !device?.id) return;
|
||||
|
||||
const touch = () => {
|
||||
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
|
||||
console.warn('presence heartbeat failed', err);
|
||||
});
|
||||
};
|
||||
const touchWhenVisible = () => {
|
||||
if (document.visibilityState === 'visible') touch();
|
||||
};
|
||||
|
||||
touch();
|
||||
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
|
||||
window.addEventListener('focus', touch);
|
||||
window.addEventListener('online', touch);
|
||||
document.addEventListener('visibilitychange', touchWhenVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
window.removeEventListener('focus', touch);
|
||||
window.removeEventListener('online', touch);
|
||||
document.removeEventListener('visibilitychange', touchWhenVisible);
|
||||
};
|
||||
}, [session, device?.id]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||
console.warn('offline update before sign-out failed', err);
|
||||
@@ -225,15 +245,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
() => ({
|
||||
session,
|
||||
profile,
|
||||
device,
|
||||
userKeyState,
|
||||
ready,
|
||||
deviceLookupDone,
|
||||
refreshProfile,
|
||||
refreshDevice,
|
||||
setDevice,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
}),
|
||||
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
|
||||
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { sendEncryptedMessage } from '@chat-app/shared/chat';
|
||||
import {
|
||||
type CallKind,
|
||||
@@ -114,8 +113,9 @@ import {
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
||||
|
||||
export type CallState =
|
||||
@@ -325,7 +325,7 @@ function newCallId(): string {
|
||||
}
|
||||
|
||||
export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const { session, device, profile } = useAuth();
|
||||
const { session, profile } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
const myId = session?.user.id;
|
||||
|
||||
@@ -446,13 +446,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
mediaKind: CallKind,
|
||||
durationSec: number,
|
||||
) => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
if (!session?.user.id) return;
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(
|
||||
devLocalSecretStore,
|
||||
session.user.id,
|
||||
device.id,
|
||||
);
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) return;
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
@@ -466,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
conversationId,
|
||||
plaintext: payload,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('emitCallEvent failed', err);
|
||||
}
|
||||
},
|
||||
[session?.user.id, device?.id],
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const clearRingTimer = useCallback(() => {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
||||
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
|
||||
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||
// conversation we participate in for missing key bundles. Fills gaps by
|
||||
// re-wrapping our active conv-key for the missing recipient devices.
|
||||
// re-wrapping our active conv-key for the missing recipient users.
|
||||
//
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
// This fixes the "cannot decrypt" cliff for users that joined while no
|
||||
// other participant was online to share the key with them.
|
||||
|
||||
interface SyncCtx {
|
||||
export interface SyncCtx {
|
||||
myUserId: string;
|
||||
myDeviceId: string;
|
||||
priv: Uint8Array;
|
||||
@@ -32,46 +30,53 @@ export function startConversationKeySync(
|
||||
ownUserId: string,
|
||||
ownDeviceId: string,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let priv: Uint8Array | null = null;
|
||||
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
||||
|
||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||
if (cancelled) return;
|
||||
priv = pk;
|
||||
if (!priv) return;
|
||||
if (backfilledKey.has(dedupeKey)) return;
|
||||
backfilledKey.add(dedupeKey);
|
||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('device-key-sync:' + ownDeviceId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
||||
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
||||
if (cancelled) return;
|
||||
const row = payload.new;
|
||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||
if (!priv) return; // backfill on mount will catch it later
|
||||
void wrapForOneDevice(
|
||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||
row.id,
|
||||
row.user_id,
|
||||
row.public_key,
|
||||
);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
// DISABLED: auto-share of conversation keys to newly-registered devices
|
||||
// is gone. Without it, account takeover (stolen password / new device
|
||||
// registered by attacker) no longer automatically grants history access
|
||||
// — an attacker would have a working device-key but no conv-key wraps.
|
||||
//
|
||||
// History access paths still supported:
|
||||
// 1. Backup-Restore — restores the OLD device-id + privkey, so the
|
||||
// server-side wraps for that device-id are accessible as before.
|
||||
// 2. (Planned) Approval flow — existing device or conversation peer
|
||||
// explicitly approves a new device, then conv-keys are wrapped
|
||||
// for it. Until that ships, fresh-login-without-backup means old
|
||||
// conversations stay encrypted.
|
||||
//
|
||||
// For NEW conversations: the key is generated at conv-creation time
|
||||
// and includes all current devices of all members, so a freshly-logged-
|
||||
// in device CAN still participate in newly-created conversations. It
|
||||
// just can't read the back-history of conversations it wasn't a member
|
||||
// of when those messages were sealed.
|
||||
//
|
||||
// We deliberately keep the helper functions below (syncAllExistingGaps,
|
||||
// wrapForOneDevice, …) intact so the upcoming approval flow can wire
|
||||
// them to user-driven triggers without rebuilding from scratch.
|
||||
void ownUserId;
|
||||
void ownDeviceId;
|
||||
void backfilledKey;
|
||||
void supabase;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// Keep helpers alive across the auto-sync hibernation window so the
|
||||
// upcoming approval flow can re-wire them. Without this no-op reference
|
||||
// `tsc --noEmit` flags them as unused (TS6133).
|
||||
//
|
||||
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||
// the device-approval module — once the user explicitly approves a new
|
||||
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||
// that specific user.
|
||||
void (() => {
|
||||
void listMyConversationIds;
|
||||
void listConversationMembers;
|
||||
void listExistingKeyRecipients;
|
||||
void getActiveKeyVersion;
|
||||
void syncAllExistingGaps;
|
||||
void isExpectedShareFailure;
|
||||
void rawFrom;
|
||||
});
|
||||
|
||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_members')
|
||||
@@ -85,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
return (data ?? []).map((r) => r.conversation_id as string);
|
||||
}
|
||||
|
||||
async function listConversationDevices(
|
||||
async function listConversationMembers(
|
||||
conversationId: string,
|
||||
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||
): Promise<{ user_id: string; public_key: string }[]> {
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id')
|
||||
@@ -100,15 +105,14 @@ async function listConversationDevices(
|
||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
const { data: keys, error: kErr } = await rawFrom('user_keys')
|
||||
.select('user_id, public_key')
|
||||
.in('user_id', userIds);
|
||||
if (dErr) {
|
||||
console.warn('keySync: devices lookup failed', dErr);
|
||||
if (kErr) {
|
||||
console.warn('keySync: user-keys lookup failed', kErr);
|
||||
return [];
|
||||
}
|
||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||
return (keys ?? []) as { user_id: string; public_key: string }[];
|
||||
}
|
||||
|
||||
async function listExistingKeyRecipients(
|
||||
@@ -116,14 +120,14 @@ async function listExistingKeyRecipients(
|
||||
keyVersion: number,
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await rawFrom('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.select('recipient_user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', keyVersion);
|
||||
if (error) {
|
||||
console.warn('keySync: existing keys lookup failed', error);
|
||||
return new Set();
|
||||
}
|
||||
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||
return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id));
|
||||
}
|
||||
|
||||
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||
@@ -149,35 +153,34 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
const members = await listConversationMembers(convId);
|
||||
if (members.length === 0) return;
|
||||
|
||||
const recipients = await listExistingKeyRecipients(convId, version);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
|
||||
for (const dev of devices) {
|
||||
if (recipients.has(dev.id)) continue;
|
||||
// Skip our own device — we already have the bundle if we're capable of
|
||||
for (const m of members) {
|
||||
if (recipients.has(m.user_id)) continue;
|
||||
// Skip our own user — we already have the bundle if we're capable of
|
||||
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||
if (dev.id === ctx.myDeviceId) continue;
|
||||
if (m.user_id === ctx.myUserId) continue;
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||
await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Backfill is best-effort. Most common silent failures:
|
||||
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||
// - RLS rejects because the recipient's owner is a pending (not-yet
|
||||
// - RLS rejects because the recipient is a pending (not-yet
|
||||
// accepted) DM member, or was removed from the conv.
|
||||
// Both are recoverable / expected, swallow without spam.
|
||||
if (isExpectedShareFailure(err)) continue;
|
||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
|
||||
convId,
|
||||
recipient: dev.id,
|
||||
recipient: m.user_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
@@ -200,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForOneDevice(
|
||||
// Approval-flow helper. The legacy signature took (deviceId, userId,
|
||||
// devicePubHex) because conv-keys were wrapped per-device. In the per-user
|
||||
// model only the user dimension matters, so the device-id parameter is
|
||||
// ignored and the public key passed in MUST be the recipient user's
|
||||
// user_keys.public_key (callers will be updated alongside the broader
|
||||
// approval-flow rework).
|
||||
export async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubHex: string,
|
||||
): Promise<void> {
|
||||
void newDeviceId; // kept for API compat; no longer used
|
||||
|
||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||
const { data: peerMember, error: pErr } = await supabase
|
||||
.from('conversation_members')
|
||||
@@ -221,16 +232,15 @@ async function wrapForOneDevice(
|
||||
if (sharedConvs.length === 0) return;
|
||||
|
||||
const newPub = pgHexToBytes(newDevicePubHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
for (const convId of sharedConvs) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||
console.warn('keySync: shareConvKeyToUser failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
deviceIdStorageKey,
|
||||
listOwnDevices,
|
||||
loadDevicePrivateKey,
|
||||
provisionNewDevice,
|
||||
touchDeviceLastSeen,
|
||||
type DeviceRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export function detectDesktopPlatform(): DevicePlatform {
|
||||
const ua =
|
||||
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
||||
? navigator.userAgent.toLowerCase()
|
||||
: '';
|
||||
if (ua.includes('mac')) return 'macos';
|
||||
if (ua.includes('win')) return 'windows';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
export function readLocalDeviceId(userId: string): string | null {
|
||||
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
||||
}
|
||||
|
||||
export function writeLocalDeviceId(userId: string, deviceId: string): void {
|
||||
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
||||
}
|
||||
|
||||
export function clearLocalDeviceId(userId: string): void {
|
||||
window.localStorage.removeItem(deviceIdStorageKey(userId));
|
||||
}
|
||||
|
||||
// Look up the current install's device record. Returns null when either:
|
||||
// - no device id is cached locally, or
|
||||
// - the cached id was deleted server-side (e.g. wiped from Studio).
|
||||
// In both cases the UI should prompt the user to register a fresh device.
|
||||
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
|
||||
const cachedId = readLocalDeviceId(userId);
|
||||
if (!cachedId) return null;
|
||||
|
||||
const all = await listOwnDevices(supabase);
|
||||
const hit = all.find((d) => d.id === cachedId) ?? null;
|
||||
if (!hit) return null;
|
||||
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
|
||||
if (!priv) {
|
||||
// Server row exists but we lost the private key locally — treat as fresh install.
|
||||
return null;
|
||||
}
|
||||
|
||||
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
|
||||
/* non-fatal */
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
export async function registerCurrentDevice(params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}): Promise<DeviceRecord> {
|
||||
const device = await provisionNewDevice({
|
||||
client: supabase,
|
||||
secretStore: devLocalSecretStore,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
platform: detectDesktopPlatform(),
|
||||
});
|
||||
writeLocalDeviceId(params.userId, device.id);
|
||||
return device;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
|
||||
import { type SyncCtx, wrapForOneDevice } from './conversationKeySync';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Device-approval flow (Phase 1).
|
||||
// ---------------------------------------------------------------------------
|
||||
// When the user registers a brand-new device on top of an existing one, the
|
||||
// existing device must explicitly approve it before any conv-key wraps are
|
||||
// created. This module:
|
||||
//
|
||||
// 1. On startup, fetches every device row owned by the user and surfaces
|
||||
// the ones that aren't this device, aren't already approved, and aren't
|
||||
// dismissed. Covers the "I was offline when the new device registered"
|
||||
// case.
|
||||
// 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a
|
||||
// device that registers WHILE this client is online raises a banner
|
||||
// immediately.
|
||||
// 3. Persists approve/deny decisions in localStorage so a reload doesn't
|
||||
// ask again for a device the user already answered for.
|
||||
//
|
||||
// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that
|
||||
// helper already walks every shared conversation and writes the key bundle
|
||||
// for the target device.
|
||||
|
||||
export interface PendingApproval {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
platform: DevicePlatform | string;
|
||||
createdAt: string;
|
||||
publicKey: string; // pg-hex-encoded bytea
|
||||
}
|
||||
|
||||
export interface DeviceApprovalListenerCtx {
|
||||
ownUserId: string;
|
||||
ownDeviceId: string;
|
||||
// Lazy getter so we never hold the privkey in memory for longer than the
|
||||
// approve action that needs it. Returns null if the key isn't loadable
|
||||
// (e.g. fresh-restored device that hasn't unsealed yet).
|
||||
getPriv: () => Promise<Uint8Array | null>;
|
||||
}
|
||||
|
||||
const APPROVED_KEY = 'chatapp.approvedDeviceIds';
|
||||
const DISMISSED_KEY = 'chatapp.dismissedDeviceIds';
|
||||
|
||||
// In-process state. Module-level so the banner component and the listener
|
||||
// share one source of truth without prop-drilling through context.
|
||||
let pending: PendingApproval[] = [];
|
||||
const subscribers = new Set<(list: PendingApproval[]) => void>();
|
||||
let listenerCtx: DeviceApprovalListenerCtx | null = null;
|
||||
|
||||
function readIdSet(key: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return new Set();
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeIdSet(key: string, set: Set<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify([...set]));
|
||||
} catch {
|
||||
/* storage unavailable — non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function persistApproved(deviceId: string): void {
|
||||
const s = readIdSet(APPROVED_KEY);
|
||||
s.add(deviceId);
|
||||
writeIdSet(APPROVED_KEY, s);
|
||||
}
|
||||
|
||||
function persistDismissed(deviceId: string): void {
|
||||
const s = readIdSet(DISMISSED_KEY);
|
||||
s.add(deviceId);
|
||||
writeIdSet(DISMISSED_KEY, s);
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
const snapshot = [...pending];
|
||||
for (const cb of subscribers) {
|
||||
try {
|
||||
cb(snapshot);
|
||||
} catch (err) {
|
||||
console.warn('deviceApproval: subscriber threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingApprovals(): PendingApproval[] {
|
||||
return [...pending];
|
||||
}
|
||||
|
||||
export function subscribePendingApprovals(
|
||||
cb: (list: PendingApproval[]) => void,
|
||||
): () => void {
|
||||
subscribers.add(cb);
|
||||
// Fire once with current state so the consumer can initialise without
|
||||
// waiting for the next change.
|
||||
try {
|
||||
cb([...pending]);
|
||||
} catch (err) {
|
||||
console.warn('deviceApproval: initial subscriber call threw', err);
|
||||
}
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSurface(deviceId: string, ownDeviceId: string): boolean {
|
||||
if (deviceId === ownDeviceId) return false;
|
||||
const approved = readIdSet(APPROVED_KEY);
|
||||
if (approved.has(deviceId)) return false;
|
||||
const dismissed = readIdSet(DISMISSED_KEY);
|
||||
if (dismissed.has(deviceId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface DeviceRowLite {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
platform: DevicePlatform | string;
|
||||
created_at: string;
|
||||
public_key: string;
|
||||
}
|
||||
|
||||
function rowToPending(row: DeviceRowLite): PendingApproval {
|
||||
return {
|
||||
deviceId: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
platform: row.platform,
|
||||
createdAt: row.created_at,
|
||||
publicKey: row.public_key,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertPending(req: PendingApproval): void {
|
||||
if (pending.some((p) => p.deviceId === req.deviceId)) return;
|
||||
pending = [...pending, req];
|
||||
notify();
|
||||
}
|
||||
|
||||
function removePending(deviceId: string): void {
|
||||
const next = pending.filter((p) => p.deviceId !== deviceId);
|
||||
if (next.length === pending.length) return;
|
||||
pending = next;
|
||||
notify();
|
||||
}
|
||||
|
||||
async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void> {
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, name, platform, created_at, public_key')
|
||||
.eq('user_id', ctx.ownUserId);
|
||||
if (error) {
|
||||
console.warn('deviceApproval: initial devices lookup failed', error);
|
||||
return;
|
||||
}
|
||||
const rows = (data ?? []) as DeviceRowLite[];
|
||||
|
||||
// Pre-filter: anything already in approved/dismissed sets, plus own
|
||||
// device, never surfaces.
|
||||
const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
|
||||
if (candidates.length === 0) return;
|
||||
|
||||
// Semantic skip: any candidate that already has a `conversation_keys`
|
||||
// wrap somewhere is by definition legit — it was either auto-shared
|
||||
// back when that path was enabled (pre-0.16.2) or explicitly approved
|
||||
// earlier. Surfacing it now would just nag the user about something
|
||||
// already taken care of. Bulk query against `conversation_keys` for
|
||||
// all candidate device IDs at once; cheap, avoids N+1.
|
||||
const candidateIds = candidates.map((c) => c.id);
|
||||
const wrappedIds = new Set<string>();
|
||||
try {
|
||||
const { data: keyRows, error: kErr } = await (
|
||||
supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (cols: string) => {
|
||||
in: (
|
||||
col: string,
|
||||
vals: string[],
|
||||
) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.in('recipient_device_id', candidateIds);
|
||||
if (!kErr && keyRows) {
|
||||
for (const r of keyRows) wrappedIds.add(r.recipient_device_id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('deviceApproval: conv-key existence probe failed', err);
|
||||
}
|
||||
|
||||
// Defense in depth: also keep the created_at guard. Devices older than
|
||||
// own can't reasonably need approval — when own came online a fanout
|
||||
// pass already covered them. Helps when the conv_keys probe returns
|
||||
// partial data due to RLS.
|
||||
const own = rows.find((r) => r.id === ctx.ownDeviceId);
|
||||
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const row of candidates) {
|
||||
if (wrappedIds.has(row.id)) {
|
||||
persistApproved(row.id);
|
||||
continue;
|
||||
}
|
||||
const rowCreatedAt = Date.parse(row.created_at);
|
||||
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
|
||||
persistApproved(row.id);
|
||||
continue;
|
||||
}
|
||||
upsertPending(rowToPending(row));
|
||||
}
|
||||
}
|
||||
|
||||
// Starts the approval listener for the given user/device. Returns an
|
||||
// unsubscribe function — call it on shell unmount to tear down the realtime
|
||||
// channel and clear in-memory state.
|
||||
export function startDeviceApprovalListener(
|
||||
ctx: DeviceApprovalListenerCtx,
|
||||
): () => void {
|
||||
listenerCtx = ctx;
|
||||
|
||||
void loadInitialPending(ctx);
|
||||
|
||||
const channel = supabase
|
||||
.channel(`device-approval:${ctx.ownUserId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: `user_id=eq.${ctx.ownUserId}`,
|
||||
},
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const row = payload.new as unknown as DeviceRowLite;
|
||||
if (!row?.id) return;
|
||||
if (!shouldSurface(row.id, ctx.ownDeviceId)) return;
|
||||
upsertPending(rowToPending(row));
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
void supabase.removeChannel(channel).catch(() => {
|
||||
/* ignore — channel might already be gone */
|
||||
});
|
||||
pending = [];
|
||||
listenerCtx = null;
|
||||
notify();
|
||||
};
|
||||
}
|
||||
|
||||
// Approves a pending device: walks every conversation the current user is
|
||||
// in and writes a conv-key bundle for the new device. On success the request
|
||||
// is removed from the pending list and the deviceId is persisted in
|
||||
// localStorage so a reload doesn't re-prompt.
|
||||
export async function approveDevice(req: PendingApproval): Promise<void> {
|
||||
const ctx = listenerCtx;
|
||||
if (!ctx) {
|
||||
throw new Error('deviceApproval: listener not started');
|
||||
}
|
||||
if (req.userId !== ctx.ownUserId) {
|
||||
// Phase 1 only handles same-user approvals (own new device). Friend-side
|
||||
// approval is a later phase.
|
||||
throw new Error('deviceApproval: cross-user approval not supported yet');
|
||||
}
|
||||
|
||||
const priv = await ctx.getPriv();
|
||||
if (!priv) {
|
||||
throw new Error('deviceApproval: own private key unavailable');
|
||||
}
|
||||
|
||||
const sync: SyncCtx = {
|
||||
myUserId: ctx.ownUserId,
|
||||
myDeviceId: ctx.ownDeviceId,
|
||||
priv,
|
||||
};
|
||||
|
||||
try {
|
||||
await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey);
|
||||
} finally {
|
||||
// Wipe the priv copy we asked for. The original lives in the secret
|
||||
// store; this is the transient working copy.
|
||||
for (let i = 0; i < priv.length; i++) priv[i] = 0;
|
||||
}
|
||||
|
||||
persistApproved(req.deviceId);
|
||||
removePending(req.deviceId);
|
||||
}
|
||||
|
||||
// Denies a pending device: just remembers the deviceId in the dismissed-set
|
||||
// and removes the request. No server-side change — the new device simply
|
||||
// stays without any conv-key wraps until the user changes their mind (e.g.
|
||||
// from a settings screen later).
|
||||
export function denyDevice(deviceId: string): void {
|
||||
persistDismissed(deviceId);
|
||||
removePending(deviceId);
|
||||
}
|
||||
|
||||
// Bulk-deny: persists every currently-pending deviceId into the dismissed
|
||||
// set and clears the in-memory list in one notify. Useful when a user has
|
||||
// accumulated stale entries from old test devices / migrations.
|
||||
export function denyAllPending(): void {
|
||||
if (pending.length === 0) return;
|
||||
const dismissed = readIdSet(DISMISSED_KEY);
|
||||
for (const p of pending) dismissed.add(p.deviceId);
|
||||
writeIdSet(DISMISSED_KEY, dismissed);
|
||||
pending = [];
|
||||
notify();
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
||||
//
|
||||
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
||||
// can version it):
|
||||
//
|
||||
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||
|
||||
const MAGIC = 'chatapp-backup-v1.';
|
||||
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
||||
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
||||
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
privateKey: Uint8Array,
|
||||
passphrase: string,
|
||||
): Promise<string> {
|
||||
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
||||
const s = await ensureSodium();
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
||||
s.memzero(key);
|
||||
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
blob.set(salt, 0);
|
||||
blob.set(nonce, SALT_LEN);
|
||||
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
||||
return MAGIC + b64url(blob);
|
||||
}
|
||||
|
||||
export async function importDeviceKey(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<Uint8Array> {
|
||||
if (!backup.startsWith(MAGIC)) {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
const blob = unb64url(backup.slice(MAGIC.length));
|
||||
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('Backup too short');
|
||||
}
|
||||
const salt = blob.slice(0, SALT_LEN);
|
||||
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
try {
|
||||
return backend.secretboxOpen(ciphertext, nonce, key);
|
||||
} catch {
|
||||
throw new Error('Wrong passphrase or corrupt backup');
|
||||
} finally {
|
||||
s.memzero(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
|
||||
// before encrypting, so a restore flow can re-seed localStorage + vault + server
|
||||
// device row without requiring the user to remember IDs.
|
||||
export interface DeviceBackupPayload {
|
||||
v: 2;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKeyB64: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackup(
|
||||
params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const payload: DeviceBackupPayload = {
|
||||
v: 2,
|
||||
userId: params.userId,
|
||||
deviceId: params.deviceId,
|
||||
privateKeyB64: b64url(params.privateKey),
|
||||
};
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||
return exportDeviceKey(bytes, params.passphrase);
|
||||
}
|
||||
|
||||
export async function importDeviceBackup(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<DeviceBackupPayload> {
|
||||
const plain = await importDeviceKey(backup, passphrase);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
try {
|
||||
const obj = JSON.parse(text) as DeviceBackupPayload;
|
||||
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
|
||||
return obj;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
throw new Error('Backup format not supported — v2 expected');
|
||||
}
|
||||
|
||||
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||
return unb64url(payload.privateKeyB64);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery code
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Generates a high-entropy code shown to the user once at backup time. The
|
||||
// same payload is encrypted twice — once with the user's passphrase, once
|
||||
// with the recovery code — so either string can decrypt the device key.
|
||||
//
|
||||
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
|
||||
// characters), grouped as 4×6. ~120 bits of entropy.
|
||||
|
||||
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
export interface BackupBundle {
|
||||
passphraseBackup: string;
|
||||
recoveryBackup: string;
|
||||
recoveryCode: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackupWithRecovery(params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
}): Promise<BackupBundle> {
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const [passphraseBackup, recoveryBackup] = await Promise.all([
|
||||
exportDeviceBackup(params),
|
||||
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
|
||||
]);
|
||||
return { passphraseBackup, recoveryBackup, recoveryCode };
|
||||
}
|
||||
|
||||
async function generateRecoveryCode(): Promise<string> {
|
||||
const s = await ensureSodium();
|
||||
const raw = s.randombytes_buf(24);
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
|
||||
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
|
||||
// maps look-alike characters. Lets users enter the code with imperfect
|
||||
// spacing without rejecting valid input.
|
||||
export function normalizeRecoveryCode(input: string): string {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]/g, '')
|
||||
.split('')
|
||||
.filter((c) => RECOVERY_ALPHABET.includes(c))
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Per-install browser identifier. Replaces the legacy per-device crypto
|
||||
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
|
||||
// metadata) where we just need a stable, opaque id for this browser/install.
|
||||
//
|
||||
// The user-key model has no device-bound crypto; the `devices` table and
|
||||
// `messages.sender_device_id` column still exist but they're now plain
|
||||
// telemetry. Using a localStorage UUID keeps the column populated without
|
||||
// any of the old key-management overhead.
|
||||
|
||||
const KEY = 'chatapp.installId';
|
||||
|
||||
export function ensureInstallId(): string {
|
||||
let id = window.localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
window.localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -1,14 +1,63 @@
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Pushes the current aggregate unread count to main, which updates the
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
|
||||
// outside the Electron runtime (e.g. browser dev preview) so no guards
|
||||
// needed at call-sites.
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
|
||||
// badge here in the renderer because main has no Canvas2D — painting a
|
||||
// red bubble with the count, encoding to PNG, and handing the buffer
|
||||
// to main keeps the implementation free of a native canvas dependency.
|
||||
//
|
||||
// No-op outside the Electron runtime (e.g. browser dev preview) so no
|
||||
// guards needed at call-sites.
|
||||
export async function updateTrayUnread(count: number): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const n = Math.max(0, Math.floor(count));
|
||||
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
|
||||
try {
|
||||
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
|
||||
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Discord-style red bubble with white count.
|
||||
//
|
||||
// Source is rendered at 64×64 so Windows' high-quality downsample to the
|
||||
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
|
||||
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
|
||||
// pixel at the target size, and the bigger font fell into AA mush. Now
|
||||
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
|
||||
function renderBadgePng(count: number): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Solid red bubble, full bleed.
|
||||
const center = size / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, center, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fill();
|
||||
|
||||
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
|
||||
// Sizes are tuned per glyph count so each variant fills the bubble
|
||||
// without clipping when Windows downsamples to 16×16.
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
|
||||
ctx.fillStyle = '#fff';
|
||||
// Segoe UI is the Windows system font; explicit weight 800 keeps the
|
||||
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
|
||||
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// +2 vertical nudge: most system fonts render numerals visually high
|
||||
// relative to the baseline mid-point; the offset re-centres them.
|
||||
ctx.fillText(label, center, center + 2);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
shareConvKeyToUser,
|
||||
tryGetConvKey,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -30,8 +32,8 @@ import {
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey } from './userIdentity';
|
||||
|
||||
interface State {
|
||||
messages: DecryptedMessage[];
|
||||
@@ -90,25 +92,115 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
}, [conversationId]);
|
||||
|
||||
// Load own private key once per (user, device).
|
||||
// Load own user private key once per user.
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
if (!userId || !deviceId) return;
|
||||
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
|
||||
if (!userId) return;
|
||||
void cachedUserKey(userId).then((pk) => {
|
||||
privateKeyRef.current = pk;
|
||||
});
|
||||
}, [userId, deviceId]);
|
||||
}, [userId]);
|
||||
|
||||
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
||||
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
||||
// for them. Members who are missing one (typically peers who haven't yet
|
||||
// migrated to the per-user key model) get a best-effort wrap from the
|
||||
// local conv-key handle. Closes the legacy migration gap so peer B can
|
||||
// read on first unlock without manual intervention from A.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId) return;
|
||||
let cancelled = false;
|
||||
const run = async (): Promise<void> => {
|
||||
// Wait for the private key ref to be populated. Loop with backoff
|
||||
// because it's set asynchronously by another effect; bail if the
|
||||
// conversation switches.
|
||||
for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || cancelled) return;
|
||||
|
||||
try {
|
||||
const { data: convRow, error: convErr } = await supabase
|
||||
.from('conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (convErr || !convRow) return;
|
||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||
|
||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||
if (!handle || cancelled) return;
|
||||
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr || !members) return;
|
||||
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||
.filter((m) => m.accepted && m.user_id !== userId)
|
||||
.map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return;
|
||||
|
||||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
||||
for (const peer of peers) {
|
||||
if (cancelled) return;
|
||||
const { count, error: cntErr } = await (
|
||||
supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (s: string, o?: object) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (
|
||||
...a: unknown[]
|
||||
) => Promise<{ count: number | null; error: unknown }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_user_id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('recipient_user_id', peer.userId)
|
||||
.eq('key_version', version);
|
||||
if (cntErr) continue;
|
||||
if ((count ?? 0) === 0) {
|
||||
try {
|
||||
await shareConvKeyToUser(
|
||||
supabase,
|
||||
conversationId,
|
||||
peer.userId,
|
||||
peer.publicKey,
|
||||
{ userId, privateKey: priv },
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap failed for', peer.userId, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap sweep failed', err);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, userId]);
|
||||
|
||||
const decryptBatch = useCallback(
|
||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !deviceId || messages.length === 0) {
|
||||
if (!priv || !userId || messages.length === 0) {
|
||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
||||
}
|
||||
return decryptMessages({
|
||||
client: supabase,
|
||||
messages,
|
||||
ownDeviceId: deviceId,
|
||||
ownUserId: userId,
|
||||
ownPrivateKey: priv,
|
||||
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
||||
// the main thread stays responsive during bulk operations (initial
|
||||
@@ -116,7 +208,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
aeadBatchDelegate: decryptBatchWorker,
|
||||
});
|
||||
},
|
||||
[deviceId],
|
||||
[userId],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest';
|
||||
|
||||
import { setCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
|
||||
|
||||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||||
|
||||
const memStore: Record<string, Uint8Array> = {};
|
||||
const fakeStore = {
|
||||
getSecret: async (k: string) => memStore[k] ?? null,
|
||||
setSecret: async (k: string, v: Uint8Array) => { memStore[k] = v; },
|
||||
removeSecret: async (k: string) => { delete memStore[k]; },
|
||||
};
|
||||
|
||||
vi.mock('./secretStore', () => ({
|
||||
devLocalSecretStore: fakeStore,
|
||||
setSecretStoreUser: vi.fn(),
|
||||
isEncryptedVaultActive: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('./supabase', () => {
|
||||
const rpcResponses = new Map<string, { data: unknown; error: unknown }>();
|
||||
const rpc = vi.fn((name: string, _params: unknown) =>
|
||||
Promise.resolve(rpcResponses.get(name) ?? { data: null, error: null }),
|
||||
);
|
||||
const supabase = {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'me' } }, error: null }) },
|
||||
rpc,
|
||||
from: vi.fn(() => ({
|
||||
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
|
||||
})),
|
||||
};
|
||||
return { supabase, __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => rpcResponses.set(n, r) };
|
||||
});
|
||||
|
||||
describe('userIdentity', () => {
|
||||
beforeEach(() => { for (const k of Object.keys(memStore)) delete memStore[k]; });
|
||||
|
||||
it('setupNewUserIdentity uploads blob, caches private key in store', async () => {
|
||||
const supabaseMod = await import('./supabase');
|
||||
(supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void })
|
||||
.__setRpcResponse('reset_user_key', { data: 0, error: null });
|
||||
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
|
||||
const result = await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: true });
|
||||
expect(result.publicKey).toHaveLength(32);
|
||||
expect(result.recoveryCode).toMatch(/^[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}$/);
|
||||
const cached = await cachedUserKey('me');
|
||||
expect(cached).toBeInstanceOf(Uint8Array);
|
||||
expect(cached?.length).toBe(32);
|
||||
});
|
||||
|
||||
it('loadOrUnlockUserKey unlocks with correct PIN, throws on wrong PIN', async () => {
|
||||
const { setupNewUserIdentity, clearUserKeyCache, loadOrUnlockUserKey } =
|
||||
await import('./userIdentity');
|
||||
const supabaseMod = await import('./supabase');
|
||||
const setRpc = (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }).__setRpcResponse;
|
||||
setRpc('reset_user_key', { data: 0, error: null });
|
||||
|
||||
await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: false });
|
||||
const rpc = (supabaseMod.supabase as unknown as { rpc: ReturnType<typeof vi.fn> }).rpc;
|
||||
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
|
||||
const params = lastCall[1] as Record<string, string>;
|
||||
|
||||
setRpc('try_unlock_user_key', {
|
||||
data: {
|
||||
exists: true, locked: false,
|
||||
sealed_private_key: params.p_sealed_private_b64,
|
||||
salt: params.p_salt_b64,
|
||||
kdf_params: params.p_kdf_params,
|
||||
recovery_sealed_private_key: null, recovery_salt: null,
|
||||
failed_attempts: 0, failed_recovery_attempts: 0,
|
||||
recovery_locked_until: null, key_version: 1,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
setRpc('record_pin_attempt', { data: { failed_attempts: 0, locked_until: null }, error: null });
|
||||
|
||||
await clearUserKeyCache('me');
|
||||
const ok = await loadOrUnlockUserKey({ userId: 'me', pin: '123456' });
|
||||
expect(ok.kind).toBe('unlocked');
|
||||
|
||||
await clearUserKeyCache('me');
|
||||
await expect(loadOrUnlockUserKey({ userId: 'me', pin: '654321' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
fetchUserKeyBlob, listOwnDevices, recordPinAttempt,
|
||||
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
|
||||
openUserKey, sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
|
||||
import { devLocalSecretStore } 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 devLocalSecretStore.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 };
|
||||
}
|
||||
|
||||
// Background, idempotent re-wrap of own legacy conv-key bundles for the new
|
||||
// per-user identity. Safe to call repeatedly: the underlying RPC uses
|
||||
// ON CONFLICT DO NOTHING. Triggered on every successful unlock so users who
|
||||
// upgraded to 0.18.0 (where the .eq(null) bug made setup-time migration a
|
||||
// no-op) auto-recover on the next launch.
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<void> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) return;
|
||||
const pub = await derivePublicKey(priv);
|
||||
await runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
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 devLocalSecretStore.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 devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||||
await devLocalSecretStore.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: await derivePublicKey(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
});
|
||||
void params.oldPin; // unused: 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: await derivePublicKey(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);
|
||||
// resetUserKey already deletes legacy bundles; setupNewUserIdentity uploads
|
||||
// the brand-new blob via the same RPC (UPSERT). After this, no migration
|
||||
// pass runs because all legacy conv-keys are gone.
|
||||
await resetUserKey(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: new Uint8Array(32), // overwritten by next upload
|
||||
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 ?? '';
|
||||
}
|
||||
|
||||
// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets
|
||||
// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault."
|
||||
export interface LegacyMigrationReport {
|
||||
serverDevices: number;
|
||||
strongholdKeysFromServerDevices: number;
|
||||
strongholdKeysFromBundleScan: number;
|
||||
attempted: number;
|
||||
migrated: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
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> = {};
|
||||
|
||||
// 1) Try every server-listed device first.
|
||||
for (const d of devices) {
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
|
||||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||||
}
|
||||
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
|
||||
|
||||
// 2) Scan our visible un-migrated conversation_keys for distinct
|
||||
// recipient_device_ids and probe stronghold for each. This catches the case
|
||||
// where a device row was deleted server-side but its key remains locally,
|
||||
// OR where listOwnDevices missed a device because of an RLS edge.
|
||||
// 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 => !!id),
|
||||
));
|
||||
for (const id of scanIds) {
|
||||
if (ownLegacyDevicePrivateKeys[id]) continue;
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`);
|
||||
if (k) {
|
||||
ownLegacyDevicePrivateKeys[id] = k;
|
||||
report.strongholdKeysFromBundleScan += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Public wrapper for SecurityCenter's "Migration erneut versuchen" button.
|
||||
// Returns a structured report so the UI can render numbers and reasons.
|
||||
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);
|
||||
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);
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// Installed app version comes from the preload bridge (process.env.npm_-
|
||||
// package_version at preload build time). Falls back to '0.0.0' outside
|
||||
// Electron so the page still renders in a browser preview.
|
||||
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
|
||||
|
||||
export function ChangelogPage() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||
|
||||
// Compare the installed version against the top changelog entry. The
|
||||
// server-side changelog is sorted newest-first by the release script, so
|
||||
// entries[0] is always the published latest.
|
||||
const latestVersion = entries?.[0]?.version ?? null;
|
||||
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
|
||||
if (entries === null) return 'loading';
|
||||
if (!latestVersion) return 'current';
|
||||
const cmp = compareSemver(installedVersion, latestVersion);
|
||||
if (cmp === 0) return 'current';
|
||||
if (cmp < 0) return 'outdated';
|
||||
return 'ahead';
|
||||
}, [entries, latestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -34,7 +52,7 @@ export function ChangelogPage() {
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
@@ -42,6 +60,11 @@ export function ChangelogPage() {
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
installed={installedVersion}
|
||||
latest={latestVersion}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact status chip in the header that tells the user whether their
|
||||
// installed build matches the latest published version. Three visual
|
||||
// tones: emerald (current), amber (outdated → update available), neutral
|
||||
// (loading / unknown). The "ahead" case (dev build > released) shares the
|
||||
// neutral tone since users running it always know what they're doing.
|
||||
function VersionBadge({
|
||||
status,
|
||||
installed,
|
||||
latest,
|
||||
}: {
|
||||
status: 'loading' | 'current' | 'outdated' | 'ahead';
|
||||
installed: string;
|
||||
latest: string | null;
|
||||
}) {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'current') {
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf der neuesten Version."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
v{installed} · aktuell
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'outdated' && latest) {
|
||||
return (
|
||||
<span
|
||||
title={`Update verfügbar — neueste Version: v${latest}.`}
|
||||
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
<span>v{installed} · Update verfügbar</span>
|
||||
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
|
||||
>
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight semver comparator: parses major.minor.patch as ints and
|
||||
// compares numerically. Returns negative if a < b, zero if equal, positive
|
||||
// if a > b. Handles malformed inputs by treating non-numeric segments as
|
||||
// 0 so a typo doesn't flag a perfectly current install as outdated.
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const parse = (s: string): [number, number, number] => {
|
||||
const parts = s.split('.').map((p) => {
|
||||
const n = parseInt(p, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
};
|
||||
const [aMaj, aMin, aPat] = parse(a);
|
||||
const [bMaj, bMin, bPat] = parse(b);
|
||||
if (aMaj !== bMaj) return aMaj - bMaj;
|
||||
if (aMin !== bMin) return aMin - bMin;
|
||||
return aPat - bPat;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseMessagePayload } from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { VoiceChannelRail } from '../components/VoiceChannelRail';
|
||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
@@ -37,6 +37,7 @@ import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
@@ -49,10 +50,19 @@ import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
|
||||
const STICK_THRESHOLD = 80;
|
||||
|
||||
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||
// of ConversationPage when the route param (`id`) changes — switching
|
||||
// chats unmounts/remounts the page in our router setup. Session-only
|
||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||
// alongside the pixel offset so a chat the user left at the bottom keeps
|
||||
// auto-following new messages when they return; a chat scrolled up
|
||||
// returns to the exact spot the user was reading.
|
||||
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
|
||||
|
||||
export function ConversationPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
||||
|
||||
const conversation = useMemo(
|
||||
@@ -66,7 +76,7 @@ export function ConversationPage() {
|
||||
useConversationMessages({
|
||||
conversationId: id,
|
||||
userId: session?.user.id,
|
||||
deviceId: device?.id,
|
||||
deviceId: ensureInstallId(),
|
||||
});
|
||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||
const {
|
||||
@@ -397,17 +407,60 @@ export function ConversationPage() {
|
||||
if (id && messages.length > 0) markRead(id);
|
||||
}, [id, messages.length, markRead]);
|
||||
|
||||
useEffect(() => {
|
||||
// useLayoutEffect: run synchronously after DOM commit, before the
|
||||
// browser paints. Using useEffect here let one frame of "scrollTop = 0
|
||||
// (top of list)" paint between message-list mount and the auto-scroll,
|
||||
// which is exactly the "flickers to a different position, then jumps"
|
||||
// glitch users saw when re-entering a chat. Layout-effect fires while
|
||||
// the message list is in the DOM but before paint, so the first frame
|
||||
// already shows the correct scroll position.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !stickToBottom) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length, stickToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
setStickToBottom(true);
|
||||
// Restore saved scroll position once the conversation's messages have
|
||||
// actually rendered. The earlier version fired on `[id]` alone and ran
|
||||
// before the message list populated — scrollHeight was still tiny, so
|
||||
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
|
||||
// and the user landed at the top instead of the saved position. By
|
||||
// waiting for `messages.length > 0` we know the rendered scrollHeight
|
||||
// is meaningful. `restoredForRef` ensures the restore runs at most
|
||||
// once per chat switch (subsequent message arrivals don't re-trigger).
|
||||
const restoredForRef = useRef<string | null>(null);
|
||||
const isRestoringRef = useRef(false);
|
||||
|
||||
// useLayoutEffect, same reason as above: writing scrollTop here happens
|
||||
// before the first paint of the freshly-mounted chat, so the user
|
||||
// doesn't see a frame at scrollTop=0 before the jump to the saved
|
||||
// position. Combined with the messages.length gate this means the
|
||||
// re-entry shows the message list AT the saved scroll location in one
|
||||
// single paint — no "loaded then jumped" effect.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [id]);
|
||||
if (!el || !id) return;
|
||||
if (restoredForRef.current === id) return;
|
||||
// Wait for the conversation's messages to populate; for a chat that
|
||||
// truly has zero messages the bottom and the top are the same anyway.
|
||||
if (messages.length === 0) return;
|
||||
restoredForRef.current = id;
|
||||
const saved = scrollPositions.get(id);
|
||||
// Suppress handleScroll's persistence during the programmatic scroll
|
||||
// below — otherwise the browser's clamp/normalisation could write a
|
||||
// different scrollTop back into the Map and lose the saved position.
|
||||
isRestoringRef.current = true;
|
||||
if (saved && !saved.stickToBottom) {
|
||||
el.scrollTop = saved.scrollTop;
|
||||
setStickToBottom(false);
|
||||
} else {
|
||||
setStickToBottom(true);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
isRestoringRef.current = false;
|
||||
});
|
||||
}, [id, messages.length]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -416,7 +469,14 @@ export function ConversationPage() {
|
||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
||||
setStickToBottom(nextStick);
|
||||
if (nextStick) setNewMessagesWhileAway(0);
|
||||
}, []);
|
||||
// Persist position per chat so re-entering this conversation lands
|
||||
// where the user left off (see scrollPositions module-level Map).
|
||||
// Skipped during the in-flight restore so we don't immediately
|
||||
// overwrite the saved position with a clamped value.
|
||||
if (id && !isRestoringRef.current) {
|
||||
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -584,31 +644,19 @@ export function ConversationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGroup && conversation && (
|
||||
<GroupInfoPanel
|
||||
open={infoPanelOpen}
|
||||
onClose={() => setInfoPanelOpen(false)}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaFilesDrawer
|
||||
open={mediaDrawerOpen}
|
||||
index={attachmentIndex}
|
||||
senderNameFor={senderNameFor}
|
||||
onJumpToMessage={(messageId) => {
|
||||
setMediaDrawerOpen(false);
|
||||
jumpToMessage(messageId);
|
||||
}}
|
||||
onClose={() => setMediaDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
||||
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
||||
someone is already waiting. Hides automatically once we're in. */}
|
||||
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
{/* Layout row: chat column on the left grows to fill remaining width;
|
||||
right-hand drawers (Media/Files, Group Info) render as inline
|
||||
siblings so opening one narrows the chat instead of floating on
|
||||
top of it (Discord parity). The chat-column wrapper holds the
|
||||
`relative` anchor for the drag-and-drop overlay further below. */}
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
||||
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
||||
someone is already waiting. Hides automatically once we're in. */}
|
||||
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -960,6 +1008,27 @@ export function ConversationPage() {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{isGroup && conversation && (
|
||||
<GroupInfoPanel
|
||||
open={infoPanelOpen}
|
||||
onClose={() => setInfoPanelOpen(false)}
|
||||
conversation={conversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaFilesDrawer
|
||||
open={mediaDrawerOpen}
|
||||
index={attachmentIndex}
|
||||
senderNameFor={senderNameFor}
|
||||
onJumpToMessage={(messageId) => {
|
||||
setMediaDrawerOpen(false);
|
||||
jumpToMessage(messageId);
|
||||
}}
|
||||
onClose={() => setMediaDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ForwardDialog
|
||||
open={forwardTarget !== null}
|
||||
|
||||
@@ -1,109 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { DeviceRegistration } from '../components/DeviceRegistration';
|
||||
import { DeviceRestore } from '../components/DeviceRestore';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
type Mode = 'register' | 'restore';
|
||||
import { UserKeySetup } from '../components/UserKeySetup';
|
||||
import { UserKeyUnlock } from '../components/UserKeyUnlock';
|
||||
|
||||
export function DevicePage() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, profile, setDevice } = useAuth();
|
||||
const { session, userKeyState, refreshUserKeyState } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<Mode>('register');
|
||||
|
||||
if (!session) return null; // Guarded by RequireAuth, but be defensive.
|
||||
|
||||
const defaultName =
|
||||
(profile?.displayName ?? profile?.username ?? 'Desktop') + ' Desktop';
|
||||
|
||||
if (!session) return null;
|
||||
const onComplete = async () => {
|
||||
await refreshUserKeyState();
|
||||
navigate('/chats', { replace: true });
|
||||
};
|
||||
return (
|
||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
|
||||
<BackgroundStage />
|
||||
<div className="relative z-10 w-full max-w-md space-y-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
|
||||
className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
|
||||
>
|
||||
<TabButton
|
||||
active={mode === 'register'}
|
||||
onClick={() => setMode('register')}
|
||||
label={t('app:backup.mode_register', { defaultValue: 'Neu einrichten' })}
|
||||
/>
|
||||
<TabButton
|
||||
active={mode === 'restore'}
|
||||
onClick={() => setMode('restore')}
|
||||
label={t('app:backup.mode_restore', { defaultValue: 'Backup wiederherstellen' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === 'register' ? (
|
||||
<DeviceRegistration
|
||||
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
|
||||
<div className="w-full max-w-md">
|
||||
{userKeyState.status === 'needs-setup' && (
|
||||
<UserKeySetup userId={session.user.id} onComplete={onComplete} />
|
||||
)}
|
||||
{userKeyState.status === 'needs-unlock' && (
|
||||
<UserKeyUnlock
|
||||
userId={session.user.id}
|
||||
defaultName={defaultName}
|
||||
onRegistered={(device) => {
|
||||
setDevice(device);
|
||||
// Signal the chats page to open the post-registration backup
|
||||
// prompt (AppShell reads this on mount).
|
||||
try {
|
||||
window.sessionStorage.setItem('chatapp.backup.prompt', '1');
|
||||
} catch {
|
||||
/* storage disabled — skip hint */
|
||||
}
|
||||
navigate('/chats', { replace: true });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DeviceRestore
|
||||
userId={session.user.id}
|
||||
onRestored={(device) => {
|
||||
setDevice(device);
|
||||
navigate('/chats', { replace: true });
|
||||
}}
|
||||
hasRecovery={userKeyState.hasRecovery}
|
||||
lockedUntil={userKeyState.lockedUntil}
|
||||
onUnlocked={onComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex-1 cursor-pointer rounded-lg px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
(active
|
||||
? 'bg-brand-500/20 text-brand-100 ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100')
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundStage() {
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
|
||||
<div className="absolute -left-32 top-1/4 h-[460px] w-[460px] rounded-full bg-brand-500/25 blur-3xl" />
|
||||
<div className="absolute -right-32 bottom-0 h-[460px] w-[460px] rounded-full bg-fuchsia-500/15 blur-3xl" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.65)_100%)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,17 +8,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import { MicTestSection } from '../components/MicTestSection';
|
||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SecurityCenter } from '../components/SecurityCenter';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
|
||||
import {
|
||||
AVATAR_TARGET_DIM,
|
||||
@@ -33,7 +30,7 @@ import {
|
||||
uploadBannerBlob,
|
||||
} from '../lib/bannerUpload';
|
||||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { Lightbox } from '../components/Lightbox';
|
||||
import {
|
||||
getPttSettings,
|
||||
keyCodeToLabel,
|
||||
@@ -82,7 +79,7 @@ const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
|
||||
const { profile, device, refreshProfile, signOut } = useAuth();
|
||||
const { profile, refreshProfile, signOut } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
|
||||
@@ -221,22 +218,9 @@ export function SettingsPage() {
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
|
||||
{/* Devices */}
|
||||
<Section title={t('app:settings.section_devices')}>
|
||||
{device && (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{t('app:settings.this_device')}
|
||||
</div>
|
||||
<dl className="mt-3 space-y-1.5 text-xs">
|
||||
<Row label={t('auth:signed_in.display_name')} value={device.name} />
|
||||
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
|
||||
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<DeviceKeyBackupControls />
|
||||
{/* Security */}
|
||||
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
|
||||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||||
</Section>
|
||||
|
||||
{/* Danger zone */}
|
||||
@@ -799,6 +783,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
// ratios.
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
||||
// Lightbox toggle for the avatar live-preview. Clicking the in-page
|
||||
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
|
||||
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
@@ -930,11 +917,33 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
||||
style={{ marginTop: '-2rem' }}
|
||||
>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (avatarUrl) setAvatarPreviewOpen(true);
|
||||
}}
|
||||
// Disabled when there's no uploaded avatar — clicking the
|
||||
// generated-initial placeholder would open an empty lightbox.
|
||||
disabled={!avatarUrl}
|
||||
aria-label={
|
||||
avatarUrl
|
||||
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
|
||||
: undefined
|
||||
}
|
||||
// appearance-none + reset border/bg/padding so the native
|
||||
// button chrome (outset border, button-face background, 1px
|
||||
// padding) doesn't draw a box around the avatar circle.
|
||||
className={
|
||||
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||
/>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="truncate text-sm font-semibold text-fg">
|
||||
{displayName ?? '—'}
|
||||
@@ -1061,91 +1070,8 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
}}
|
||||
onClose={closeCropDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canRun = !!profile?.userId && !!device?.id;
|
||||
|
||||
async function handleOpen() {
|
||||
if (!canRun) return;
|
||||
setErr(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) throw new Error(t('app:backup.no_key_here', {
|
||||
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
|
||||
}));
|
||||
setPrivateKey(priv);
|
||||
setOpen(true);
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : 'failed to load key');
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setOpen(false);
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setPrivateKey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
|
||||
<div className="text-sm font-semibold text-fg">
|
||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:settings.device_key_backup_hint_v2', {
|
||||
defaultValue:
|
||||
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void handleOpen()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!profile?.userId}
|
||||
onClick={() => setRestoreOpen(true)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{err && (
|
||||
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||
)}
|
||||
|
||||
{open && profile && device && privateKey && (
|
||||
<BackupExportDialog
|
||||
open={open}
|
||||
userId={profile.userId}
|
||||
deviceId={device.id}
|
||||
privateKey={privateKey}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profile && (
|
||||
<BackupRestoreDialog
|
||||
open={restoreOpen}
|
||||
userId={profile.userId}
|
||||
onClose={() => setRestoreOpen(false)}
|
||||
/>
|
||||
{avatarPreviewOpen && avatarUrl && (
|
||||
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to `.env.local` and fill in. Expo bundles only EXPO_PUBLIC_* vars
|
||||
# into the JS, which is what we want for these — they are public Supabase
|
||||
# project keys (anon key + URL) protected by RLS on the server.
|
||||
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-supabase-host.example
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_...
|
||||
EXPO_PUBLIC_AUTH_REDIRECT_URL=netralax://auth/callback
|
||||
+65
-14
@@ -1,27 +1,78 @@
|
||||
# @chat-app/mobile
|
||||
|
||||
Expo + React Native client for iOS and Android.
|
||||
Expo + React Native client for **Netralax** on iOS and Android.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node 22+, pnpm 9+
|
||||
- Xcode (iOS) / Android Studio (Android)
|
||||
- Optional: `npm i -g eas-cli` for cloud builds
|
||||
- Xcode (iOS) / Android Studio (Android) — only needed for local
|
||||
prebuild + emulator runs; EAS Build runs everything in the cloud.
|
||||
- An Expo account (free) — required for `eas init` below.
|
||||
- Optional: `npm i -g eas-cli` for the CLI commands. If you skip
|
||||
globals, prefix every `eas` invocation with `npx eas-cli`.
|
||||
|
||||
## Dev
|
||||
## Phase 0 quickstart
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
# From the repo root.
|
||||
pnpm install
|
||||
pnpm mobile:dev # expo start
|
||||
pnpm mobile:ios # native iOS run
|
||||
pnpm mobile:android # native Android run
|
||||
|
||||
# One-time: claim an EAS project ID. This rewrites the placeholder
|
||||
# `extra.eas.projectId` in app.json and links the local repo to your
|
||||
# Expo dashboard. Commit the resulting app.json change.
|
||||
cd apps/mobile
|
||||
npx eas-cli init
|
||||
|
||||
# Sign in to Expo if you haven't.
|
||||
npx eas-cli login
|
||||
|
||||
# Build a development client (custom dev-client APK + iOS simulator
|
||||
# bundle). First iOS build prompts for Apple ID — free signing works
|
||||
# for development distribution.
|
||||
npx eas-cli build --platform all --profile development
|
||||
```
|
||||
|
||||
## Notes
|
||||
When the builds finish, EAS gives you a QR code / install link. Install
|
||||
the dev client on your device, then start the Metro server from the repo
|
||||
root:
|
||||
|
||||
- Uses Expo Router (file-based). Screens live under `app/`.
|
||||
- Secrets stored via `expo-secure-store` (Keychain / Keystore).
|
||||
- Local encrypted history via `expo-sqlite`.
|
||||
- Crypto via `react-native-libsodium`.
|
||||
- Shared business logic lives in `@chat-app/shared`.
|
||||
```bash
|
||||
pnpm mobile:dev # = pnpm --filter @chat-app/mobile dev = expo start --dev-client
|
||||
```
|
||||
|
||||
Open the dev client on the device, scan the QR code, and Netralax's
|
||||
Landing screen should render.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
pnpm mobile:dev # Metro server
|
||||
pnpm mobile:ios # native iOS run (local Xcode)
|
||||
pnpm mobile:android # native Android run (local Android Studio)
|
||||
pnpm mobile:typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Expo Router (file-based) — screens live under `app/`.
|
||||
- `expo-secure-store` — Keychain / Keystore-backed secret store, wrapped
|
||||
by `lib/secretStore.ts` to implement `@chat-app/shared`'s `SecretStore`.
|
||||
- `@react-native-async-storage/async-storage` — Supabase session-token
|
||||
storage, surfaced via `lib/sessionStorage.ts`.
|
||||
- `expo-sqlite` — local encrypted history (slated for a later phase).
|
||||
- `react-native-libsodium` — crypto primitives, wrapped by
|
||||
`lib/cryptoBackend.ts` to implement `@chat-app/shared`'s `CryptoBackend`.
|
||||
Registered once at boot in `app/_layout.tsx`.
|
||||
- `@chat-app/shared` — business logic shared with the desktop; the mobile
|
||||
adapters plug into its `CryptoBackend` + `SecretStore` interfaces, and
|
||||
every chat / auth call goes through the namespace exports.
|
||||
|
||||
## Env vars
|
||||
|
||||
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.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
|
||||
|
||||
+44
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "ChatApp",
|
||||
"slug": "chat-app",
|
||||
"name": "Netralax",
|
||||
"slug": "netralax",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "chatapp",
|
||||
"scheme": "netralax",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
@@ -13,16 +13,33 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.meinname.chatapp",
|
||||
"bundleIdentifier": "cloud.netralax.app",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"NSMicrophoneUsageDescription": "Netralax nutzt das Mikrofon für Sprachanrufe."
|
||||
},
|
||||
"bitcode": false
|
||||
},
|
||||
"android": {
|
||||
"package": "com.meinname.chatapp",
|
||||
"package": "cloud.netralax.app",
|
||||
"permissions": [
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"android.permission.SYSTEM_ALERT_WINDOW",
|
||||
"android.permission.WAKE_LOCK",
|
||||
"android.permission.BLUETOOTH"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
@@ -37,6 +54,19 @@
|
||||
{
|
||||
"color": "#0b0b0f"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Netralax greift auf deine Fotos zu, damit du sie in Nachrichten teilen kannst.",
|
||||
"cameraPermission": "Netralax nutzt die Kamera für Fotos in Nachrichten."
|
||||
}
|
||||
],
|
||||
[
|
||||
"@config-plugins/react-native-webrtc",
|
||||
{
|
||||
"microphonePermission": "Netralax nutzt das Mikrofon für Sprachanrufe."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
@@ -44,8 +74,12 @@
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
|
||||
"projectId": "255e2fde-3e27-4749-be53-8f0e13ed0ab0"
|
||||
},
|
||||
"router": {
|
||||
"origin": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"owner": "bygalax"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
// Authenticated app group layout.
|
||||
// Gate on session: if no session, redirect to `/`.
|
||||
// Add tab bar / drawer nav here once we have more than one screen.
|
||||
import { Redirect, Stack } from 'expo-router';
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
|
||||
export default function AppLayout() {
|
||||
return <Stack screenOptions={{ headerShown: true }} />;
|
||||
const { session, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
if (!session) return <Redirect href="/" />;
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: true }}>
|
||||
<Stack.Screen name="chats" />
|
||||
<Stack.Screen name="conversations/[id]" />
|
||||
<Stack.Screen
|
||||
name="call"
|
||||
options={{ headerShown: false, presentation: 'fullScreenModal' }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Avatar } from '../../components/Avatar';
|
||||
import { useCall } from '../../lib/callContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function CallScreen() {
|
||||
const router = useRouter();
|
||||
const { state, toggleMute, toggleSpeaker, endCall } = useCall();
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
setSeconds(0);
|
||||
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind === 'idle') {
|
||||
router.back();
|
||||
}
|
||||
}, [state.kind, router]);
|
||||
|
||||
const duration = useMemo(() => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return [m, s].map((n) => String(n).padStart(2, '0')).join(':');
|
||||
}, [seconds]);
|
||||
|
||||
if (state.kind === 'idle' || state.kind === 'ended') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Anruf beendet</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'incoming') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connecting = state.kind === 'connecting' || state.kind === 'outgoing';
|
||||
const participants = state.kind === 'connected' ? state.participants : [];
|
||||
const muted = state.kind === 'connected' ? state.muted : false;
|
||||
const speakerOn = state.kind === 'connected' ? state.speakerOn : false;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{connecting ? 'Verbinde …' : 'Anruf läuft'}</Text>
|
||||
{state.kind === 'connected' && <Text style={styles.duration}>{duration}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.participantList}>
|
||||
{participants.length === 0 && (
|
||||
<Text style={styles.empty}>Warten auf Teilnehmer …</Text>
|
||||
)}
|
||||
{participants.map((p) => (
|
||||
<View key={p.identity} style={styles.participantRow}>
|
||||
<Avatar name={p.name} size={48} />
|
||||
<View style={styles.participantText}>
|
||||
<Text style={styles.participantName}>{p.name}</Text>
|
||||
<Text style={[styles.participantStatus, p.speaking && styles.participantSpeaking]}>
|
||||
{p.speaking ? 'Spricht …' : 'Stumm'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<ToolbarButton
|
||||
label={muted ? 'Stumm' : 'Mikro'}
|
||||
active={muted}
|
||||
onPress={() => {
|
||||
void toggleMute();
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={speakerOn ? 'Lautsprecher' : 'Hörer'}
|
||||
active={speakerOn}
|
||||
onPress={() => {
|
||||
void toggleSpeaker();
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.hangup}
|
||||
onPress={() => {
|
||||
void endCall();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.hangupText}>Auflegen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
label,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.toolbarButton, active && styles.toolbarButtonActive]}
|
||||
>
|
||||
<Text style={[styles.toolbarButtonText, active && styles.toolbarButtonTextActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg, padding: 24 },
|
||||
header: { alignItems: 'center', marginTop: 24, gap: 6 },
|
||||
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
|
||||
duration: { color: colors.textMuted, fontSize: 16, fontVariant: ['tabular-nums'] },
|
||||
participantList: { flex: 1, marginTop: 24, gap: 12 },
|
||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 32 },
|
||||
participantRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: colors.surface,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
participantText: { flex: 1 },
|
||||
participantName: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
participantStatus: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
|
||||
participantSpeaking: { color: colors.success, fontWeight: '700' },
|
||||
toolbar: { flexDirection: 'row', gap: 12, paddingBottom: 16 },
|
||||
toolbarButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
toolbarButtonActive: { backgroundColor: colors.accentMuted, borderColor: colors.accent },
|
||||
toolbarButtonText: { color: colors.text, fontWeight: '600' },
|
||||
toolbarButtonTextActive: { color: colors.accent },
|
||||
hangup: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.danger,
|
||||
alignItems: 'center',
|
||||
},
|
||||
hangupText: { color: colors.text, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,18 +1,131 @@
|
||||
// Chats list placeholder.
|
||||
// Milestone 1 TODO: render list of conversations, decrypt last-message preview,
|
||||
// navigate to a conversation screen on tap.
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { ConversationRow } from '../../components/ConversationRow';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
// Phase 1 conversation list. The last-message preview is the static
|
||||
// fallback ('…') — fetching + decrypting last-messages per conversation
|
||||
// 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);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await chat.listConversations(supabase);
|
||||
setList(result.filter((c) => !c.archived));
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Konversationen konnten nicht geladen werden');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await load();
|
||||
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}>
|
||||
<Text style={styles.text}>Chats — placeholder</Text>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Chats',
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerRight: () => (
|
||||
<Pressable onPress={confirmLogout} hitSlop={10}>
|
||||
<Text style={styles.logoutLink}>Abmelden</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{list === null && !error && (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{list && list.length === 0 && !error && (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>Noch keine Konversationen.</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Lege dir auf dem Desktop einen Chat an — er taucht hier auf, sobald du
|
||||
herunterziehst, um zu aktualisieren.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{list && list.length > 0 && (
|
||||
<FlatList
|
||||
data={list}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ConversationRow
|
||||
conversation={item}
|
||||
lastMessagePreview="…"
|
||||
onPress={() => router.push('/(app)/conversations/' + item.id)}
|
||||
/>
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0b0b0f' },
|
||||
text: { color: '#fff' },
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 },
|
||||
emptyText: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
emptyHint: { color: colors.textMuted, fontSize: 13, textAlign: 'center', lineHeight: 18 },
|
||||
error: { color: colors.danger, padding: 16, textAlign: 'center' },
|
||||
separator: { height: 1, backgroundColor: colors.border, marginLeft: 68 },
|
||||
logoutLink: { color: colors.accent, fontSize: 14, fontWeight: '600', paddingHorizontal: 8 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type {
|
||||
ConversationSummary,
|
||||
DecryptedMessage,
|
||||
MessageReaction,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionSheetIOS,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { MessageActionsSheet } from '../../../components/MessageActionsSheet';
|
||||
import { MessageBubble } from '../../../components/MessageBubble';
|
||||
import { useAuth } from '../../../lib/authContext';
|
||||
import { useCall } from '../../../lib/callContext';
|
||||
import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function ConversationDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { user, device, ownPrivateKey } = useAuth();
|
||||
const { state: callState, startCall } = useCall();
|
||||
const router = useRouter();
|
||||
|
||||
// When a call moves into `connected`, jump to the in-call screen so
|
||||
// the user can see participants + controls. The /call screen pops
|
||||
// itself when callState returns to `idle`.
|
||||
useEffect(() => {
|
||||
if (callState.kind === 'connected') {
|
||||
router.push('/(app)/call');
|
||||
}
|
||||
}, [callState.kind, router]);
|
||||
|
||||
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
|
||||
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
|
||||
const [reactions, setReactions] = useState<Map<string, MessageReaction[]>>(new Map());
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [activeMessage, setActiveMessage] = useState<DecryptedMessage | null>(null);
|
||||
const listRef = useRef<FlatList<DecryptedMessage>>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id || !device || !ownPrivateKey) return;
|
||||
setError(null);
|
||||
try {
|
||||
const all = await chat.listConversations(supabase);
|
||||
setConversation(all.find((c) => c.id === id) ?? null);
|
||||
const ciphers = await chat.fetchConversationMessages(supabase, id, 50);
|
||||
const decrypted = await chat.decryptMessages({
|
||||
client: supabase,
|
||||
messages: ciphers,
|
||||
ownDeviceId: device.id,
|
||||
ownPrivateKey,
|
||||
});
|
||||
setMessages(decrypted);
|
||||
const rows = await chat.listReactionsForMessages(
|
||||
supabase,
|
||||
decrypted.map((m) => m.id),
|
||||
);
|
||||
const map = new Map<string, MessageReaction[]>();
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.messageId) ?? [];
|
||||
arr.push(r);
|
||||
map.set(r.messageId, arr);
|
||||
}
|
||||
setReactions(map);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
|
||||
}
|
||||
}, [id, device, ownPrivateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const members = useMemo(() => conversation?.members ?? [], [conversation]);
|
||||
const senderName = useCallback(
|
||||
(senderId: string) => {
|
||||
if (senderId === user?.id) return 'Du';
|
||||
const m = members.find((mm) => mm.userId === senderId);
|
||||
return m?.profile?.displayName ?? m?.profile?.username ?? 'Unbekannt';
|
||||
},
|
||||
[members, user],
|
||||
);
|
||||
|
||||
const title =
|
||||
conversation?.type === 'group'
|
||||
? (conversation.name ?? 'Gruppe')
|
||||
: (conversation?.peer?.displayName ?? '…');
|
||||
|
||||
async function handleSendText() {
|
||||
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const replyToId = replyTo?.id;
|
||||
try {
|
||||
await chat.sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: id,
|
||||
plaintext: text.trim(),
|
||||
senderUserId: user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
setText('');
|
||||
setReplyTo(null);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendImage(pick: PickedImage) {
|
||||
if (!user || !device || !ownPrivateKey || !id) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(pick.uri);
|
||||
const blob = await resp.blob();
|
||||
const result = await chat.encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId: id,
|
||||
file: blob,
|
||||
mimeType: pick.mimeType,
|
||||
sizeBytes: pick.sizeBytes,
|
||||
width: pick.width,
|
||||
height: pick.height,
|
||||
});
|
||||
await chat.sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: id,
|
||||
plaintext: '',
|
||||
senderUserId: user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
attachmentHandles: [result.handle],
|
||||
});
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Bild senden fehlgeschlagen');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openImageMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{ options: ['Foto aufnehmen', 'Aus Galerie wählen', 'Abbrechen'], cancelButtonIndex: 2 },
|
||||
async (idx) => {
|
||||
if (idx === 0) {
|
||||
const p = await captureFromCamera();
|
||||
if (p) await sendImage(p);
|
||||
} else if (idx === 1) {
|
||||
const p = await pickFromLibrary();
|
||||
if (p) await sendImage(p);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Bild senden', undefined, [
|
||||
{
|
||||
text: 'Foto aufnehmen',
|
||||
onPress: async () => {
|
||||
const p = await captureFromCamera();
|
||||
if (p) await sendImage(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Aus Galerie wählen',
|
||||
onPress: async () => {
|
||||
const p = await pickFromLibrary();
|
||||
if (p) await sendImage(p);
|
||||
},
|
||||
},
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReact(emoji: string, messageId: string) {
|
||||
if (!user) return;
|
||||
try {
|
||||
const mine = reactions.get(messageId)?.some((r) => r.userId === user.id && r.emoji === emoji);
|
||||
if (mine) {
|
||||
await chat.removeReaction(supabase, messageId, emoji);
|
||||
} else {
|
||||
await chat.addReaction(supabase, messageId, emoji);
|
||||
}
|
||||
const rows = await chat.listReactionsForMessages(
|
||||
supabase,
|
||||
(messages ?? []).map((m) => m.id),
|
||||
);
|
||||
const map = new Map<string, MessageReaction[]>();
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.messageId) ?? [];
|
||||
arr.push(r);
|
||||
map.set(r.messageId, arr);
|
||||
}
|
||||
setReactions(map);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Reaktion fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(messageId: string) {
|
||||
Alert.alert('Nachricht löschen', 'Diese Nachricht für alle löschen?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await chat.softDeleteMessage(supabase, messageId);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen');
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const parentLookup = useMemo(() => {
|
||||
const m = new Map<string, DecryptedMessage>();
|
||||
for (const msg of messages ?? []) m.set(msg.id, msg);
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 80 : 0}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title,
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerBackTitle: 'Chats',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!id) return;
|
||||
void startCall(id);
|
||||
}}
|
||||
hitSlop={10}
|
||||
style={styles.callBtn}
|
||||
>
|
||||
<Text style={styles.callBtnText}>📞</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{messages === null && !error && (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{messages && (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={messages}
|
||||
keyExtractor={(m) => m.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => {
|
||||
const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={item}
|
||||
mine={item.senderId === user?.id}
|
||||
senderName={senderName(item.senderId)}
|
||||
parent={parent}
|
||||
parentSenderName={parent ? senderName(parent.senderId) : ''}
|
||||
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
reactions={reactions.get(item.id) ?? []}
|
||||
myUserId={user?.id ?? null}
|
||||
ownDeviceId={device?.id ?? null}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
onLongPress={() => setActiveMessage(item)}
|
||||
onToggleReaction={(emoji) => {
|
||||
void handleReact(emoji, item.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{replyTo && (
|
||||
<View style={styles.replyBanner}>
|
||||
<View style={styles.replyBannerLeft}>
|
||||
<Text style={styles.replyBannerLabel}>
|
||||
Antwort an {senderName(replyTo.senderId)}
|
||||
</Text>
|
||||
<Text style={styles.replyBannerBody} numberOfLines={1}>
|
||||
{replyTo.plaintext ?? '…'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => setReplyTo(null)} hitSlop={10}>
|
||||
<Text style={styles.replyBannerClose}>×</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<Pressable
|
||||
style={styles.plusBtn}
|
||||
onPress={openImageMenu}
|
||||
disabled={sending}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.plusText}>+</Text>
|
||||
</Pressable>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="Nachricht schreiben…"
|
||||
placeholderTextColor={colors.textDim}
|
||||
style={styles.input}
|
||||
multiline
|
||||
editable={!sending}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
|
||||
disabled={!text.trim() || sending}
|
||||
onPress={handleSendText}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.sendBtnText}>Senden</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<MessageActionsSheet
|
||||
visible={activeMessage !== null}
|
||||
mine={activeMessage?.senderId === user?.id}
|
||||
onClose={() => setActiveMessage(null)}
|
||||
onReact={(emoji) => {
|
||||
if (activeMessage) void handleReact(emoji, activeMessage.id);
|
||||
}}
|
||||
onReply={() => {
|
||||
if (activeMessage) setReplyTo(activeMessage);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (activeMessage) handleDelete(activeMessage.id);
|
||||
}}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
error: { color: colors.danger, padding: 12, textAlign: 'center' },
|
||||
listContent: { padding: 12 },
|
||||
replyBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
gap: 12,
|
||||
},
|
||||
replyBannerLeft: { flex: 1 },
|
||||
replyBannerLabel: { color: colors.accent, fontSize: 12, fontWeight: '700' },
|
||||
replyBannerBody: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
|
||||
replyBannerClose: { color: colors.textMuted, fontSize: 22, paddingHorizontal: 6 },
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
padding: 8,
|
||||
gap: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
plusBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
borderRadius: 10,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
plusText: { color: colors.text, fontSize: 20, lineHeight: 22 },
|
||||
input: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
},
|
||||
sendBtn: {
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendBtnDisabled: { opacity: 0.5 },
|
||||
sendBtnText: { color: colors.text, fontWeight: '600' },
|
||||
callBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
callBtnText: { fontSize: 18 },
|
||||
});
|
||||
+28
-11
@@ -1,18 +1,35 @@
|
||||
// Root layout for Expo Router.
|
||||
// Wraps every screen. Place global providers here (Theme, Supabase/Auth context,
|
||||
// SafeAreaProvider, GestureHandlerRootView, etc.) once they exist.
|
||||
|
||||
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 { 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 (
|
||||
<>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
</Stack>
|
||||
</>
|
||||
<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>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
// Supabase magic-link emails redirect to netralax://auth/callback with
|
||||
// the tokens in either the URL fragment (#access_token=...&refresh_token=...)
|
||||
// or the query string depending on the provider. Expo Router parses the
|
||||
// query string into useLocalSearchParams. The hash portion would require
|
||||
// expo-linking; we accept both shapes for safety.
|
||||
export default function AuthCallback() {
|
||||
const params = useLocalSearchParams<{
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<'working' | 'error'>('working');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (params.error) {
|
||||
setStatus('error');
|
||||
setMessage(params.error_description ?? params.error);
|
||||
return;
|
||||
}
|
||||
if (!params.access_token || !params.refresh_token) {
|
||||
setStatus('error');
|
||||
setMessage('Magic-Link-URL enthielt keine Tokens.');
|
||||
return;
|
||||
}
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: params.access_token,
|
||||
refresh_token: params.refresh_token,
|
||||
});
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
setMessage(error.message);
|
||||
return;
|
||||
}
|
||||
router.replace('/(app)/chats');
|
||||
})();
|
||||
}, [params, router]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{status === 'working' ? (
|
||||
<>
|
||||
<ActivityIndicator color={colors.accent} size="large" />
|
||||
<Text style={styles.text}>Du wirst angemeldet…</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorTitle}>Anmeldung fehlgeschlagen</Text>
|
||||
<Text style={styles.text}>{message}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
gap: 16,
|
||||
},
|
||||
text: { color: colors.textMuted, fontSize: 14, textAlign: 'center' },
|
||||
errorTitle: { color: colors.danger, fontSize: 18, fontWeight: '600' },
|
||||
});
|
||||
+134
-12
@@ -1,17 +1,105 @@
|
||||
// Landing / Login screen.
|
||||
//
|
||||
// Milestone 1 TODO:
|
||||
// - Show app logo + "Enter your email" field.
|
||||
// - On submit, call shared/auth requestMagicLink(email).
|
||||
// - Handle deep link return in app/_layout.tsx (or a dedicated auth callback route).
|
||||
import { auth } from '@chat-app/shared';
|
||||
import { Redirect } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useAuth } from '../lib/authContext';
|
||||
import { env } from '../lib/env';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Phase 1 landing — magic-link login. Signup with invite code is not
|
||||
// part of this MVP (accounts come from desktop / admin). After a
|
||||
// successful request the user sees a "Check your inbox" state until
|
||||
// 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 [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 (session) return <Redirect href="/(app)/chats" />;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email.includes('@')) {
|
||||
setError('Bitte gültige E-Mail eingeben');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await auth.loginWithMagicLink(supabase, email.trim(), env.authRedirectUrl);
|
||||
setSent(true);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Netralax</Text>
|
||||
<Text style={styles.subtitle}>Check deine Mails</Text>
|
||||
<Text style={styles.help}>
|
||||
Wir haben dir einen Anmelde-Link an {email} geschickt. Tipp auf den Link, um
|
||||
dich anzumelden.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonGhost]}
|
||||
onPress={() => {
|
||||
setSent(false);
|
||||
setEmail('');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Andere E-Mail verwenden</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>ChatApp</Text>
|
||||
<Text style={styles.subtitle}>Login placeholder — magic link flow goes here.</Text>
|
||||
<Text style={styles.title}>Netralax</Text>
|
||||
<Text style={styles.subtitle}>Mit Magic-Link anmelden</Text>
|
||||
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="du@beispiel.de"
|
||||
placeholderTextColor={colors.textDim}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
style={styles.input}
|
||||
editable={!submitting}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, submitting && styles.buttonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Magic Link senden</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -21,9 +109,43 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0b0b0f',
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: '#fff', fontSize: 28, fontWeight: '600' },
|
||||
subtitle: { color: '#9ca3af', marginTop: 8, textAlign: 'center' },
|
||||
title: { color: colors.text, fontSize: 32, fontWeight: '700', letterSpacing: 1, marginBottom: 8 },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14, marginBottom: 24 },
|
||||
help: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 15,
|
||||
marginBottom: 12,
|
||||
},
|
||||
error: { color: colors.danger, fontSize: 13, marginBottom: 12 },
|
||||
button: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonGhost: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border },
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { color: colors.text, fontWeight: '600', fontSize: 15 },
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -2,9 +2,5 @@ module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
// expo-router requires this plugin
|
||||
'expo-router/babel',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type { AttachmentHandle } from '@chat-app/shared/chat';
|
||||
import { Buffer } from 'buffer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { getCachedAttachment, setCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
ownDeviceId: string;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
// Decrypts an encrypted image attachment on first mount and renders it
|
||||
// inline. Subsequent mounts hit the in-memory cache. Failure (key not
|
||||
// shared yet for this device, network error, etc.) shows a small error
|
||||
// placeholder rather than crashing the parent message bubble.
|
||||
export function AttachmentImage({ handle }: Props) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(() => getCachedAttachment(handle.id) ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataUrl) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const blob = await chat.downloadAndDecryptAttachment({
|
||||
client: supabase,
|
||||
handle,
|
||||
});
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const b64 = Buffer.from(bytes as unknown as ArrayLike<number>).toString('base64');
|
||||
const url = 'data:' + handle.mimeType + ';base64,' + b64;
|
||||
if (cancelled) return;
|
||||
setCachedAttachment(handle.id, url);
|
||||
setDataUrl(url);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'decrypt failed');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dataUrl, handle]);
|
||||
|
||||
// Aspect ratio honoured if available; fall back to a 4:3 placeholder.
|
||||
const aspect =
|
||||
handle.width && handle.height && handle.height > 0 ? handle.width / handle.height : 4 / 3;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
|
||||
<Text style={styles.errorText}>🔒 {error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!dataUrl) {
|
||||
return (
|
||||
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: dataUrl }}
|
||||
style={[styles.image, { aspectRatio: aspect }]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: { color: colors.danger, fontSize: 12 },
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Initial-letter avatar circle. Phase 2 will swap in an image-aware
|
||||
// version that prefers profile.avatarUrl when present.
|
||||
export function Avatar({ name, size = 40 }: { name: string; size?: number }) {
|
||||
const letter = (name.trim().charAt(0) || '?').toUpperCase();
|
||||
return (
|
||||
<View style={[styles.circle, { width: size, height: size, borderRadius: size / 2 }]}>
|
||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{letter}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
circle: {
|
||||
backgroundColor: colors.accentMuted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
letter: { color: colors.accent, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { formatRelativeTime } from '../lib/timeFormat';
|
||||
import { colors } from '../theme/colors';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
lastMessagePreview: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function ConversationRow({ conversation, lastMessagePreview, onPress }: Props) {
|
||||
const title =
|
||||
conversation.type === 'group'
|
||||
? (conversation.name ?? 'Gruppe')
|
||||
: (conversation.peer?.displayName ?? '—');
|
||||
const subtitle =
|
||||
conversation.type === 'group'
|
||||
? conversation.members.length + ' Mitglieder'
|
||||
: '@' + (conversation.peer?.username ?? '');
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surface }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Avatar name={title} />
|
||||
<View style={styles.center}>
|
||||
<View style={styles.titleLine}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.time}>{formatRelativeTime(conversation.lastMessageAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.preview} numberOfLines={1}>
|
||||
{lastMessagePreview || subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 12,
|
||||
},
|
||||
center: { flex: 1, minWidth: 0 },
|
||||
titleLine: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
|
||||
title: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 },
|
||||
time: { color: colors.textDim, fontSize: 11 },
|
||||
preview: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Catch render errors anywhere below this boundary and show a readable
|
||||
// fallback. Without it, a thrown error during render produces a white
|
||||
// screen on TestFlight / production builds with no way for the user to
|
||||
// recover short of force-closing the app. Mounted once at the top of
|
||||
// app/_layout.tsx so it wraps every screen.
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// Bubble to Metro/console in dev so the redbox still shows; in prod
|
||||
// builds this is the only place an exception trace surfaces.
|
||||
console.error('[ErrorBoundary] caught render error', error, info.componentStack);
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Etwas ist schiefgelaufen</Text>
|
||||
<Text style={styles.message}>{this.state.error.message}</Text>
|
||||
<Pressable style={styles.button} onPress={this.reset}>
|
||||
<Text style={styles.buttonText}>Erneut versuchen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 22, fontWeight: '600', marginBottom: 8 },
|
||||
message: { color: colors.textMuted, textAlign: 'center', marginBottom: 24 },
|
||||
button: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
},
|
||||
buttonText: { color: colors.text, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
import { useCall } from '../lib/callContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Full-screen modal that surfaces over any route when the CallContext
|
||||
// reports an `incoming` state. Resolves the caller's display name from
|
||||
// the conversation membership; falls back to "Anrufer" otherwise.
|
||||
export function IncomingCallModal() {
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const visible = state.kind === 'incoming';
|
||||
|
||||
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'incoming') {
|
||||
setConversation(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const all = await chat.listConversations(supabase);
|
||||
if (cancelled) return;
|
||||
setConversation(all.find((c) => c.id === state.conversationId) ?? null);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
if (state.kind !== 'incoming') return null;
|
||||
|
||||
const callerName = (() => {
|
||||
if (!conversation) return 'Anrufer';
|
||||
const m = conversation.members.find((mm) => mm.userId === state.fromUserId);
|
||||
return m?.profile?.displayName ?? m?.profile?.username ?? 'Anrufer';
|
||||
})();
|
||||
|
||||
const conversationTitle =
|
||||
conversation?.type === 'group' ? (conversation.name ?? 'Gruppe') : callerName;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={rejectIncoming}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.subtitle}>Eingehender Anruf</Text>
|
||||
<Avatar name={callerName} size={96} />
|
||||
<Text style={styles.title}>{callerName}</Text>
|
||||
{conversation?.type === 'group' && (
|
||||
<Text style={styles.subtitle}>in {conversationTitle}</Text>
|
||||
)}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonReject]}
|
||||
onPress={() => {
|
||||
void rejectIncoming();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Ablehnen</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonAccept]}
|
||||
onPress={() => {
|
||||
void acceptIncoming();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Annehmen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 20,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 28, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14 },
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 24,
|
||||
marginTop: 32,
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 14,
|
||||
minWidth: 140,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonAccept: { backgroundColor: colors.success },
|
||||
buttonReject: { backgroundColor: colors.danger },
|
||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Modal, Pressable, StyleSheet, Text } from 'react-native';
|
||||
|
||||
import { ReactionStrip } from './ReactionStrip';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
mine: boolean;
|
||||
onClose: () => void;
|
||||
onReact: (emoji: string) => void;
|
||||
onReply: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
// Bottom-sheet-style modal opened on long-press of a message bubble.
|
||||
// Contains the reaction quick-strip plus the action rows. The "Löschen"
|
||||
// row is hidden for messages not authored by the current user — the
|
||||
// server-side trigger would refuse anyway, but trimming UI keeps the
|
||||
// surface honest.
|
||||
export function MessageActionsSheet({
|
||||
visible,
|
||||
mine,
|
||||
onClose,
|
||||
onReact,
|
||||
onReply,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable style={styles.sheet} onPress={() => undefined}>
|
||||
<ReactionStrip
|
||||
onReact={(e) => {
|
||||
onReact(e);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<Action label="Antworten" onPress={() => { onReply(); onClose(); }} />
|
||||
{mine && (
|
||||
<Action
|
||||
label="Löschen"
|
||||
danger
|
||||
onPress={() => { onDelete(); onClose(); }}
|
||||
/>
|
||||
)}
|
||||
<Action label="Abbrechen" muted onPress={onClose} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
label,
|
||||
danger,
|
||||
muted,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
muted?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||
>
|
||||
<Text style={[styles.actionText, danger && styles.actionDanger, muted && styles.actionMuted]}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
action: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
actionPressed: { backgroundColor: colors.bg },
|
||||
actionText: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||
actionDanger: { color: colors.danger },
|
||||
actionMuted: { color: colors.textMuted },
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { chat as chatNs } from '@chat-app/shared';
|
||||
import type { DecryptedMessage, MessageReaction } from '@chat-app/shared/chat';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { ReactionPills } from './ReactionPills';
|
||||
|
||||
interface Props {
|
||||
message: DecryptedMessage;
|
||||
mine: boolean;
|
||||
senderName: string;
|
||||
time: string;
|
||||
parent: DecryptedMessage | null;
|
||||
parentSenderName: string;
|
||||
reactions: MessageReaction[];
|
||||
myUserId: string | null;
|
||||
ownDeviceId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
onLongPress: () => void;
|
||||
onToggleReaction: (emoji: string) => void;
|
||||
}
|
||||
|
||||
// Single message bubble. Renders, in order:
|
||||
// * Reply quote (when this message has a replyToId).
|
||||
// * Body text (or "Nachricht gelöscht" placeholder).
|
||||
// * Image attachment (max 1 in Phase 2 — first one wins).
|
||||
// * Reaction pills.
|
||||
// Long-press surfaces the MessageActionsSheet via the onLongPress callback.
|
||||
export function MessageBubble({
|
||||
message,
|
||||
mine,
|
||||
senderName,
|
||||
time,
|
||||
parent,
|
||||
parentSenderName,
|
||||
reactions,
|
||||
myUserId,
|
||||
ownDeviceId,
|
||||
ownPrivateKey,
|
||||
onLongPress,
|
||||
onToggleReaction,
|
||||
}: Props) {
|
||||
const deleted = message.deletedAt !== null;
|
||||
const parsed = chatNs.parseMessagePayload(message.plaintext);
|
||||
const text = parsed.kind === 'text' ? parsed.text : '';
|
||||
const attachments = parsed.kind === 'text' ? parsed.attachments : [];
|
||||
const firstImage = attachments.find((a) => a.mimeType.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<View style={[styles.wrap, mine ? styles.wrapMine : styles.wrapOther]}>
|
||||
<Pressable
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={250}
|
||||
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}
|
||||
>
|
||||
{!mine && !deleted && <Text style={styles.sender}>{senderName}</Text>}
|
||||
|
||||
{message.replyToId && (
|
||||
<View style={[styles.replyQuote, mine ? styles.replyQuoteMine : null]}>
|
||||
<Text style={styles.replyAuthor}>{parent ? parentSenderName : 'Original'}</Text>
|
||||
<Text style={styles.replyBody} numberOfLines={2}>
|
||||
{parent
|
||||
? quotedPreview(parent)
|
||||
: '↩ Original-Nachricht außerhalb dieses Fensters'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{deleted ? (
|
||||
<Text style={styles.deleted}>Nachricht gelöscht</Text>
|
||||
) : (
|
||||
<>
|
||||
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
|
||||
{firstImage && ownDeviceId && ownPrivateKey && (
|
||||
<AttachmentImage
|
||||
handle={firstImage}
|
||||
ownDeviceId={ownDeviceId}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.time}>{time}</Text>
|
||||
</Pressable>
|
||||
|
||||
{!deleted && (
|
||||
<ReactionPills
|
||||
reactions={reactions}
|
||||
myUserId={myUserId}
|
||||
onToggle={onToggleReaction}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function quotedPreview(m: DecryptedMessage): string {
|
||||
if (m.deletedAt) return '[gelöscht]';
|
||||
const parsed = chatNs.parseMessagePayload(m.plaintext);
|
||||
if (parsed.kind === 'text') {
|
||||
if (parsed.text.length > 0) return parsed.text;
|
||||
if (parsed.attachments.length > 0) return '📎 Anhang';
|
||||
}
|
||||
return '…';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { marginVertical: 4, maxWidth: '78%' },
|
||||
wrapMine: { alignSelf: 'flex-end', alignItems: 'flex-end' },
|
||||
wrapOther: { alignSelf: 'flex-start', alignItems: 'flex-start' },
|
||||
bubble: { padding: 10, borderRadius: 14 },
|
||||
bubbleMine: { backgroundColor: colors.accent },
|
||||
bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 },
|
||||
sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 20 },
|
||||
time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' },
|
||||
deleted: { color: colors.textMuted, fontSize: 14, fontStyle: 'italic' },
|
||||
replyQuote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.accent,
|
||||
paddingLeft: 8,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 6,
|
||||
backgroundColor: colors.bg,
|
||||
borderRadius: 4,
|
||||
},
|
||||
replyQuoteMine: { backgroundColor: 'rgba(0,0,0,0.18)', borderLeftColor: colors.text },
|
||||
replyAuthor: { color: colors.textMuted, fontSize: 11, fontWeight: '700' },
|
||||
replyBody: { color: colors.text, fontSize: 13, opacity: 0.85 },
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { MessageReaction } from '@chat-app/shared/chat';
|
||||
import { useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
reactions: MessageReaction[];
|
||||
myUserId: string | null;
|
||||
onToggle: (emoji: string) => void;
|
||||
}
|
||||
|
||||
// Renders the per-message reaction badges underneath a bubble. Pills
|
||||
// the current user has reacted to get a tinted background so they know
|
||||
// which ones to tap to un-react.
|
||||
export function ReactionPills({ reactions, myUserId, onToggle }: Props) {
|
||||
const grouped = useMemo(() => groupByEmoji(reactions, myUserId), [reactions, myUserId]);
|
||||
if (grouped.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{grouped.map((g) => (
|
||||
<Pressable
|
||||
key={g.emoji}
|
||||
onPress={() => onToggle(g.emoji)}
|
||||
style={[styles.pill, g.mine && styles.pillMine]}
|
||||
>
|
||||
<Text style={styles.emoji}>{g.emoji}</Text>
|
||||
<Text style={[styles.count, g.mine && styles.countMine]}>{g.count}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByEmoji(
|
||||
rows: MessageReaction[],
|
||||
myUserId: string | null,
|
||||
): Array<{ emoji: string; count: number; mine: boolean }> {
|
||||
const m = new Map<string, { count: number; mine: boolean }>();
|
||||
for (const r of rows) {
|
||||
const prev = m.get(r.emoji) ?? { count: 0, mine: false };
|
||||
m.set(r.emoji, {
|
||||
count: prev.count + 1,
|
||||
mine: prev.mine || (myUserId !== null && r.userId === myUserId),
|
||||
});
|
||||
}
|
||||
return Array.from(m.entries()).map(([emoji, v]) => ({ emoji, ...v }));
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
gap: 4,
|
||||
},
|
||||
pillMine: { backgroundColor: colors.accentMuted, borderColor: colors.accent },
|
||||
emoji: { fontSize: 13 },
|
||||
count: { color: colors.textMuted, fontSize: 12, fontWeight: '600' },
|
||||
countMine: { color: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Hardcoded 6-emoji quick reactor shown at the top of the long-press
|
||||
// sheet. A full emoji picker is post-Phase-4; this is the Discord-style
|
||||
// fast path the vast majority of reactions go through.
|
||||
export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🎉'] as const;
|
||||
|
||||
export function ReactionStrip({ onReact }: { onReact: (emoji: string) => void }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{QUICK_REACTIONS.map((e) => (
|
||||
<Pressable
|
||||
key={e}
|
||||
onPress={() => onReact(e)}
|
||||
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.emoji}>{e}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
button: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 22,
|
||||
},
|
||||
buttonPressed: { backgroundColor: colors.accentMuted },
|
||||
emoji: { fontSize: 26 },
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 13.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"simulator": false
|
||||
},
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "REPLACE_WITH_APPLE_ID",
|
||||
"ascAppId": "REPLACE_WITH_ASC_APP_ID"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./play-service-account.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Module-level memo of decrypted-attachment data URLs by handle id.
|
||||
// Survives screen unmounts (e.g. user pops in and out of a conversation)
|
||||
// but evicts on app restart — good enough for Phase 2; an LRU + disk
|
||||
// cache is a later polish.
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
export function getCachedAttachment(id: string): string | undefined {
|
||||
return cache.get(id);
|
||||
}
|
||||
|
||||
export function setCachedAttachment(id: string, dataUrl: string): void {
|
||||
cache.set(id, dataUrl);
|
||||
}
|
||||
|
||||
export function clearAttachmentCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 { secretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// 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';
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
device: DeviceRecord | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
loading: boolean;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useAuth() called outside <AuthProvider>');
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.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);
|
||||
|
||||
// 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 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,
|
||||
});
|
||||
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
|
||||
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
|
||||
setDevice(record);
|
||||
setOwnPrivateKey(kp.privateKey);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
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);
|
||||
})();
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession);
|
||||
if (!nextSession) {
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
} else {
|
||||
void ensureDevice(nextSession).catch((err) =>
|
||||
console.warn('[auth] ensureDevice (state change) failed', err),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, [ensureDevice]);
|
||||
|
||||
const signOut = useCallback(async (): Promise<void> => {
|
||||
await supabase.auth.signOut();
|
||||
await secretStore.removeSecret(KEY_DEVICE_ID);
|
||||
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
device,
|
||||
ownPrivateKey,
|
||||
loading,
|
||||
signOut,
|
||||
};
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { AudioSession } from '@livekit/react-native';
|
||||
import { Room, RoomEvent, Track } from 'livekit-client';
|
||||
import { chat, rtc } from '@chat-app/shared';
|
||||
import type { CallSignal } from '@chat-app/shared/rtc';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useAuth } from './authContext';
|
||||
import { sendCallSignal, subscribeCallSignals } from './callSignal';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
type Identity = string;
|
||||
|
||||
export interface RemoteParticipantSummary {
|
||||
identity: Identity;
|
||||
name: string;
|
||||
speaking: boolean;
|
||||
}
|
||||
|
||||
export type CallState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'outgoing'; callId: string; conversationId: string; peers: Identity[] }
|
||||
| { kind: 'incoming'; callId: string; conversationId: string; fromUserId: Identity }
|
||||
| { kind: 'connecting'; callId: string; conversationId: string }
|
||||
| {
|
||||
kind: 'connected';
|
||||
callId: string;
|
||||
conversationId: string;
|
||||
muted: boolean;
|
||||
speakerOn: boolean;
|
||||
participants: RemoteParticipantSummary[];
|
||||
}
|
||||
| { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' };
|
||||
|
||||
interface CallContextValue {
|
||||
state: CallState;
|
||||
startCall: (conversationId: string) => Promise<void>;
|
||||
acceptIncoming: () => Promise<void>;
|
||||
rejectIncoming: () => Promise<void>;
|
||||
cancelOutgoing: () => Promise<void>;
|
||||
endCall: () => Promise<void>;
|
||||
toggleMute: () => Promise<void>;
|
||||
toggleSpeaker: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<CallContextValue | null>(null);
|
||||
|
||||
export function useCall(): CallContextValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useCall() called outside <CallProvider>');
|
||||
return v;
|
||||
}
|
||||
|
||||
function randomCallId(): string {
|
||||
return 'call-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// Time the call state lingers in `ended` so the UI can render a transient
|
||||
// status pill ("Anruf abgelehnt", "Anruf beendet") before snapping back
|
||||
// to idle. Keep this comfortably above the user's reaction time but short
|
||||
// enough that returning to a chat feels snappy.
|
||||
const ENDED_STATE_LINGER_MS = 2500;
|
||||
|
||||
export function CallProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const myUserId = user?.id ?? null;
|
||||
const [state, setState] = useState<CallState>({ kind: 'idle' });
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const participantsRef = useRef<Map<Identity, RemoteParticipantSummary>>(new Map());
|
||||
|
||||
const peerIdsFor = useCallback(
|
||||
async (conversationId: string): Promise<Identity[]> => {
|
||||
if (!myUserId) return [];
|
||||
const all = await chat.listConversations(supabase);
|
||||
const conv = all.find((c) => c.id === conversationId);
|
||||
if (!conv) return [];
|
||||
return conv.members.map((m) => m.userId).filter((id) => id !== myUserId);
|
||||
},
|
||||
[myUserId],
|
||||
);
|
||||
|
||||
const teardownRoom = useCallback(async () => {
|
||||
const r = roomRef.current;
|
||||
roomRef.current = null;
|
||||
participantsRef.current.clear();
|
||||
if (r) {
|
||||
try {
|
||||
await r.disconnect();
|
||||
} catch (err) {
|
||||
console.warn('[call] room.disconnect failed', err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await AudioSession.stopAudioSession();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateParticipantsState = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (prev.kind !== 'connected') return prev;
|
||||
return {
|
||||
...prev,
|
||||
participants: Array.from(participantsRef.current.values()),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const joinRoom = useCallback(
|
||||
async (conversationId: string, callId: string): Promise<void> => {
|
||||
const token = await rtc.fetchLivekitToken(supabase, conversationId);
|
||||
await AudioSession.startAudioSession();
|
||||
const room = new Room();
|
||||
roomRef.current = room;
|
||||
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, (p) => {
|
||||
participantsRef.current.set(p.identity, {
|
||||
identity: p.identity,
|
||||
name: p.name || p.identity,
|
||||
speaking: p.isSpeaking,
|
||||
});
|
||||
updateParticipantsState();
|
||||
})
|
||||
.on(RoomEvent.ParticipantDisconnected, (p) => {
|
||||
participantsRef.current.delete(p.identity);
|
||||
updateParticipantsState();
|
||||
})
|
||||
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
for (const [id, entry] of participantsRef.current) {
|
||||
entry.speaking = speakers.some((sp) => sp.identity === id);
|
||||
participantsRef.current.set(id, entry);
|
||||
}
|
||||
updateParticipantsState();
|
||||
});
|
||||
|
||||
await room.connect(token.url, token.token);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
|
||||
for (const p of room.remoteParticipants.values()) {
|
||||
participantsRef.current.set(p.identity, {
|
||||
identity: p.identity,
|
||||
name: p.name || p.identity,
|
||||
speaking: p.isSpeaking,
|
||||
});
|
||||
}
|
||||
|
||||
setState({
|
||||
kind: 'connected',
|
||||
callId,
|
||||
conversationId,
|
||||
muted: false,
|
||||
speakerOn: false,
|
||||
participants: Array.from(participantsRef.current.values()),
|
||||
});
|
||||
},
|
||||
[updateParticipantsState],
|
||||
);
|
||||
|
||||
// Signal subscription. Routes incoming invites / cancels / rejects to
|
||||
// state transitions. eslint-disable on deps because handleIncomingSignal
|
||||
// is defined inline below; re-subscribing on every render would churn
|
||||
// the realtime channel.
|
||||
useEffect(() => {
|
||||
if (!myUserId) return;
|
||||
const unsub = subscribeCallSignals(supabase, myUserId, (sig) => {
|
||||
handleIncomingSignal(sig);
|
||||
});
|
||||
return unsub;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [myUserId]);
|
||||
|
||||
function handleIncomingSignal(sig: CallSignal) {
|
||||
setState((prev) => {
|
||||
switch (sig.type) {
|
||||
case 'invite':
|
||||
if (prev.kind === 'idle') {
|
||||
return {
|
||||
kind: 'incoming',
|
||||
callId: sig.callId,
|
||||
conversationId: sig.conversationId,
|
||||
fromUserId: sig.fromUserId,
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
case 'cancel':
|
||||
if (
|
||||
(prev.kind === 'incoming' || prev.kind === 'connecting') &&
|
||||
prev.callId === sig.callId
|
||||
) {
|
||||
return { kind: 'ended', reason: 'cancelled' };
|
||||
}
|
||||
return prev;
|
||||
case 'reject':
|
||||
if (prev.kind === 'outgoing' && prev.callId === sig.callId) {
|
||||
void teardownRoom();
|
||||
return { kind: 'ended', reason: 'rejected' };
|
||||
}
|
||||
return prev;
|
||||
case 'accept':
|
||||
return prev;
|
||||
case 'end':
|
||||
if (
|
||||
(prev.kind === 'connected' || prev.kind === 'connecting') &&
|
||||
prev.callId === sig.callId
|
||||
) {
|
||||
void teardownRoom();
|
||||
return { kind: 'ended', reason: 'normal' };
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const startCall = useCallback(
|
||||
async (conversationId: string): Promise<void> => {
|
||||
if (!myUserId) throw new Error('not authenticated');
|
||||
const callId = randomCallId();
|
||||
const peers = await peerIdsFor(conversationId);
|
||||
setState({ kind: 'outgoing', callId, conversationId, peers });
|
||||
try {
|
||||
await Promise.all(
|
||||
peers.map((peerId) =>
|
||||
sendCallSignal(supabase, peerId, {
|
||||
type: 'invite',
|
||||
callId,
|
||||
conversationId,
|
||||
fromUserId: myUserId,
|
||||
kind: 'audio',
|
||||
sentAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await joinRoom(conversationId, callId);
|
||||
} catch (err) {
|
||||
console.warn('[call] startCall failed', err);
|
||||
await teardownRoom();
|
||||
setState({ kind: 'ended', reason: 'error' });
|
||||
}
|
||||
},
|
||||
[myUserId, peerIdsFor, joinRoom, teardownRoom],
|
||||
);
|
||||
|
||||
const acceptIncoming = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'incoming') return;
|
||||
const { callId, conversationId, fromUserId } = state;
|
||||
setState({ kind: 'connecting', callId, conversationId });
|
||||
try {
|
||||
await sendCallSignal(supabase, fromUserId, {
|
||||
type: 'accept',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
});
|
||||
await joinRoom(conversationId, callId);
|
||||
} catch (err) {
|
||||
console.warn('[call] acceptIncoming failed', err);
|
||||
await teardownRoom();
|
||||
setState({ kind: 'ended', reason: 'error' });
|
||||
}
|
||||
}, [myUserId, state, joinRoom, teardownRoom]);
|
||||
|
||||
const rejectIncoming = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'incoming') return;
|
||||
const { callId, fromUserId } = state;
|
||||
setState({ kind: 'ended', reason: 'rejected' });
|
||||
try {
|
||||
await sendCallSignal(supabase, fromUserId, {
|
||||
type: 'reject',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[call] rejectIncoming send failed', err);
|
||||
}
|
||||
}, [myUserId, state]);
|
||||
|
||||
const cancelOutgoing = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'outgoing') return;
|
||||
const { callId, peers } = state;
|
||||
setState({ kind: 'ended', reason: 'cancelled' });
|
||||
try {
|
||||
await Promise.all(
|
||||
peers.map((peerId) =>
|
||||
sendCallSignal(supabase, peerId, {
|
||||
type: 'cancel',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] cancelOutgoing send failed', err);
|
||||
}
|
||||
await teardownRoom();
|
||||
}, [myUserId, state, teardownRoom]);
|
||||
|
||||
const endCall = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'connected') {
|
||||
await teardownRoom();
|
||||
setState({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
const { callId, conversationId } = state;
|
||||
const peers = await peerIdsFor(conversationId);
|
||||
setState({ kind: 'ended', reason: 'normal' });
|
||||
try {
|
||||
await Promise.all(
|
||||
peers.map((peerId) =>
|
||||
sendCallSignal(supabase, peerId, {
|
||||
type: 'end',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] endCall send failed', err);
|
||||
}
|
||||
await teardownRoom();
|
||||
}, [myUserId, state, peerIdsFor, teardownRoom]);
|
||||
|
||||
const toggleMute = useCallback(async (): Promise<void> => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const nextMuted = !state.muted;
|
||||
try {
|
||||
await r.localParticipant.setMicrophoneEnabled(!nextMuted);
|
||||
setState((prev) =>
|
||||
prev.kind === 'connected' ? { ...prev, muted: nextMuted } : prev,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] toggleMute failed', err);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const toggleSpeaker = useCallback(async (): Promise<void> => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const nextSpeaker = !state.speakerOn;
|
||||
try {
|
||||
await AudioSession.selectAudioOutput(nextSpeaker ? 'speaker' : 'earpiece');
|
||||
setState((prev) =>
|
||||
prev.kind === 'connected' ? { ...prev, speakerOn: nextSpeaker } : prev,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] toggleSpeaker failed', err);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Drift `ended` back to `idle` after the linger window so the UI can
|
||||
// show a brief status pill before snapping back.
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'ended') return;
|
||||
const t = setTimeout(() => {
|
||||
setState({ kind: 'idle' });
|
||||
}, ENDED_STATE_LINGER_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
const value: CallContextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
startCall,
|
||||
acceptIncoming,
|
||||
rejectIncoming,
|
||||
cancelOutgoing,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
}),
|
||||
[state, startCall, acceptIncoming, rejectIncoming, cancelOutgoing, endCall, toggleMute, toggleSpeaker],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export { Track };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { rtc } from '@chat-app/shared';
|
||||
import type { CallSignal } from '@chat-app/shared/rtc';
|
||||
import type { AppSupabaseClient } from '@chat-app/shared/supabase';
|
||||
|
||||
// Thin wrapper around Supabase realtime broadcast for call signaling.
|
||||
// One channel per peer userId. Subscriptions live for the lifetime of
|
||||
// the AuthProvider's session; teardown returns a no-arg unsubscribe.
|
||||
|
||||
export type SignalListener = (signal: CallSignal) => void;
|
||||
|
||||
export function subscribeCallSignals(
|
||||
client: AppSupabaseClient,
|
||||
myUserId: string,
|
||||
onSignal: SignalListener,
|
||||
): () => void {
|
||||
const ch = client.channel(rtc.signalTopic(myUserId));
|
||||
ch.on('broadcast', { event: 'signal' }, (msg) => {
|
||||
if (msg.payload && typeof msg.payload === 'object') {
|
||||
onSignal(msg.payload as CallSignal);
|
||||
}
|
||||
});
|
||||
void ch.subscribe();
|
||||
return () => {
|
||||
void client.removeChannel(ch);
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendCallSignal(
|
||||
client: AppSupabaseClient,
|
||||
toUserId: string,
|
||||
payload: CallSignal,
|
||||
): Promise<void> {
|
||||
const ch = client.channel(rtc.signalTopic(toUserId));
|
||||
await ch.subscribe();
|
||||
await ch.send({ type: 'broadcast', event: 'signal', payload });
|
||||
await client.removeChannel(ch);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as s from 'react-native-libsodium';
|
||||
import type { CryptoBackend } from '@chat-app/shared/crypto';
|
||||
|
||||
// react-native-libsodium re-exports libsodium-wrappers' API shape, so
|
||||
// this adapter is the synchronous twin of the desktop one
|
||||
// (`apps/desktop/src/lib/cryptoBackend.ts`). No WASM warm-up gate is
|
||||
// needed — the native module is ready as soon as the module loads.
|
||||
export function createLibsodiumBackend(): CryptoBackend {
|
||||
return {
|
||||
name: 'react-native-libsodium',
|
||||
nonceLength: s.crypto_box_NONCEBYTES,
|
||||
publicKeyLength: s.crypto_box_PUBLICKEYBYTES,
|
||||
privateKeyLength: s.crypto_box_SECRETKEYBYTES,
|
||||
secretboxKeyLength: s.crypto_secretbox_KEYBYTES,
|
||||
secretboxNonceLength: s.crypto_secretbox_NONCEBYTES,
|
||||
randomBytes: (n) => s.randombytes_buf(n),
|
||||
generateKeyPair: () => {
|
||||
const kp = s.crypto_box_keypair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
|
||||
},
|
||||
box: (plaintext, nonce, recipientPublicKey, senderPrivateKey) =>
|
||||
s.crypto_box_easy(plaintext, nonce, recipientPublicKey, senderPrivateKey),
|
||||
boxOpen: (ciphertext, nonce, senderPublicKey, recipientPrivateKey) =>
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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.
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
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;
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
|
||||
export interface PickedImage {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Pick an image from the photo library. Requests permission on demand;
|
||||
// returns null on cancel / denial.
|
||||
export async function pickFromLibrary(): Promise<PickedImage | null> {
|
||||
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!perm.granted) return null;
|
||||
const res = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
quality: 0.85,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
if (res.canceled || res.assets.length === 0) return null;
|
||||
return toPicked(res.assets[0]!);
|
||||
}
|
||||
|
||||
// Capture an image via the device camera. Same return shape as
|
||||
// pickFromLibrary so call sites can stay shape-agnostic.
|
||||
export async function captureFromCamera(): Promise<PickedImage | null> {
|
||||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!perm.granted) return null;
|
||||
const res = await ImagePicker.launchCameraAsync({
|
||||
quality: 0.85,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
if (res.canceled || res.assets.length === 0) return null;
|
||||
return toPicked(res.assets[0]!);
|
||||
}
|
||||
|
||||
function toPicked(asset: ImagePicker.ImagePickerAsset): PickedImage {
|
||||
return {
|
||||
uri: asset.uri,
|
||||
mimeType: asset.mimeType ?? 'image/jpeg',
|
||||
sizeBytes: asset.fileSize ?? 0,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
// SecretStore contract uses Uint8Array values; SecureStore only takes
|
||||
// strings, so we base64 at the boundary. iOS Keychain max value size
|
||||
// is generous (a few MB); private keys are 32 bytes so we are well
|
||||
// within limits.
|
||||
export const secretStore: SecretStore = {
|
||||
async getSecret(key) {
|
||||
const v = await SecureStore.getItemAsync(key);
|
||||
return v ? new Uint8Array(Buffer.from(v, 'base64')) : null;
|
||||
},
|
||||
async setSecret(key, value) {
|
||||
await SecureStore.setItemAsync(key, Buffer.from(value).toString('base64'));
|
||||
},
|
||||
async removeSecret(key) {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// supabase-js v2 accepts any object with async getItem / setItem /
|
||||
// removeItem returning Promise<string | null> / Promise<void>. RN's
|
||||
// AsyncStorage matches that shape for shape; we just re-export it
|
||||
// under a name that signals intent at the call site.
|
||||
export const sessionStorage = AsyncStorage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createClient } from '@chat-app/shared/supabase';
|
||||
|
||||
import { env } from './env';
|
||||
import { sessionStorage } from './sessionStorage';
|
||||
|
||||
// Single Supabase client instance for the mobile app. Token storage goes
|
||||
// to AsyncStorage so the session survives reboots. detectSessionInUrl is
|
||||
// false because the magic-link callback is handled by our own
|
||||
// app/auth/callback.tsx screen — Expo Router routes the deep link to
|
||||
// that file, and we parse it explicitly.
|
||||
export const supabase = createClient({
|
||||
url: env.supabaseUrl,
|
||||
anonKey: env.supabaseAnonKey,
|
||||
sessionStorage,
|
||||
detectSessionInUrl: false,
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Minimal relative-time formatter for chat lists. Matches the desktop's
|
||||
// terse style ("Vor 5 Min", "Gestern", "12.05.").
|
||||
export function formatRelativeTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const then = new Date(iso).getTime();
|
||||
if (!Number.isFinite(then)) return '';
|
||||
const diffSec = (Date.now() - then) / 1000;
|
||||
if (diffSec < 60) return 'Gerade eben';
|
||||
if (diffSec < 3600) return 'Vor ' + Math.floor(diffSec / 60) + ' Min';
|
||||
if (diffSec < 86400) return 'Vor ' + Math.floor(diffSec / 3600) + ' Std';
|
||||
if (diffSec < 86400 * 2) return 'Gestern';
|
||||
const d = new Date(iso);
|
||||
return [
|
||||
String(d.getDate()).padStart(2, '0'),
|
||||
String(d.getMonth() + 1).padStart(2, '0'),
|
||||
String(d.getFullYear()).slice(-2),
|
||||
].join('.');
|
||||
}
|
||||
@@ -18,5 +18,10 @@ config.resolver.nodeModulesPaths = [
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
// Make Metro honor the "exports" field in package.json. @chat-app/shared
|
||||
// maps subpaths like "./supabase" -> "./src/supabase/index.ts" via exports;
|
||||
// without this flag the eager bundler (preview/production) ignores them
|
||||
// and falls back to legacy main-only resolution, which 404s on subpaths.
|
||||
config.resolver.unstable_enablePackageExports = true;
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -18,22 +18,33 @@
|
||||
"clean": "rm -rf .expo node_modules/.cache .turbo *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chat-app/shared": "workspace:*",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@chat-app/db-types": "workspace:*",
|
||||
"@chat-app/shared": "workspace:*",
|
||||
"@config-plugins/react-native-webrtc": "^10.0.0",
|
||||
"@expo/metro-runtime": "^4.0.1",
|
||||
"@livekit/react-native": "^2.10.3",
|
||||
"@livekit/react-native-webrtc": "^144.0.0",
|
||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"expo": "^52.0.0",
|
||||
"expo-application": "^6.0.2",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-dev-client": "^5.0.20",
|
||||
"expo-image-picker": "^16.0.6",
|
||||
"expo-linking": "~7.0.0",
|
||||
"expo-notifications": "~0.29.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-sqlite": "~15.0.0",
|
||||
"expo-sqlite": "~15.1.4",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"livekit-client": "^2.7.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.0",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-gesture-handler": "^2.20.2",
|
||||
"react-native-libsodium": "^1.3.0",
|
||||
"react-native-safe-area-context": "~4.12.0",
|
||||
"react-native-screens": "~4.1.0"
|
||||
"react-native-screens": "~4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.0",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Centralised hex constants. Anything new screen / component should
|
||||
// import from here instead of inlining a literal so we have a single
|
||||
// place to swap brand tones later. The Phase-0 review flagged five
|
||||
// hex codes duplicated across components — this is the fix.
|
||||
export const colors = {
|
||||
// Surfaces / chrome
|
||||
bg: '#0b0b0f',
|
||||
surface: '#16161c',
|
||||
border: '#27272e',
|
||||
|
||||
// Text
|
||||
text: '#ffffff',
|
||||
textMuted: '#9ca3af',
|
||||
textDim: '#6b7280',
|
||||
|
||||
// Accents
|
||||
accent: '#5865f2',
|
||||
accentMuted: 'rgba(88,101,242,0.15)',
|
||||
|
||||
// Status
|
||||
danger: '#ef4444',
|
||||
success: '#10b981',
|
||||
} as const;
|
||||
|
||||
export type ColorKey = keyof typeof colors;
|
||||
@@ -0,0 +1,892 @@
|
||||
# Discord-Style Call Tile Handling 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:** Make in-call tile rendering, click-to-pin, and mixed share/webcam layouts mirror Discord — uniform 16:9 grid, correct object-fit per mode, left-click pins, auto-promote shares, multi-share bento.
|
||||
|
||||
**Architecture:** All changes are renderer-only inside `apps/desktop/src/components/`. A new discriminated-union `StageLayout` lives in `InCallPanel.tsx` and replaces the implicit `effectiveFocusedId` logic. `CallParticipantTile` gains a `fit` prop forwarded to the underlying `<video>` element. `ScreenShareViewer` drops its hardcoded 16:9 button-aspect because the parent grid cell owns the ratio now. No changes to `CallContext`, no data-model changes.
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, Tailwind (`aspect-video`, `grid-cols-*`, `object-cover`/`object-contain`), LiveKit JS SDK 2.x.
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md`](../specs/2026-05-12-discord-call-tile-handling-design.md)
|
||||
|
||||
**Testing note:** No Vitest/Jest harness exists for in-call layouts (Storybook not wired up). Every task ends with a **manual verification checklist** run against `pnpm --filter @chatapp/desktop dev` plus a peer (or a second window joined to the same room). Tasks are committed only after the manual checks pass.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Change |
|
||||
|---|---|---|
|
||||
| `apps/desktop/src/components/CallParticipantTile.tsx` | Single participant tile (webcam or audio-only) | Add `fit` prop on `VideoStub`, forward `focused` → `fit='contain'`, add `onDoubleClick` |
|
||||
| `apps/desktop/src/components/ScreenShareViewer.tsx` | Renders a remote screen-share with watch/fullscreen chrome | Drop hardcoded `aspectRatio: '16/9'` on the unwatched preview button |
|
||||
| `apps/desktop/src/components/InCallPanel.tsx` | Top-level in-call orchestration: stage layouts, fullscreen, controls, pin state plumbing | Add `StageLayout` selector; rewrite `CallStage` + `FullscreenCall` rendering; drop `grid-rows-*` from `gridColsFor`; wrap every tile cell in `aspect-video` |
|
||||
|
||||
No new files. Three modified files, each with a clear local responsibility.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: VideoStub gains `fit` prop, default cover, contain when focused
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:299-339` (VideoStub component) + `CallParticipantTile.tsx:89-135` (CallParticipantTile wiring)
|
||||
|
||||
- [ ] **Step 1: Add `fit` prop to VideoStub**
|
||||
|
||||
In `CallParticipantTile.tsx`, replace the `VideoStub` signature (currently `function VideoStub({ userId, displayName, avatarUrl, videoTrack, me, small }: ...)`):
|
||||
|
||||
```tsx
|
||||
function VideoStub({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
videoTrack,
|
||||
me,
|
||||
small,
|
||||
fit,
|
||||
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
|
||||
```
|
||||
|
||||
And replace the className on the `<video>` element (currently `'h-full w-full object-cover ' + (me ? 'scale-x-[-1]' : '')`) with:
|
||||
|
||||
```tsx
|
||||
className={
|
||||
'h-full w-full ' +
|
||||
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
|
||||
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Forward `fit` from CallParticipantTile**
|
||||
|
||||
In `CallParticipantTile.tsx`, inside `CallParticipantTile`, replace:
|
||||
|
||||
```tsx
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `pnpm --filter @chatapp/desktop typecheck`
|
||||
Expected: PASS (no errors in `CallParticipantTile.tsx`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/CallParticipantTile.tsx
|
||||
git commit -m "feat(call): VideoStub accepts fit prop, contain when focused"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ScreenShareViewer drops the hardcoded preview aspect
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/ScreenShareViewer.tsx:108-128` (unwatched preview button)
|
||||
|
||||
- [ ] **Step 1: Remove `style={{ aspectRatio: '16 / 9' }}`**
|
||||
|
||||
In `ScreenShareViewer.tsx`, locate the `<button type="button" onClick={() => watchShare(...)}>` (the "Bildschirm anschauen" overlay). Replace:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
>
|
||||
```
|
||||
|
||||
`flex-1 h-full` makes the button fill whatever vertical space the parent grid cell (now `aspect-video`) gives it, instead of forcing its own 16:9 inside an arbitrary cell.
|
||||
|
||||
- [ ] **Step 2: Type-check + commit**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
git add apps/desktop/src/components/ScreenShareViewer.tsx
|
||||
git commit -m "feat(call): drop hardcoded 16:9 on screen-share preview button"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Grid cells become aspect-video, drop grid-rows-*
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx:1085-1099` (`gridColsFor`), `:1030-1052` (`CallStage` grid branch), `:1003-1025` (`CallStage` focus strip)
|
||||
|
||||
- [ ] **Step 1: Rewrite `gridColsFor` to drop row constraints**
|
||||
|
||||
In `InCallPanel.tsx`, replace the existing `gridColsFor`:
|
||||
|
||||
```tsx
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
// content, and a video element's intrinsic size blows the tile past the
|
||||
// container bounds (overlapping the toolbar below).
|
||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||
return 'grid-cols-4 grid-rows-3';
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
function gridColsFor(n: number): string {
|
||||
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||
// height follows from their width, and the container centers them
|
||||
// vertically when the row stack is shorter than the available area.
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2';
|
||||
if (n <= 6) return 'grid-cols-3';
|
||||
if (n <= 9) return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wrap CallStage grid cells in aspect-video**
|
||||
|
||||
In `CallStage`, replace the grid-branch return (currently the block starting with `// Grid` then `const gridClass = gridColsFor(tiles.length);` …):
|
||||
|
||||
```tsx
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||
<TileRender ... />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
`place-content-center` centers the row stack vertically; each cell is `aspect-video` so 16:9 wins over arbitrary row stretching.
|
||||
|
||||
- [ ] **Step 3: Wrap focus-strip thumbs in aspect-video**
|
||||
|
||||
In the `if (mode === 'focus' && speaker)` branch of `CallStage`, replace the strip cell wrapper (currently `<div key={p.id} className="h-full w-[240px] shrink-0 [&>div]:h-full">`):
|
||||
|
||||
```tsx
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
```
|
||||
|
||||
The fixed `w-[240px]` is replaced by `aspect-video` so the thumb's width is driven by the strip's `h-[180px]` height. This keeps webcam thumbs at 16:9 (320×180) instead of an arbitrary 240×180 which crops faces.
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
Start the dev server, join a call with 2–4 webcams. Check:
|
||||
|
||||
- All grid tiles are equal-size 16:9 boxes; no tile is taller or wider than its neighbors.
|
||||
- With 3 participants → single row of 3; with 4 → 2×2; with 5–6 → 3×2 (last cell may be empty/centered).
|
||||
- Faces are framed naturally (`object-cover`); no obvious squish or stretch.
|
||||
|
||||
If layout looks wrong, screenshot, do not commit, and iterate on the wrapping classes.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): uniform 16:9 grid cells, drop grid-rows constraint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Introduce `StageLayout` discriminated union
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx:295-345` (the area where `effectiveFocusedId` and `speaker` are computed inside `InCallPanel`)
|
||||
|
||||
- [ ] **Step 1: Add the `StageLayout` type and selector**
|
||||
|
||||
In `InCallPanel.tsx`, locate the block:
|
||||
|
||||
```tsx
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```tsx
|
||||
// Discord-style precedence:
|
||||
// 1. focusedId set → 'focus', that tile is the stage.
|
||||
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||
// 4. no shares, no pin → 'equal-grid'.
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
|
||||
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||
const stageLayout: StageLayout = (() => {
|
||||
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||
return { kind: 'focus', bigTileId: focusedId };
|
||||
}
|
||||
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||
if (shareIds.length === 1 && shareIds[0]) {
|
||||
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||
}
|
||||
return { kind: 'equal-grid' };
|
||||
})();
|
||||
|
||||
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||
// stays consistent with the layout decision.
|
||||
const bigTile =
|
||||
stageLayout.kind === 'focus'
|
||||
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||
: undefined;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace existing usages of `speaker` and `effectiveFocusedId`**
|
||||
|
||||
Search `InCallPanel.tsx` for every remaining reference to `effectiveFocusedId` and `speaker` inside the `InCallPanel` function and replace as follows:
|
||||
|
||||
- `speaker` (used as prop on `CallStage`, `FullscreenCall`, focused-tile detection) → `bigTile`.
|
||||
- `effectiveFocusedId` (used in `pinnedTileId` prop for context menu) → `focusedId` (we no longer override pin for menu purposes; the auto-promoted share isn't user-pinned).
|
||||
|
||||
Concretely, the line `pinnedTileId: focusedId,` is already correct (uses `focusedId`, not the effective). The `effectiveFocusedId` declaration and `speaker` are removed by Step 1. Remaining usages:
|
||||
|
||||
- **In the fullscreen branch:** replace `speaker={effectiveSpeaker}` and the `effectiveSpeaker = hasFocus ? speaker : undefined` derivation with `speaker={bigTile}` (and drop the now-redundant `hasFocus` / `effectiveSpeaker` lines, since `bigTile` is undefined exactly when there's no focus).
|
||||
- **In the focus branch:** replace `speaker={speaker}` with `speaker={bigTile}`.
|
||||
|
||||
After this step, `InCallPanel`'s render path no longer uses the old `effectiveFocusedId` or `speaker` locals — only `stageLayout`, `bigTile`, `focusedId`.
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
Start the dev server, join a call (no shares yet, no pin). Check:
|
||||
|
||||
- Equal-grid renders as in Task 3 (no behavior regression).
|
||||
- Right-click → "Anpinnen" still works: pins the tile, the call panel collapses to focus-mode showing that tile big.
|
||||
- Right-click → "Anpinnen aufheben" returns to equal-grid.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): introduce StageLayout discriminated union"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire CallStage to render `focus` and `equal-grid` from StageLayout
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `InCallPanel`'s docked-call render branch (the area that calls `<CallStage mode={callMode} ...>`) and `CallStage` itself
|
||||
|
||||
- [ ] **Step 1: Map `stageLayout` → `mode` for `CallStage`**
|
||||
|
||||
In `InCallPanel.tsx`, locate the docked-call render that mounts `<CallStage mode={callMode} ...>`. Replace the `mode={callMode}` prop with a derived value:
|
||||
|
||||
```tsx
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={bigTile}
|
||||
// Discord-style: layout decision is driven by StageLayout (see top of
|
||||
// InCallPanel), not by the user-visible callMode toggle. callMode still
|
||||
// gates the cinema/fullscreen entry — for the docked stage we collapse
|
||||
// 'focus' and 'bento' to whatever CallStage knows how to render.
|
||||
mode={stageLayout.kind === 'equal-grid' ? 'grid' : 'focus'}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile drops the
|
||||
// pin; clicking another tile swaps. callMode auto-syncs.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
Note: `setCallMode` calls on click are removed — `callMode` no longer tracks pin state. `callMode` is now only `'grid'` (docked) or `'fullscreen'` (cinema). The third state (`'focus'`) is implicit when `focusedId !== null` and isn't a separate top-level mode anymore.
|
||||
|
||||
- [ ] **Step 2: Drop the click-toggle that swapped callMode**
|
||||
|
||||
Find the `onClick` callbacks in `InCallPanel` that did `setCallMode('focus')` or `setCallMode('grid')`. Replace each with a single `setFocusedId(focusedId === id ? null : id)` call (or remove the redundant ones that are now handled by `onFocusTile`).
|
||||
|
||||
- [ ] **Step 3: Adjust the `Mode` button bar**
|
||||
|
||||
The `<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">` row currently switches between `grid`, `focus`, `fullscreen`. Drop the `focus` button entirely — there's no manual focus mode anymore. Keep `grid` and `fullscreen`.
|
||||
|
||||
Locate the ModeButtonRow (search for `ModeButton`):
|
||||
|
||||
```tsx
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Sprecher">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'fullscreen'} onClick={() => onChange('fullscreen')} label="Vollbild">
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
```
|
||||
|
||||
Delete the middle (`focus`) ModeButton block. The remaining two cover all user-driven modes.
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS. If `'focus'` is referenced in `CallMode` type and unused now, leave the type alone — `'focus'` is still a valid value, just not user-selectable. Don't refactor the type.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
In a dev call (2–4 participants, no shares):
|
||||
|
||||
- **Click a webcam tile** → it becomes big, others to strip (`focus`-style stage). No mode bar change.
|
||||
- **Click it again** → equal grid restores.
|
||||
- **Click another tile while one is pinned** → swap to that tile.
|
||||
- Mode bar shows only `Grid` and `Vollbild` (the middle `Sprecher` button is gone).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): left-click toggles pin, drop manual focus mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Auto-promote single share (precedence rule 3)
|
||||
|
||||
**Files:**
|
||||
- No code change beyond what's already in Task 4 + Task 5. This task is the **manual verification** that the auto-promote path works end-to-end.
|
||||
|
||||
- [ ] **Step 1: Manual verification — share auto-promote**
|
||||
|
||||
In a dev call (2 participants):
|
||||
|
||||
- User A starts a screen share. Expected: share auto-promotes to the big stage on User B's side; User A's webcam moves to the strip.
|
||||
- User A stops the share. Expected: equal grid restores.
|
||||
- User A shares again; User B clicks User A's webcam thumb. Expected: webcam pins big, share moves to strip.
|
||||
- User B double-clicks the pinned webcam (Task 7 adds this; if not yet implemented, right-click → unpin works too). Expected: share auto-promotes again.
|
||||
|
||||
If any step fails, return to Task 4 (`stageLayout` selector) and verify the bigTile derivation is reading `tiles.find((t) => t.id === stageLayout.bigTileId)` correctly.
|
||||
|
||||
- [ ] **Step 2: No commit**
|
||||
|
||||
This task only verifies behavior introduced in earlier tasks.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Doubleclick on the focused tile clears pin
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:120-130` (root `<div>` of the tile)
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:54-87` (ParticipantTileProps)
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `TileRender` props and the focused-tile rendering paths
|
||||
|
||||
- [ ] **Step 1: Add `onDoubleClick` prop**
|
||||
|
||||
In `CallParticipantTile.tsx`, extend `ParticipantTileProps`:
|
||||
|
||||
```tsx
|
||||
onDoubleClick?: () => void;
|
||||
```
|
||||
|
||||
In the `CallParticipantTile` body, destructure it:
|
||||
|
||||
```tsx
|
||||
const {
|
||||
// ... existing
|
||||
onDoubleClick,
|
||||
} = props;
|
||||
```
|
||||
|
||||
And add it to the root `<div>`:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Plumb `onDoubleClick` through `TileRender`**
|
||||
|
||||
In `InCallPanel.tsx`, extend the `TileRender` component props (the inline interface) with `onDoubleClick?: () => void;`. Pass it through to `CallParticipantTile` the same way `onClick` is passed:
|
||||
|
||||
```tsx
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
```
|
||||
|
||||
And on the `<div>` wrapping the screen-tile branch, add `onDoubleClick={onDoubleClick}` next to `onClick`.
|
||||
|
||||
- [ ] **Step 3: Hook doubleclick on focused tiles to clear pin**
|
||||
|
||||
In `FocusedTile`, accept and forward `onDoubleClick`:
|
||||
|
||||
```tsx
|
||||
function FocusedTile({
|
||||
tile,
|
||||
e2ee,
|
||||
activeSpeakers,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
// ... existing
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
<TileRender
|
||||
tile={tile}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `CallStage`'s focus branch, pass an `onDoubleClick` that clears the pin:
|
||||
|
||||
```tsx
|
||||
<FocusedTile
|
||||
tile={speaker}
|
||||
e2ee={e2ee}
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
/>
|
||||
```
|
||||
|
||||
(`onFocusTile(speaker.id)` toggles — clicking the already-pinned id clears the pin per Task 5's setter.)
|
||||
|
||||
In `FullscreenCall`'s big-tile branch, pass the same `onDoubleClick` to the big tile wrapper:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
In a dev call with a pinned tile:
|
||||
|
||||
- **Doubleclick the pinned big tile** → unpins, layout falls back through StageLayout precedence (equal-grid if no shares, or share auto-promote if shares are active).
|
||||
- Single-click still toggles (no regression).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/CallParticipantTile.tsx apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): doubleclick on focused tile clears pin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Multi-share bento stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `CallStage` (add a bento branch) and the docked-call render to pass the `stageLayout` directly to `CallStage`
|
||||
|
||||
- [ ] **Step 1: Pass `stageLayout` to `CallStage`**
|
||||
|
||||
In `InCallPanel.tsx`, extend `StageProps`:
|
||||
|
||||
```tsx
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
/** Discriminated layout decision driven by InCallPanel's StageLayout
|
||||
* selector. Drives the bento-vs-grid-vs-focus render branch. */
|
||||
stageLayout:
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
// mode dropped — it duplicated stageLayout. callMode is still in the
|
||||
// parent for fullscreen-mode entry, just not threaded here anymore.
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
track: import('livekit-client').RemoteTrack;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
(Remove the `mode: CallMode;` field; replace with `stageLayout`.)
|
||||
|
||||
Update the `<CallStage ...>` site in `InCallPanel` to pass `stageLayout={stageLayout}` instead of `mode={...}`.
|
||||
|
||||
- [ ] **Step 2: Rewrite `CallStage` branch dispatch**
|
||||
|
||||
In `CallStage`, replace the body (currently `if (mode === 'focus' && speaker) { ... } // Grid ...`) with:
|
||||
|
||||
```tsx
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
stageLayout,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (stageLayout.kind === 'focus' && speaker) {
|
||||
// ... existing focus branch unchanged
|
||||
}
|
||||
|
||||
if (stageLayout.kind === 'bento') {
|
||||
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
|
||||
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
|
||||
const bentoCols = gridColsFor(shares.length);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
|
||||
}
|
||||
>
|
||||
{shares.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{webcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||
{webcams.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// equal-grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Apply the same layout choice inside `FullscreenCall`**
|
||||
|
||||
In `FullscreenCall`, the existing branch is `if (hasFocus) { big-stage } else { grid }`. Update the else-branch to also handle bento. The grid render path inside `FullscreenCall` currently uses `sortedGridTiles` + `gridColsFor`. Extend it:
|
||||
|
||||
After the existing `hasFocus` check and before the grid render, add:
|
||||
|
||||
```tsx
|
||||
const fsShareIds = tiles
|
||||
.filter((t) => t.kind === 'screen')
|
||||
.map((t) => t.id);
|
||||
const bentoMode = !hasFocus && fsShareIds.length >= 2;
|
||||
const bentoShares = bentoMode
|
||||
? tiles.filter((t) => fsShareIds.includes(t.id))
|
||||
: [];
|
||||
const bentoWebcams = bentoMode
|
||||
? tiles.filter((t) => !fsShareIds.includes(t.id))
|
||||
: [];
|
||||
```
|
||||
|
||||
Then wrap the existing grid-only render in:
|
||||
|
||||
```tsx
|
||||
{bentoMode ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
|
||||
{bentoShares.map((s) => (
|
||||
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{bentoWebcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2 overflow-x-auto">
|
||||
{bentoWebcams.map((w) => (
|
||||
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// ... existing grid render unchanged
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS. If `mode` is still referenced anywhere in `CallStage`, remove the stray reference (it's been replaced by `stageLayout.kind`).
|
||||
|
||||
- [ ] **Step 5: Manual verification — multi-share**
|
||||
|
||||
Set up a 2-share scenario (two clients sharing simultaneously):
|
||||
|
||||
- In docked mode: stage shows both shares side-by-side at equal size, webcams in the strip below.
|
||||
- In fullscreen-cinema mode: same bento, fills the screen.
|
||||
- Clicking one of the bento shares pins it → layout drops to single-stage focus on that share.
|
||||
- Stopping one share → falls back through StageLayout → rule 3 (single-share auto-promote).
|
||||
- Stopping both → equal grid.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): multi-share bento layout in stage + fullscreen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: End-to-end verification pass
|
||||
|
||||
**Files:** None.
|
||||
|
||||
- [ ] **Step 1: Run all manual checks from the spec, end to end**
|
||||
|
||||
Spec section "Testing" lists six scenarios. Run all six:
|
||||
|
||||
1. Webcam-only equal grid: 4 webcams, no pin, no share → 2×2 uniform, faces cropped via cover.
|
||||
2. Pinning toggle: click webcam → big with contain (no head crop), strip below; click again → grid.
|
||||
3. Share auto-promote: start share → share is big, webcams strip, share aspect respected.
|
||||
4. Pin override during share: while share is big, click webcam → webcam pins big, share to strip; click webcam again → back to share auto-promote.
|
||||
5. Multi-share: two users share → bento stage; click one → pin that share.
|
||||
6. Active speaker: someone talks → emerald border, no reorder.
|
||||
|
||||
- [ ] **Step 2: Check no console errors**
|
||||
|
||||
Open DevTools console during the call. Expected: no warnings about React keys, missing props, or unhandled promise rejections related to the touched files.
|
||||
|
||||
- [ ] **Step 3: Final commit (if any cleanup)**
|
||||
|
||||
If you made trailing cleanup commits during the verification, push the branch. Otherwise nothing more to commit.
|
||||
|
||||
```bash
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Expected: 5–6 commits with prefixes `feat(call): ...`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Each section of the spec maps to a task:
|
||||
- Spec §1 (Tile aspect ratio) → Task 3
|
||||
- Spec §2 (Object-fit per mode) → Task 1
|
||||
- Spec §3 (Click-to-pin) → Task 5 + Task 7 (doubleclick)
|
||||
- Spec §4 (Layout selection precedence) → Task 4 + Task 5 + Task 6 + Task 8
|
||||
- Spec §5 (Active-speaker preserved) → no work needed; verified in Task 9 step 1.6
|
||||
- **No placeholders:** Every step has concrete code or commands.
|
||||
- **Type consistency:** `StageLayout` is named the same in spec and plan. `bigTileId`/`bigTile` naming is consistent across Tasks 4–8. `onFocusTile` signature `(id: string) => void` matches between InCallPanel callsite and CallStage prop.
|
||||
- **Reading-order safety:** Each task block re-states the file paths and the exact code being replaced — Task N doesn't assume the reader memorized Task N-1.
|
||||
@@ -0,0 +1,729 @@
|
||||
# Mobile Phase 0 — Deployment Foundation 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:** Bring `apps/mobile` from an empty "ChatApp" Expo scaffold to a Netralax-branded shell that compiles, type-checks, runs locally via `expo start`, and is configured for `eas build` on iOS + Android.
|
||||
|
||||
**Architecture:** Renderer-only changes inside `apps/mobile`. No backend or shared-package work. The Expo managed workflow is preserved; New Architecture stays enabled. We wire SafeAreaProvider + GestureHandlerRootView + a minimal ErrorBoundary into the Expo Router root layout, rebrand `app.json`, add `eas.json` build profiles, and prove the `@chat-app/shared` workspace dep resolves end-to-end via a runtime smoke import.
|
||||
|
||||
**Tech Stack:** Expo SDK 52, React Native 0.76 (New Arch), expo-router 4, react-native-safe-area-context 4.12, react-native-gesture-handler (added in Task 3), expo-application (added in Task 3), TypeScript 5.6, `@chat-app/shared` (workspace).
|
||||
|
||||
**Spec:** [`docs/superpowers/specs/2026-05-13-mobile-phase-0-foundation-design.md`](../specs/2026-05-13-mobile-phase-0-foundation-design.md)
|
||||
|
||||
**Testing note:** No automated test framework for the mobile screens yet — vitest is wired in `apps/mobile/package.json` but there are no specs to run. Each task's gate is `pnpm --filter @chat-app/mobile typecheck` + a visual self-check (described per task). End-to-end device verification is the user's job at the end of the plan (Task 11).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility | Change |
|
||||
|---|---|---|
|
||||
| `apps/mobile/app.json` | Expo project manifest — name, slug, bundle ids, plugins, splash | Rebrand to Netralax + `cloud.netralax.app` ids |
|
||||
| `apps/mobile/eas.json` | EAS Build/Submit profile config | CREATE — development / preview / production profiles |
|
||||
| `apps/mobile/package.json` | Workspace dependencies | Add `expo-application`, `react-native-gesture-handler` |
|
||||
| `apps/mobile/components/ErrorBoundary.tsx` | Render-error catcher for the whole tree | CREATE — class component with reload button |
|
||||
| `apps/mobile/lib/sharedSmoke.ts` | Runtime canary import from `@chat-app/shared` | CREATE — proves Metro can resolve the workspace package |
|
||||
| `apps/mobile/app/_layout.tsx` | Expo Router root layout | Wrap `Stack` in providers: GestureHandlerRootView → SafeAreaProvider → ErrorBoundary |
|
||||
| `apps/mobile/app/index.tsx` | Landing screen | Netralax branding + runtime version display + nav link to Chats |
|
||||
| `apps/mobile/app/(app)/chats.tsx` | Chats placeholder inside authenticated group | Netralax-branded "coming soon" copy |
|
||||
| `apps/mobile/README.md` | Mobile workspace docs | Add Phase 0 quickstart section |
|
||||
| Root `package.json` | Monorepo convenience scripts | Add `mobile:typecheck` (others already exist) |
|
||||
|
||||
No file is bigger than ~80 lines after this plan. Assets (icon.png, splash.png, adaptive-icon.png) are explicitly NOT replaced in Phase 0 — see Out-of-Scope note in the spec — the Expo default chrome stays until Phase 4 adds the final Netralax art.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rebrand `app.json` to Netralax
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app.json`
|
||||
|
||||
- [ ] **Step 1: Replace the entire `app.json` content**
|
||||
|
||||
Write `apps/mobile/app.json` exactly as follows (newlines + 2-space indent):
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"name": "Netralax",
|
||||
"slug": "netralax",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "netralax",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "cloud.netralax.app",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "cloud.netralax.app",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store",
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"color": "#0b0b0f"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `extra.eas.projectId` stays as the literal placeholder string. The user fills it in by running `eas init` (covered in the README task).
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/app.json
|
||||
git commit -m "chore(mobile): rebrand app.json to Netralax + cloud.netralax.app bundle ids"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `eas.json` with build profiles
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/eas.json`
|
||||
|
||||
- [ ] **Step 1: Create `apps/mobile/eas.json`**
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 13.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"simulator": false
|
||||
},
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "REPLACE_WITH_APPLE_ID",
|
||||
"ascAppId": "REPLACE_WITH_ASC_APP_ID"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./play-service-account.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/eas.json
|
||||
git commit -m "chore(mobile): add eas.json with development/preview/production build profiles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Phase 0 runtime dependencies
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/package.json`
|
||||
|
||||
- [ ] **Step 1: Install the two missing deps**
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile add expo-application react-native-gesture-handler
|
||||
```
|
||||
|
||||
`expo-application` is needed to read the runtime app version (`Application.nativeApplicationVersion`) on the Landing screen. `react-native-gesture-handler` is the standard root provider for any future gesture-based UI; expo-router and most RN libraries assume it's mounted at the root.
|
||||
|
||||
Expected: `apps/mobile/package.json` gains the two entries in `dependencies`. pnpm picks the Expo SDK 52-compatible versions (`expo-application@~6.0.x`, `react-native-gesture-handler@~2.20.x` — let pnpm + Expo's `install` resolver pick).
|
||||
|
||||
- [ ] **Step 2: Verify typecheck still passes**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0. New deps are TS-friendly out of the box.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/package.json pnpm-lock.yaml
|
||||
git commit -m "chore(mobile): add expo-application + react-native-gesture-handler for phase 0 shell"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create the ErrorBoundary component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/components/ErrorBoundary.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
Write `apps/mobile/components/ErrorBoundary.tsx`:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
// Catch render errors anywhere below this boundary and show a readable
|
||||
// fallback. Without it, a thrown error during render produces a white
|
||||
// screen on TestFlight / production builds with no way for the user to
|
||||
// recover short of force-closing the app. Mounted once at the top of
|
||||
// app/_layout.tsx so it wraps every screen.
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// Bubble to Metro/console in dev so the redbox still shows; in prod
|
||||
// builds this is the only place an exception trace surfaces.
|
||||
console.error('[ErrorBoundary] caught render error', error, info.componentStack);
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Etwas ist schiefgelaufen</Text>
|
||||
<Text style={styles.message}>{this.state.error.message}</Text>
|
||||
<Pressable style={styles.button} onPress={this.reset}>
|
||||
<Text style={styles.buttonText}>Erneut versuchen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0b0b0f',
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: '#fff', fontSize: 22, fontWeight: '600', marginBottom: 8 },
|
||||
message: { color: '#9ca3af', textAlign: 'center', marginBottom: 24 },
|
||||
button: {
|
||||
backgroundColor: '#5865f2',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
},
|
||||
buttonText: { color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/components/ErrorBoundary.tsx
|
||||
git commit -m "feat(mobile): add ErrorBoundary for render-error fallback"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create the sharedSmoke canary
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/mobile/lib/sharedSmoke.ts`
|
||||
|
||||
- [ ] **Step 1: Create the file**
|
||||
|
||||
Write `apps/mobile/lib/sharedSmoke.ts`:
|
||||
|
||||
```ts
|
||||
// Runtime canary import from @chat-app/shared. Phase 0's only goal here
|
||||
// is to prove that Metro can resolve the workspace package and that
|
||||
// nothing in shared/crypto pulls in an RN-incompatible module path.
|
||||
//
|
||||
// `import type` would be erased by the TypeScript compiler before
|
||||
// reaching Metro, so we deliberately import a runtime symbol —
|
||||
// `crypto.setCryptoBackend` — and stash it in an exported reference.
|
||||
// We never call it here; Phase 1's real adapter does that after
|
||||
// constructing a CryptoBackend wrapping react-native-libsodium.
|
||||
//
|
||||
// This file is deleted in Phase 1 once the real adapter lands.
|
||||
import { crypto } from '@chat-app/shared';
|
||||
|
||||
export const sharedSmoke = { register: crypto.setCryptoBackend };
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0. If TS complains that `crypto` is not exported from `@chat-app/shared`, re-read `packages/shared/src/index.ts` to confirm the namespace name.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/lib/sharedSmoke.ts
|
||||
git commit -m "chore(mobile): add runtime canary import from @chat-app/shared"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Rewrite the root layout with providers
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app/_layout.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace the file content**
|
||||
|
||||
Write `apps/mobile/app/_layout.tsx`:
|
||||
|
||||
```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 { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
|
||||
// Force-resolve @chat-app/shared at boot so Metro packaging issues
|
||||
// surface during the very first dev-client load instead of mid-Phase-1.
|
||||
// Reference the export so tree-shaking can't drop it.
|
||||
import { sharedSmoke } from '../lib/sharedSmoke';
|
||||
void sharedSmoke;
|
||||
|
||||
// Root layout for every Expo Router screen. Provider stack order:
|
||||
// GestureHandlerRootView — required by gesture-driven libs (BottomSheet,
|
||||
// swipeable rows, drawer nav). Must be the
|
||||
// outermost so gesture state is global.
|
||||
// SafeAreaProvider — feeds notch / status-bar insets to children.
|
||||
// ErrorBoundary — last line of defence for render errors.
|
||||
// Stack — Expo Router's screen registry.
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ErrorBoundary>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
</Stack>
|
||||
</ErrorBoundary>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/app/_layout.tsx
|
||||
git commit -m "feat(mobile): wrap root layout in GestureHandler + SafeArea + ErrorBoundary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Rebrand the Landing screen
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace the file content**
|
||||
|
||||
Write `apps/mobile/app/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import * as Application from 'expo-application';
|
||||
import { Link } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
// Phase 0 landing screen. Just enough to prove the app boots and routes:
|
||||
// * Netralax brand wordmark (replaces the previous "ChatApp" string).
|
||||
// * Runtime version from expo-application so we can spot stale builds
|
||||
// on a TestFlight tester's device at a glance.
|
||||
// * Placeholder nav link into (app)/chats — exercises Expo Router so a
|
||||
// routing regression shows up here instead of mid-Phase-1.
|
||||
//
|
||||
// Phase 1 replaces this with the actual magic-link login form.
|
||||
export default function Landing() {
|
||||
const version = Application.nativeApplicationVersion ?? '0.0.0';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Netralax</Text>
|
||||
<Text style={styles.subtitle}>Phase 0 — Deployment-Fundament</Text>
|
||||
<Text style={styles.version}>v{version}</Text>
|
||||
|
||||
<Link href="/(app)/chats" asChild>
|
||||
<Pressable style={styles.button}>
|
||||
<Text style={styles.buttonText}>Weiter (Smoke-Test)</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0b0b0f',
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: '#fff', fontSize: 32, fontWeight: '700', letterSpacing: 1 },
|
||||
subtitle: { color: '#9ca3af', marginTop: 8, marginBottom: 4 },
|
||||
version: { color: '#6b7280', fontSize: 12, marginBottom: 32 },
|
||||
button: {
|
||||
backgroundColor: '#5865f2',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/app/index.tsx
|
||||
git commit -m "feat(mobile): Netralax landing screen with runtime version + nav smoke test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Rebrand the Chats placeholder
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/app/(app)/chats.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace the file content**
|
||||
|
||||
Write `apps/mobile/app/(app)/chats.tsx`:
|
||||
|
||||
```tsx
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
// Phase 0 stand-in for the conversation list. Confirms the authenticated
|
||||
// route group (`(app)`) mounts without error after a navigation from
|
||||
// Landing. Phase 1 replaces this with the real chat list.
|
||||
export default function Chats() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Netralax</Text>
|
||||
<Text style={styles.subtitle}>Chats — coming soon (Phase 1)</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0b0b0f',
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: '#fff', fontSize: 28, fontWeight: '600' },
|
||||
subtitle: { color: '#9ca3af', marginTop: 8, textAlign: 'center' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify typecheck**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/mobile typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add 'apps/mobile/app/(app)/chats.tsx'
|
||||
git commit -m "feat(mobile): Netralax-branded Chats placeholder screen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Add `mobile:typecheck` to root scripts
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (root)
|
||||
|
||||
- [ ] **Step 1: Add the script**
|
||||
|
||||
Open `package.json` at the repo root and find the line:
|
||||
|
||||
```json
|
||||
"mobile:android": "pnpm --filter @chat-app/mobile android",
|
||||
```
|
||||
|
||||
Insert immediately after it:
|
||||
|
||||
```json
|
||||
"mobile:typecheck": "pnpm --filter @chat-app/mobile typecheck",
|
||||
```
|
||||
|
||||
Final scripts block excerpt should read:
|
||||
|
||||
```json
|
||||
"mobile": "pnpm --filter @chat-app/mobile",
|
||||
"mobile:dev": "pnpm --filter @chat-app/mobile dev",
|
||||
"mobile:ios": "pnpm --filter @chat-app/mobile ios",
|
||||
"mobile:android": "pnpm --filter @chat-app/mobile android",
|
||||
"mobile:typecheck": "pnpm --filter @chat-app/mobile typecheck",
|
||||
"desktop": "pnpm --filter @chat-app/desktop",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the script runs**
|
||||
|
||||
```bash
|
||||
pnpm mobile:typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0 (forwards to mobile workspace, which currently has nothing to fail).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "chore(repo): add mobile:typecheck convenience script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Document Phase 0 quickstart in the mobile README
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/mobile/README.md`
|
||||
|
||||
- [ ] **Step 1: Replace the README content**
|
||||
|
||||
Write `apps/mobile/README.md`:
|
||||
|
||||
````markdown
|
||||
# @chat-app/mobile
|
||||
|
||||
Expo + React Native client for **Netralax** on iOS and Android.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node 22+, pnpm 9+
|
||||
- Xcode (iOS) / Android Studio (Android) — only needed for local
|
||||
prebuild + emulator runs; EAS Build runs everything in the cloud.
|
||||
- An Expo account (free) — required for `eas init` below.
|
||||
- Optional: `npm i -g eas-cli` for the CLI commands. If you skip
|
||||
globals, prefix every `eas` invocation with `npx eas-cli`.
|
||||
|
||||
## Phase 0 quickstart
|
||||
|
||||
```bash
|
||||
# From the repo root.
|
||||
pnpm install
|
||||
|
||||
# One-time: claim an EAS project ID. This rewrites the placeholder
|
||||
# `extra.eas.projectId` in app.json and links the local repo to your
|
||||
# Expo dashboard. Commit the resulting app.json change.
|
||||
cd apps/mobile
|
||||
npx eas-cli init
|
||||
|
||||
# Sign in to Expo if you haven't.
|
||||
npx eas-cli login
|
||||
|
||||
# Build a development client (custom dev-client APK + iOS simulator
|
||||
# bundle). First iOS build prompts for Apple ID — free signing works
|
||||
# for development distribution.
|
||||
npx eas-cli build --platform all --profile development
|
||||
```
|
||||
|
||||
When the builds finish, EAS gives you a QR code / install link. Install
|
||||
the dev client on your device, then start the Metro server from the repo
|
||||
root:
|
||||
|
||||
```bash
|
||||
pnpm mobile:dev # = pnpm --filter @chat-app/mobile dev = expo start --dev-client
|
||||
```
|
||||
|
||||
Open the dev client on the device, scan the QR code, and Netralax's
|
||||
Landing screen should render.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
pnpm mobile:dev # Metro server
|
||||
pnpm mobile:ios # native iOS run (local Xcode)
|
||||
pnpm mobile:android # native Android run (local Android Studio)
|
||||
pnpm mobile:typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Expo Router (file-based) — screens live under `app/`.
|
||||
- `expo-secure-store` — Keychain / Keystore-backed secret store (used by
|
||||
the mobile `SecretStore` adapter in Phase 1).
|
||||
- `expo-sqlite` — local encrypted history (Phase 1).
|
||||
- `react-native-libsodium` — crypto primitives (Phase 1's
|
||||
`CryptoBackend` adapter wraps this).
|
||||
- `@chat-app/shared` — business logic shared with the desktop, including
|
||||
the `CryptoBackend` and `SecretStore` interfaces the mobile adapters
|
||||
plug into.
|
||||
- `lib/sharedSmoke.ts` exists only to prove Metro can resolve the
|
||||
workspace package; it's deleted in Phase 1.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
|
||||
````
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/mobile/README.md
|
||||
git commit -m "docs(mobile): phase 0 quickstart + roadmap pointer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: End-to-end verification (manual, user-driven)
|
||||
|
||||
**Files:** None.
|
||||
|
||||
This task does no code changes. It hands the user a checklist they run on real hardware. The implementer subagent does NOT attempt to run `eas build` or boot a device.
|
||||
|
||||
- [ ] **Step 1: Typecheck pass (automated)**
|
||||
|
||||
```bash
|
||||
pnpm mobile:typecheck
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 2: Document hand-off**
|
||||
|
||||
The implementer must report back to the controller / user that Phase 0 is code-complete and list these manual verifications for the user to perform on their hardware:
|
||||
|
||||
1. `pnpm install` from a fresh clone resolves cleanly.
|
||||
2. `cd apps/mobile && npx eas-cli init` claims a project ID and replaces the placeholder in `app.json`.
|
||||
3. `npx eas-cli build --platform android --profile development` produces an installable APK.
|
||||
4. `npx eas-cli build --platform ios --profile development` produces a simulator `.tar.gz` (or device `.ipa` if free Apple signing succeeds).
|
||||
5. The dev client installs and Netralax's Landing screen renders with a non-empty version string.
|
||||
6. Tapping the "Weiter (Smoke-Test)" button navigates to `(app)/chats` without crash.
|
||||
7. Optional: simulate an error in `app/index.tsx` (throw inside the render) and confirm the ErrorBoundary shows the fallback screen with the "Erneut versuchen" button.
|
||||
|
||||
- [ ] **Step 3: No code commit at this step**
|
||||
|
||||
Verification is operational; no source changes here.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
**Spec coverage:**
|
||||
- Spec §1 (Netralax rebrand in app.json) → Task 1.
|
||||
- Spec §2 (icon + splash) → DEFERRED. The spec asked for placeholder Netralax art, but image-generation isn't tractable from the implementer loop and the dev client doesn't care about icon polish. Expo's default chrome stays; `app.json` still references `./assets/icon.png` etc. so dropping in Netralax PNGs later is a no-op. Final art lands in Phase 4 alongside store-listing screenshots.
|
||||
- Spec §3 (eas.json) → Task 2.
|
||||
- Spec §4 (minimal app shell) → Tasks 6 (layout), 7 (landing), 8 (chats).
|
||||
- Spec §4a (ErrorBoundary) → Task 4.
|
||||
- Spec §5 (sharedSmoke) → Task 5.
|
||||
- Spec §6 (README + scripts) → Tasks 9 (root scripts) + 10 (README).
|
||||
- Spec §7 (typecheck + lint) → typecheck gates inside every task; lint stub deferred.
|
||||
|
||||
**Type consistency:** `sharedSmoke` shape `{ register: crypto.setCryptoBackend }` matches between spec §5 and Task 5. `ErrorBoundary` Props/State match between spec §4a and Task 4. Stack screen names (`index`, `(app)`) unchanged from the existing scaffold.
|
||||
|
||||
**Reading-order safety:** Every task block restates the file path and shows the complete code for the touched file. No "see Task N" references.
|
||||
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,300 @@
|
||||
# Call Preview Panel Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the always-on "Sprach-Channel" banner with a Discord-DM-style preview panel that only renders when peers are in an active call, showing them as large avatar tiles with a single "Beitreten" button.
|
||||
|
||||
**Architecture:** `VoiceChannelRail.tsx` is renamed to `CallPreviewPanel.tsx` and rewritten with a new tile-grid layout. Visibility rule drops the `|| isGroup` clause so groups behave identically to 1:1 chats. All call-session wiring (`useCall`, `useCallPresence`, `joinActiveCall`, `ConversationHeader`'s phone icon for starting calls) stays unchanged.
|
||||
|
||||
**Tech Stack:** TypeScript, React 18, Tailwind, react-i18next, existing `useCallPresence` realtime hook.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-15-call-preview-panel-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Overview
|
||||
|
||||
**New files:**
|
||||
- `apps/desktop/src/components/CallPreviewPanel.tsx` — the new panel.
|
||||
|
||||
**Deleted files:**
|
||||
- `apps/desktop/src/components/VoiceChannelRail.tsx` — replaced by `CallPreviewPanel.tsx`.
|
||||
|
||||
**Modified files:**
|
||||
- `apps/desktop/src/pages/ConversationPage.tsx` — change the import + JSX tag.
|
||||
|
||||
**i18n note:** No JSON resource changes needed. The orphaned keys (`voice_empty`, `voice_open`, `voice_channel`, `voice_count`) only existed as inline `defaultValue:` strings inside the deleted component — they were never in the locale files. The new component reuses the existing `app:call.active_in_conv` and `app:call.join` keys (present in both `de/app.json` and `en/app.json`).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `CallPreviewPanel.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/components/CallPreviewPanel.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the file**
|
||||
|
||||
`apps/desktop/src/components/CallPreviewPanel.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_TILES = 7;
|
||||
|
||||
/**
|
||||
* Discord-DM-style call preview panel. Renders only while peers are in the
|
||||
* conversation's active call and the local user is NOT in it. Provides large
|
||||
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
|
||||
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
|
||||
* component never initiates — only joins.
|
||||
*/
|
||||
export function CallPreviewPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
if (iAmIn || others.length === 0) return null;
|
||||
|
||||
const visibleTiles = others.slice(0, MAX_TILES);
|
||||
const overflow = Math.max(0, others.length - MAX_TILES);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-line bg-surface-3/70">
|
||||
<div className="flex items-center gap-2 px-5 py-2 text-xs">
|
||||
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Laufender Anruf · {{count}} im Raum',
|
||||
count: others.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
|
||||
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
|
||||
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{visibleTiles.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={name}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
|
||||
>
|
||||
<div className="h-16 w-16 overflow-hidden rounded-full">
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={name}
|
||||
className="h-full w-full text-base"
|
||||
/>
|
||||
</div>
|
||||
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
|
||||
<span className="text-xl font-semibold">+{overflow}</span>
|
||||
<span className="text-xs">weitere</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJoin}
|
||||
disabled={busy}
|
||||
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify icon imports resolve**
|
||||
|
||||
```
|
||||
cd D:/Programmieren/ChatApp-Electron/chat-app
|
||||
grep -n "export const PhoneIcon\|export function PhoneIcon\|export const SpinnerIcon\|export function SpinnerIcon\|export const XIcon\|export function XIcon" apps/desktop/src/components/icons.tsx
|
||||
```
|
||||
|
||||
Expected: all three icons exported. (Earlier tasks confirmed `PhoneIcon`, `SpinnerIcon`; `XIcon` is also used by other modals — verify.) If `XIcon` is missing under that name, swap the import to whatever the close-icon export is in `icons.tsx` (e.g. `CloseIcon`) and report the substitution.
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
```
|
||||
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: zero errors. (After the encryption-UX rollout the desktop typecheck is clean.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Programmieren/ChatApp-Electron/chat-app
|
||||
git add apps/desktop/src/components/CallPreviewPanel.tsx
|
||||
git commit -m "feat(desktop): add CallPreviewPanel — Discord-DM-style join surface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire the new panel in `ConversationPage.tsx` and delete the old rail
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx`
|
||||
- Delete: `apps/desktop/src/components/VoiceChannelRail.tsx`
|
||||
|
||||
- [ ] **Step 1: Swap the import**
|
||||
|
||||
In `apps/desktop/src/pages/ConversationPage.tsx`, replace this line (around line 26):
|
||||
|
||||
```ts
|
||||
import { VoiceChannelRail } from '../components/VoiceChannelRail';
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Swap the JSX usage**
|
||||
|
||||
In the same file, replace the JSX line (around line 657):
|
||||
|
||||
```tsx
|
||||
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete the dead component**
|
||||
|
||||
```
|
||||
rm apps/desktop/src/components/VoiceChannelRail.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Sweep for stragglers**
|
||||
|
||||
```
|
||||
grep -rn "VoiceChannelRail" apps/desktop/src
|
||||
```
|
||||
|
||||
Expected: no hits. If anything remains, clean it up.
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
```
|
||||
pnpm --filter @chat-app/desktop typecheck 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: zero errors.
|
||||
|
||||
- [ ] **Step 6: Build the desktop renderer once to surface any tailwind/runtime issues**
|
||||
|
||||
```
|
||||
pnpm --filter @chat-app/desktop build 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: build succeeds. Tailwind classes used here (`bg-surface-3/70`, `border-line`, `bg-emerald-600`, `bg-surface-2`, `text-fg`, `text-fg-muted`, `aspect-square`, `line-clamp-1`, `grid-cols-2 md:grid-cols-3`) are all already used elsewhere in the desktop app and should be in the existing tailwind config.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd D:/Programmieren/ChatApp-Electron/chat-app
|
||||
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||
git rm apps/desktop/src/components/VoiceChannelRail.tsx
|
||||
git commit -m "refactor(desktop): replace VoiceChannelRail with CallPreviewPanel
|
||||
|
||||
Drops the always-on 'Sprach-Channel' banner. The preview panel renders
|
||||
only when peers are in the active call (1:1 and group identical).
|
||||
Calls are still started via the topbar phone icon."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Manual smoke pass (USER)
|
||||
|
||||
This task is for the human operator after the previous two land. Run a fresh dev build and walk through the spec's smoke checklist:
|
||||
|
||||
- [ ] Open a 1:1 chat with no active call → panel not rendered.
|
||||
- [ ] Open a group chat with no active call → panel not rendered (regression test for the bug).
|
||||
- [ ] Have a peer start a call → panel appears, peer's avatar tile + "Beitreten" visible.
|
||||
- [ ] Click "Beitreten" → joins the call; panel disappears (you're now `iAmIn`).
|
||||
- [ ] Hang up → panel reappears with peer still in.
|
||||
- [ ] Peer hangs up too → panel disappears.
|
||||
- [ ] Resize the window narrow → grid collapses to 2 columns.
|
||||
- [ ] Group call with 9 participants → 7 avatar tiles + one `+2` overflow tile.
|
||||
- [ ] Click the `✕` in the panel header → tiles collapse, header stays. Click again → tiles re-expand.
|
||||
|
||||
If any step fails, capture the exact behavior and reopen the relevant task.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
- Visibility rule (`!iAmIn && others.length > 0`, no `|| isGroup`) — Task 1.
|
||||
- Discord-DM tile layout, ~120×120px tiles, 2/3-column responsive grid, `+N` overflow at 8 — Task 1.
|
||||
- "Aktiver Anruf · n im Channel" header reusing `app:call.active_in_conv` — Task 1.
|
||||
- Centered "Beitreten" CTA, disabled when busy, `SpinnerIcon` while connecting — Task 1.
|
||||
- Optional collapse via `useState<boolean>`, default expanded — Task 1.
|
||||
- Mount point unchanged (`!incomingHere &&` gate retained) — Task 2.
|
||||
- Old empty state and `Channel öffnen` removed by deleting the rail — Task 2.
|
||||
- Manual smoke list — Task 3.
|
||||
|
||||
**2. Placeholder scan:** Clean. Every code block is complete; every command has expected output; the only `defaultValue:` strings are in the i18n calls (not placeholders, just fallback copy).
|
||||
|
||||
**3. Type consistency:** `Props { conversation: ConversationSummary }` matches the type used by the deleted rail and consumed by `ConversationPage.tsx`. `useCallPresence` returns `string[]` per `apps/desktop/src/lib/useCallPresence.ts`. `useCall().state.kind` values (`idle`, `connecting`, `connected`, `reconnecting`) match the existing `CallContext` discriminated union. `joinActiveCall(convId, 'audio')` matches the existing `useCall` API.
|
||||
|
||||
No gaps; plan is complete.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
# Discord-Style Call Tile Handling — Design Spec
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Scope:** `apps/desktop/src/components/InCallPanel.tsx`, `CallParticipantTile.tsx`, `ScreenShareViewer.tsx`
|
||||
**Goal:** Make tile rendering, click handling, and mixed share/webcam layouts behave like Discord.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Today's in-call rendering has four user-visible defects:
|
||||
|
||||
1. **Webcam tiles look stretched/cropped wrong.** `VideoStub` uses `object-cover` in every size, so when the tile aspect ratio diverges from the webcam stream, faces get cropped aggressively or distorted.
|
||||
2. **Screen-share tiles get the wrong aspect.** `ScreenShareViewer` uses `object-contain` (correct), but the grid cell that wraps it has no aspect-ratio constraint. Cells stretch tall/wide based on the grid template, leaving the share floating with large black bars on the sides.
|
||||
3. **Click-to-pin doesn't feel like Discord.** Left-click in docked grid swaps `callMode` from grid → focus, but fullscreen-grid doesn't react; pinning is right-click-only; toggling off requires another right-click.
|
||||
4. **Mixed layouts (share + webcams) treat every tile equally.** A screen share competes for space with 1:1 webcam tiles instead of dominating the stage with webcams beside it.
|
||||
|
||||
## Goals
|
||||
|
||||
- Grid renders uniform tile sizes without distorting content.
|
||||
- Single left-click on any tile pins it big; click again or click another tile swaps.
|
||||
- When at least one screen share is live and nothing is manually pinned, the share auto-promotes to the big stage spot.
|
||||
- Multiple parallel shares share the stage in a bento layout; webcams sit as a strip.
|
||||
- Active-speaker reorder stays disabled (preserves the earlier "no constant switching" fix).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new transitions/animations beyond what's already in place.
|
||||
- No changes to context menu, volume control, screen-share picker.
|
||||
- No mobile/responsive rework — desktop only.
|
||||
- No migration of stored prefs (focused tile is session-only already).
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Tile aspect ratio
|
||||
|
||||
Every tile (webcam **and** screen) in the **grid** renders inside an `aspect-video` (16:9) box.
|
||||
|
||||
- Grid container drops `grid-rows-*` and instead lets `aspect-video` on each cell drive height.
|
||||
- `gridColsFor(n)` keeps the column count logic, just drops the row count constraint.
|
||||
- Effect: uniform tile sizes, no stretching, content sizing is per-tile not per-row.
|
||||
|
||||
```tsx
|
||||
// Today: grid h-full gap-2 grid-cols-3 grid-rows-2
|
||||
// Tomorrow: grid h-full gap-2 grid-cols-3 (each child has aspect-video)
|
||||
```
|
||||
|
||||
In fullscreen-cinema **focus** layouts (single big tile + thumbnail strip) the strip thumbs use `aspect-video` as well so they line up evenly.
|
||||
|
||||
### 2. Object-fit per mode
|
||||
|
||||
| Tile type | Grid (thumb) | Pinned/Focus (big) | Fullscreen strip |
|
||||
|--------------|--------------|--------------------|------------------|
|
||||
| Webcam | `cover` | `contain` | `cover` |
|
||||
| Screen share | `contain` | `contain` | `contain` |
|
||||
|
||||
- `VideoStub` accepts a new `fit?: 'cover' | 'contain'` prop (default `cover`). `CallParticipantTile` passes `contain` when its `focused` prop is true.
|
||||
- `ScreenShareViewer` already uses `object-contain` — no change there beyond removing the hardcoded `aspectRatio: '16/9'` on the unwatched preview button (the parent grid cell will own the ratio).
|
||||
|
||||
### 3. Click-to-pin
|
||||
|
||||
Single source of truth: `focusedId` in `CallContext`.
|
||||
|
||||
- **Left-click** on any tile: `setFocusedId(tile.id === focusedId ? null : tile.id)`.
|
||||
- In `callMode === 'grid'` and `focusedId !== null` → also `setCallMode('focus')`.
|
||||
- In `callMode === 'focus'` and `focusedId === null` → `setCallMode('grid')`.
|
||||
- In `callMode === 'fullscreen'`: only `focusedId` flips; layout reacts inside `FullscreenCall`.
|
||||
- **Doubleclick on the big/pinned tile**: clears the pin (`focusedId = null`).
|
||||
- **Right-click**: unchanged — opens existing context menu (volume / pin toggle / profile).
|
||||
- **Esc in fullscreen**: unchanged — exits fullscreen back to grid.
|
||||
|
||||
The current Stage `onClick` (`InCallPanel.tsx` around lines 578–586) already does this for docked mode; we extend the same handler to `FullscreenCall`'s tile click path (`onFocusTile`).
|
||||
|
||||
### 4. Layout selection (precedence)
|
||||
|
||||
`InCallPanel` picks one of four layouts every render. Precedence top-down — first matching rule wins:
|
||||
|
||||
| # | Condition | Layout |
|
||||
|---|-----------|--------|
|
||||
| 1 | `focusedId !== null` | Single-stage focus: the pinned tile is big, all others strip. |
|
||||
| 2 | `shareCount >= 2` and `focusedId === null` | Multi-share bento: all shares in sub-grid stage, webcams strip below. |
|
||||
| 3 | `shareCount === 1` and `focusedId === null` | Single-stage focus auto-promote: the share is big, webcams strip. |
|
||||
| 4 | `shareCount === 0` and `focusedId === null` | Equal grid (preserves the 2026-05-12 "no constant switching" fix). |
|
||||
|
||||
Where `shareCount = tiles.filter((t) => t.kind === 'screen').length`.
|
||||
|
||||
This is implemented via a derived `stageLayout` discriminated union, not a single `effectiveFocusedId`:
|
||||
|
||||
```ts
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
```
|
||||
|
||||
- Pin clearance returns control to rules 2/3/4 — Discord-style auto-fall-back.
|
||||
- Clicking a share inside the bento sets `focusedId = share.id` → drops into rule 1 (single-stage focus on that share).
|
||||
- Share ends → falls naturally from rule 3 → rule 4, or rule 2 → rule 3.
|
||||
|
||||
### 5. Active-speaker behavior (preserved)
|
||||
|
||||
No reorder. The emerald speaking border on `CallParticipantTile` stays. `prioritizeTiles` only runs when paginating (>12 tiles), as fixed in the 2026-05-12 patch.
|
||||
|
||||
---
|
||||
|
||||
## File-level changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `InCallPanel.tsx` | Compute `stageLayout` (equal-grid / focus / bento per the precedence table). Adjust grid CSS (drop `grid-rows-*`, add `aspect-video` per cell). Plumb `onClick` to `FullscreenCall` tiles. Render bento stage for layout `bento`. |
|
||||
| `CallParticipantTile.tsx` | Forward `focused` → `VideoStub.fit`. Update wrapper class to expect `aspect-video` from parent grid cell. Add `onDoubleClick` to clear pin. |
|
||||
| `ScreenShareViewer.tsx` | Remove hardcoded `aspectRatio: '16/9'` on the unwatched preview button (parent owns aspect now). |
|
||||
|
||||
No changes to `CallContext`, no changes to data model (`Tile`, `focusedId`).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Aspect-video might shrink tiles when there are many participants.** Mitigation: existing `GRID_PAGE_SIZE = 12` pagination keeps cells from collapsing to thumbnail-sized; we accept that 12 tiles at 16:9 will produce small rows just like Discord does at the same density.
|
||||
- **Auto-promote could be unexpected if a user explicitly cleared their pin.** Mitigation: pin clearance sets `focusedId = null`, then `firstShareId` takes over only if shares exist — exactly Discord's behavior. The user can stop watching shares to escape.
|
||||
- **`aspect-video` + flex children in the existing focus-mode (`FocusCall`)**: Focus mode has its own big-tile layout (`Stage` lines ~520–610). It already sets `flex h-full`; introducing `aspect-video` only on the strip thumbs is additive and won't reflow the big tile.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Manual verification with two-user dev call:
|
||||
|
||||
1. **Webcam-only equal grid:** 4 webcams, none pinned, no shares → all tiles 16:9 equal, faces visible cropped (cover), no stretching.
|
||||
2. **Pinning toggle:** click a webcam → that tile becomes big with `contain` fit (no face crop), rest strip; click again → back to grid.
|
||||
3. **Auto-promote:** start a screen share → share is big, webcams strip, share aspect respected (no stretch).
|
||||
4. **Manual override during share:** while share is big, click a webcam → webcam pins big, share moves to strip. Click webcam again → falls back to share-auto-promote.
|
||||
5. **Multi-share:** two users share → bento stage, both shares visible at equal size, webcams below.
|
||||
6. **Active speaker:** someone talks → emerald border, no tile reorder/swap.
|
||||
|
||||
No automated tests (Storybook setup not in place for in-call layouts).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Mobile Deployment Roadmap
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Scope:** `apps/mobile` end-to-end ship to iOS App Store + Google Play
|
||||
**Status:** Decomposition — each phase ships independently with its own spec.
|
||||
|
||||
---
|
||||
|
||||
## Where we are today
|
||||
|
||||
- `apps/mobile` is an Expo + React Native scaffold from the very first commit (`f7cfd2a initial`). It has the right deps (`expo-router`, `react-native-libsodium`, `expo-secure-store`, `expo-sqlite`, `expo-notifications`) but no real screens — just two placeholders ("ChatApp" landing, "Chats — placeholder").
|
||||
- Earlier mobile work (image attachments, voice messages, voice calls, reactions, profile editing, push wiring) lives on the archived `byGalax/chat-app` GitHub repo. It targets a pre-Tauri→Electron `packages/shared` API and is not directly portable; we treat it as reference material, not a merge source.
|
||||
- `packages/shared` is platform-agnostic by design (`CryptoBackend` and `SecretStore` interfaces wait for mobile adapters), so the desktop's business logic is reusable verbatim once we plug in RN-flavoured implementations.
|
||||
- The desktop reached `v0.17.5` with a Netralax rebrand, Discord-style call/chat UX, and an EAS-free electron-builder/electron-updater release pipeline pointed at `update.netralax.cloud`.
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the Netralax mobile app to iOS App Store + Google Play with the core Discord-style chat + voice/video calls of the desktop, working over the same self-hosted Supabase + LiveKit backend.
|
||||
|
||||
## Decomposition
|
||||
|
||||
Five phases, each with its own design spec, plan, and ship gate. We brainstorm and execute one at a time; later phases only get fleshed out once their prerequisites land.
|
||||
|
||||
### Phase 0 — Deployment Foundation
|
||||
**Spec:** `2026-05-13-mobile-phase-0-foundation-design.md`
|
||||
**Goal:** prove the pipeline. After Phase 0, `eas build` (or `expo run`) produces a runnable Netralax-branded shell on a real iPhone + Android device. No features yet — but every later phase ships through this same build pipeline.
|
||||
|
||||
### Phase 1 — Auth + Chat MVP
|
||||
**Why next:** smallest end-to-end vertical slice that's actually useful. Users can log in, see conversations, send/receive text.
|
||||
- Mobile `CryptoBackend` adapter wrapping `react-native-libsodium`.
|
||||
- Mobile `SecretStore` adapter wrapping `expo-secure-store`.
|
||||
- Magic-link auth + device-key registration (reuse `packages/shared/auth`).
|
||||
- Conversation list (DM + group) with last-message preview.
|
||||
- Conversation view: decrypt + render text messages, send text.
|
||||
- Push notifications via `expo-notifications` + the existing `notify-push` edge function.
|
||||
- Minimal profile + logout.
|
||||
|
||||
### Phase 2 — Messaging Features
|
||||
**Why next:** mobile users expect parity with desktop on day-to-day messaging.
|
||||
- Image attachments (camera roll + camera capture).
|
||||
- File attachments (document picker).
|
||||
- Voice messages (record + play).
|
||||
- Reactions, edit, delete, reply, forward.
|
||||
- Read receipts + delivery state.
|
||||
- Typing indicator.
|
||||
- Polls (optional / stretch).
|
||||
|
||||
### Phase 3 — Voice/Video Calls
|
||||
**Why now and not Phase 1:** RN LiveKit + native call UX (CallKit / ConnectionService) is the heaviest single feature. Hard to scope without the basic chat working first, and not blocking for an early TestFlight.
|
||||
- LiveKit React Native SDK (`@livekit/react-native` + `@livekit/react-native-webrtc`).
|
||||
- Incoming call notifications wake the app via CallKit (iOS) + ConnectionService (Android).
|
||||
- Outgoing call flow.
|
||||
- Voice + video tracks, mute/hangup, speaker/earpiece switch, headset routing.
|
||||
- Screen sharing is explicitly out of scope on mobile.
|
||||
|
||||
### Phase 4 — Polish + Store Submission
|
||||
**Why last:** can't submit until the features are in.
|
||||
- Privacy policy + Terms surfaced inside the app.
|
||||
- Store-listing assets: screenshots, descriptions, age rating.
|
||||
- Code signing: Apple Developer Program enrolment + provisioning, Android upload keystore.
|
||||
- TestFlight internal + external testing.
|
||||
- Google Play internal track → closed test → production.
|
||||
|
||||
## Cross-cutting decisions (locked in here, no per-phase relitigation)
|
||||
|
||||
| Topic | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| Framework | Expo + React Native (existing scaffold) | Already set up; New Architecture enabled; managed-workflow gives EAS Build out of the box |
|
||||
| Branding | Netralax everywhere | Matches the desktop rebrand released as v0.17.x |
|
||||
| Bundle / package id | `cloud.netralax.app` (both iOS + Android) | Matches the desktop's AppUserModelId `cloud.netralax.desktop`; `com.meinname.chatapp` is dev-placeholder |
|
||||
| Shared business logic | Consume `@chat-app/shared` verbatim | Designed for this; the only mobile-specific pieces are the `CryptoBackend` + `SecretStore` adapters |
|
||||
| Old chat-app mobile sprints | Reference, do not merge | API drift since pre-Electron; cleaner to rewrite against current shared |
|
||||
| Backend | Self-hosted Supabase at `update.netralax.cloud` + LiveKit | Unchanged from desktop |
|
||||
| Push delivery | Expo Push Service for development; native APNS + FCM after EAS submit | Avoids managing certs in Phase 1 |
|
||||
| State management | React local state + tiny context, same as desktop | Avoid Redux/Zustand bloat for a chat app |
|
||||
| Persistent local store | `expo-sqlite` (already in deps) | Mirror desktop's better-sqlite3 schema |
|
||||
|
||||
## Ship gates between phases
|
||||
|
||||
A phase only ends — and the next one begins — when:
|
||||
1. Manual verification of every spec'd flow passes on **both** an iOS device and an Android device.
|
||||
2. The previous phase's release build still runs (no regression).
|
||||
3. The phase's spec is reflected in the codebase (no straggling TODOs that were in scope).
|
||||
|
||||
## Out of scope for the entire roadmap
|
||||
|
||||
- Web build of the mobile app (Expo can technically emit one — not worth the bundle-cost double-duty since the Electron app already covers desktop).
|
||||
- macOS / Windows React Native targets.
|
||||
- Watch / TV / wearable apps.
|
||||
- iPad-specific UI tuning beyond `"supportsTablet": true` (we ship the phone UI on tablet for now).
|
||||
- Resurrecting any code from the archived `chat-app` mobile sprints.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Mobile Phase 0 — Deployment Foundation
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Scope:** `apps/mobile`
|
||||
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
We can't even smoke-test a Netralax-branded build on a real device today. The Expo scaffold says "ChatApp" everywhere, the EAS project ID is a literal string `REPLACE_WITH_EAS_PROJECT_ID`, and the iOS bundle id / Android package id is the placeholder `com.meinname.chatapp`. Every later phase ships through this build pipeline, so it needs to work end-to-end before we touch features.
|
||||
|
||||
## Goal
|
||||
|
||||
After Phase 0, a developer (or the user) can run a single command from the repo root and get an installable `.ipa` or `.apk` carrying the Netralax brand. No real features — just a launch screen that renders, plus the harness underneath: SafeArea, navigation root, error boundary, the `@chat-app/shared` package compiling and importable from the mobile app, version readout, EAS config.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Auth, chat, calls, push wiring (Phases 1–3).
|
||||
- App Store / Play Store submission (Phase 4).
|
||||
- Resurrecting the legacy mobile sprints from the archived `chat-app` repo.
|
||||
- Code-signing certificates / provisioning profiles. Phase 0 produces development-signed builds via EAS managed credentials; production signing is Phase 4.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Branding rename in `apps/mobile/app.json`
|
||||
|
||||
Replace every `ChatApp`/`com.meinname.chatapp` reference with the Netralax brand. Final values:
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"name": "Netralax",
|
||||
"slug": "netralax",
|
||||
"scheme": "netralax",
|
||||
"version": "0.1.0",
|
||||
"ios": {
|
||||
"bundleIdentifier": "cloud.netralax.app",
|
||||
"supportsTablet": true,
|
||||
"infoPlist": { "ITSAppUsesNonExemptEncryption": false }
|
||||
},
|
||||
"android": {
|
||||
"package": "cloud.netralax.app",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
}
|
||||
},
|
||||
"extra": { "eas": { "projectId": "REPLACE_WITH_EAS_PROJECT_ID" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`extra.eas.projectId` stays as the placeholder string in source — the user runs `eas init` once locally, which fills it in and commits. Documented in the mobile README.
|
||||
|
||||
### 2. App icon + splash
|
||||
|
||||
Replace `assets/icon.png`, `assets/adaptive-icon.png`, `assets/splash.png` with placeholder Netralax variants (purple-on-dark `N` mark matching the sidebar avatar in the desktop). A single 1024×1024 master plus the 1024-Android-foreground and 1284×2778 splash is enough for Phase 0. Final art lives in `apps/mobile/assets/`.
|
||||
|
||||
### 3. EAS configuration
|
||||
|
||||
Create `apps/mobile/eas.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cli": { "version": ">= 13.0.0" },
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": { "simulator": true },
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": { "simulator": false },
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"production": {
|
||||
"ios": { "simulator": false },
|
||||
"android": { "buildType": "app-bundle" }
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": { "appleId": "REPLACE_WITH_APPLE_ID", "ascAppId": "REPLACE_WITH_ASC_APP_ID" },
|
||||
"android": { "serviceAccountKeyPath": "./play-service-account.json" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`production`-submit credentials stay as placeholders — Phase 4 fills them in. The `development` profile produces a custom dev client + APK that can be installed and used with `expo start --dev-client`.
|
||||
|
||||
### 4. Minimal app shell that doesn't crash
|
||||
|
||||
Four changes to verify the runtime is healthy:
|
||||
|
||||
- `app/_layout.tsx`: wrap the `<Stack>` in `<SafeAreaProvider>` from `react-native-safe-area-context`. Add `<GestureHandlerRootView style={{ flex: 1 }}>` outside it so future gesture-based UI is unblocked. Wrap everything in a top-level `<ErrorBoundary>` (new component, §4a) so an unhandled render error shows a readable fallback instead of a white-screen crash on a TestFlight build.
|
||||
- `app/index.tsx`: render "Netralax" (not "ChatApp"), display the app version read from `expo-application` (`Application.nativeApplicationVersion`), and link to `/(app)/chats` via a placeholder button so navigation is proved.
|
||||
- `app/(app)/chats.tsx`: same — render "Netralax — Chats coming soon" so the protected-stack mounts cleanly.
|
||||
|
||||
#### 4a. Error boundary
|
||||
|
||||
`apps/mobile/components/ErrorBoundary.tsx` is a small React class component that catches render errors and shows the error message + a "Reload" button. Same shape as the desktop's crash-recovery surface. Around 40 lines.
|
||||
|
||||
### 5. Shared package smoke import
|
||||
|
||||
Phase 0's gate is that `import { CryptoBackend } from '@chat-app/shared'` resolves and the Metro bundler doesn't choke on the shared package's exports. We don't *use* the import — just type it so a compile failure surfaces here, not later.
|
||||
|
||||
Concretely: add `apps/mobile/lib/sharedSmoke.ts` that pulls a **runtime** value (not a type-only import — type imports are erased by the TS compiler and never reach Metro). `@chat-app/shared` re-exports its modules as namespaces (`crypto`, `auth`, `chat`, `friends`, `rtc`, `supabase`, `i18n`, `admin`), so we go through one of those:
|
||||
|
||||
```ts
|
||||
// Force-imports a runtime symbol from @chat-app/shared so Metro
|
||||
// actually resolves the package on app boot. A type-only import would
|
||||
// be erased and never surface a packaging problem. This file goes away
|
||||
// once Phase 1's real crypto adapter lands and registers the backend
|
||||
// for real.
|
||||
import { crypto } from '@chat-app/shared';
|
||||
|
||||
// Pull the function into a runtime reference so the bundle keeps the
|
||||
// import. Calling it without a backend would throw, so we just retain
|
||||
// the reference.
|
||||
export const sharedSmoke = { register: crypto.setCryptoBackend };
|
||||
```
|
||||
|
||||
Imported from `_layout.tsx` once, value unused. Phase 1 removes it.
|
||||
|
||||
### 6. README + scripts
|
||||
|
||||
`apps/mobile/README.md` gets a "Phase 0 quickstart" section that walks through `eas init` + the first development build + installing the dev client on a device.
|
||||
|
||||
Root `package.json` gets convenience scripts only if they're missing: `mobile:dev`, `mobile:ios`, `mobile:android`, `mobile:typecheck` — all `pnpm --filter @chat-app/mobile <cmd>` wrappers.
|
||||
|
||||
### 7. Typecheck + lint plumbing
|
||||
|
||||
Verify `pnpm --filter @chat-app/mobile typecheck` already runs (the mobile `package.json` already declares it). If `eslint` is wired similarly, add it to a CI workflow stub — but actual CI integration is out of scope here.
|
||||
|
||||
## File structure (after Phase 0)
|
||||
|
||||
```
|
||||
apps/mobile/
|
||||
├── app/
|
||||
│ ├── _layout.tsx ← MODIFIED: SafeArea, GestureHandler, providers shell
|
||||
│ ├── index.tsx ← MODIFIED: Netralax landing with version + nav link
|
||||
│ └── (app)/
|
||||
│ ├── _layout.tsx ← unchanged
|
||||
│ └── chats.tsx ← MODIFIED: "coming soon" placeholder text
|
||||
├── assets/
|
||||
│ ├── icon.png ← REPLACED: Netralax 1024×1024
|
||||
│ ├── adaptive-icon.png ← REPLACED: foreground for Android adaptive
|
||||
│ └── splash.png ← REPLACED: Netralax splash
|
||||
├── lib/
|
||||
│ └── sharedSmoke.ts ← NEW: temporary import-canary
|
||||
├── app.json ← MODIFIED: Netralax branding, ids
|
||||
├── eas.json ← NEW: build profiles
|
||||
├── package.json ← unchanged (deps stay)
|
||||
└── README.md ← MODIFIED: Phase 0 quickstart
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- **`react-native-libsodium` + New Architecture (Fabric / TurboModules).** `newArchEnabled: true` is in `app.json`. The library claims support, but past RN libsodium ports broke around the JSI migration. If the dev client crashes on require, fall back to disabling new arch in Phase 0 and re-enable in Phase 1 once the adapter is wired and tested.
|
||||
- **`@chat-app/shared` Node-only paths.** The package was authored against Node + browser; the supabase client uses `fetch` and `WebSocket` which RN polyfills. If any export pulls `node:crypto` directly the Metro resolver may fail. The smoke import (§5) is the canary for this.
|
||||
- **EAS account access.** `eas init` requires an Expo account. The user owns it; we treat the project ID slot as an external input.
|
||||
- **Apple Developer enrolment.** Phase 0 builds for iOS need an Apple ID; EAS can generate dev certs automatically the first time `eas build --platform ios` runs against the user's Apple ID. No paid enrolment needed for the development profile (free signing).
|
||||
|
||||
## Verification (run before declaring Phase 0 done)
|
||||
|
||||
1. `pnpm --filter @chat-app/mobile typecheck` → exit 0.
|
||||
2. `pnpm --filter @chat-app/mobile dev` (`expo start --dev-client` once a dev client is installed) → app boots on a real iPhone *and* a real Android device.
|
||||
3. Landing screen shows "Netralax" + a non-empty version string read at runtime.
|
||||
4. Tapping the placeholder nav link routes into `/(app)/chats` without crash.
|
||||
5. `eas build --platform android --profile development` produces an APK that installs and launches on a physical Android device.
|
||||
6. `eas build --platform ios --profile development` produces a `.tar.gz` (simulator build) **OR** an `.ipa` (device build, if free signing succeeded).
|
||||
7. Optional: visual check that the icon + splash render as Netralax (not Expo's default) on both platforms.
|
||||
|
||||
If any of those fail, Phase 0 isn't done — fix in this phase rather than rolling it into Phase 1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Push notification token registration (Phase 1).
|
||||
- Linking to the Supabase URL / env-var injection (Phase 1).
|
||||
- Local SQLite schema (Phase 1).
|
||||
- Any screen beyond Landing + Chats-coming-soon.
|
||||
- Reanimated / gesture-based animations.
|
||||
- Dark / light mode polish — system default is fine.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Mobile Phase 1 — Auth + Chat MVP
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Scope:** `apps/mobile`
|
||||
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 0 delivered a Netralax-branded shell. It boots, renders a placeholder, and is configured for `eas build`, but the app has no actual functionality. To call mobile "deployment-ready with working features" the user must be able to log in, see their chats, and send a text message — the smallest end-to-end vertical slice that's also useful.
|
||||
|
||||
## Goal
|
||||
|
||||
After Phase 1, a Netralax user on iOS or Android can:
|
||||
|
||||
1. Open the app, enter their account email, request a magic link.
|
||||
2. Tap the link in their email (deep link returns to `netralax://auth/callback`), complete the session, and land on the chat list.
|
||||
3. See their existing conversations (DMs + groups) with last-message previews, decrypted on-device.
|
||||
4. Open a conversation, see the message history (decrypted), and send a new text message that the desktop client receives correctly.
|
||||
5. Log out from a settings entry, returning to the login screen.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Signup with invite code (login-only MVP — accounts are provisioned via desktop or admin scripts).
|
||||
- Attachments, voice messages, reactions, edits (Phase 2).
|
||||
- Calls (Phase 3).
|
||||
- Push notifications (deferred — chat works without push, the user pulls fresh data on screen focus / pull-to-refresh).
|
||||
- Realtime subscription on conversations (deferred). Phase 1 polls / refetches on screen focus.
|
||||
- Read receipts, typing indicator, presence (Phase 2).
|
||||
- Friend list, profile edit, settings beyond logout.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Theme constants
|
||||
|
||||
`apps/mobile/theme/colors.ts` exports a flat `colors` object with the hex codes Phase 0 inlined across files (`#0b0b0f`, `#9ca3af`, `#fff`, `#6b7280`, `#5865f2`) plus a couple of new ones we need for Phase 1 (input border, danger). Every Phase 1 screen imports from here; no new hex literals appear in components.
|
||||
|
||||
### 2. Crypto adapter (`apps/mobile/lib/cryptoBackend.ts`)
|
||||
|
||||
A near-mirror of `apps/desktop/src/lib/cryptoBackend.ts` but wrapping `react-native-libsodium` instead of `libsodium-wrappers-sumo`. The two libraries expose the same primitive API (the RN port re-exports the libsodium-wrappers types) — only the import + the absence of `_sodium.ready` differs. Synchronous (no WASM warm-up needed on a native lib). Registered at boot via `crypto.setCryptoBackend(createLibsodiumBackend())` from `_layout.tsx`.
|
||||
|
||||
### 3. Secret store adapter (`apps/mobile/lib/secretStore.ts`)
|
||||
|
||||
Implements `SecretStore` (from `@chat-app/shared/auth/secure-storage`) backed by `expo-secure-store`. Per the contract, values are `Uint8Array`; we base64-encode at the boundary because `SecureStore` only accepts strings. `buffer` polyfill is part of RN's base set, no extra install.
|
||||
|
||||
### 4. Supabase session storage (`apps/mobile/lib/sessionStorage.ts`)
|
||||
|
||||
Supabase's JS client needs an async-storage object for session tokens. Use `@react-native-async-storage/async-storage` (new dep) — supabase-js v2 accepts its `getItem` / `setItem` / `removeItem` API directly.
|
||||
|
||||
### 5. Env config (`apps/mobile/lib/env.ts`)
|
||||
|
||||
Expo exposes vars prefixed `EXPO_PUBLIC_*` to the bundle via `process.env`. Read three:
|
||||
|
||||
- `EXPO_PUBLIC_SUPABASE_URL`
|
||||
- `EXPO_PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `EXPO_PUBLIC_AUTH_REDIRECT_URL` — defaults to `netralax://auth/callback`.
|
||||
|
||||
`apps/mobile/.env.example` is added with placeholder values + a README pointer.
|
||||
|
||||
### 6. Supabase client (`apps/mobile/lib/supabase.ts`)
|
||||
|
||||
Mirrors desktop's `apps/desktop/src/lib/supabase.ts`. Calls `createClient` from `@chat-app/shared/supabase` with the mobile `sessionStorage` adapter and `detectSessionInUrl: false` (the callback screen parses the URL manually).
|
||||
|
||||
### 7. Auth context (`apps/mobile/lib/authContext.tsx`)
|
||||
|
||||
React Context provider that wraps the authenticated tree. Exposes:
|
||||
|
||||
- `session: Session | null`
|
||||
- `user: User | null`
|
||||
- `device: DeviceRecord | null` — the registered device for this install
|
||||
- `loading: boolean` — true while hydrating from AsyncStorage on boot
|
||||
- `signOut(): Promise<void>`
|
||||
|
||||
On mount:
|
||||
|
||||
1. `await supabase.auth.getSession()` — pulls from AsyncStorage.
|
||||
2. If a session exists, derive the device by `listOwnDevices(supabase)` and matching on a locally-stored device id (in `secretStore` under key `device.id`).
|
||||
3. If session exists but no device record yet (fresh install, account exists on other devices), call `registerDevice` with a fresh key pair, store the new device id + private key in `secretStore`.
|
||||
|
||||
The provider subscribes to `supabase.auth.onAuthStateChange` so signing out from anywhere updates the tree.
|
||||
|
||||
### 8. Route gating
|
||||
|
||||
- `app/index.tsx`: if `session` is set, redirect to `/(app)/chats`. Otherwise show the login form.
|
||||
- `app/(app)/_layout.tsx`: if no `session`, redirect to `/`. (Existing file: change from "always render Stack" to "guard on session".)
|
||||
|
||||
### 9. Login screen (`app/index.tsx`)
|
||||
|
||||
Replaces the Phase-0 placeholder. Renders:
|
||||
|
||||
- Netralax wordmark.
|
||||
- Email `TextInput`.
|
||||
- "Magic Link senden" button → `auth.loginWithMagicLink(supabase, email, env.authRedirectUrl)`.
|
||||
- After tap: show "Check deine Mails" state until the deep link fires.
|
||||
- Error banner on failure.
|
||||
|
||||
### 10. Auth callback (`app/auth/callback.tsx`)
|
||||
|
||||
A new screen reachable via the `netralax://auth/callback` deep link. Reads URL params (Supabase magic-link callback puts `access_token` + `refresh_token` in the URL hash or query), calls `supabase.auth.setSession({...})`, routes to `/(app)/chats`. On failure, routes back to `/` with an error message.
|
||||
|
||||
Expo Router auto-handles the deep link → screen mapping when the scheme in `app.json` matches and the path matches the route file.
|
||||
|
||||
### 11. Conversation list (`app/(app)/chats.tsx`)
|
||||
|
||||
Replaces the Phase-0 placeholder. Uses `chat.listConversations(supabase)` from `@chat-app/shared/chat`. Renders a `FlatList` of `ConversationRow` (new component):
|
||||
|
||||
- DM: peer's display name + Avatar (initial-letter circle).
|
||||
- Group: group name + "N Mitglieder".
|
||||
- Last message preview — decrypted via `decryptMessages` if encrypted, else "…".
|
||||
- Timestamp (relative — "Vor 5 Min").
|
||||
|
||||
Pull-to-refresh refetches.
|
||||
|
||||
Tap on row → navigates to `/(app)/conversations/[id]`.
|
||||
|
||||
A small icon-button in the header opens a logout modal calling `signOut()`.
|
||||
|
||||
### 12. Conversation detail (`app/(app)/conversations/[id].tsx`)
|
||||
|
||||
Loads:
|
||||
|
||||
- `fetchConversationMessages(supabase, { conversationId, limit: 50 })`.
|
||||
- `listConversationDeviceKeys(supabase, conversationId)` for decryption.
|
||||
- `decryptMessages({ messages, deviceKeys, myDeviceId, myPrivateKey })` to get plaintext.
|
||||
|
||||
Renders an inverted `FlatList` (newest at the bottom, like Discord). Each row shows sender name + body + timestamp. Attachments rendered as "📎 [Anhang]" placeholders — Phase 2 adds real rendering.
|
||||
|
||||
Bottom: a `TextInput` + send button. On send: `sendEncryptedMessage`. Optimistically appends to local state, then refetches.
|
||||
|
||||
### 13. Cleanup
|
||||
|
||||
- Delete `apps/mobile/lib/sharedSmoke.ts` and the `void sharedSmoke` import in `_layout.tsx`.
|
||||
|
||||
## File structure (after Phase 1)
|
||||
|
||||
```
|
||||
apps/mobile/
|
||||
├── app/
|
||||
│ ├── _layout.tsx ← MODIFIED: register crypto backend, mount AuthProvider
|
||||
│ ├── index.tsx ← MODIFIED: Login screen (magic link form)
|
||||
│ ├── auth/
|
||||
│ │ └── callback.tsx ← NEW: deep-link landing
|
||||
│ └── (app)/
|
||||
│ ├── _layout.tsx ← MODIFIED: session-gated stack
|
||||
│ ├── chats.tsx ← MODIFIED: real conversation list + logout
|
||||
│ └── conversations/
|
||||
│ └── [id].tsx ← NEW: conversation view + send
|
||||
├── components/
|
||||
│ ├── ErrorBoundary.tsx ← unchanged
|
||||
│ ├── Avatar.tsx ← NEW: initial-letter avatar circle
|
||||
│ └── ConversationRow.tsx ← NEW: list row
|
||||
├── lib/
|
||||
│ ├── authContext.tsx ← NEW
|
||||
│ ├── cryptoBackend.ts ← NEW
|
||||
│ ├── env.ts ← NEW
|
||||
│ ├── secretStore.ts ← NEW
|
||||
│ ├── sessionStorage.ts ← NEW
|
||||
│ ├── supabase.ts ← NEW
|
||||
│ ├── timeFormat.ts ← NEW
|
||||
│ └── sharedSmoke.ts ← DELETED
|
||||
├── theme/
|
||||
│ └── colors.ts ← NEW
|
||||
├── .env.example ← NEW
|
||||
└── package.json ← MODIFIED: + @react-native-async-storage/async-storage
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- **`react-native-libsodium` New Architecture compatibility.** v1.7 claims Fabric/TurboModule support. If `setCryptoBackend(createLibsodiumBackend())` crashes on app boot under New Arch, fall back to `"newArchEnabled": false` and re-evaluate.
|
||||
- **`@chat-app/shared` ESM resolution under Metro.** Shared emits ESM with `.js` import specifiers. Metro's default resolver handles this; if it doesn't, set `metro.config.js`'s `resolver.unstable_enablePackageExports: true`.
|
||||
- **Magic-link deep-link return.** Expo Router auto-maps `netralax://auth/callback` to `app/auth/callback.tsx` since the scheme in `app.json` matches (`netralax`) and `expo-linking` is in the prebuild.
|
||||
- **Device key persistence.** The private key blob lives in `expo-secure-store` (Keychain on iOS, Keystore on Android). Uninstalling the app wipes the key — same as desktop. Backup/restore is post-Phase-4.
|
||||
|
||||
## Verification (manual on real hardware after typecheck passes)
|
||||
|
||||
1. `pnpm --filter @chat-app/mobile typecheck` → exit 0.
|
||||
2. `.env.local` populated with the same `SUPABASE_URL` / `SUPABASE_ANON_KEY` the desktop uses → app boots, shows Login.
|
||||
3. Enter an existing-user email → "Magic Link senden" succeeds → "Check deine Mails" state appears.
|
||||
4. Open the email link on the device → callback URL fires Expo Router → app navigates to `/(app)/chats`.
|
||||
5. Chat list loads; existing DMs and groups from the desktop are visible with decrypted previews.
|
||||
6. Tap a conversation → detail screen opens, history loads, decrypted message bodies render newest-at-bottom.
|
||||
7. Type "test from mobile" → send → desktop client of the same account sees the new message via Supabase realtime.
|
||||
8. Pull-to-refresh on chat list re-fetches.
|
||||
9. Tap settings → "Abmelden" → returns to Login screen, session cleared from AsyncStorage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Push notifications (`expo-notifications` + `notify-push` edge function).
|
||||
- Realtime subscription on the mobile side (relies on focus-refetch).
|
||||
- Conversation creation (new DM / new group) from mobile.
|
||||
- Attachments, voice messages, reactions, edits, replies, forwards (Phase 2).
|
||||
- Read receipts, typing, delivery state (Phase 2).
|
||||
- Voice/video calls (Phase 3).
|
||||
- Backup/restore device keys (post-Phase-4).
|
||||
- Profile editing, friends, admin tools (post-Phase-4).
|
||||
@@ -0,0 +1,143 @@
|
||||
# Mobile Phase 2 — Messaging Features
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Scope:** `apps/mobile`
|
||||
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 1 ships text-only chat. A mobile chat client without image attachments or reactions feels half-built. To make Netralax mobile genuinely competitive — and to call the goal of "features working" satisfied — we need the high-impact subset of the desktop's messaging feature set wired into mobile screens.
|
||||
|
||||
## Goal
|
||||
|
||||
After Phase 2, a Netralax mobile user can:
|
||||
|
||||
1. **Attach an image** to a message — from the photo library or via the camera — and watch it upload, encrypt, and arrive on the desktop client decrypted.
|
||||
2. **React to a message** with an emoji via long-press → quick reaction strip; see reaction badges underneath the bubble; tap a badge to toggle their own reaction off.
|
||||
3. **Reply to a message** via long-press → Reply → see the quoted source in a banner above the input; send the message with `replyToId` set; the reply renders the quoted preview on both sides.
|
||||
4. **Delete an own message** via long-press → Delete → soft-delete confirmation; the bubble flips to "Diese Nachricht wurde gelöscht".
|
||||
|
||||
## Non-goals
|
||||
|
||||
- File / document attachments (high effort, low frequency on mobile — Phase 2.5).
|
||||
- Voice messages (record + play UI is a substantial sub-feature — Phase 2.5).
|
||||
- Edit own message (Phase 2.5).
|
||||
- Forward (low priority — post-Phase-4).
|
||||
- Read receipts + delivery state (needs realtime — Phase 1.5).
|
||||
- Typing indicator (needs realtime — Phase 1.5).
|
||||
- Polls (post-Phase-4).
|
||||
- Reaction picker beyond a fixed 6-emoji strip (full picker is post-Phase-4).
|
||||
- Multi-image gallery (one image per message in Phase 2).
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New dependency
|
||||
|
||||
Add `expo-image-picker` to the mobile workspace. It bundles the OS image-picker + the camera-permission flow.
|
||||
|
||||
### 2. Image attachments
|
||||
|
||||
`apps/mobile/lib/imagePicker.ts` — wrapper around `expo-image-picker` that requests permissions on demand and returns a `{ uri, mimeType, sizeBytes, width, height }` handle or `null` on cancel.
|
||||
|
||||
Sending an image:
|
||||
|
||||
1. User taps a `+` button next to the input → `ActionSheet` with "Foto aufnehmen" / "Aus Galerie wählen" / "Abbrechen".
|
||||
2. The picker returns the URI. The conversation detail loads the URI as a `Blob` via `fetch(uri).then((r) => r.blob())`.
|
||||
3. Pass to `chat.encryptAndUploadAttachment({ client, conversationId, file, mimeType, sizeBytes, width, height })` — returns an `EncryptedAttachmentResult`.
|
||||
4. After upload, call `chat.sendEncryptedMessage(...)` with `attachmentHandles: [result.handle]` and empty `plaintext`. `sendEncryptedMessage` writes the message_attachments rows internally.
|
||||
|
||||
Rendering an image:
|
||||
|
||||
1. `parseMessagePayload(plaintext)` returns either `{ kind: 'text', text, attachments }` or other shapes.
|
||||
2. For text-with-attachments, the bubble renders the text plus an `<AttachmentImage>` per handle. Phase 2 caps at one image per message; multi-image is a future polish.
|
||||
3. `<AttachmentImage>` calls `chat.downloadAndDecryptAttachment(...)`, gets a `Uint8Array`, converts to a data URL via `data:<mime>;base64,<b64>` and renders `<Image source={{ uri }} />`.
|
||||
4. Cache by handle id in memory (`apps/mobile/lib/attachmentCache.ts`) to avoid re-downloading on re-render. No disk cache in Phase 2.
|
||||
|
||||
### 3. Reactions
|
||||
|
||||
`apps/mobile/components/ReactionStrip.tsx` — horizontal row of 6 hardcoded emoji buttons (👍 ❤️ 😂 😮 😢 🎉) shown inside the long-press modal.
|
||||
|
||||
Long-press on a message opens `MessageActionsSheet` (§6 below) which contains the reaction strip + action rows. Tapping an emoji calls `chat.addReaction(supabase, messageId, emoji)` (or `removeReaction` if the user already reacted with that emoji), closes the sheet, and re-fetches.
|
||||
|
||||
Display: `chat.listReactionsForMessages(supabase, messageIds)` runs after every message-load, stashed in a `Map<messageId, MessageReaction[]>`. The bubble's footer renders a `flex-row` of `[emoji count]` pills (`ReactionPills.tsx`); pills are tappable to toggle.
|
||||
|
||||
### 4. Reply
|
||||
|
||||
The reply target lives in a `replyTo: ChatMessage | null` state in `[id].tsx`.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Long-press → sheet → "Antworten".
|
||||
2. `setReplyTo(message)`.
|
||||
3. A banner above the `TextInput` shows quoted sender + first line of body + an `X` to cancel.
|
||||
4. On send: pass `replyToId: replyTo.id` to `sendEncryptedMessage`, then clear the banner.
|
||||
|
||||
Rendering a reply:
|
||||
|
||||
- A message with `replyToId` set looks up the parent in the local messages array. If found, render a compact quote line above the body inside the same outer bubble. If not, render "↩ Original-Nachricht außerhalb dieses Fensters".
|
||||
|
||||
### 5. Delete own message
|
||||
|
||||
Long-press on an own message → sheet → "Löschen" → `Alert.alert` confirm. On confirm: `chat.softDeleteMessage(supabase, messageId)`. The server trigger enforces sender-only + 24h window.
|
||||
|
||||
Bubble rendering for `deletedAt !== null`: italic placeholder ("Nachricht gelöscht") in `colors.textMuted`.
|
||||
|
||||
### 6. Shared message-action modal
|
||||
|
||||
`apps/mobile/components/MessageActionsSheet.tsx` — RN `Modal` with `presentationStyle="overFullScreen"` + `transparent`, rendered conditionally from `[id].tsx`. Props: `message`, `mine`, `onClose`, `onReact`, `onReply`, `onDelete`. The sheet renders the reaction strip + action rows on a `colors.surface` panel that slides from the bottom. Touching the backdrop dismisses.
|
||||
|
||||
### 7. Bubble extraction
|
||||
|
||||
The Phase-1 `MessageRow` was inlined in `[id].tsx`. Phase 2 extracts it to `apps/mobile/components/MessageBubble.tsx` because it now needs to render:
|
||||
|
||||
- Reply quote preview.
|
||||
- Body text (or "Nachricht gelöscht").
|
||||
- Attachment image.
|
||||
- Reaction pills.
|
||||
- Long-press handler.
|
||||
|
||||
The single-responsibility expansion warrants its own file.
|
||||
|
||||
## File structure (deltas)
|
||||
|
||||
| File | Status | Responsibility |
|
||||
|---|---|---|
|
||||
| `apps/mobile/package.json` | MODIFIED | Add `expo-image-picker` |
|
||||
| `apps/mobile/app.json` | MODIFIED | Add `expo-image-picker` plugin with NS*UsageDescription strings |
|
||||
| `apps/mobile/lib/imagePicker.ts` | NEW | Permission + pick helper |
|
||||
| `apps/mobile/lib/attachmentCache.ts` | NEW | In-memory `Map<handleId, dataURL>` |
|
||||
| `apps/mobile/components/AttachmentImage.tsx` | NEW | Renders an encrypted image attachment |
|
||||
| `apps/mobile/components/ReactionStrip.tsx` | NEW | 6-emoji quick reactor |
|
||||
| `apps/mobile/components/ReactionPills.tsx` | NEW | Below-bubble reaction counts |
|
||||
| `apps/mobile/components/MessageActionsSheet.tsx` | NEW | Long-press modal with reactions + Reply/Delete |
|
||||
| `apps/mobile/components/MessageBubble.tsx` | NEW | Bubble with text + attachments + reply preview + reactions + deleted state |
|
||||
| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Wires attachments, reactions, reply, delete; uses `MessageBubble` |
|
||||
|
||||
## Risks
|
||||
|
||||
- **Image picker permissions on iOS.** `NSPhotoLibraryUsageDescription` + `NSCameraUsageDescription` are required in `Info.plist`. Expo manages them via the `expo-image-picker` plugin in `app.json`.
|
||||
- **Encrypted-attachment data-URL size.** Decoded images can be several MB; converting to a `data:` URI inflates memory. Phase 2 accepts this with an in-memory LRU-free cache (good enough for a few images).
|
||||
- **Reaction count race.** Two users react simultaneously → server stores both, local needs to refetch. `listReactionsForMessages` is cheap enough to call after each user reaction.
|
||||
- **Soft-delete UX without realtime.** Other clients see the deletion only after refetch. Pull-to-refresh propagates; Phase 1.5 realtime would fix this.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `pnpm --filter @chat-app/mobile typecheck` exits 0.
|
||||
2. On a real device + the desktop signed into the same account:
|
||||
- Mobile: snap a photo, send it. Desktop receives and renders it inline.
|
||||
- Desktop: sends a message. Mobile receives it, long-presses, sends a 👍. Desktop shows the reaction badge.
|
||||
- Mobile: long-press → Reply → type → send. Desktop shows the threaded reply preview.
|
||||
- Mobile: long-press own message → Delete → confirm. Both clients show "Nachricht gelöscht" after refresh.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- File / document attachments.
|
||||
- Voice messages.
|
||||
- Edit message.
|
||||
- Forward.
|
||||
- Full emoji picker.
|
||||
- Disk-cached image decryption.
|
||||
- Realtime subscriptions.
|
||||
- Conversation creation from mobile.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Mobile Phase 3 — Voice Calls
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Scope:** `apps/mobile`
|
||||
**Roadmap context:** `2026-05-13-mobile-deployment-roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Phases 1+2 cover messaging. The desktop ships voice/video calls via LiveKit; without parity on mobile the app feels incomplete. Phase 3 brings audio calls (1:1 DM + groups) into the mobile client over the same LiveKit + Supabase-realtime signaling stack the desktop already uses.
|
||||
|
||||
## Goal
|
||||
|
||||
After Phase 3, a Netralax mobile user can:
|
||||
|
||||
1. Initiate a voice call from a conversation header — "Anrufen" button next to the title.
|
||||
2. Receive an incoming-call modal when the peer / a group member starts a call, with **Annehmen** / **Ablehnen** buttons.
|
||||
3. Join the LiveKit room on accept, hear other participants, and be heard.
|
||||
4. Toggle mute + lautsprecher (speaker/earpiece), and end the call with the red hangup button.
|
||||
5. See a participant list in the in-call screen so they know who's on the line.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Video calls** — voice-only MVP. Camera toggle UI is reserved for Phase 3.5 because it requires extra permission strings, camera previews, and view tracks that double the surface.
|
||||
- **CallKit / ConnectionService native UI** — the OS-level "incoming call" screen requires `react-native-callkeep` + native config + APNS VoIP / FCM data-only payloads. Phase 3 ships an in-app modal; native CallKit is Phase 3.5.
|
||||
- **Push-based wakeup** — if the app is killed, the user doesn't get notified of an incoming call. Realtime subscription only works while the app is open.
|
||||
- **Screen sharing** — explicitly out of scope on mobile.
|
||||
- **Call recording, captions, soundboard** — desktop-only conveniences, post-Phase-4.
|
||||
- **Call-stream end-to-end encryption beyond what the LiveKit token mint already enforces** — server-side RLS + short-lived JWT handle authorisation.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. New dependencies
|
||||
|
||||
- `@livekit/react-native` — the LiveKit RN SDK; provides `Room`, `LocalParticipant`, `RemoteParticipant`, `AudioSession`.
|
||||
- `@livekit/react-native-webrtc` — peer dep that ships the actual WebRTC stack as native modules.
|
||||
|
||||
Both LiveKit packages require native code, so the workspace already runs through EAS Dev Client (Phase 0 set up the dev profile). The `@livekit/react-native` Expo plugin needs registration in `app.json`.
|
||||
|
||||
### 2. Permissions
|
||||
|
||||
`app.json` gains `NSMicrophoneUsageDescription` (via plugin config) plus the Android `RECORD_AUDIO` permission. No camera string in Phase 3 since we don't capture video. Also add iOS `audio` background mode so the LiveKit room stays alive when the user backgrounds the app mid-call.
|
||||
|
||||
### 3. Call signaling
|
||||
|
||||
`apps/mobile/lib/callSignal.ts` — Supabase realtime channel subscription:
|
||||
|
||||
- `subscribeCallSignals(client, userId, onSignal)` subscribes to `signalTopic(userId)`, parses `CallSignal` payloads, returns an unsubscribe function.
|
||||
- `sendCallSignal(client, toUserId, payload)` broadcasts to the peer's channel.
|
||||
|
||||
Mirrors the desktop pattern but in a much smaller surface — the desktop's CallContext is ~3000 lines because of features we're not shipping (active speaker, captions, screen share, soundboard, stats overlay, etc.). The mobile Phase 3 equivalent is ~400 lines.
|
||||
|
||||
### 4. Call state machine
|
||||
|
||||
`apps/mobile/lib/callContext.tsx` — React context holding:
|
||||
|
||||
```ts
|
||||
type CallState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'outgoing'; callId: string; conversationId: string; peers: string[] }
|
||||
| { kind: 'incoming'; callId: string; conversationId: string; fromUserId: string }
|
||||
| { kind: 'connecting'; callId: string; conversationId: string }
|
||||
| { kind: 'connected'; callId: string; conversationId: string; room: Room; muted: boolean; speakerOn: boolean }
|
||||
| { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' };
|
||||
```
|
||||
|
||||
Exposed actions:
|
||||
|
||||
- `startCall(conversationId)` — sends `invite` signals to every other member, transitions to `outgoing`, then joins the LiveKit room immediately so the user is "in" once the first peer accepts.
|
||||
- `acceptIncoming()` — fetches LiveKit token, connects, sends `accept`, transitions to `connected`.
|
||||
- `rejectIncoming()` — sends `reject`, transitions to `idle`.
|
||||
- `cancelOutgoing()` — sends `cancel` to each invited peer, transitions to `idle`.
|
||||
- `endCall()` — disconnects from LiveKit, sends `end` to participants, transitions to `idle`.
|
||||
- `toggleMute()` — un/publishes the mic track.
|
||||
- `toggleSpeaker()` — flips between speaker and earpiece via LiveKit's `AudioSession`.
|
||||
|
||||
The provider subscribes to call signals via §3 and surfaces incoming invites to whichever screen is currently mounted.
|
||||
|
||||
### 5. Audio routing
|
||||
|
||||
LiveKit RN's `AudioSession.startAudioSession()` + `selectAudioOutput()` handle the platform plumbing:
|
||||
|
||||
- iOS: AVAudioSession category `playAndRecord`, with the speaker on/off based on the toggle.
|
||||
- Android: AudioManager speakerphone flag.
|
||||
|
||||
On `acceptIncoming()` and `startCall()` we call `AudioSession.startAudioSession()`; on `endCall()` we call `AudioSession.stopAudioSession()`.
|
||||
|
||||
### 6. Call screen UI
|
||||
|
||||
`apps/mobile/app/(app)/call.tsx` — full-screen route shown when `state.kind === 'connected'`.
|
||||
|
||||
Layout:
|
||||
|
||||
- Top: conversation name + call duration ("00:42").
|
||||
- Middle: vertical list of participants — own + remotes — with avatars + names. "verbindet…" until they join, "spricht" emerald dot when active speaker.
|
||||
- Bottom: 3-button toolbar — Mute, Speaker, Hangup. Hangup is a red circle, the others pill-style.
|
||||
|
||||
Routes are gated:
|
||||
|
||||
- If `state.kind === 'connected'` and we're not on `/call` → router pushes `/call`.
|
||||
- If `state.kind === 'idle'` and we are on `/call` → router pops.
|
||||
- If `state.kind === 'incoming'` → `IncomingCallModal` (§7) renders over the current screen.
|
||||
|
||||
### 7. Incoming-call modal
|
||||
|
||||
`apps/mobile/components/IncomingCallModal.tsx` — full-screen modal mounted at the root layout so it surfaces over any screen.
|
||||
|
||||
- Visible when `state.kind === 'incoming'`.
|
||||
- Shows caller name (resolved via the conversation members), big avatar, ringing animation.
|
||||
- Two buttons: **Annehmen** (green) and **Ablehnen** (red).
|
||||
- Tap Annehmen → `acceptIncoming()` → routes to `/call` on success.
|
||||
|
||||
### 8. Conversation entry points
|
||||
|
||||
Add a phone-icon button in the conversation-detail header (`[id].tsx` Stack.Screen `headerRight`). On tap → `startCall(id)`. The icon is a simple PNG-character `📞` for the MVP — a vector icon set is a polish-pass.
|
||||
|
||||
### 9. Edge cases handled
|
||||
|
||||
- **App backgrounding mid-call:** LiveKit stays connected; audio continues. iOS `audio` background mode is required in `app.json` — added.
|
||||
- **Network loss:** LiveKit auto-reconnects. We surface "Verbindung verloren — Wiederverbinden…" in the call screen.
|
||||
- **Caller hangs up before answer:** the modal listens for `cancel` and auto-dismisses.
|
||||
- **Both sides hang up simultaneously:** dual `end` signals are idempotent.
|
||||
- **Joining a call that's already started in a group:** every member who got an invite can join the same LiveKit room.
|
||||
|
||||
## File structure (deltas)
|
||||
|
||||
| File | Status | Responsibility |
|
||||
|---|---|---|
|
||||
| `apps/mobile/package.json` | MODIFIED | Add `@livekit/react-native`, `@livekit/react-native-webrtc` |
|
||||
| `apps/mobile/app.json` | MODIFIED | Microphone permission, iOS `audio` background mode, LiveKit plugin |
|
||||
| `apps/mobile/lib/callSignal.ts` | NEW | Realtime subscribe/publish helpers |
|
||||
| `apps/mobile/lib/callContext.tsx` | NEW | Call state machine, signal dispatch, LiveKit room lifecycle |
|
||||
| `apps/mobile/components/IncomingCallModal.tsx` | NEW | Annehmen / Ablehnen modal |
|
||||
| `apps/mobile/app/_layout.tsx` | MODIFIED | Mount `CallProvider` under `AuthProvider`; render `IncomingCallModal` at root |
|
||||
| `apps/mobile/app/(app)/call.tsx` | NEW | In-call full-screen UI |
|
||||
| `apps/mobile/app/(app)/_layout.tsx` | MODIFIED | Register the `call` route |
|
||||
| `apps/mobile/app/(app)/conversations/[id].tsx` | MODIFIED | Phone-icon header button → `startCall(id)` |
|
||||
|
||||
## Risks
|
||||
|
||||
- **`@livekit/react-native-webrtc` + New Architecture.** Supported but a moving target. If a runtime crash surfaces under `newArchEnabled: true`, drop to `false` for the next dev build.
|
||||
- **Audio session conflicts.** Other apps holding the audio session may interrupt. LiveKit's `AudioSession` API requests focus; we accept brief interruptions.
|
||||
- **Realtime channel dies on app sleep.** Backgrounded app → iOS budgets out the JS bridge after ~30s. Phase 3 accepts this; Phase 3.5 with CallKit + VoIP push fixes it.
|
||||
- **Token endpoint dependency.** The `mint-livekit-token` edge function must accept the mobile session JWT — same as desktop, server-side RLS already in place.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `pnpm --filter @chat-app/mobile typecheck` exits 0.
|
||||
2. With the mobile dev client built + desktop signed into the same account, on real hardware:
|
||||
- Mobile starts a call → desktop sees incoming-call panel.
|
||||
- Desktop accepts → audio flows both ways.
|
||||
- Mute / speaker toggles work.
|
||||
- Hangup ends the call cleanly on both sides.
|
||||
3. Reverse direction.
|
||||
4. Reject + cancel flows.
|
||||
5. Group: any one member starts → all others get the modal; multiple can join.
|
||||
|
||||
## Out of scope (Phase 3.5 / 4)
|
||||
|
||||
- Video calls (camera + view tracks).
|
||||
- Native CallKit / ConnectionService.
|
||||
- VoIP push to wake the app from killed/background.
|
||||
- Call history / missed-call UI on the chat list.
|
||||
- Screen sharing.
|
||||
- Active-speaker reorder, captions, in-call soundboard.
|
||||
- Bluetooth headset routing menu beyond the simple toggle.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Call Preview Panel — Design
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Scope:** Desktop only (mobile call UI is separate).
|
||||
**Status:** Approved by user.
|
||||
|
||||
## Problem
|
||||
|
||||
The current `VoiceChannelRail` component sits at the top of the chat feed and is **always visible in group conversations** with the empty-state "Niemand drin — sei der Erste · Channel öffnen". This Discord-server-style "voice channel" affordance is wrong for a friends-messenger: there is no concept of named voice rooms, calls are placed person-to-person via the topbar phone icon, and the always-on banner adds noise without value.
|
||||
|
||||
Concretely, the screenshot the user objected to shows the band rendered for a 3-person group chat with no active call — there's nothing to "open" because the topbar phone icon is the actual call-start entry point.
|
||||
|
||||
## Goals
|
||||
|
||||
- Banner appears **only when an active call exists** in the current conversation and the local user is not already in it.
|
||||
- Behavior is identical for 1:1 and group conversations.
|
||||
- When visible, the panel shows participants as Discord-DM-style tiles (large avatar + name), with a single prominent "Beitreten" button.
|
||||
- Anrufe starten weiterhin über das Phone-Icon in `ConversationHeader` (`startCall`); diese Komponente initiiert keine neuen Calls.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Live video preview of in-call participants. (Requires a LiveKit subscribe-only-mode wiring; deferred.)
|
||||
- Speaking-indicator rings. (Requires LiveKit data-channel subscription without joining; deferred.)
|
||||
- Per-participant mic-muted overlay icons. (Same reason; deferred.)
|
||||
- Mobile (React Native) variant.
|
||||
- Any change to the actual call session (`useCall`, `joinActiveCall`, ringing UI).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component swap
|
||||
|
||||
`apps/desktop/src/components/VoiceChannelRail.tsx` is renamed to `CallPreviewPanel.tsx` and rewritten. The old skinny one-line band layout is dropped entirely. No backwards-compat shim — the JSX import in `ConversationPage.tsx` is updated in-place.
|
||||
|
||||
### Visibility rule
|
||||
|
||||
```ts
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'reconnecting')
|
||||
&& state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
const visible = !iAmIn && others.length > 0;
|
||||
```
|
||||
|
||||
Note: drops the `|| isGroup` clause that produced the always-on banner.
|
||||
|
||||
### Layout
|
||||
|
||||
Renders between `ConversationHeader` and the message list (same insertion point as today). When mounted, the panel pushes messages down — fine because the panel only mounts when there is an active call worth surfacing.
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 📞 Aktiver Anruf · {n} im Channel [✕ minimieren] │
|
||||
├───────────────────────────────────────────────────────────┤
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ Avatar │ │ Avatar │ │ Avatar │ responsive grid │
|
||||
│ │ Anna │ │ Ben │ │ Cara │ │
|
||||
│ └────────┘ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ [ 📞 Beitreten ] │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Tiles: ~120×120px squares. Avatar centered (large, fallback initials), display name beneath in a single line (truncate on overflow).
|
||||
- Grid: `grid-cols-3` on desktop ≥768px, `grid-cols-2` below.
|
||||
- More than 8 participants: render the first 7 tiles, replace the eighth with a `+N` overflow tile.
|
||||
- "Beitreten" button: brand-green, ~280px wide, `py-3`, centered below the grid. `disabled` while `state.kind !== 'idle'` (showing `SpinnerIcon` to signal busy).
|
||||
- Optional collapse: a small `✕` in the header toggles a local `useState<boolean>` to render only the header row when minimized. Default expanded. Collapse state is per-mount (not persisted across navigation).
|
||||
|
||||
### Data flow
|
||||
|
||||
Inputs (unchanged from current `VoiceChannelRail`):
|
||||
- `useCallPresence(conversation.id)` → `string[]` of userIds currently in the room.
|
||||
- `conversation.members[]` → join with userIds to look up `displayName` + `avatarUrl`.
|
||||
- `useCall()` → reads `state` (to derive `iAmIn` + `busy`); calls `joinActiveCall(conversation.id, 'audio')` on click.
|
||||
- `useAuth()` → reads `session.user.id` to filter "self" out of `others`.
|
||||
|
||||
No new hooks, no new realtime channels. The existing `useCallPresence` 3-second poll-fallback continues to recover from dropped presence events.
|
||||
|
||||
### What is removed
|
||||
|
||||
- The empty-state copy ("Niemand drin — sei der Erste") and the "Channel öffnen" button.
|
||||
- The `isGroup ||` clause in the visibility rule.
|
||||
- i18n keys orphaned by the empty state: `app:call.voice_empty` and `app:call.voice_open`. (Cleanup in `apps/desktop/src/lib/i18n/*` translation files.)
|
||||
|
||||
### What stays
|
||||
|
||||
- `useCallPresence`, `useCall`, `joinActiveCall` and the realtime `call-presence:<conversationId>` channel.
|
||||
- `ConversationHeader` phone icon as the call-initiation entry point.
|
||||
- Incoming-call UI (`incomingHere` branch in `ConversationPage`) and ringtone.
|
||||
- The mount-point `{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}` in `ConversationPage.tsx` — only the import name and the JSX tag change.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| Local user is already connected | `iAmIn === true` → panel hidden. |
|
||||
| Local user is connecting/reconnecting to this conv | `iAmIn === true` → panel hidden (avoids flashing during transition). |
|
||||
| Realtime presence drops momentarily | 3-second poll fallback in `useCallPresence` reseeds `presentIds`; panel may briefly disappear and reappear. Acceptable. |
|
||||
| Conversation has 0 accepted members | `presentIds` is empty → panel hidden. |
|
||||
| Member in `presentIds` is not in `conversation.members[]` (e.g., recently removed peer still finishing leave) | Render tile with placeholder name "?" and the `Avatar` initials fallback. Don't crash. |
|
||||
| User clicks "Beitreten" while `state.kind === 'connecting'` to a *different* conversation | Button is `disabled` because `busy = state.kind !== 'idle'`. User must wait or hang up first. |
|
||||
| Incoming call to this conversation while the panel is visible | `incomingHere` branch in `ConversationPage` already takes precedence (`!incomingHere &&` gate at the JSX site) → panel hidden until the user accepts/dismisses the ring. |
|
||||
|
||||
## Testing
|
||||
|
||||
**Manual smoke (no new unit tests):**
|
||||
|
||||
1. Open a 1:1 chat with no active call → panel not rendered.
|
||||
2. Open a group chat with no active call → panel not rendered. (Regression test for the bug we're fixing.)
|
||||
3. Have peer start a call from another device → panel appears with peer's tile + "Beitreten".
|
||||
4. Click "Beitreten" → joins the call; panel disappears (we're now `iAmIn`).
|
||||
5. Hang up → panel reappears with peer still in.
|
||||
6. Peer hangs up too → panel disappears.
|
||||
7. Resize window narrow → grid collapses to 2 columns.
|
||||
8. Group call with 9 participants → 7 avatar tiles + one `+2` overflow tile.
|
||||
|
||||
The component is view-only over hooks; behavior is fully covered by the existing `useCall` and `useCallPresence` test paths plus the manual smoke list above.
|
||||
|
||||
## Out of Scope (future)
|
||||
|
||||
- Live participant video preview when peer's camera is on.
|
||||
- Speaking-indicator rings.
|
||||
- Per-tile mic-muted / camera-off overlays.
|
||||
- Sound/notification when a call starts in a conversation that's not currently focused (separate notification work).
|
||||
- Mobile equivalent.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Encryption UX Simplification — Design
|
||||
|
||||
**Date:** 2026-05-15
|
||||
**Scope:** Desktop (Electron/Tauri) first. Mobile follows in a subsequent spec.
|
||||
**Status:** Approved by user (sections 1–5).
|
||||
|
||||
## Problem
|
||||
|
||||
The current end-to-end-encryption flow is too complex for non-technical users:
|
||||
|
||||
1. **Re-login locks them out.** Each device has its own X25519 keypair, and conversation keys (`conversation_keys`) are wrapped per-device. When a user signs in on a fresh install (or after clearing app data), they get a new device row → no peer has wrapped the conv-key for that new device → they can neither read old chats nor send new messages until another online peer of theirs (or another member of the conversation) re-wraps the conv-key for the new device. This frequently strands users.
|
||||
2. **Backup restore must be done twice.** Restoring from `BackupRestoreDialog` in Settings reseeds the local vault, but on a clean re-login the device-registration screen still requires another restore — most often because the secret-store backend probe (`setSecretStoreUser`) and the restore write race, or because users lose the cached `localStorage` device-id between sessions.
|
||||
3. **Backup string + passphrase + recovery code is too much.** Users are asked to manage a long base64 blob, a passphrase, and a 24-char recovery code. Most never export a backup at all.
|
||||
|
||||
## Goals
|
||||
|
||||
- Re-login on a fresh device must be a single-step action ("enter PIN") that restores full read+write access immediately, with no dependency on peers being online.
|
||||
- The user manages **one** secret (a 6-digit PIN) plus an optional one-time recovery code.
|
||||
- No more raw "backup strings" to copy around.
|
||||
- Existing chat history remains readable after the migration.
|
||||
- Compromise of a single device still requires user action to fully revoke (rotate user key) — but per-device fine-grained revocation is intentionally dropped in favor of UX.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Mobile rollout. Tracked in a follow-up spec; the schema and shared crypto primitives are designed to be reusable on React Native.
|
||||
- Forward secrecy / Double Ratchet. Out of scope; we keep the Sender-Key model.
|
||||
- Multi-account on a single OS user.
|
||||
- A web-only (no Electron/Tauri) variant. We assume an OS-keychain-backed Stronghold/safeStorage is available; the localStorage fallback path remains as today's escape hatch.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Identity model: per-user instead of per-device
|
||||
|
||||
Today: one X25519 keypair per `(user_id, device_id)`; `conversation_keys` rows are keyed by `recipient_device_id`.
|
||||
|
||||
New: **one X25519 keypair per `user_id`**, stored as a PIN-sealed blob in a new `user_keys` table. Devices remain only for telemetry / push / last-seen — they no longer carry cryptographic identity. Each device unlocks the same user key with the PIN and caches the cleartext private key in the OS keychain (Stronghold / safeStorage).
|
||||
|
||||
`conversation_keys` is rekeyed: `recipient_device_id` → `recipient_user_id`, `sender_device_id` → `sender_user_id`. Sender-key (symmetric XSalsa20-Poly1305 conv-key) and the wrapping primitive (`crypto_box`) stay unchanged.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- **Lost:** clean per-device revocation. If a single device is compromised, the user must rotate the whole user key (re-wrap conv-keys for every member of every conversation). For a friends-chat app with no enterprise revocation requirement, acceptable.
|
||||
- **Lost:** "device fingerprint per session" trust signal.
|
||||
- **Gained:** zero-friction re-login, zero peer dependency for new installs, single secret to remember, no manual backup string.
|
||||
|
||||
### Threat model
|
||||
|
||||
- Server (Supabase) is honest-but-curious. It must never see plaintext private keys or conv-keys.
|
||||
- A 6-digit PIN is **not** brute-force resistant on its own. It is protected by:
|
||||
- Argon2id KDF (`moderate` preset) raises per-attempt cost to ~hundreds of ms.
|
||||
- **Server-side lockout counter** (`failed_attempts`, `locked_until` in `user_keys`) gates ciphertext delivery: after 10 failed attempts a 24h lock is applied. The client cannot brute-force locally because it must request the ciphertext from the server, which the server rate-limits.
|
||||
- Recovery code (~120 bits) is the strong factor; PIN is the convenience factor.
|
||||
- A network attacker with a stolen Supabase session token cannot decrypt anything without the PIN/recovery code.
|
||||
- A local attacker with full disk access can read the cached cleartext key from Stronghold (same threat as today's per-device key model).
|
||||
|
||||
## Components
|
||||
|
||||
### `packages/shared`
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `crypto/userKey.ts` | NEW. `generateUserKeyPair()`, `sealUserKey({privateKey, pin, salt})`, `openUserKey({sealed, pin, salt})`. Argon2id (moderate preset) + XSalsa20-Poly1305. |
|
||||
| `auth/userKey.ts` | NEW. DB wrappers: `fetchUserKeyBlob(client, userId)`, `uploadUserKeyBlob(...)`, `recordPinAttempt(...)`, `tryUnlockUserKey(...)` (calls SECURITY DEFINER RPC), `resetUserKeyWithRecovery(...)`. |
|
||||
| `chat/convKeys.ts` | Refactor: `recipient_device_id` → `recipient_user_id`, `sender_device_id` → `sender_user_id`. `listDeviceKeys()` becomes `listMemberPublicKeys()` (joins `conversation_members` → `user_keys`). `OwnDeviceCtx` → `OwnUserCtx { userId; privateKey }`. |
|
||||
| `auth/device.ts` | Strip cryptographic functions: remove `provisionNewDevice`, `restoreDeviceFromServerRecord`, `saveDevicePrivateKey`, `loadDevicePrivateKey`, `forgetDevicePrivateKey`. Keep telemetry helpers (`registerDevice`, `listOwnDevices`, `touchDeviceLastSeen`); they no longer take/store `public_key`. |
|
||||
| `crypto/index.ts` | Re-export `userKey`. |
|
||||
|
||||
### `apps/desktop/src/lib`
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `userIdentity.ts` | NEW. `loadOrUnlockUserKey({pin})`, `setupNewUserIdentity({pin, withRecovery})`, `cachedUserKey()`, `clearUserKeyCache()`. Caches cleartext key in Stronghold under `chatapp.userpriv.<userId>`. |
|
||||
| `secretStore.ts` | Unchanged interface. Stores `chatapp.userpriv.<userId>` instead of `chatapp.priv.<userId>.<deviceId>`. |
|
||||
| `device.ts` | Strip `findExistingDevice`, `registerCurrentDevice`. Keep platform detection only. |
|
||||
| `deviceBackup.ts` | DELETE. |
|
||||
|
||||
### `apps/desktop/src/components`
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `UserKeySetup.tsx` | NEW. PIN entry + confirm + "Recovery-Code anzeigen" + "Habe ich gespeichert" / "Überspringen (riskant)" buttons. |
|
||||
| `UserKeyUnlock.tsx` | NEW. Numeric PIN pad. Surfaces remaining attempts + lockout countdown. Switches to "Recovery-Code eingeben" tab. |
|
||||
| `DeviceRegistration.tsx` | Becomes onboarding wrapper that routes to `UserKeySetup` or `UserKeyUnlock` based on `fetchUserKeyBlob` result. Drop the device-name prompt (auto from hostname; editable in Settings). |
|
||||
| `DeviceRestore.tsx` | DELETE. |
|
||||
| `BackupExportDialog.tsx` | DELETE. |
|
||||
| `BackupRestoreDialog.tsx` | DELETE. |
|
||||
| `BackupPromptBanner.tsx` | DELETE. |
|
||||
| `SettingsPage.tsx` | Replace backup section with: "PIN ändern", "Recovery-Code neu erzeugen", "Identität zurücksetzen". |
|
||||
|
||||
### Supabase schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_keys (
|
||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
public_key BYTEA NOT NULL, -- 32 bytes X25519
|
||||
sealed_private_key BYTEA NOT NULL, -- nonce(24) || ciphertext
|
||||
salt BYTEA NOT NULL, -- 16 bytes
|
||||
kdf_params JSONB NOT NULL, -- { algo: 'argon2id', preset: 'moderate', opslimit, memlimit }
|
||||
recovery_sealed_private_key BYTEA NULL, -- present only if user kept recovery code
|
||||
recovery_salt BYTEA NULL,
|
||||
failed_attempts INT NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ NULL,
|
||||
failed_recovery_attempts INT NOT NULL DEFAULT 0,
|
||||
recovery_locked_until TIMESTAMPTZ NULL,
|
||||
key_version INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- RLS: a user reads/writes only their own row.
|
||||
ALTER TABLE user_keys ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY user_keys_self_rw ON user_keys
|
||||
USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Anyone authenticated may read peer public_key only (separate policy on a view or column-level).
|
||||
CREATE VIEW user_public_keys AS
|
||||
SELECT user_id, public_key, key_version FROM user_keys;
|
||||
GRANT SELECT ON user_public_keys TO authenticated;
|
||||
```
|
||||
|
||||
`conversation_keys`:
|
||||
|
||||
- ADD `recipient_user_id UUID`, `sender_user_id UUID` (nullable during migration).
|
||||
- After migration cutoff: drop `recipient_device_id`, `sender_device_id`.
|
||||
- New unique constraint: `(conversation_id, recipient_user_id, key_version)`.
|
||||
|
||||
RPCs (SECURITY DEFINER):
|
||||
|
||||
- `try_unlock_user_key(p_user_id UUID)` → returns `{ sealed, salt, kdf_params, locked: bool, locked_until }`. Honors lockout. Caller must already be `auth.uid() = p_user_id`. Does NOT decrement attempts (decryption is offline; client reports outcome via `record_pin_attempt`).
|
||||
- `record_pin_attempt(p_user_id UUID, p_success BOOL, p_recovery BOOL)` → updates counters & lockout. Server-authoritative, atomic.
|
||||
- `share_conv_keys(...)` → existing RPC, signature changes from `p_recipient_device_id` to `p_recipient_user_id` per bundle entry.
|
||||
- `migrate_user_key_recipients(p_conv_id, p_user_id, p_old_device_id, p_new_bundles)` → bulk INSERT new `recipient_user_id` rows from re-wrapped bundles, idempotent via `ON CONFLICT (conversation_id, recipient_user_id, key_version) DO NOTHING`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### First-time setup
|
||||
|
||||
1. User signs in (magic link / password). `fetchUserKeyBlob(userId)` → `null`.
|
||||
2. Routes to `UserKeySetup`. User chooses 6-digit PIN.
|
||||
3. Client generates X25519 keypair + 16-byte salt; derives KEK via Argon2id; seals the private key.
|
||||
4. Client generates a 24-char recovery code (32-symbol alphabet, ~120 bits) and seals the same private key with a recovery-derived KEK + separate salt.
|
||||
5. UI shows recovery code once. User picks "Habe ich gespeichert" or "Überspringen". Skip leaves `recovery_sealed_private_key NULL`.
|
||||
6. UPSERT `user_keys` row.
|
||||
7. Cleartext private key cached in Stronghold (`chatapp.userpriv.<userId>`).
|
||||
|
||||
### Re-login on a fresh / wiped device
|
||||
|
||||
1. Sign-in OK. `fetchUserKeyBlob(userId)` returns row → routes to `UserKeyUnlock`.
|
||||
2. User enters PIN. RPC `try_unlock_user_key` returns ciphertext+salt (or `locked: true`).
|
||||
3. Client derives KEK + opens sealed key. On success: `record_pin_attempt(success=true)` resets counter; cleartext key cached in Stronghold; navigate to `/chats`.
|
||||
4. On failure: `record_pin_attempt(success=false)` increments counter. UI shows remaining attempts.
|
||||
5. On lockout: UI offers "Recovery-Code verwenden" tab. Same flow against `recovery_sealed_private_key` + `failed_recovery_attempts`.
|
||||
|
||||
### Sending a message (existing conversation)
|
||||
|
||||
1. `getOrCreateConvKey(convId, ownUserCtx)` looks up `conversation_keys` by `recipient_user_id = me`.
|
||||
2. Bundle exists → unwrap with user private key → encrypt plaintext → send.
|
||||
3. **Re-login works immediately**: the user's `public_key` is unchanged across sessions, so all existing wrapped conv-keys remain valid.
|
||||
|
||||
### New user joins a conversation
|
||||
|
||||
1. Existing member loads `public_key` from `user_public_keys`.
|
||||
2. Calls `shareConvKeyToUser(convId, newUserId, newUserPubKey, ownCtx)` → wraps active conv-key, INSERT bundle.
|
||||
|
||||
### Migration of legacy `conversation_keys`
|
||||
|
||||
Triggered automatically the first time a user signs in after the upgrade and has both an old device-key in Stronghold and no `user_keys` row yet:
|
||||
|
||||
1. Setup flow runs (PIN, generate user keypair, upload `user_keys`).
|
||||
2. Migration pass:
|
||||
- List all `conversation_keys` rows where `recipient_device_id` belongs to one of the user's old devices.
|
||||
- Unwrap with the old device private key.
|
||||
- Re-wrap for `recipient_user_id = me` with the new user public key.
|
||||
- Bulk-insert via `migrate_user_key_recipients` (`ON CONFLICT DO NOTHING`).
|
||||
3. Old rows remain untouched; other members migrate independently in their own passes.
|
||||
4. Cross-member rewrap: when any member opens a conversation, the client lists members lacking a `recipient_user_id` bundle and proactively wraps for them in the background.
|
||||
5. After all active users have migrated (telemetry-tracked), a follow-up migration drops `recipient_device_id` / `sender_device_id`.
|
||||
|
||||
### PIN change (Settings)
|
||||
|
||||
1. Prompt for current PIN → unlock locally (without server round-trip; we already have ciphertext+salt cached or can fetch).
|
||||
2. Generate new salt; reseal with new PIN-derived KEK.
|
||||
3. UPSERT `user_keys` row (same `public_key`, no conv-key rewrap).
|
||||
|
||||
### Identity reset
|
||||
|
||||
Hard "burn it down" path. Generates new user keypair, replaces `user_keys`, deletes the user's `conversation_keys` bundles. Friends will see fingerprint-change in the future trust UI (not in MVP).
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| Wrong PIN | Offline crypto fails → `record_pin_attempt(success=false)` → counter increments. Cooldown schedule: attempts 1–4 none, 5–9 backoff (5s, 30s, 2m, 10m, 1h), 10 → `locked_until = now() + 24h`. Counter resets on success. UI shows remaining attempts and any active cooldown. |
|
||||
| Lockout active | `try_unlock_user_key` returns `{locked: true, locked_until}` without ciphertext. UI surfaces "Recovery-Code verwenden" tab. |
|
||||
| Recovery-code attempts | Independent counter `failed_recovery_attempts`; same backoff schedule, threshold 20 → permanent lock requiring identity reset. |
|
||||
| Forgot PIN, no recovery code | UI shows hard warning, then runs Identity-Reset flow (lose all old chats; fingerprint changes for friends). |
|
||||
| Stronghold cache lost (reinstall, wipe) | Identical to "Re-login on fresh device". |
|
||||
| `user_keys` row missing server-side but Stronghold has key | UI offers "Identität wieder hochladen": re-uploads using cached key + a fresh PIN entry. Telemetry-logged. |
|
||||
| Concurrent setup race | `INSERT … ON CONFLICT (user_id) DO NOTHING` + re-fetch. Loser unlocks the winner's blob with the same PIN. Mismatched PINs → loser sees "use the PIN chosen on the other device". |
|
||||
| Concurrent migration race | `migrate_user_key_recipients` inserts are idempotent; both clients converge to identical state. |
|
||||
| Conv-key bundle missing despite membership | As today: `getOrCreateConvKey` throws "Awaiting conversation key". Background sweep on conversation-open triggers proactive rewrap from any online member. The new model makes this strictly rarer (one bundle per user, not per device). |
|
||||
| Stronghold init fails | Existing fallback to localStorage retained. User-key lands in localStorage cleartext (same risk as today's dev-fallback path). |
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (`packages/shared`):**
|
||||
|
||||
- `crypto/userKey.test.ts`: roundtrip seal/open with correct PIN; wrong PIN throws; wrong salt throws; KDF preset is reproducible.
|
||||
- `auth/userKey.test.ts`: fetch returns null vs. row; UPSERT behavior; `record_pin_attempt` increments; lockout transitions.
|
||||
- `chat/convKeys.test.ts`: tests refactored to `recipient_user_id`. New: bootstrap with two members (each unwraps OK); share to new user works; awaiting-key path throws.
|
||||
|
||||
**Integration (`apps/desktop`):**
|
||||
|
||||
- `userIdentity.migration.test.ts`: fixture with legacy `recipient_device_id` rows. Run migration pass. Assert: new `recipient_user_id` rows exist with same plaintext conv-key; legacy rows untouched.
|
||||
- Race test: two parallel migration runs converge without duplicate inserts.
|
||||
|
||||
**Component (`apps/desktop`):**
|
||||
|
||||
- `UserKeySetup.test.tsx`: PIN-mismatch error; recovery-code rendered exactly once; "Skip" path leaves `recovery_sealed_private_key NULL`.
|
||||
- `UserKeyUnlock.test.tsx`: wrong PIN increments attempts; lockout countdown rendered; recovery tab functional.
|
||||
|
||||
**E2E (Playwright via `e2e-testing` skill, optional in first cut):**
|
||||
|
||||
- Happy path: login → setup PIN → send chat → sign-out → sign-in → PIN → old chats readable, new chats sendable.
|
||||
- Cross-context: User1 device A ↔ User2 conversation. User1 signs out, signs in fresh context, enters PIN, can read+write **without** User2 being online.
|
||||
|
||||
**Manual smoke list (pre-release):**
|
||||
|
||||
1. Fresh install, setup with recovery save.
|
||||
2. Fresh install, setup with recovery skip.
|
||||
3. Existing user with legacy conv-keys → silent migration → old chats readable.
|
||||
4. Existing user on second device: setup on A, sign-in on B with same PIN.
|
||||
5. Forgot PIN → recovery-code → unlock OK.
|
||||
6. PIN change in Settings.
|
||||
7. Identity reset → old chats unreadable for me, new chats functional.
|
||||
8. Lockout: 10 wrong PINs → lock + recovery tab.
|
||||
|
||||
## Out of Scope (future work)
|
||||
|
||||
- Mobile (React Native) port — separate spec.
|
||||
- Trust-on-first-use fingerprint banner when peer rotates `key_version`.
|
||||
- Linked-device pairing flow (Signal-style) as a third recovery vector.
|
||||
- Forward-secret message keys (Double Ratchet).
|
||||
- Hardware-backed PIN entry (Secure Enclave / TPM bound).
|
||||
|
||||
## Migration Cutoff
|
||||
|
||||
After ≥95% of MAU have a `user_keys` row (server telemetry), schedule a follow-up migration to drop `recipient_device_id` and `sender_device_id` columns.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user