Compare commits

..

24 Commits

Author SHA1 Message Date
byGalax d1f38ce313 chore(desktop): release v0.17.3 2026-05-12 22:59:44 +02:00
byGalax 0d65a134fd feat(settings): click own avatar in profile preview to view fullscreen
Lightbox was previously a file-private component inside AttachmentImage
(used for enlarging chat image attachments). Extracted to a standalone
components/Lightbox.tsx so other surfaces can reuse the same dialog
without duplicating Esc/backdrop/body-overflow plumbing.

In SettingsPage's profile live-preview, the round avatar overlapping the
banner is now wrapped in a transparent button that opens the Lightbox
with the full-resolution avatar URL on click. Cursor switches to
zoom-in. Disabled when the user only has the initial-letter placeholder
(nothing meaningful to enlarge). Native button chrome (border, padding,
button-face background) is reset to keep the avatar circle's appearance
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:22 +02:00
byGalax 12c66d676a fix(chat): use useLayoutEffect for scroll restore + auto-bottom to avoid mount flicker
The scroll-position memory introduced in 0.17.2 still produced a visible
"chat appears at the top then jumps" frame when switching back into a
conversation. Cause: both scroll-affecting effects (auto-bottom on new
messages, restore on chat re-entry) used useEffect, which fires AFTER
the browser paints the freshly-committed DOM. So users saw scrollTop=0
for one frame before the effect ran and corrected it.

Switching both to useLayoutEffect moves the scroll write into the same
commit phase as the message-list DOM update, so the very first paint
already shows the correct position — single paint, no flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:58:07 +02:00
byGalax 0dde1dd1a3 chore(desktop): release v0.17.2 2026-05-12 22:24:32 +02:00
byGalax 81d3587a91 feat(chat): per-conversation scroll memory + version badge on changelog page
Two small UX polishes:

1. Switching between chats no longer slams you to the bottom. Each
   conversation's scroll position (pixel offset + stickToBottom flag)
   is remembered in a module-scoped Map for the lifetime of the
   renderer process. Discord-style: leave Chat A scrolled up, peek at
   another conversation, come back — same spot you were reading.
   Chats left at the bottom keep auto-following new messages on return.
   Reload resets everything (session-only, no localStorage).

   The restore runs once messages.length > 0 to avoid the browser
   clamping scrollTop to a near-zero scrollHeight before the message
   list has rendered. A small isRestoringRef guard prevents the
   programmatic scroll event from immediately overwriting the saved
   position with a clamped value.

2. Changelog page now shows a version badge in the header that compares
   the installed app version against entries[0].version from the
   server-side changelog feed. Three states:
   * `vX.Y.Z · aktuell` (emerald) — installed matches latest
   * `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated
   * `vX.Y.Z` neutral — installed is ahead of the published feed
     (dev/test builds)
   Semver compare is integer-major.minor.patch with a graceful
   garbage-fallback so a malformed version string doesn't false-flag
   a current install as outdated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:22:49 +02:00
byGalax 8be1105333 chore(desktop): release v0.17.1 2026-05-12 22:00:27 +02:00
byGalax f9e1d2f073 fix(secure-store): preserve original ciphertext on decrypt failure + startup path log
Critical hotfix for the 0.17.0 regression: users upgrading from 0.16.x
were logged out, and their next login wrote a fresh empty secure-store
on top of the original ciphertext — destroying device keys irrecoverably.

Why it happened: loadState used a blanket `catch {}` that conflated
"file doesn't exist (genuine new user)" with "file exists but can't be
decrypted (DPAPI / OSCrypt quirk after the install rename)". Both paths
returned an empty Map; the next scheduledSave then overwrote the
original .bin file with a fresh blob.

Fix:
* Separate ENOENT from decrypt/parse failures. ENOENT → empty Map. Any
  other read error → log, empty Map (no quarantine, matches old
  behaviour for transient lock issues).
* When decrypt/parse fails the original file is renamed to
  <file>.broken-<iso-ts> BEFORE returning empty Map. The next save
  writes to a fresh file; the original ciphertext is preserved on disk
  so a future build (or manual recovery) can still get at the bytes.
* Loud console.error around the failure so future regressions surface
  in main-process logs.

main.ts: move setPath('userData', appData/ChatApp) BEFORE setName so
any productName-derived path caching inside setName can't beat us to
it. Add a startup log of the resolved paths so future debugging has
hard evidence instead of guessing.

Affected users on 0.17.0 should still recover via Settings → Backup
Wiederherstellen (account-level keys are unchanged); this fix prevents
the data destruction for anyone who hasn't upgraded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:54:14 +02:00
byGalax 341f5f227d chore(desktop): release v0.17.0 2026-05-12 21:35:29 +02:00
byGalax 8baac2fd1e fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.

Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.

ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.

docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:26 +02:00
byGalax 05870ef8fa feat(call): split resolution/fps in share picker + restore window state after fullscreen
ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p
· 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality
presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old
preset list didn't surface. The underlying screenShareSettings framerateOverride
slot already existed; the modal just stopped resetting it to null on every
start and now plumbs the chosen FPS through to startScreenShare.

Cinema-mode fullscreen on Windows had two defects:

1. Maximized → fullscreen left the taskbar drawn on top of the window
   because DWM kept the maximized work-area constraints. We now unmaximize
   first so DWM recomposes cleanly and setFullScreen actually covers the
   whole monitor including the taskbar strip.

2. Esc out of cinema came back as a small floating window even when the
   user had been maximized before clicking the Vollbild button — the
   unmaximize from (1) was never undone. We now memo the pre-fullscreen
   maximized flag per window-id and call win.maximize() once the
   leave-full-screen event has fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:10 +02:00
byGalax f9e340dbec feat(branding): Netralax rebrand + Discord-style taskbar unread badge
App productName becomes Netralax (driving exe name and window title);
existing installs keep their %APPDATA%\ChatApp profile via an explicit
app.setPath('userData', appData/ChatApp) so no Login/Sounds/Secret store
data is lost.

The Windows taskbar overlay now renders a red bubble with the actual
unread count (Discord parity) instead of just a static red dot. Renderer
paints a 64×64 PNG via canvas — full-bleed red circle, white bold count
with a "99+" cap, no outer ring — and passes the data URL through the
existing setTrayUnread IPC. Main decodes via nativeImage and applies it
as the BrowserWindow overlay icon. Falls back to the static dot if the
renderer canvas pipeline is unavailable.

Also: app.setName('Netralax') + setAppUserModelId('cloud.netralax.desktop')
for Windows taskbar grouping and notification source attribution, and
release.mjs now reads productName dynamically from package.json so the
artifact lookup stays correct after the rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:30:54 +02:00
byGalax 91eedc0e8e fix(call): aspect-video for fullscreen grid+strip, switch section sizing to stageLayout 2026-05-12 19:03:23 +02:00
byGalax e56533918e feat(call): multi-share bento layout in stage + fullscreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:54:23 +02:00
byGalax c87d4e82d3 feat(call): doubleclick on focused tile clears pin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:50:29 +02:00
byGalax 187f8dc95a feat(call): left-click toggles pin, drop manual focus mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:47:10 +02:00
byGalax f612c1bb50 feat(call): introduce StageLayout discriminated union
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:43:55 +02:00
byGalax a9d5e2d430 feat(call): uniform 16:9 grid cells, drop grid-rows constraint 2026-05-12 18:40:54 +02:00
byGalax a065cc0a2c feat(call): drop hardcoded 16:9 on screen-share preview button 2026-05-12 18:39:06 +02:00
byGalax 005aebd60b feat(call): VideoStub accepts fit prop, contain when focused 2026-05-12 18:36:32 +02:00
byGalax 68bc1f76f6 chore(desktop): release v0.16.3 2026-05-07 17:25:56 +02:00
byGalax f1c7501807 fix(crypto): suppress approval banner for devices with existing wraps
After 0.16.2 some users saw the approval banner stack up to 6+ entries
on first launch — every old device they ever registered (Tauri-era,
test installs, dev builds) showed up because the only "already legit"
filter was `created_at <= ownDevice.created_at`. That fails when own
device is restored from Backup (older than every other entry) or when
the user accumulated installs around the migration window.

Add a semantic check: if a device already has at least one row in
`conversation_keys` (recipient_device_id), it has been wrapped before
and is by definition not awaiting approval. Treat as approved silently.
Bulk query against the candidate IDs, no N+1.

Plus UX: when more than one request is pending, render a sticky header
with a count and "Alle ablehnen" button so users with stale piles can
clear them in one click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:24:17 +02:00
byGalax e16b248366 chore(desktop): release v0.16.2 2026-05-07 17:07:28 +02:00
byGalax 9b764053c4 feat(crypto): explicit device-approval flow + dev userData isolation
Disable the previous auto-share of conversation keys to newly-registered
devices: a stolen password / new device registered by an attacker no
longer automatically grants history access. Backup-Restore (which
restores the old device-id) still opens existing wraps as before.

Phase 1 of the approval replacement:
- New `lib/deviceApproval.ts`: realtime listener for `devices` INSERT,
  surfaces a pending list, persists approve/deny decisions in
  `chatapp.approvedDeviceIds` / `chatapp.dismissedDeviceIds`. Filters the
  initial fetch by created_at > own-device's created_at so a freshly
  installed client doesn't try to "approve" pre-existing devices.
- New `components/DeviceApprovalBanner.tsx`: bottom-right Discord-style
  banner per pending request with Genehmigen / Ablehnen actions; reuses
  `wrapForOneDevice` from conversationKeySync to fan out conv-keys.
- AppShell mounts both the listener and the banner.

Plus dev userData isolation in main.ts: when running unpackaged, append
`-Dev` to the userData path so `pnpm dev` runs side-by-side with the
installed packaged build instead of colliding on the single-instance
lock. Window title also distinguished as "ChatApp (Dev)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:03:04 +02:00
byGalax 950ef5b706 chore(release): inject releaseNotes into latest.yml
electron-builder doesn't write CLI-supplied --notes into the
auto-update manifest, so clients saw an empty body in UpdateToast
even when the release script logged notes. After the build but
before scp, patch latest.yml in place: append a block scalar
(`releaseNotes: |-`) so multi-line notes survive intact.

Idempotent — skips if a releaseNotes entry is already present
(reruns / hand-edited manifests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:45:10 +02:00
24 changed files with 2503 additions and 257 deletions
+46 -1
View File
@@ -37,6 +37,51 @@ const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420'; const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json'; 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; let mainWindow: BrowserWindow | null = null;
function resolvePreloadPath(): string { function resolvePreloadPath(): string {
@@ -64,7 +109,7 @@ async function createWindow(): Promise<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE); const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({ const win = new BrowserWindow({
title: 'ChatApp', title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
width: state.width, width: state.width,
height: state.height, height: state.height,
...(state.x !== undefined ? { x: state.x } : {}), ...(state.x !== undefined ? { x: state.x } : {}),
+56 -8
View File
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
} }
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, 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 { try {
if (encrypted) { if (encrypted) {
const buf = await fs.readFile(filePath); rawBuf = await fs.readFile(filePath);
const json = safeStorage.decryptString(buf); } 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> }; const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {})); return new Map(Object.entries(parsed.entries ?? {}));
} else { }
const raw = await fs.readFile(filePath, 'utf8'); if (rawStr) {
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> }; const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {})); return new Map(Object.entries(parsed.entries ?? {}));
} }
} catch { return new Map();
// Missing file or malformed contents — start fresh. The next write } catch (err: unknown) {
// will overwrite with a fresh blob. // 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(); return new Map();
} }
} }
+25 -14
View File
@@ -55,7 +55,7 @@ function buildOverlay(): NativeImage {
export function register(mainWindow: BrowserWindow): void { export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon(); const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon); trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('ChatApp'); trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([ const menu = Menu.buildFromTemplate([
{ {
@@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void {
else mainWindow.show(); else mainWindow.show();
}); });
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => { ipcMain.handle(
const n = Math.max(0, Math.floor(Number(count) || 0)); CHANNELS.TRAY_UNREAD,
if (trayRef && !trayRef.isDestroyed()) { async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp'); const n = Math.max(0, Math.floor(Number(count) || 0));
} if (trayRef && !trayRef.isDestroyed()) {
if (mainWindow.isDestroyed()) return; trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
if (process.platform === 'win32') {
if (n > 0) {
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
} else {
mainWindow.setOverlayIcon(null, '');
} }
} 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', () => { app.on('before-quit', () => {
try { try {
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types'; import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void { 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) => { ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try { try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups // 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). // registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow; const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return; 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) { } catch (err: unknown) {
console.warn('window setFullscreen failed', err); console.warn('window setFullscreen failed', err);
throw err; throw err;
+1 -1
View File
@@ -58,7 +58,7 @@ export interface ElectronAPI {
notify: (args: NotifyArgs) => Promise<void>; notify: (args: NotifyArgs) => Promise<void>;
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>; getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
setTrayUnread: (count: number) => Promise<void>; setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>; secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
secureStoreGet: (handle: string, key: string) => Promise<string | null>; secureStoreGet: (handle: string, key: string) => Promise<string | null>;
+6 -1
View File
@@ -94,7 +94,12 @@ const api = {
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION), ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray ------------------------------------------------------------------- // 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 ----------------------------------------------------------- // Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> => secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.16.1", "version": "0.17.3",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
@@ -54,7 +54,7 @@
}, },
"build": { "build": {
"appId": "com.meinname.chatapp", "appId": "com.meinname.chatapp",
"productName": "ChatApp", "productName": "Netralax",
"directories": { "directories": {
"output": "release", "output": "release",
"buildResources": "resources" "buildResources": "resources"
+17 -1
View File
@@ -1,11 +1,15 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync'; import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureNotificationPermission } from '../lib/osNotify'; import { ensureNotificationPermission } from '../lib/osNotify';
import { devLocalSecretStore } from '../lib/secretStore';
import { BackupPromptBanner } from './BackupPromptBanner'; import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI'; import { CallUI } from './CallUI';
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
export function AppShell() { export function AppShell() {
@@ -18,7 +22,18 @@ export function AppShell() {
useEffect(() => { useEffect(() => {
if (!session?.user.id || !device?.id) return; if (!session?.user.id || !device?.id) return;
return startConversationKeySync(session.user.id, device.id); const userId = session.user.id;
const deviceId = device.id;
const stopKeySync = startConversationKeySync(userId, deviceId);
const stopApproval = startDeviceApprovalListener({
ownUserId: userId,
ownDeviceId: deviceId,
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
});
return () => {
stopKeySync();
stopApproval();
};
}, [session?.user.id, device?.id]); }, [session?.user.id, device?.id]);
return ( return (
@@ -34,6 +49,7 @@ export function AppShell() {
</div> </div>
<CallUI /> <CallUI />
<BackupPromptBanner /> <BackupPromptBanner />
<DeviceApprovalBanner />
</div> </div>
); );
} }
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache'; import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons'; import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
interface Props { interface Props {
handle: AttachmentHandle; handle: AttachmentHandle;
@@ -136,42 +137,5 @@ export function AttachmentImage({ handle }: Props) {
); );
} }
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) { // Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
useEffect(() => { // any future surface can reuse the same dialog without duplication.
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>
);
}
@@ -83,6 +83,7 @@ export interface ParticipantTileProps {
size?: 'default' | 'small'; size?: 'default' | 'small';
focused?: boolean; focused?: boolean;
onClick?: () => void; onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void;
} }
@@ -102,6 +103,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
size = 'default', size = 'default',
focused = false, focused = false,
onClick, onClick,
onDoubleClick,
onContextMenu, onContextMenu,
} = props; } = props;
@@ -120,6 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
className={ className={
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' + '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 ? ( {video ? (
<VideoStub {...props} small={small} /> <VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
) : ( ) : (
<AudioContent {...props} small={small} /> <AudioContent {...props} small={small} />
)} )}
@@ -303,7 +306,8 @@ function VideoStub({
videoTrack, videoTrack,
me, me,
small, small,
}: ParticipantTileProps & { small: boolean }) { fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => { useEffect(() => {
const el = videoRef.current; const el = videoRef.current;
@@ -330,7 +334,8 @@ function VideoStub({
playsInline playsInline
muted muted
className={ className={
'h-full w-full object-cover ' + 'h-full w-full ' +
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */ (me ? 'scale-x-[-1]' : '') /* mirror local preview */
} }
/> />
@@ -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;
}
}
+260 -105
View File
@@ -1,7 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client'; import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } 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 { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile'; import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay'; import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal'; 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 { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu'; import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu'; import { ScreenShareContextMenu } from './ScreenShareContextMenu';
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
if (soundboardCount === 0) setSoundboardOpen(false); if (soundboardCount === 0) setSoundboardOpen(false);
}, [soundboardCount]); }, [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 // Single right-click dispatcher for all tiles. User-tiles open the volume
// menu; screen-tiles open the share-specific menu (volume + mute + stop // menu; screen-tiles open the share-specific menu (volume + mute + stop
// watching). Self-tiles get no menu — no volume to control, and you can // 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.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected'); : t('app:call.connected');
// Screen shares no longer auto-promote — the user opts in by clicking the // Discord-style precedence:
// "Bildschirm anschauen" overlay, which also toggles whether the audio // 1. focusedId set → 'focus', that tile is the stage.
// plays. Focus falls back to the first tile so focus-mode always has // 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
// something to show when no tile was explicitly picked. // 3. exactly 1 share, no pin → 'focus' (auto-promote share).
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null; // 4. no shares, no pin → 'equal-grid'.
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0]; 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 = ( const controls = (
<CallControls <CallControls
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
})); }));
if (callMode === 'fullscreen') { 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 ( return (
<> <>
{micError && ( {micError && (
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
)} )}
<FullscreenCall <FullscreenCall
tiles={tiles} tiles={tiles}
speaker={effectiveSpeaker} speaker={bigTile}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members} conversationMembers={conversation.members}
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
// Focus mode dedicates the entire call-panel vertical slot to the speaker so // 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). // the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass = 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-[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'; : '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 ( return (
<section <section
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
<CallStage <CallStage
tiles={tiles} tiles={tiles}
speaker={speaker} speaker={bigTile}
mode={callMode} stageLayout={stageLayout}
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
e2ee={isE2EEActive} e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members} conversationMembers={conversation.members}
onFocusTile={(id) => { onFocusTile={(id) => {
// Discord-style toggle: clicking the already-focused tile setFocusedId(focusedId === id ? null : id);
// 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');
}} }}
onTileContextMenu={openTileContextMenu} onTileContextMenu={openTileContextMenu}
compact compact
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
y={volumeMenu.y} y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId} pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => { onTogglePin={() => {
const isPinned = focusedId === volumeMenu.tileId; setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
setFocusedId(isPinned ? null : volumeMenu.tileId);
if (!isPinned && callMode === 'grid') setCallMode('focus');
}} }}
{...(volumeMenu.self ? { renderVolume: false } : {})} {...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self {...(volumeMenu.self
@@ -869,9 +847,6 @@ function ModeToggles({
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid"> <ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" /> <GridIcon className="h-4 w-4" />
</ModeButton> </ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton <ModeButton
active={mode === 'fullscreen'} active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')} onClick={() => onChange('fullscreen')}
@@ -916,7 +891,12 @@ function ModeButton({
interface StageProps { interface StageProps {
tiles: Tile[]; tiles: Tile[];
speaker: Tile | undefined; 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>; activeSpeakers: Set<string>;
e2ee: boolean; e2ee: boolean;
remoteScreenShares: { remoteScreenShares: {
@@ -941,7 +921,9 @@ function TileRender({
conversationMembers, conversationMembers,
size, size,
focused, focused,
cinema,
onClick, onClick,
onDoubleClick,
onContextMenu, onContextMenu,
}: { }: {
tile: Tile; tile: Tile;
@@ -951,7 +933,12 @@ function TileRender({
conversationMembers: StageProps['conversationMembers']; conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small'; size?: 'default' | 'small';
focused?: boolean; 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; onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element { }): JSX.Element {
if (tile.kind === 'screen') { if (tile.kind === 'screen') {
@@ -964,6 +951,7 @@ function TileRender({
return ( return (
<div <div
onClick={onClick} onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')} className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
> >
@@ -971,6 +959,7 @@ function TileRender({
share={share} share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl} avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName} displayName={member?.profile?.displayName ?? tile.displayName}
hideFullscreenToggle={cinema === true}
/> />
</div> </div>
); );
@@ -994,6 +983,7 @@ function TileRender({
{...(size ? { size } : {})} {...(size ? { size } : {})}
{...(focused ? { focused } : {})} {...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})} {...(onClick ? { onClick } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})} {...(onContextMenu ? { onContextMenu } : {})}
/> />
); );
@@ -1002,7 +992,7 @@ function TileRender({
function CallStage({ function CallStage({
tiles, tiles,
speaker, speaker,
mode, stageLayout,
activeSpeakers, activeSpeakers,
e2ee, e2ee,
remoteScreenShares, remoteScreenShares,
@@ -1011,7 +1001,7 @@ function CallStage({
onTileContextMenu, onTileContextMenu,
compact = false, compact = false,
}: StageProps) { }: StageProps) {
if (mode === 'focus' && speaker) { if (stageLayout.kind === 'focus' && speaker) {
const others = tiles.filter((p) => p.id !== speaker.id); const others = tiles.filter((p) => p.id !== speaker.id);
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3"> <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
@@ -1022,6 +1012,7 @@ function CallStage({
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers} conversationMembers={conversationMembers}
onDoubleClick={() => onFocusTile(speaker.id)}
{...(onTileContextMenu {...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) } ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
: {})} : {})}
@@ -1032,7 +1023,11 @@ function CallStage({
{others.map((p) => ( {others.map((p) => (
<div <div
key={p.id} 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 <TileRender
tile={p} 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); 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 ( return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}> <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) => ( {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 <TileRender
tile={p} tile={p}
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
@@ -1086,6 +1159,7 @@ function FocusedTile({
remoteScreenShares, remoteScreenShares,
conversationMembers, conversationMembers,
onContextMenu, onContextMenu,
onDoubleClick,
}: { }: {
tile: Tile; tile: Tile;
e2ee: boolean; e2ee: boolean;
@@ -1093,6 +1167,7 @@ function FocusedTile({
remoteScreenShares: StageProps['remoteScreenShares']; remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers']; conversationMembers: StageProps['conversationMembers'];
onContextMenu?: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) { }) {
return ( return (
<div className="h-full [&>div]:h-full"> <div className="h-full [&>div]:h-full">
@@ -1104,6 +1179,7 @@ function FocusedTile({
conversationMembers={conversationMembers} conversationMembers={conversationMembers}
focused focused
{...(onContextMenu ? { onContextMenu } : {})} {...(onContextMenu ? { onContextMenu } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
/> />
</div> </div>
); );
@@ -1112,17 +1188,16 @@ function FocusedTile({
const GRID_PAGE_SIZE = 12; const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string { function gridColsFor(n: number): string {
// Explicit `grid-rows-*` so cells get a defined height (1fr of available // Discord-style: column count only. Cells are `aspect-video` so their
// space). Without this, implicit rows default to auto → they size to // height follows from their width, and the container centers them
// content, and a video element's intrinsic size blows the tile past the // vertically when the row stack is shorter than the available area.
// container bounds (overlapping the toolbar below). if (n <= 1) return 'grid-cols-1';
if (n <= 1) return 'grid-cols-1 grid-rows-1'; if (n === 2) return 'grid-cols-2';
if (n === 2) return 'grid-cols-2 grid-rows-1'; if (n === 3) return 'grid-cols-3';
if (n === 3) return 'grid-cols-3 grid-rows-1'; if (n === 4) return 'grid-cols-2';
if (n === 4) return 'grid-cols-2 grid-rows-2'; if (n <= 6) return 'grid-cols-3';
if (n <= 6) return 'grid-cols-3 grid-rows-2'; if (n <= 9) return 'grid-cols-3';
if (n <= 9) return 'grid-cols-3 grid-rows-3'; return 'grid-cols-4';
return 'grid-cols-4 grid-rows-3';
} }
// Promote self + active speakers to the front of the tile list. Stable // Promote self + active speakers to the front of the tile list. Stable
@@ -1216,12 +1291,25 @@ function FullscreenCall({
const hasFocus = speaker !== undefined; const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : []; 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 // 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 // exist, slice them into pages and prioritize active speakers onto page 1.
// below the current page (someone left). // 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( const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers), () => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
[tiles, activeSpeakers], [tiles, activeSpeakers, needsPagination],
); );
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE)); const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => { useEffect(() => {
@@ -1235,14 +1323,18 @@ function FullscreenCall({
return ( return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface"> <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 {/* Content area. pb-28 reserves ~112px space at the bottom for the
floating controls bar so tiles never sit behind it. */} floating controls bar plus extra clearance so the tiles' bottom
<div className="relative flex min-h-0 flex-1 flex-col pb-24"> 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 ? ( {hasFocus ? (
<> <>
<div <div
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full" className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
onClick={() => onFocusTile(speaker!.id)} onClick={() => onFocusTile(speaker!.id)}
onDoubleClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht" title="Zurück zur Übersicht"
> >
<TileRender <TileRender
@@ -1252,6 +1344,7 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers} conversationMembers={conversationMembers}
focused focused
cinema
{...(onTileContextMenu {...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) } ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})} : {})}
@@ -1262,7 +1355,7 @@ function FullscreenCall({
{others.map((p) => ( {others.map((p) => (
<div <div
key={p.id} 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 <TileRender
tile={p} tile={p}
@@ -1281,24 +1374,86 @@ function FullscreenCall({
</div> </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="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}> <div
{visibleTiles.map((p) => ( className={
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full"> 'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
<TileRender gridClass
tile={p} }
activeSpeakers={activeSpeakers} >
e2ee={e2ee} {visibleTiles.map((p) => {
remoteScreenShares={remoteScreenShares} const isSolo = visibleTiles.length === 1;
conversationMembers={conversationMembers} return (
onClick={() => onFocusTile(p.id)} <div
{...(onTileContextMenu key={p.id}
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) } // 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
</div> // 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> </div>
{pageCount > 1 && ( {pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted"> <div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
+51
View File
@@ -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>
);
}
@@ -26,13 +26,65 @@ const TABS = [
]; ];
type TabId = (typeof TABS)[number]['id']; 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: 'auto', label: 'Auto' },
{ id: '720p60', label: '720p · 60' }, { id: '720p', label: '720p' },
{ id: '1080p60', label: '1080p · 60' }, { id: '1080p', label: '1080p' },
{ id: '1440p60', label: '1440p · 60' }, { 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; const THUMBNAIL_REFRESH_MS = 3500;
export function ScreenSharePickerModal({ onClose }: Props) { export function ScreenSharePickerModal({ onClose }: Props) {
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>( const [res, setRes] = useState<ResChoice>(() => {
() => getScreenShareSettings().preset, 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>( const [audio, setAudio] = useState<boolean>(
() => getScreenShareSettings().includeSystemAudio, () => getScreenShareSettings().includeSystemAudio,
); );
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
setError(null); setError(null);
setBusy(true); setBusy(true);
try { try {
// Persist quality + audio toggle. Also force-clear any stale duck const preset = presetForResFps(res, fps);
// setting users may have inherited from earlier builds — the // Persist resolution + fps + audio. The duck flag is force-cleared
// native loopback addon excludes the app's own audio at OS level // because the native loopback addon now excludes the app's own audio
// now, so JS-side ducking (which muted the user's incoming peer // at OS level; JS-side ducking (which also muted incoming peer audio)
// audio) is no longer needed and was causing "I can't hear anyone". // was causing "I can't hear anyone" on earlier builds.
updateScreenShareSettings({ updateScreenShareSettings({
preset, preset,
framerateOverride: fps,
includeSystemAudio: audio, includeSystemAudio: audio,
duckRemoteAudioWhileSharing: false, duckRemoteAudioWhileSharing: false,
}); });
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
await startScreenShare({ await startScreenShare({
preset, preset,
displaySurface: tab === 'screen' ? 'monitor' : 'window', displaySurface: tab === 'screen' ? 'monitor' : 'window',
framerate: null, framerate: fps,
// Forward the picked source id so the native loopback path can // Forward the picked source id so the native loopback path can
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares // switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
// (parses HWND from `window:<HWND>:0`). For screen-shares this // (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 flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted"> <span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
Qualität Auflösung
</span> </span>
<div className="flex gap-1"> <div className="flex gap-1">
{QUALITY_PILLS.map((q) => { {RES_PILLS.map((q) => {
const active = preset === q.id; const active = res === q.id;
return ( return (
<button <button
key={q.id} key={q.id}
type="button" 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={ className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' + 'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active (active
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
share: RemoteScreenShare; share: RemoteScreenShare;
avatarUrl: string | null; avatarUrl: string | null;
displayName: string; 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. // Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
share, share,
avatarUrl, avatarUrl,
displayName, displayName,
hideFullscreenToggle = false,
}: ScreenShareViewerProps) { }: ScreenShareViewerProps) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
defaultValue: displayName + ' teilt den Bildschirm', defaultValue: displayName + ' teilt den Bildschirm',
})} })}
</span> </span>
{watching && ( {watching && !hideFullscreenToggle && (
<button <button
type="button" type="button"
onClick={toggleFullscreen} onClick={toggleFullscreen}
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
type="button" type="button"
onClick={() => watchShare(share.participantId)} onClick={() => watchShare(share.participantId)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })} 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" className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}
> >
<BlurredTile avatarUrl={avatarUrl} letter={letter} /> <BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40"> <div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
+50 -41
View File
@@ -12,7 +12,7 @@ import { supabase } from './supabase';
// This fixes the "cannot decrypt" cliff for devices that registered while // This fixes the "cannot decrypt" cliff for devices that registered while
// no other participant device was online to share the key with them. // no other participant device was online to share the key with them.
interface SyncCtx { export interface SyncCtx {
myUserId: string; myUserId: string;
myDeviceId: string; myDeviceId: string;
priv: Uint8Array; priv: Uint8Array;
@@ -32,46 +32,55 @@ export function startConversationKeySync(
ownUserId: string, ownUserId: string,
ownDeviceId: string, ownDeviceId: string,
): () => void { ): () => void {
let cancelled = false; // DISABLED: auto-share of conversation keys to newly-registered devices
let priv: Uint8Array | null = null; // is gone. Without it, account takeover (stolen password / new device
const dedupeKey = ownUserId + ':' + ownDeviceId; // registered by attacker) no longer automatically grants history access
// — an attacker would have a working device-key but no conv-key wraps.
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => { //
if (cancelled) return; // History access paths still supported:
priv = pk; // 1. Backup-Restore — restores the OLD device-id + privkey, so the
if (!priv) return; // server-side wraps for that device-id are accessible as before.
if (backfilledKey.has(dedupeKey)) return; // 2. (Planned) Approval flow — existing device or conversation peer
backfilledKey.add(dedupeKey); // explicitly approves a new device, then conv-keys are wrapped
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv }); // for it. Until that ships, fresh-login-without-backup means old
}); // conversations stay encrypted.
//
const channel = supabase // For NEW conversations: the key is generated at conv-creation time
.channel('device-key-sync:' + ownDeviceId) // and includes all current devices of all members, so a freshly-logged-
.on( // in device CAN still participate in newly-created conversations. It
'postgres_changes', // just can't read the back-history of conversations it wasn't a member
{ event: 'INSERT', schema: 'public', table: 'devices' }, // of when those messages were sealed.
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => { //
if (cancelled) return; // We deliberately keep the helper functions below (syncAllExistingGaps,
const row = payload.new; // wrapForOneDevice, …) intact so the upcoming approval flow can wire
if (!row?.id || !row.user_id || !row.public_key) return; // them to user-driven triggers without rebuilding from scratch.
if (row.user_id === ownUserId && row.id === ownDeviceId) return; void ownUserId;
if (!priv) return; // backfill on mount will catch it later void ownDeviceId;
void wrapForOneDevice( void backfilledKey;
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv }, void loadDevicePrivateKey;
row.id, void devLocalSecretStore;
row.user_id, void supabase;
row.public_key, return () => {};
);
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
} }
// 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 deviceId.
void (() => {
void listMyConversationIds;
void listConversationDevices;
void listExistingKeyRecipients;
void getActiveKeyVersion;
void syncAllExistingGaps;
void isExpectedShareFailure;
void rawFrom;
});
async function listMyConversationIds(myUserId: string): Promise<string[]> { async function listMyConversationIds(myUserId: string): Promise<string[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from('conversation_members') .from('conversation_members')
@@ -149,7 +158,7 @@ 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 version = await getActiveKeyVersion(convId);
const devices = await listConversationDevices(convId); const devices = await listConversationDevices(convId);
if (devices.length === 0) return; if (devices.length === 0) return;
@@ -200,7 +209,7 @@ function isExpectedShareFailure(err: unknown): boolean {
); );
} }
async function wrapForOneDevice( export async function wrapForOneDevice(
ctx: SyncCtx, ctx: SyncCtx,
newDeviceId: string, newDeviceId: string,
newDeviceUserId: string, newDeviceUserId: string,
+322
View File
@@ -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();
}
+53 -4
View File
@@ -1,14 +1,63 @@
import { isTauriRuntime } from './globalShortcut'; import { isTauriRuntime } from './globalShortcut';
// Pushes the current aggregate unread count to main, which updates the // Pushes the current aggregate unread count to main, which updates the
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op // Tray tooltip and (on Windows) the taskbar overlay icon. We render the
// outside the Electron runtime (e.g. browser dev preview) so no guards // badge here in the renderer because main has no Canvas2D — painting a
// needed at call-sites. // 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> { export async function updateTrayUnread(count: number): Promise<void> {
if (!isTauriRuntime()) return; if (!isTauriRuntime()) return;
const n = Math.max(0, Math.floor(count));
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
try { try {
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count))); await window.electronAPI.setTrayUnread(n, badgeDataUrl);
} catch (err: unknown) { } catch (err: unknown) {
console.warn('updateTrayUnread failed', err); 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');
}
+97 -2
View File
@@ -1,15 +1,33 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog'; import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
import { SparklesIcon, SpinnerIcon } from '../components/icons'; import { SparklesIcon, SpinnerIcon } from '../components/icons';
const PAGE_SIZE = 10; 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() { export function ChangelogPage() {
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null); const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(PAGE_SIZE); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { 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"> <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" /> <SparklesIcon className="h-5 w-5" />
</div> </div>
<div> <div className="flex-1">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
Was ist neu Was ist neu
</h1> </h1>
@@ -42,6 +60,11 @@ export function ChangelogPage() {
Alle Änderungen in dieser App, neueste zuerst. Alle Änderungen in dieser App, neueste zuerst.
</p> </p>
</div> </div>
<VersionBadge
status={versionStatus}
installed={installedVersion}
latest={latestVersion}
/>
</header> </header>
{entries === null && !error && ( {entries === null && !error && (
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
return iso; 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;
}
+66 -7
View File
@@ -1,6 +1,6 @@
import { parseMessagePayload } from '@chat-app/shared/chat'; import { parseMessagePayload } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; 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 { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80; 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() { export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -397,17 +406,60 @@ export function ConversationPage() {
if (id && messages.length > 0) markRead(id); if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]); }, [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; const el = scrollRef.current;
if (!el || !stickToBottom) return; if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]); }, [messages.length, stickToBottom]);
useEffect(() => { // Restore saved scroll position once the conversation's messages have
setStickToBottom(true); // 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; const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight; if (!el || !id) return;
}, [id]); 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 handleScroll = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
@@ -416,7 +468,14 @@ export function ConversationPage() {
const nextStick = distanceFromBottom < STICK_THRESHOLD; const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick); setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0); 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 jumpToBottom = useCallback(() => {
const el = scrollRef.current; const el = scrollRef.current;
+34 -5
View File
@@ -33,6 +33,7 @@ import {
uploadBannerBlob, uploadBannerBlob,
} from '../lib/bannerUpload'; } from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog'; import { ImageCropDialog } from '../components/ImageCropDialog';
import { Lightbox } from '../components/Lightbox';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { import {
getPttSettings, getPttSettings,
@@ -799,6 +800,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
// ratios. // ratios.
const [cropFile, setCropFile] = useState<File | null>(null); const [cropFile, setCropFile] = useState<File | null>(null);
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | 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 userId = profile?.userId;
const avatarUrl = profile?.avatarUrl ?? null; const avatarUrl = profile?.avatarUrl ?? null;
@@ -930,11 +934,33 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
className="relative z-10 flex items-end gap-3 px-4 pb-3" className="relative z-10 flex items-end gap-3 px-4 pb-3"
style={{ marginTop: '-2rem' }} style={{ marginTop: '-2rem' }}
> >
<Avatar <button
url={avatarUrl} type="button"
displayName={displayName} onClick={() => {
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3" 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="min-w-0 flex-1 pb-1">
<div className="truncate text-sm font-semibold text-fg"> <div className="truncate text-sm font-semibold text-fg">
{displayName ?? '—'} {displayName ?? '—'}
@@ -1061,6 +1087,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
}} }}
onClose={closeCropDialog} onClose={closeCropDialog}
/> />
{avatarPreviewOpen && avatarUrl && (
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
)}
</div> </div>
); );
} }
@@ -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 24 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 56 → 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 (24 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: 56 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 48. `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,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 578586) 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 ~520610). 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).
+29 -1
View File
@@ -89,7 +89,12 @@ execSync('pnpm --filter @chat-app/desktop run build:win', {
// --- Locate artifacts ----------------------------------------------------- // --- Locate artifacts -----------------------------------------------------
const releaseDir = join(ROOT, 'apps/desktop/release'); const releaseDir = join(ROOT, 'apps/desktop/release');
const exeName = `ChatApp Setup ${versionArg}.exe`; // productName lives in the electron-builder block of the desktop package.
// Read it back from disk (post-bump) so installer filenames stay in sync
// after a rebrand (e.g. ChatApp → Netralax) without manual script edits.
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const productName = pkgJson?.build?.productName ?? 'ChatApp';
const exeName = `${productName} Setup ${versionArg}.exe`;
const blockmapName = `${exeName}.blockmap`; const blockmapName = `${exeName}.blockmap`;
const latestYml = 'latest.yml'; const latestYml = 'latest.yml';
@@ -104,6 +109,29 @@ for (const p of [exePath, blockmapPath, latestYmlPath]) {
} }
} }
// --- Inject releaseNotes into latest.yml ----------------------------------
//
// electron-builder doesn't write the CLI-supplied notes into the
// manifest by default — clients then see an empty body in the
// UpdateToast. Patch the YAML in place: append a block scalar
// (`releaseNotes: |-`) so multi-line content survives intact.
// Idempotent — skip if a `releaseNotes:` entry is already present
// (covers reruns / hand-edited manifests).
{
let yml = readFileSync(latestYmlPath, 'utf8');
if (!/^releaseNotes:/m.test(yml)) {
const indented = notes
.split('\n')
.map((l) => ' ' + l)
.join('\n');
yml = yml.replace(/\s*$/, '') + `\nreleaseNotes: |-\n${indented}\n`;
writeFileSync(latestYmlPath, yml, 'utf8');
console.log('Injected releaseNotes into latest.yml');
} else {
console.log('latest.yml already has releaseNotes — skipping injection');
}
}
// --- scp helpers ---------------------------------------------------------- // --- scp helpers ----------------------------------------------------------
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`; const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;