Compare commits

...

24 Commits

Author SHA1 Message Date
byGalax a560f24c45 chore(desktop): release v0.17.5 2026-05-13 22:35:43 +02:00
byGalax 87c2e45bb4 fix(preload): read appVersion from package.json instead of npm_package_version
`appVersion: process.env.npm_package_version ?? '0.0.0'` only worked in
dev — pnpm sets that env var while running its lifecycle scripts. In
the packaged Electron build npm_package_version is unset, so every
installed user saw `v0.0.0` on the Changelog page and the version badge
permanently flagged them as "Update verfügbar" against their own
actually-current version.

Replace with a static `import pkg from '../package.json'` so Vite
inlines the version string into the preload bundle at build time. The
release script bumps package.json before electron-builder runs, so the
inlined value always matches the freshly-released version.

resolveJsonModule + esModuleInterop are already on in tsconfig.node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:33:18 +02:00
byGalax b65a3994f3 chore(desktop): release v0.17.4 2026-05-13 22:25:11 +02:00
byGalax aa609389fa feat(chat): render Media/Files + Group Info drawers inline, not overlaid
Both right-hand panels used absolute inset-y-0 right-0 and floated on
top of the conversation, hiding the messages directly underneath the
panel and looking unlike Discord's actual layout. Restructure:

* MediaFilesDrawer: drop absolute/z-index/shadow chrome, become a
  static flex column (w-[380px] shrink-0) with a left border. Internal
  layout unchanged.
* GroupInfoPanel: same treatment (w-[320px] shrink-0). Dropped the
  backdrop-blur and slide-up animation that only made sense as a modal.
* ConversationPage: wrap the chat content (voice rail, in-call panel,
  messages list, drag-overlay, input form) in a new
  `flex min-w-0 flex-1 flex-col` chat-column, and make that column a
  sibling of the drawers inside a new `flex flex-1 flex-row` row. The
  conversation header + search bar stay full-width above the row.

Result: opening a drawer narrows the chat column instead of covering
it, matching Discord's behaviour. The chat-column wrapper also carries
the `relative` anchor previously held by the outer wrapper so the
drag-and-drop overlay positions correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:21:05 +02:00
byGalax cb3cbd8827 feat(chat): show caller's name on call event pills in groups
In group conversations the "Outgoing/Incoming/Missed call" system pill
gave no clue WHO triggered the event — fine in a 1:1 where the only two
players are obvious, useless in a group with three+ members. Discord
puts the caller's name in the pill; mirror that.

CallEventRow now takes a senderDisplayName prop (plumbed through from
MessageBubble) and switches non-own labels to the name-aware variants:

* ended  + !mine + name → "{name} hat einen Anruf gestartet"
* missed + !mine + name → "Verpasster Anruf von {name}"
* declined + !mine + name → "Anruf von {name} abgelehnt"

Own events (mine) stay generic ("Outgoing call" / "No answer") since
the user already knows they were the initiator. Fallback path without
a name keeps the previous generic labels so nothing regresses if the
sender is unresolvable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:20:21 +02:00
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
23 changed files with 1965 additions and 247 deletions
+36 -1
View File
@@ -37,6 +37,26 @@ const __dirnameSafe = path.dirname(__filenameSafe);
const DEV_URL = 'http://localhost:1420';
const WINDOW_STATE_FILE = 'window-state.json';
// Pin userData FIRST — before any other Electron call that might cache a
// productName-derived path. The 0.17.0 release saw users get logged out
// after upgrading from 0.16.x: the most likely culprit was an internal
// path resolution kicking off the moment `setName('Netralax')` ran, so
// 0.17.1 swaps the order so the explicit override wins regardless of
// what setName triggers internally. The literal 'ChatApp' here is the
// pre-rename product folder — installed users' SQLite, secrets, sounds,
// IndexedDB all live there and we never want to leave them stranded by
// a future rebrand.
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
// App branding. productName in package.json drives the packaged exe name
// (Netralax.exe) and electron-builder installer title. setName + the
// AppUserModelId cover the live process: window title fallback, Windows
// taskbar grouping, notification source attribution.
app.setName('Netralax');
if (process.platform === 'win32') {
app.setAppUserModelId('cloud.netralax.desktop');
}
// Run dev side-by-side with the installed packaged build by isolating the
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
@@ -47,6 +67,21 @@ if (!app.isPackaged) {
app.setPath('userData', app.getPath('userData') + '-Dev');
}
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
// because we had no record of the actual resolved paths. With this log
// any future user can paste their main-process output and we can tell
// at a glance whether userData ended up where we intended.
console.log(
'[main] resolved paths',
JSON.stringify({
appName: app.getName(),
appData: app.getPath('appData'),
userData: app.getPath('userData'),
isPackaged: app.isPackaged,
platform: process.platform,
}),
);
let mainWindow: BrowserWindow | null = null;
function resolvePreloadPath(): string {
@@ -74,7 +109,7 @@ async function createWindow(): Promise<BrowserWindow> {
const state = await loadState(WINDOW_STATE_FILE);
const win = new BrowserWindow({
title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)',
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
width: state.width,
height: state.height,
...(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>> {
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
// is correct) from "file exists but unreadable" (corruption / DPAPI
// breakage — we MUST NOT let the next write overwrite those bytes,
// because the original ciphertext is the only path back to the user's
// device keys if a future build can fix the read path).
let rawBuf: Buffer | null = null;
let rawStr: string | null = null;
try {
if (encrypted) {
const buf = await fs.readFile(filePath);
const json = safeStorage.decryptString(buf);
rawBuf = await fs.readFile(filePath);
} else {
rawStr = await fs.readFile(filePath, 'utf8');
}
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException | null)?.code;
if (code === 'ENOENT') return new Map();
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
// the file might be transiently locked. Empty map + future writes
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
// these rarer cases.
return new Map();
}
// Parse / decrypt step.
try {
if (encrypted && rawBuf) {
const json = safeStorage.decryptString(rawBuf);
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
} else {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
}
if (rawStr) {
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
return new Map(Object.entries(parsed.entries ?? {}));
}
} catch {
// Missing file or malformed contents — start fresh. The next write
// will overwrite with a fresh blob.
return new Map();
} catch (err: unknown) {
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
// pre-0.17.1 build we silently started fresh — the next set() then
// scheduledSave() over-wrote the original ciphertext, destroying the
// user's device keys forever. Now we rename the original to
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
// write goes to a new file and the original bytes survive for
// forensics or a future decrypt-recovery path.
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const brokenPath = `${filePath}.broken-${ts}`;
try {
await fs.rename(filePath, brokenPath);
console.error(
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
err,
);
} catch (renameErr: unknown) {
// Even rename failed — fall back to the old behaviour (silent empty
// map) but log loudly so it's visible in the main-process output.
console.error(
'[secure-store] rename of broken file failed; original may be overwritten on next save',
renameErr,
'original decrypt error:',
err,
);
}
return new Map();
}
}
+25 -14
View File
@@ -55,7 +55,7 @@ function buildOverlay(): NativeImage {
export function register(mainWindow: BrowserWindow): void {
const icon = loadTrayIcon();
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
trayRef.setToolTip('ChatApp');
trayRef.setToolTip('Netralax');
const menu = Menu.buildFromTemplate([
{
@@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void {
else mainWindow.show();
});
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
}
if (mainWindow.isDestroyed()) return;
if (process.platform === 'win32') {
if (n > 0) {
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
} else {
mainWindow.setOverlayIcon(null, '');
ipcMain.handle(
CHANNELS.TRAY_UNREAD,
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
const n = Math.max(0, Math.floor(Number(count) || 0));
if (trayRef && !trayRef.isDestroyed()) {
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
}
}
});
if (mainWindow.isDestroyed()) return;
if (process.platform !== 'win32') return;
if (n <= 0) {
mainWindow.setOverlayIcon(null, '');
return;
}
// Discord-style: prefer the renderer-painted badge (red circle with
// the actual unread number). Fall back to the static red dot only if
// the renderer didn't supply one or decoding failed — keeps the
// visual indicator alive even when the canvas pipeline is unavailable.
let overlay: NativeImage | null = null;
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
if (!decoded.isEmpty()) overlay = decoded;
}
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
},
);
app.on('before-quit', () => {
try {
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
import { CHANNELS } from '../ipc-types';
export function register(mainWindow: BrowserWindow): void {
// Per-window maximize-before-fullscreen memo. We have to drop the
// maximized flag on Windows before setFullScreen so DWM recomposes
// cleanly (taskbar quirk), but Electron doesn't remember that the
// window WAS maximized — exiting fullscreen would leave it as a small
// floating window. Track it ourselves keyed by window-id so a future
// multi-window setup doesn't cross-pollute state.
const wasMaximized = new Map<number, boolean>();
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
try {
// Prefer the BrowserWindow that issued the IPC so multi-window setups
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
// registered against (matches autostart.ts's app-singleton shape).
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
if (!win || win.isDestroyed()) return;
win.setFullScreen(!!enabled);
const id = win.id;
if (enabled) {
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
// taskbar drawn on top of the window because DWM keeps the
// maximized work-area constraints. Drop the maximize flag first
// so setFullScreen covers the whole monitor cleanly. Remember the
// pre-fullscreen state so the exit path can restore it.
if (process.platform === 'win32') {
const was = win.isMaximized();
wasMaximized.set(id, was);
if (was) win.unmaximize();
}
win.setFullScreen(true);
} else {
win.setFullScreen(false);
// Restore maximize if we dropped it on entry. setFullScreen(false)
// emits 'leave-full-screen' asynchronously; maximize() needs to
// wait until the window is back in normal mode or it silently
// no-ops. The event fires same-tick in Electron 33, but we listen
// for it once just to be safe across versions.
if (process.platform === 'win32' && wasMaximized.get(id)) {
wasMaximized.delete(id);
const restore = (): void => {
if (!win.isDestroyed()) win.maximize();
};
if (win.isFullScreen()) {
win.once('leave-full-screen', restore);
} else {
restore();
}
}
}
} catch (err: unknown) {
console.warn('window setFullscreen failed', err);
throw err;
+1 -1
View File
@@ -58,7 +58,7 @@ export interface ElectronAPI {
notify: (args: NotifyArgs) => Promise<void>;
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
setTrayUnread: (count: number) => Promise<void>;
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
+14 -2
View File
@@ -28,6 +28,13 @@ import {
type UpdateCheckResult,
type UpdateProgress,
} from './ipc-types';
// Static import of the desktop package.json so the bundler inlines the
// version string at build time. The previous `process.env.npm_package_-
// version` approach worked in dev (pnpm sets it as a script env var) but
// fell back to '0.0.0' in packaged builds — every installed user got
// flagged as "Update verfügbar" against their own actually-current
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
import pkg from '../package.json';
type Unsubscribe = () => void;
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
const api = {
platform: ELECTRON_RUNTIME_MARKER,
osPlatform: process.platform as NodeJS.Platform,
appVersion: process.env.npm_package_version ?? '0.0.0',
appVersion: pkg.version,
// Screen sources ---------------------------------------------------------
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
@@ -94,7 +101,12 @@ const api = {
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
// Tray -------------------------------------------------------------------
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
// that main applies as the Windows taskbar overlay icon. We render in the
// renderer because main has no Canvas2D; passing a finished image avoids
// bundling a native canvas backend just for a 32×32 badge.
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
// Secure store -----------------------------------------------------------
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.16.3",
"version": "0.17.5",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
@@ -54,7 +54,7 @@
},
"build": {
"appId": "com.meinname.chatapp",
"productName": "ChatApp",
"productName": "Netralax",
"directories": {
"output": "release",
"buildResources": "resources"
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
interface Props {
handle: AttachmentHandle;
@@ -136,42 +137,5 @@ export function AttachmentImage({ handle }: Props) {
);
}
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt="attachment full"
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
// any future surface can reuse the same dialog without duplication.
@@ -83,6 +83,7 @@ export interface ParticipantTileProps {
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
@@ -102,6 +103,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
size = 'default',
focused = false,
onClick,
onDoubleClick,
onContextMenu,
} = props;
@@ -120,6 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
@@ -129,7 +132,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
}
>
{video ? (
<VideoStub {...props} small={small} />
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
<AudioContent {...props} small={small} />
)}
@@ -303,7 +306,8 @@ function VideoStub({
videoTrack,
me,
small,
}: ParticipantTileProps & { small: boolean }) {
fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoRef.current;
@@ -330,7 +334,8 @@ function VideoStub({
playsInline
muted
className={
'h-full w-full object-cover ' +
'h-full w-full ' +
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
<aside
role="dialog"
aria-label={t('app:group.info_title')}
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
>
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
<div>
+260 -105
View File
@@ -1,7 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay';
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
if (soundboardCount === 0) setSoundboardOpen(false);
}, [soundboardCount]);
// Active-speaker auto-focus uses "who most recently started speaking"
// rather than "exactly one speaker" — matches Discord more closely and
// handles the case where two people talk briefly without the focus
// collapsing to nobody.
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
useEffect(() => {
for (const id of activeSpeakers) {
if (!prevActiveSpeakersRef.current.has(id)) {
setLastStartedSpeakerId(id);
break;
}
}
prevActiveSpeakersRef.current = new Set(activeSpeakers);
}, [activeSpeakers]);
// Single right-click dispatcher for all tiles. User-tiles open the volume
// menu; screen-tiles open the share-specific menu (volume + mute + stop
// watching). Self-tiles get no menu — no volume to control, and you can
@@ -310,12 +294,35 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
// Screen shares no longer auto-promote — the user opts in by clicking the
// "Bildschirm anschauen" overlay, which also toggles whether the audio
// plays. Focus falls back to the first tile so focus-mode always has
// something to show when no tile was explicitly picked.
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
// Discord-style precedence:
// 1. focusedId set → 'focus', that tile is the stage.
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
// 4. no shares, no pin → 'equal-grid'.
type StageLayout =
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
const stageLayout: StageLayout = (() => {
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
return { kind: 'focus', bigTileId: focusedId };
}
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
if (shareIds.length === 1 && shareIds[0]) {
return { kind: 'focus', bigTileId: shareIds[0] };
}
return { kind: 'equal-grid' };
})();
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
// stays consistent with the layout decision.
const bigTile =
stageLayout.kind === 'focus'
? tiles.find((t) => t.id === stageLayout.bigTileId)
: undefined;
const controls = (
<CallControls
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
}));
if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone OR
// the person who most recently started speaking (tracked in
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
// trigger; they stay as equal-size grid tiles until the user clicks
// one. "Most recent speaker" beats "exactly one currently speaking"
// because two people briefly overlapping shouldn't kick us out of
// auto-focus.
const autoSpeaker =
focusedId === null && lastStartedSpeakerId !== null
? tiles.find(
(t) =>
t.kind === 'user' &&
!t.self &&
t.userId === lastStartedSpeakerId,
)
: undefined;
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return (
<>
{micError && (
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
)}
<FullscreenCall
tiles={tiles}
speaker={effectiveSpeaker}
speaker={bigTile}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
stageLayout.kind === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
return (
<section
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
<CallStage
tiles={tiles}
speaker={speaker}
mode={callMode}
speaker={bigTile}
stageLayout={stageLayout}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
// Discord-style toggle: clicking the already-focused tile
// collapses back to grid; clicking another tile swaps focus;
// clicking any tile in grid mode focuses it.
if (callMode === 'focus' && focusedId === id) {
setFocusedId(null);
setCallMode('grid');
return;
}
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
setFocusedId(focusedId === id ? null : id);
}}
onTileContextMenu={openTileContextMenu}
compact
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
y={volumeMenu.y}
pinned={focusedId === volumeMenu.tileId}
onTogglePin={() => {
const isPinned = focusedId === volumeMenu.tileId;
setFocusedId(isPinned ? null : volumeMenu.tileId);
if (!isPinned && callMode === 'grid') setCallMode('focus');
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
}}
{...(volumeMenu.self ? { renderVolume: false } : {})}
{...(volumeMenu.self
@@ -869,9 +847,6 @@ function ModeToggles({
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton
active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')}
@@ -916,7 +891,12 @@ function ModeButton({
interface StageProps {
tiles: Tile[];
speaker: Tile | undefined;
mode: CallMode;
/** Discriminated layout decision driven by InCallPanel's StageLayout
* selector. Drives the bento-vs-grid-vs-focus render branch. */
stageLayout:
| { kind: 'equal-grid' }
| { kind: 'focus'; bigTileId: string }
| { kind: 'bento'; shareIds: string[] };
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
@@ -941,7 +921,9 @@ function TileRender({
conversationMembers,
size,
focused,
cinema,
onClick,
onDoubleClick,
onContextMenu,
}: {
tile: Tile;
@@ -951,7 +933,12 @@ function TileRender({
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
/** True when rendered inside FullscreenCall's big-tile slot. Drives
* chrome-suppression on the inner ScreenShareViewer so its toggle
* doesn't visually collide with the cinema-mode strip-hidden button. */
cinema?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): JSX.Element {
if (tile.kind === 'screen') {
@@ -964,6 +951,7 @@ function TileRender({
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
@@ -971,6 +959,7 @@ function TileRender({
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
hideFullscreenToggle={cinema === true}
/>
</div>
);
@@ -994,6 +983,7 @@ function TileRender({
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
@@ -1002,7 +992,7 @@ function TileRender({
function CallStage({
tiles,
speaker,
mode,
stageLayout,
activeSpeakers,
e2ee,
remoteScreenShares,
@@ -1011,7 +1001,7 @@ function CallStage({
onTileContextMenu,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
if (stageLayout.kind === 'focus' && speaker) {
const others = tiles.filter((p) => p.id !== speaker.id);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
@@ -1022,6 +1012,7 @@ function CallStage({
activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onDoubleClick={() => onFocusTile(speaker.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
: {})}
@@ -1032,7 +1023,11 @@ function CallStage({
{others.map((p) => (
<div
key={p.id}
className="h-full w-[240px] shrink-0 [&>div]:h-full"
// Docked strip: thumbs grow to share the row width evenly
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
// with the share above; min-w keeps them readable when many.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1054,13 +1049,91 @@ function CallStage({
);
}
// Grid
if (stageLayout.kind === 'bento') {
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
const bentoCols = gridColsFor(shares.length);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<div
className={
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
}
>
{shares.map((s) => (
<div
key={s.id}
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{webcams.length > 0 && (
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
{webcams.map((w) => (
<div
key={w.id}
// Same docked-strip sizing as the focus branch above.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid (equal-grid fallthrough)
const gridClass = gridColsFor(tiles.length);
// aspect-video on every cell pushed the row past the section height on
// wide chat panels — a single cell at full width forced height = width
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
// below. Let cells fill grid tracks normally for n>=2, and only enforce
// a 16:9 silhouette (capped width, centred) for the solo-user case.
const isSolo = tiles.length === 1;
return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}>
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{tiles.map((p) => (
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
<div
key={p.id}
className={
isSolo
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
@@ -1086,6 +1159,7 @@ function FocusedTile({
remoteScreenShares,
conversationMembers,
onContextMenu,
onDoubleClick,
}: {
tile: Tile;
e2ee: boolean;
@@ -1093,6 +1167,7 @@ function FocusedTile({
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
onContextMenu?: (e: React.MouseEvent) => void;
onDoubleClick?: () => void;
}) {
return (
<div className="h-full [&>div]:h-full">
@@ -1104,6 +1179,7 @@ function FocusedTile({
conversationMembers={conversationMembers}
focused
{...(onContextMenu ? { onContextMenu } : {})}
{...(onDoubleClick ? { onDoubleClick } : {})}
/>
</div>
);
@@ -1112,17 +1188,16 @@ function FocusedTile({
const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string {
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
// space). Without this, implicit rows default to auto → they size to
// content, and a video element's intrinsic size blows the tile past the
// container bounds (overlapping the toolbar below).
if (n <= 1) return 'grid-cols-1 grid-rows-1';
if (n === 2) return 'grid-cols-2 grid-rows-1';
if (n === 3) return 'grid-cols-3 grid-rows-1';
if (n === 4) return 'grid-cols-2 grid-rows-2';
if (n <= 6) return 'grid-cols-3 grid-rows-2';
if (n <= 9) return 'grid-cols-3 grid-rows-3';
return 'grid-cols-4 grid-rows-3';
// Discord-style: column count only. Cells are `aspect-video` so their
// height follows from their width, and the container centers them
// vertically when the row stack is shorter than the available area.
if (n <= 1) return 'grid-cols-1';
if (n === 2) return 'grid-cols-2';
if (n === 3) return 'grid-cols-3';
if (n === 4) return 'grid-cols-2';
if (n <= 6) return 'grid-cols-3';
if (n <= 9) return 'grid-cols-3';
return 'grid-cols-4';
}
// Promote self + active speakers to the front of the tile list. Stable
@@ -1216,12 +1291,25 @@ function FullscreenCall({
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
const fsShareIds = tiles
.filter((t) => t.kind === 'screen')
.map((t) => t.id);
const bentoMode = !hasFocus && fsShareIds.length >= 2;
const bentoShares = bentoMode
? tiles.filter((t) => fsShareIds.includes(t.id))
: [];
const bentoWebcams = bentoMode
? tiles.filter((t) => !fsShareIds.includes(t.id))
: [];
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages. Reset to page 0 if the page count drops
// below the current page (someone left).
// exist, slice them into pages and prioritize active speakers onto page 1.
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
// whenever someone speaks.
const needsPagination = tiles.length > GRID_PAGE_SIZE;
const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers),
[tiles, activeSpeakers],
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
[tiles, activeSpeakers, needsPagination],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
@@ -1235,14 +1323,18 @@ function FullscreenCall({
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
{/* Content area. pb-24 reserves ~96px space at the bottom for the
floating controls bar so tiles never sit behind it. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
{/* Content area. pb-28 reserves ~112px space at the bottom for the
floating controls bar plus extra clearance so the tiles' bottom
name-chip (positioned `bottom-2` inside each tile) doesn't sit
directly underneath the controls — pb-24 was tight enough that
on wide screens with audio-only avatars the chip got eclipsed. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
{hasFocus ? (
<>
<div
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
onClick={() => onFocusTile(speaker!.id)}
onDoubleClick={() => onFocusTile(speaker!.id)}
title="Zurück zur Übersicht"
>
<TileRender
@@ -1252,6 +1344,7 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
cinema
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
@@ -1262,7 +1355,7 @@ function FullscreenCall({
{others.map((p) => (
<div
key={p.id}
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1281,24 +1374,86 @@ function FullscreenCall({
</div>
)}
</>
) : bentoMode ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
<div className="min-h-0 flex-1">
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
{bentoShares.map((s) => (
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
<TileRender
tile={s}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(s.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
: {})}
/>
</div>
))}
</div>
</div>
{bentoWebcams.length > 0 && (
<div className="flex h-[180px] gap-2 overflow-x-auto">
{bentoWebcams.map((w) => (
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={w}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small"
onClick={() => onFocusTile(w.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
: {})}
/>
</div>
))}
</div>
)}
</div>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
<div
className={
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{visibleTiles.map((p) => {
const isSolo = visibleTiles.length === 1;
return (
<div
key={p.id}
// Same fix as docked equal-grid: aspect-video w-full on
// wide screens pushed cell height to ~ width × 9/16,
// which dragged the tile's bottom name-chip down behind
// the floating controls bar. For n>=2 fill the grid
// tracks normally; for solo, cap width + centre.
className={
isSolo
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
);
})}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
+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>
);
}
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
if (!open) return null;
return (
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
<ImageIcon className="h-5 w-5" />
+36 -4
View File
@@ -294,7 +294,14 @@ export function MessageBubble({
}
if (parsed.kind === 'call_event') {
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
return (
<CallEventRow
parsed={parsed}
mine={mine}
time={time}
senderDisplayName={senderDisplayName ?? null}
/>
);
}
return (
@@ -866,10 +873,15 @@ function CallEventRow({
parsed,
mine,
time,
senderDisplayName,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
time: string;
/** Discord-parity: in group chats the system pill should say WHO
* started/missed the call. Null means we don't know (fall back to the
* legacy generic labels). */
senderDisplayName: string | null;
}) {
const { t } = useTranslation(['app']);
const status = parsed.status;
@@ -880,16 +892,36 @@ function CallEventRow({
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
// For non-own events we prefer the name-aware label so group chats
// make it clear who triggered the call event. Own events stay generic
// ("Outgoing call" / "No answer") since the user already knows they
// were the initiator.
const hasName = !mine && !!senderDisplayName;
const label =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: hasName
? t('app:chats.call_started_by', {
name: senderDisplayName,
defaultValue: '{{name}} hat einen Anruf gestartet',
})
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: status === 'missed'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
: hasName
? t('app:chats.call_missed_by', {
name: senderDisplayName,
defaultValue: 'Verpasster Anruf von {{name}}',
})
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: hasName
? t('app:chats.call_declined_by', {
name: senderDisplayName,
defaultValue: 'Anruf von {{name}} abgelehnt',
})
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
@@ -26,13 +26,65 @@ const TABS = [
];
type TabId = (typeof TABS)[number]['id'];
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
// Resolution and framerate are picked independently. The preset table
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
// caps, so we map (res, fps) → existing preset and rely on
// `framerateOverride` for the non-default framerate combinations
// (e.g. 1440p · 30, 4K · 30).
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
type FpsChoice = 30 | 60;
const RES_PILLS: { id: ResChoice; label: string }[] = [
{ id: 'auto', label: 'Auto' },
{ id: '720p60', label: '720p · 60' },
{ id: '1080p60', label: '1080p · 60' },
{ id: '1440p60', label: '1440p · 60' },
{ id: '720p', label: '720p' },
{ id: '1080p', label: '1080p' },
{ id: '1440p', label: '1440p' },
{ id: '4k', label: '4K' },
];
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
{ id: 30, label: '30 fps' },
{ id: 60, label: '60 fps' },
];
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
switch (res) {
case 'auto':
return 'auto';
case '720p':
return fps === 60 ? '720p60' : '720p30';
case '1080p':
return fps === 60 ? '1080p60' : '1080p30';
case '1440p':
return '1440p60';
case '4k':
return '4k60';
}
}
function decomposePreset(
p: ScreenSharePreset,
framerateOverride: number | null,
): { res: ResChoice; fps: FpsChoice } {
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
switch (p) {
case 'auto':
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
case '720p30':
return { res: '720p', fps: 30 };
case '720p60':
return { res: '720p', fps: 60 };
case '1080p30':
return { res: '1080p', fps: 30 };
case '1080p60':
return { res: '1080p', fps: 60 };
case '1440p60':
return { res: '1440p', fps: fallback };
case '4k60':
return { res: '4k', fps: fallback };
}
}
const THUMBNAIL_REFRESH_MS = 3500;
export function ScreenSharePickerModal({ onClose }: Props) {
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>(
() => getScreenShareSettings().preset,
);
const [res, setRes] = useState<ResChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).res;
});
const [fps, setFps] = useState<FpsChoice>(() => {
const s = getScreenShareSettings();
return decomposePreset(s.preset, s.framerateOverride).fps;
});
const [audio, setAudio] = useState<boolean>(
() => getScreenShareSettings().includeSystemAudio,
);
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
setError(null);
setBusy(true);
try {
// Persist quality + audio toggle. Also force-clear any stale duck
// setting users may have inherited from earlier builds — the
// native loopback addon excludes the app's own audio at OS level
// now, so JS-side ducking (which muted the user's incoming peer
// audio) is no longer needed and was causing "I can't hear anyone".
const preset = presetForResFps(res, fps);
// Persist resolution + fps + audio. The duck flag is force-cleared
// because the native loopback addon now excludes the app's own audio
// at OS level; JS-side ducking (which also muted incoming peer audio)
// was causing "I can't hear anyone" on earlier builds.
updateScreenShareSettings({
preset,
framerateOverride: fps,
includeSystemAudio: audio,
duckRemoteAudioWhileSharing: false,
});
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
await startScreenShare({
preset,
displaySurface: tab === 'screen' ? 'monitor' : 'window',
framerate: null,
framerate: fps,
// Forward the picked source id so the native loopback path can
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
// (parses HWND from `window:<HWND>:0`). For screen-shares this
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
Qualität
Auflösung
</span>
<div className="flex gap-1">
{QUALITY_PILLS.map((q) => {
const active = preset === q.id;
{RES_PILLS.map((q) => {
const active = res === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setPreset(q.id)}
onClick={() => setRes(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
}
>
{q.label}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
FPS
</span>
<div className="flex gap-1">
{FPS_PILLS.map((q) => {
const active = fps === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setFps(q.id)}
className={
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
(active
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
/** Suppress the in-share fullscreen toggle button. Used inside cinema
* mode where (a) the window is already OS-fullscreen and (b) the
* toggle collides visually with FullscreenCall's strip-hidden button
* at the same top-right corner. */
hideFullscreenToggle?: boolean;
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
share,
avatarUrl,
displayName,
hideFullscreenToggle = false,
}: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
{watching && !hideFullscreenToggle && (
<button
type="button"
onClick={toggleFullscreen}
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
type="button"
onClick={() => watchShare(share.participantId)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
>
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
+53 -4
View File
@@ -1,14 +1,63 @@
import { isTauriRuntime } from './globalShortcut';
// Pushes the current aggregate unread count to main, which updates the
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
// outside the Electron runtime (e.g. browser dev preview) so no guards
// needed at call-sites.
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
// badge here in the renderer because main has no Canvas2D — painting a
// red bubble with the count, encoding to PNG, and handing the buffer
// to main keeps the implementation free of a native canvas dependency.
//
// No-op outside the Electron runtime (e.g. browser dev preview) so no
// guards needed at call-sites.
export async function updateTrayUnread(count: number): Promise<void> {
if (!isTauriRuntime()) return;
const n = Math.max(0, Math.floor(count));
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
try {
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
} catch (err: unknown) {
console.warn('updateTrayUnread failed', err);
}
}
// Discord-style red bubble with white count.
//
// Source is rendered at 64×64 so Windows' high-quality downsample to the
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
// pixel at the target size, and the bigger font fell into AA mush. Now
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
function renderBadgePng(count: number): string | null {
if (typeof document === 'undefined') return null;
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// Solid red bubble, full bleed.
const center = size / 2;
ctx.beginPath();
ctx.arc(center, center, center, 0, Math.PI * 2);
ctx.fillStyle = '#ef4444';
ctx.fill();
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
// Sizes are tuned per glyph count so each variant fills the bubble
// without clipping when Windows downsamples to 16×16.
const label = count > 99 ? '99+' : String(count);
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
ctx.fillStyle = '#fff';
// Segoe UI is the Windows system font; explicit weight 800 keeps the
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// +2 vertical nudge: most system fonts render numerals visually high
// relative to the baseline mid-point; the offset re-centres them.
ctx.fillText(label, center, center + 2);
return canvas.toDataURL('image/png');
}
+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 { SparklesIcon, SpinnerIcon } from '../components/icons';
const PAGE_SIZE = 10;
// Installed app version comes from the preload bridge (process.env.npm_-
// package_version at preload build time). Falls back to '0.0.0' outside
// Electron so the page still renders in a browser preview.
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
export function ChangelogPage() {
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [visible, setVisible] = useState(PAGE_SIZE);
// Compare the installed version against the top changelog entry. The
// server-side changelog is sorted newest-first by the release script, so
// entries[0] is always the published latest.
const latestVersion = entries?.[0]?.version ?? null;
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
if (entries === null) return 'loading';
if (!latestVersion) return 'current';
const cmp = compareSemver(installedVersion, latestVersion);
if (cmp === 0) return 'current';
if (cmp < 0) return 'outdated';
return 'ahead';
}, [entries, latestVersion]);
useEffect(() => {
let cancelled = false;
void (async () => {
@@ -34,7 +52,7 @@ export function ChangelogPage() {
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
<SparklesIcon className="h-5 w-5" />
</div>
<div>
<div className="flex-1">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
Was ist neu
</h1>
@@ -42,6 +60,11 @@ export function ChangelogPage() {
Alle Änderungen in dieser App, neueste zuerst.
</p>
</div>
<VersionBadge
status={versionStatus}
installed={installedVersion}
latest={latestVersion}
/>
</header>
{entries === null && !error && (
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
return iso;
}
}
// Compact status chip in the header that tells the user whether their
// installed build matches the latest published version. Three visual
// tones: emerald (current), amber (outdated → update available), neutral
// (loading / unknown). The "ahead" case (dev build > released) shares the
// neutral tone since users running it always know what they're doing.
function VersionBadge({
status,
installed,
latest,
}: {
status: 'loading' | 'current' | 'outdated' | 'ahead';
installed: string;
latest: string | null;
}) {
if (status === 'loading') {
return (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
v{installed}
</span>
);
}
if (status === 'current') {
return (
<span
title="Du läufst auf der neuesten Version."
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
v{installed} · aktuell
</span>
);
}
if (status === 'outdated' && latest) {
return (
<span
title={`Update verfügbar — neueste Version: v${latest}.`}
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
>
<span>v{installed} · Update verfügbar</span>
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
</span>
);
}
return (
<span
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
>
v{installed}
</span>
);
}
// Lightweight semver comparator: parses major.minor.patch as ints and
// compares numerically. Returns negative if a < b, zero if equal, positive
// if a > b. Handles malformed inputs by treating non-numeric segments as
// 0 so a typo doesn't flag a perfectly current install as outdated.
function compareSemver(a: string, b: string): number {
const parse = (s: string): [number, number, number] => {
const parts = s.split('.').map((p) => {
const n = parseInt(p, 10);
return Number.isFinite(n) ? n : 0;
});
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
};
const [aMaj, aMin, aPat] = parse(a);
const [bMaj, bMin, bPat] = parse(b);
if (aMaj !== bMaj) return aMaj - bMaj;
if (aMin !== bMin) return aMin - bMin;
return aPat - bPat;
}
+100 -32
View File
@@ -1,6 +1,6 @@
import { parseMessagePayload } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
@@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the pixel offset so a chat the user left at the bottom keeps
// auto-following new messages when they return; a chat scrolled up
// returns to the exact spot the user was reading.
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
@@ -397,17 +406,60 @@ export function ConversationPage() {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
useEffect(() => {
// useLayoutEffect: run synchronously after DOM commit, before the
// browser paints. Using useEffect here let one frame of "scrollTop = 0
// (top of list)" paint between message-list mount and the auto-scroll,
// which is exactly the "flickers to a different position, then jumps"
// glitch users saw when re-entering a chat. Layout-effect fires while
// the message list is in the DOM but before paint, so the first frame
// already shows the correct scroll position.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
useEffect(() => {
setStickToBottom(true);
// Restore saved scroll position once the conversation's messages have
// actually rendered. The earlier version fired on `[id]` alone and ran
// before the message list populated — scrollHeight was still tiny, so
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
// and the user landed at the top instead of the saved position. By
// waiting for `messages.length > 0` we know the rendered scrollHeight
// is meaningful. `restoredForRef` ensures the restore runs at most
// once per chat switch (subsequent message arrivals don't re-trigger).
const restoredForRef = useRef<string | null>(null);
const isRestoringRef = useRef(false);
// useLayoutEffect, same reason as above: writing scrollTop here happens
// before the first paint of the freshly-mounted chat, so the user
// doesn't see a frame at scrollTop=0 before the jump to the saved
// position. Combined with the messages.length gate this means the
// re-entry shows the message list AT the saved scroll location in one
// single paint — no "loaded then jumped" effect.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [id]);
if (!el || !id) return;
if (restoredForRef.current === id) return;
// Wait for the conversation's messages to populate; for a chat that
// truly has zero messages the bottom and the top are the same anyway.
if (messages.length === 0) return;
restoredForRef.current = id;
const saved = scrollPositions.get(id);
// Suppress handleScroll's persistence during the programmatic scroll
// below — otherwise the browser's clamp/normalisation could write a
// different scrollTop back into the Map and lose the saved position.
isRestoringRef.current = true;
if (saved && !saved.stickToBottom) {
el.scrollTop = saved.scrollTop;
setStickToBottom(false);
} else {
setStickToBottom(true);
el.scrollTop = el.scrollHeight;
}
requestAnimationFrame(() => {
isRestoringRef.current = false;
});
}, [id, messages.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
@@ -416,7 +468,14 @@ export function ConversationPage() {
const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0);
}, []);
// Persist position per chat so re-entering this conversation lands
// where the user left off (see scrollPositions module-level Map).
// Skipped during the in-flight restore so we don't immediately
// overwrite the saved position with a clamped value.
if (id && !isRestoringRef.current) {
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
}
}, [id]);
const jumpToBottom = useCallback(() => {
const el = scrollRef.current;
@@ -584,31 +643,19 @@ export function ConversationPage() {
/>
)}
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
<MediaFilesDrawer
open={mediaDrawerOpen}
index={attachmentIndex}
senderNameFor={senderNameFor}
onJumpToMessage={(messageId) => {
setMediaDrawerOpen(false);
jumpToMessage(messageId);
}}
onClose={() => setMediaDrawerOpen(false)}
/>
{/* Discord-style persistent voice-channel rail. Always visible in groups
so anyone can pop in without an invite-ring; hidden in 1:1s unless
someone is already waiting. Hides automatically once we're in. */}
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
{/* Layout row: chat column on the left grows to fill remaining width;
right-hand drawers (Media/Files, Group Info) render as inline
siblings so opening one narrows the chat instead of floating on
top of it (Discord parity). The chat-column wrapper holds the
`relative` anchor for the drag-and-drop overlay further below. */}
<div className="flex min-h-0 flex-1 flex-row">
<div className="relative flex min-w-0 flex-1 flex-col">
{/* Discord-style persistent voice-channel rail. Always visible in groups
so anyone can pop in without an invite-ring; hidden in 1:1s unless
someone is already waiting. Hides automatically once we're in. */}
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
<div
ref={scrollRef}
@@ -960,6 +1007,27 @@ export function ConversationPage() {
</button>
</div>
</form>
</div>
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
<MediaFilesDrawer
open={mediaDrawerOpen}
index={attachmentIndex}
senderNameFor={senderNameFor}
onJumpToMessage={(messageId) => {
setMediaDrawerOpen(false);
jumpToMessage(messageId);
}}
onClose={() => setMediaDrawerOpen(false)}
/>
</div>
<ForwardDialog
open={forwardTarget !== null}
+34 -5
View File
@@ -33,6 +33,7 @@ import {
uploadBannerBlob,
} from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog';
import { Lightbox } from '../components/Lightbox';
import { devLocalSecretStore } from '../lib/secretStore';
import {
getPttSettings,
@@ -799,6 +800,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
// ratios.
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
// Lightbox toggle for the avatar live-preview. Clicking the in-page
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
const userId = profile?.userId;
const avatarUrl = profile?.avatarUrl ?? null;
@@ -930,11 +934,33 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
className="relative z-10 flex items-end gap-3 px-4 pb-3"
style={{ marginTop: '-2rem' }}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
<button
type="button"
onClick={() => {
if (avatarUrl) setAvatarPreviewOpen(true);
}}
// Disabled when there's no uploaded avatar — clicking the
// generated-initial placeholder would open an empty lightbox.
disabled={!avatarUrl}
aria-label={
avatarUrl
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
: undefined
}
// appearance-none + reset border/bg/padding so the native
// button chrome (outset border, button-face background, 1px
// padding) doesn't draw a box around the avatar circle.
className={
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
</button>
<div className="min-w-0 flex-1 pb-1">
<div className="truncate text-sm font-semibold text-fg">
{displayName ?? '—'}
@@ -1061,6 +1087,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
}}
onClose={closeCropDialog}
/>
{avatarPreviewOpen && avatarUrl && (
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
)}
</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).
+6 -1
View File
@@ -89,7 +89,12 @@ execSync('pnpm --filter @chat-app/desktop run build:win', {
// --- Locate artifacts -----------------------------------------------------
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 latestYml = 'latest.yml';