Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a560f24c45 | |||
| 87c2e45bb4 | |||
| b65a3994f3 | |||
| aa609389fa | |||
| cb3cbd8827 | |||
| d1f38ce313 | |||
| 0d65a134fd | |||
| 12c66d676a | |||
| 0dde1dd1a3 | |||
| 81d3587a91 | |||
| 8be1105333 | |||
| f9e1d2f073 | |||
| 341f5f227d | |||
| 8baac2fd1e | |||
| 05870ef8fa | |||
| f9e340dbec | |||
| 91eedc0e8e | |||
| e56533918e | |||
| c87d4e82d3 | |||
| 187f8dc95a | |||
| f612c1bb50 | |||
| a9d5e2d430 | |||
| a065cc0a2c | |||
| 005aebd60b |
@@ -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 } : {}),
|
||||
|
||||
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
|
||||
}
|
||||
|
||||
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
|
||||
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
|
||||
// is correct) from "file exists but unreadable" (corruption / DPAPI
|
||||
// breakage — we MUST NOT let the next write overwrite those bytes,
|
||||
// because the original ciphertext is the only path back to the user's
|
||||
// device keys if a future build can fix the read path).
|
||||
let rawBuf: Buffer | null = null;
|
||||
let rawStr: string | null = null;
|
||||
try {
|
||||
if (encrypted) {
|
||||
const buf = await fs.readFile(filePath);
|
||||
const json = safeStorage.decryptString(buf);
|
||||
rawBuf = await fs.readFile(filePath);
|
||||
} else {
|
||||
rawStr = await fs.readFile(filePath, 'utf8');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (code === 'ENOENT') return new Map();
|
||||
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
|
||||
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
|
||||
// the file might be transiently locked. Empty map + future writes
|
||||
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
|
||||
// these rarer cases.
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Parse / decrypt step.
|
||||
try {
|
||||
if (encrypted && rawBuf) {
|
||||
const json = safeStorage.decryptString(rawBuf);
|
||||
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
} else {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
|
||||
}
|
||||
if (rawStr) {
|
||||
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
}
|
||||
} catch {
|
||||
// Missing file or malformed contents — start fresh. The next write
|
||||
// will overwrite with a fresh blob.
|
||||
return new Map();
|
||||
} catch (err: unknown) {
|
||||
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
|
||||
// pre-0.17.1 build we silently started fresh — the next set() then
|
||||
// scheduledSave() over-wrote the original ciphertext, destroying the
|
||||
// user's device keys forever. Now we rename the original to
|
||||
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
|
||||
// write goes to a new file and the original bytes survive for
|
||||
// forensics or a future decrypt-recovery path.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const brokenPath = `${filePath}.broken-${ts}`;
|
||||
try {
|
||||
await fs.rename(filePath, brokenPath);
|
||||
console.error(
|
||||
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
|
||||
err,
|
||||
);
|
||||
} catch (renameErr: unknown) {
|
||||
// Even rename failed — fall back to the old behaviour (silent empty
|
||||
// map) but log loudly so it's visible in the main-process output.
|
||||
console.error(
|
||||
'[secure-store] rename of broken file failed; original may be overwritten on next save',
|
||||
renameErr,
|
||||
'original decrypt error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ function buildOverlay(): NativeImage {
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
const icon = loadTrayIcon();
|
||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
||||
trayRef.setToolTip('ChatApp');
|
||||
trayRef.setToolTip('Netralax');
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
@@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void {
|
||||
else mainWindow.show();
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (trayRef && !trayRef.isDestroyed()) {
|
||||
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
|
||||
}
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (process.platform === 'win32') {
|
||||
if (n > 0) {
|
||||
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
|
||||
} else {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
ipcMain.handle(
|
||||
CHANNELS.TRAY_UNREAD,
|
||||
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (trayRef && !trayRef.isDestroyed()) {
|
||||
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
|
||||
}
|
||||
}
|
||||
});
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (process.platform !== 'win32') return;
|
||||
if (n <= 0) {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
return;
|
||||
}
|
||||
// Discord-style: prefer the renderer-painted badge (red circle with
|
||||
// the actual unread number). Fall back to the static red dot only if
|
||||
// the renderer didn't supply one or decoding failed — keeps the
|
||||
// visual indicator alive even when the canvas pipeline is unavailable.
|
||||
let overlay: NativeImage | null = null;
|
||||
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
|
||||
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
|
||||
if (!decoded.isEmpty()) overlay = decoded;
|
||||
}
|
||||
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
|
||||
},
|
||||
);
|
||||
|
||||
app.on('before-quit', () => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { CHANNELS } from '../ipc-types';
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
// Per-window maximize-before-fullscreen memo. We have to drop the
|
||||
// maximized flag on Windows before setFullScreen so DWM recomposes
|
||||
// cleanly (taskbar quirk), but Electron doesn't remember that the
|
||||
// window WAS maximized — exiting fullscreen would leave it as a small
|
||||
// floating window. Track it ourselves keyed by window-id so a future
|
||||
// multi-window setup doesn't cross-pollute state.
|
||||
const wasMaximized = new Map<number, boolean>();
|
||||
|
||||
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
|
||||
try {
|
||||
// Prefer the BrowserWindow that issued the IPC so multi-window setups
|
||||
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
|
||||
// registered against (matches autostart.ts's app-singleton shape).
|
||||
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.setFullScreen(!!enabled);
|
||||
const id = win.id;
|
||||
if (enabled) {
|
||||
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
|
||||
// taskbar drawn on top of the window because DWM keeps the
|
||||
// maximized work-area constraints. Drop the maximize flag first
|
||||
// so setFullScreen covers the whole monitor cleanly. Remember the
|
||||
// pre-fullscreen state so the exit path can restore it.
|
||||
if (process.platform === 'win32') {
|
||||
const was = win.isMaximized();
|
||||
wasMaximized.set(id, was);
|
||||
if (was) win.unmaximize();
|
||||
}
|
||||
win.setFullScreen(true);
|
||||
} else {
|
||||
win.setFullScreen(false);
|
||||
// Restore maximize if we dropped it on entry. setFullScreen(false)
|
||||
// emits 'leave-full-screen' asynchronously; maximize() needs to
|
||||
// wait until the window is back in normal mode or it silently
|
||||
// no-ops. The event fires same-tick in Electron 33, but we listen
|
||||
// for it once just to be safe across versions.
|
||||
if (process.platform === 'win32' && wasMaximized.get(id)) {
|
||||
wasMaximized.delete(id);
|
||||
const restore = (): void => {
|
||||
if (!win.isDestroyed()) win.maximize();
|
||||
};
|
||||
if (win.isFullScreen()) {
|
||||
win.once('leave-full-screen', restore);
|
||||
} else {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('window setFullscreen failed', err);
|
||||
throw err;
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export interface ElectronAPI {
|
||||
notify: (args: NotifyArgs) => Promise<void>;
|
||||
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
|
||||
|
||||
setTrayUnread: (count: number) => Promise<void>;
|
||||
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
|
||||
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
|
||||
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
type UpdateCheckResult,
|
||||
type UpdateProgress,
|
||||
} from './ipc-types';
|
||||
// Static import of the desktop package.json so the bundler inlines the
|
||||
// version string at build time. The previous `process.env.npm_package_-
|
||||
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||||
// fell back to '0.0.0' in packaged builds — every installed user got
|
||||
// flagged as "Update verfügbar" against their own actually-current
|
||||
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||||
import pkg from '../package.json';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||||
const api = {
|
||||
platform: ELECTRON_RUNTIME_MARKER,
|
||||
osPlatform: process.platform as NodeJS.Platform,
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
appVersion: pkg.version,
|
||||
|
||||
// Screen sources ---------------------------------------------------------
|
||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||
@@ -94,7 +101,12 @@ const api = {
|
||||
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||||
|
||||
// Tray -------------------------------------------------------------------
|
||||
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
|
||||
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
|
||||
// that main applies as the Windows taskbar overlay icon. We render in the
|
||||
// renderer because main has no Canvas2D; passing a finished image avoids
|
||||
// bundling a native canvas backend just for a 32×32 badge.
|
||||
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
|
||||
|
||||
// Secure store -----------------------------------------------------------
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.16.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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||
import { RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||
}, [soundboardCount]);
|
||||
|
||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
||||
// rather than "exactly one speaker" — matches Discord more closely and
|
||||
// handles the case where two people talk briefly without the focus
|
||||
// collapsing to nobody.
|
||||
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
||||
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
for (const id of activeSpeakers) {
|
||||
if (!prevActiveSpeakersRef.current.has(id)) {
|
||||
setLastStartedSpeakerId(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
||||
}, [activeSpeakers]);
|
||||
|
||||
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||
@@ -310,12 +294,35 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
// Discord-style precedence:
|
||||
// 1. focusedId set → 'focus', that tile is the stage.
|
||||
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||
// 4. no shares, no pin → 'equal-grid'.
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
|
||||
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||
const stageLayout: StageLayout = (() => {
|
||||
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||
return { kind: 'focus', bigTileId: focusedId };
|
||||
}
|
||||
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||
if (shareIds.length === 1 && shareIds[0]) {
|
||||
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||
}
|
||||
return { kind: 'equal-grid' };
|
||||
})();
|
||||
|
||||
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||
// stays consistent with the layout decision.
|
||||
const bigTile =
|
||||
stageLayout.kind === 'focus'
|
||||
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||
: undefined;
|
||||
|
||||
const controls = (
|
||||
<CallControls
|
||||
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
}));
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||
// the person who most recently started speaking (tracked in
|
||||
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
||||
// trigger; they stay as equal-size grid tiles until the user clicks
|
||||
// one. "Most recent speaker" beats "exactly one currently speaking"
|
||||
// because two people briefly overlapping shouldn't kick us out of
|
||||
// auto-focus.
|
||||
const autoSpeaker =
|
||||
focusedId === null && lastStartedSpeakerId !== null
|
||||
? tiles.find(
|
||||
(t) =>
|
||||
t.kind === 'user' &&
|
||||
!t.self &&
|
||||
t.userId === lastStartedSpeakerId,
|
||||
)
|
||||
: undefined;
|
||||
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||
return (
|
||||
<>
|
||||
{micError && (
|
||||
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
)}
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={effectiveSpeaker}
|
||||
speaker={bigTile}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
|
||||
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
|
||||
const sectionClass =
|
||||
callMode === 'focus'
|
||||
stageLayout.kind === 'focus'
|
||||
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
|
||||
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
|
||||
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
|
||||
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
mode={callMode}
|
||||
speaker={bigTile}
|
||||
stageLayout={stageLayout}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile
|
||||
// collapses back to grid; clicking another tile swaps focus;
|
||||
// clicking any tile in grid mode focuses it.
|
||||
if (callMode === 'focus' && focusedId === id) {
|
||||
setFocusedId(null);
|
||||
setCallMode('grid');
|
||||
return;
|
||||
}
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
onTileContextMenu={openTileContextMenu}
|
||||
compact
|
||||
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
const isPinned = focusedId === volumeMenu.tileId;
|
||||
setFocusedId(isPinned ? null : volumeMenu.tileId);
|
||||
if (!isPinned && callMode === 'grid') setCallMode('focus');
|
||||
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
|
||||
}}
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
@@ -869,9 +847,6 @@ function ModeToggles({
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={mode === 'fullscreen'}
|
||||
onClick={() => onChange('fullscreen')}
|
||||
@@ -916,7 +891,12 @@ function ModeButton({
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
mode: CallMode;
|
||||
/** Discriminated layout decision driven by InCallPanel's StageLayout
|
||||
* selector. Drives the bento-vs-grid-vs-focus render branch. */
|
||||
stageLayout:
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
@@ -941,7 +921,9 @@ function TileRender({
|
||||
conversationMembers,
|
||||
size,
|
||||
focused,
|
||||
cinema,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
@@ -951,7 +933,12 @@ function TileRender({
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
/** True when rendered inside FullscreenCall's big-tile slot. Drives
|
||||
* chrome-suppression on the inner ScreenShareViewer so its toggle
|
||||
* doesn't visually collide with the cinema-mode strip-hidden button. */
|
||||
cinema?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
@@ -964,6 +951,7 @@ function TileRender({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||
>
|
||||
@@ -971,6 +959,7 @@ function TileRender({
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||
hideFullscreenToggle={cinema === true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -994,6 +983,7 @@ function TileRender({
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
@@ -1002,7 +992,7 @@ function TileRender({
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
mode,
|
||||
stageLayout,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
@@ -1011,7 +1001,7 @@ function CallStage({
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
if (stageLayout.kind === 'focus' && speaker) {
|
||||
const others = tiles.filter((p) => p.id !== speaker.id);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
@@ -1022,6 +1012,7 @@ function CallStage({
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
@@ -1032,7 +1023,11 @@ function CallStage({
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
||||
// Docked strip: thumbs grow to share the row width evenly
|
||||
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
|
||||
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
|
||||
// with the share above; min-w keeps them readable when many.
|
||||
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -1054,13 +1049,91 @@ function CallStage({
|
||||
);
|
||||
}
|
||||
|
||||
// Grid
|
||||
if (stageLayout.kind === 'bento') {
|
||||
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
|
||||
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
|
||||
const bentoCols = gridColsFor(shares.length);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
|
||||
}
|
||||
>
|
||||
{shares.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{webcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||
{webcams.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
// Same docked-strip sizing as the focus branch above.
|
||||
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid (equal-grid fallthrough)
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
// aspect-video on every cell pushed the row past the section height on
|
||||
// wide chat panels — a single cell at full width forced height = width
|
||||
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
|
||||
// below. Let cells fill grid tracks normally for n>=2, and only enforce
|
||||
// a 16:9 silhouette (capped width, centred) for the solo-user case.
|
||||
const isSolo = tiles.length === 1;
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||
gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||
<div
|
||||
key={p.id}
|
||||
className={
|
||||
isSolo
|
||||
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||
}
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -1086,6 +1159,7 @@ function FocusedTile({
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
@@ -1093,6 +1167,7 @@ function FocusedTile({
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
@@ -1104,6 +1179,7 @@ function FocusedTile({
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1112,17 +1188,16 @@ function FocusedTile({
|
||||
const GRID_PAGE_SIZE = 12;
|
||||
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
// content, and a video element's intrinsic size blows the tile past the
|
||||
// container bounds (overlapping the toolbar below).
|
||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||
return 'grid-cols-4 grid-rows-3';
|
||||
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||
// height follows from their width, and the container centers them
|
||||
// vertically when the row stack is shorter than the available area.
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2';
|
||||
if (n <= 6) return 'grid-cols-3';
|
||||
if (n <= 9) return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
}
|
||||
|
||||
// Promote self + active speakers to the front of the tile list. Stable
|
||||
@@ -1216,12 +1291,25 @@ function FullscreenCall({
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
|
||||
const fsShareIds = tiles
|
||||
.filter((t) => t.kind === 'screen')
|
||||
.map((t) => t.id);
|
||||
const bentoMode = !hasFocus && fsShareIds.length >= 2;
|
||||
const bentoShares = bentoMode
|
||||
? tiles.filter((t) => fsShareIds.includes(t.id))
|
||||
: [];
|
||||
const bentoWebcams = bentoMode
|
||||
? tiles.filter((t) => !fsShareIds.includes(t.id))
|
||||
: [];
|
||||
|
||||
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||
// exist, slice them into pages. Reset to page 0 if the page count drops
|
||||
// below the current page (someone left).
|
||||
// exist, slice them into pages and prioritize active speakers onto page 1.
|
||||
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
|
||||
// whenever someone speaks.
|
||||
const needsPagination = tiles.length > GRID_PAGE_SIZE;
|
||||
const sortedGridTiles = useMemo(
|
||||
() => prioritizeTiles(tiles, activeSpeakers),
|
||||
[tiles, activeSpeakers],
|
||||
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
|
||||
[tiles, activeSpeakers, needsPagination],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
@@ -1235,14 +1323,18 @@ function FullscreenCall({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||
{/* Content area. pb-24 reserves ~96px space at the bottom for the
|
||||
floating controls bar so tiles never sit behind it. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
|
||||
{/* Content area. pb-28 reserves ~112px space at the bottom for the
|
||||
floating controls bar plus extra clearance so the tiles' bottom
|
||||
name-chip (positioned `bottom-2` inside each tile) doesn't sit
|
||||
directly underneath the controls — pb-24 was tight enough that
|
||||
on wide screens with audio-only avatars the chip got eclipsed. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
|
||||
{hasFocus ? (
|
||||
<>
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onClick={() => onFocusTile(speaker!.id)}
|
||||
onDoubleClick={() => onFocusTile(speaker!.id)}
|
||||
title="Zurück zur Übersicht"
|
||||
>
|
||||
<TileRender
|
||||
@@ -1252,6 +1344,7 @@ function FullscreenCall({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
cinema
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||
: {})}
|
||||
@@ -1262,7 +1355,7 @@ function FullscreenCall({
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -1281,24 +1374,86 @@ function FullscreenCall({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : bentoMode ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
|
||||
{bentoShares.map((s) => (
|
||||
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{bentoWebcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2 overflow-x-auto">
|
||||
{bentoWebcams.map((w) => (
|
||||
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{visibleTiles.map((p) => (
|
||||
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||
gridClass
|
||||
}
|
||||
>
|
||||
{visibleTiles.map((p) => {
|
||||
const isSolo = visibleTiles.length === 1;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
// Same fix as docked equal-grid: aspect-video w-full on
|
||||
// wide screens pushed cell height to ~ width × 9/16,
|
||||
// which dragged the tile's bottom name-chip down behind
|
||||
// the floating controls bar. For n>=2 fill the grid
|
||||
// tracks normally; for solo, cap width + centre.
|
||||
className={
|
||||
isSolo
|
||||
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||
}
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
|
||||
// inside AttachmentImage.tsx as a file-private component; extracted here
|
||||
// so other surfaces (settings avatar preview, future profile popover,
|
||||
// etc.) can reuse the exact same dialog without duplicating the chrome.
|
||||
//
|
||||
// The image itself stops click propagation so a click on the picture
|
||||
// keeps the lightbox open — only the backdrop or the explicit close
|
||||
// button dismisses.
|
||||
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bildansicht"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
|
||||
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,14 +1,63 @@
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Pushes the current aggregate unread count to main, which updates the
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
|
||||
// outside the Electron runtime (e.g. browser dev preview) so no guards
|
||||
// needed at call-sites.
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
|
||||
// badge here in the renderer because main has no Canvas2D — painting a
|
||||
// red bubble with the count, encoding to PNG, and handing the buffer
|
||||
// to main keeps the implementation free of a native canvas dependency.
|
||||
//
|
||||
// No-op outside the Electron runtime (e.g. browser dev preview) so no
|
||||
// guards needed at call-sites.
|
||||
export async function updateTrayUnread(count: number): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const n = Math.max(0, Math.floor(count));
|
||||
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
|
||||
try {
|
||||
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
|
||||
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Discord-style red bubble with white count.
|
||||
//
|
||||
// Source is rendered at 64×64 so Windows' high-quality downsample to the
|
||||
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
|
||||
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
|
||||
// pixel at the target size, and the bigger font fell into AA mush. Now
|
||||
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
|
||||
function renderBadgePng(count: number): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Solid red bubble, full bleed.
|
||||
const center = size / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, center, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fill();
|
||||
|
||||
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
|
||||
// Sizes are tuned per glyph count so each variant fills the bubble
|
||||
// without clipping when Windows downsamples to 16×16.
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
|
||||
ctx.fillStyle = '#fff';
|
||||
// Segoe UI is the Windows system font; explicit weight 800 keeps the
|
||||
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
|
||||
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// +2 vertical nudge: most system fonts render numerals visually high
|
||||
// relative to the baseline mid-point; the offset re-centres them.
|
||||
ctx.fillText(label, center, center + 2);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// Installed app version comes from the preload bridge (process.env.npm_-
|
||||
// package_version at preload build time). Falls back to '0.0.0' outside
|
||||
// Electron so the page still renders in a browser preview.
|
||||
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
|
||||
|
||||
export function ChangelogPage() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||
|
||||
// Compare the installed version against the top changelog entry. The
|
||||
// server-side changelog is sorted newest-first by the release script, so
|
||||
// entries[0] is always the published latest.
|
||||
const latestVersion = entries?.[0]?.version ?? null;
|
||||
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
|
||||
if (entries === null) return 'loading';
|
||||
if (!latestVersion) return 'current';
|
||||
const cmp = compareSemver(installedVersion, latestVersion);
|
||||
if (cmp === 0) return 'current';
|
||||
if (cmp < 0) return 'outdated';
|
||||
return 'ahead';
|
||||
}, [entries, latestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -34,7 +52,7 @@ export function ChangelogPage() {
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
@@ -42,6 +60,11 @@ export function ChangelogPage() {
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
installed={installedVersion}
|
||||
latest={latestVersion}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact status chip in the header that tells the user whether their
|
||||
// installed build matches the latest published version. Three visual
|
||||
// tones: emerald (current), amber (outdated → update available), neutral
|
||||
// (loading / unknown). The "ahead" case (dev build > released) shares the
|
||||
// neutral tone since users running it always know what they're doing.
|
||||
function VersionBadge({
|
||||
status,
|
||||
installed,
|
||||
latest,
|
||||
}: {
|
||||
status: 'loading' | 'current' | 'outdated' | 'ahead';
|
||||
installed: string;
|
||||
latest: string | null;
|
||||
}) {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'current') {
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf der neuesten Version."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
v{installed} · aktuell
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'outdated' && latest) {
|
||||
return (
|
||||
<span
|
||||
title={`Update verfügbar — neueste Version: v${latest}.`}
|
||||
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
<span>v{installed} · Update verfügbar</span>
|
||||
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
|
||||
>
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight semver comparator: parses major.minor.patch as ints and
|
||||
// compares numerically. Returns negative if a < b, zero if equal, positive
|
||||
// if a > b. Handles malformed inputs by treating non-numeric segments as
|
||||
// 0 so a typo doesn't flag a perfectly current install as outdated.
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const parse = (s: string): [number, number, number] => {
|
||||
const parts = s.split('.').map((p) => {
|
||||
const n = parseInt(p, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
};
|
||||
const [aMaj, aMin, aPat] = parse(a);
|
||||
const [bMaj, bMin, bPat] = parse(b);
|
||||
if (aMaj !== bMaj) return aMaj - bMaj;
|
||||
if (aMin !== bMin) return aMin - bMin;
|
||||
return aPat - bPat;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseMessagePayload } from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 2–4 webcams. Check:
|
||||
|
||||
- All grid tiles are equal-size 16:9 boxes; no tile is taller or wider than its neighbors.
|
||||
- With 3 participants → single row of 3; with 4 → 2×2; with 5–6 → 3×2 (last cell may be empty/centered).
|
||||
- Faces are framed naturally (`object-cover`); no obvious squish or stretch.
|
||||
|
||||
If layout looks wrong, screenshot, do not commit, and iterate on the wrapping classes.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): uniform 16:9 grid cells, drop grid-rows constraint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Introduce `StageLayout` discriminated union
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx:295-345` (the area where `effectiveFocusedId` and `speaker` are computed inside `InCallPanel`)
|
||||
|
||||
- [ ] **Step 1: Add the `StageLayout` type and selector**
|
||||
|
||||
In `InCallPanel.tsx`, locate the block:
|
||||
|
||||
```tsx
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```tsx
|
||||
// Discord-style precedence:
|
||||
// 1. focusedId set → 'focus', that tile is the stage.
|
||||
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||
// 4. no shares, no pin → 'equal-grid'.
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
|
||||
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||
const stageLayout: StageLayout = (() => {
|
||||
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||
return { kind: 'focus', bigTileId: focusedId };
|
||||
}
|
||||
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||
if (shareIds.length === 1 && shareIds[0]) {
|
||||
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||
}
|
||||
return { kind: 'equal-grid' };
|
||||
})();
|
||||
|
||||
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||
// stays consistent with the layout decision.
|
||||
const bigTile =
|
||||
stageLayout.kind === 'focus'
|
||||
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||
: undefined;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace existing usages of `speaker` and `effectiveFocusedId`**
|
||||
|
||||
Search `InCallPanel.tsx` for every remaining reference to `effectiveFocusedId` and `speaker` inside the `InCallPanel` function and replace as follows:
|
||||
|
||||
- `speaker` (used as prop on `CallStage`, `FullscreenCall`, focused-tile detection) → `bigTile`.
|
||||
- `effectiveFocusedId` (used in `pinnedTileId` prop for context menu) → `focusedId` (we no longer override pin for menu purposes; the auto-promoted share isn't user-pinned).
|
||||
|
||||
Concretely, the line `pinnedTileId: focusedId,` is already correct (uses `focusedId`, not the effective). The `effectiveFocusedId` declaration and `speaker` are removed by Step 1. Remaining usages:
|
||||
|
||||
- **In the fullscreen branch:** replace `speaker={effectiveSpeaker}` and the `effectiveSpeaker = hasFocus ? speaker : undefined` derivation with `speaker={bigTile}` (and drop the now-redundant `hasFocus` / `effectiveSpeaker` lines, since `bigTile` is undefined exactly when there's no focus).
|
||||
- **In the focus branch:** replace `speaker={speaker}` with `speaker={bigTile}`.
|
||||
|
||||
After this step, `InCallPanel`'s render path no longer uses the old `effectiveFocusedId` or `speaker` locals — only `stageLayout`, `bigTile`, `focusedId`.
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
Start the dev server, join a call (no shares yet, no pin). Check:
|
||||
|
||||
- Equal-grid renders as in Task 3 (no behavior regression).
|
||||
- Right-click → "Anpinnen" still works: pins the tile, the call panel collapses to focus-mode showing that tile big.
|
||||
- Right-click → "Anpinnen aufheben" returns to equal-grid.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): introduce StageLayout discriminated union"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire CallStage to render `focus` and `equal-grid` from StageLayout
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `InCallPanel`'s docked-call render branch (the area that calls `<CallStage mode={callMode} ...>`) and `CallStage` itself
|
||||
|
||||
- [ ] **Step 1: Map `stageLayout` → `mode` for `CallStage`**
|
||||
|
||||
In `InCallPanel.tsx`, locate the docked-call render that mounts `<CallStage mode={callMode} ...>`. Replace the `mode={callMode}` prop with a derived value:
|
||||
|
||||
```tsx
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={bigTile}
|
||||
// Discord-style: layout decision is driven by StageLayout (see top of
|
||||
// InCallPanel), not by the user-visible callMode toggle. callMode still
|
||||
// gates the cinema/fullscreen entry — for the docked stage we collapse
|
||||
// 'focus' and 'bento' to whatever CallStage knows how to render.
|
||||
mode={stageLayout.kind === 'equal-grid' ? 'grid' : 'focus'}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile drops the
|
||||
// pin; clicking another tile swaps. callMode auto-syncs.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
Note: `setCallMode` calls on click are removed — `callMode` no longer tracks pin state. `callMode` is now only `'grid'` (docked) or `'fullscreen'` (cinema). The third state (`'focus'`) is implicit when `focusedId !== null` and isn't a separate top-level mode anymore.
|
||||
|
||||
- [ ] **Step 2: Drop the click-toggle that swapped callMode**
|
||||
|
||||
Find the `onClick` callbacks in `InCallPanel` that did `setCallMode('focus')` or `setCallMode('grid')`. Replace each with a single `setFocusedId(focusedId === id ? null : id)` call (or remove the redundant ones that are now handled by `onFocusTile`).
|
||||
|
||||
- [ ] **Step 3: Adjust the `Mode` button bar**
|
||||
|
||||
The `<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">` row currently switches between `grid`, `focus`, `fullscreen`. Drop the `focus` button entirely — there's no manual focus mode anymore. Keep `grid` and `fullscreen`.
|
||||
|
||||
Locate the ModeButtonRow (search for `ModeButton`):
|
||||
|
||||
```tsx
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Sprecher">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'fullscreen'} onClick={() => onChange('fullscreen')} label="Vollbild">
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
```
|
||||
|
||||
Delete the middle (`focus`) ModeButton block. The remaining two cover all user-driven modes.
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS. If `'focus'` is referenced in `CallMode` type and unused now, leave the type alone — `'focus'` is still a valid value, just not user-selectable. Don't refactor the type.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
In a dev call (2–4 participants, no shares):
|
||||
|
||||
- **Click a webcam tile** → it becomes big, others to strip (`focus`-style stage). No mode bar change.
|
||||
- **Click it again** → equal grid restores.
|
||||
- **Click another tile while one is pinned** → swap to that tile.
|
||||
- Mode bar shows only `Grid` and `Vollbild` (the middle `Sprecher` button is gone).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): left-click toggles pin, drop manual focus mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Auto-promote single share (precedence rule 3)
|
||||
|
||||
**Files:**
|
||||
- No code change beyond what's already in Task 4 + Task 5. This task is the **manual verification** that the auto-promote path works end-to-end.
|
||||
|
||||
- [ ] **Step 1: Manual verification — share auto-promote**
|
||||
|
||||
In a dev call (2 participants):
|
||||
|
||||
- User A starts a screen share. Expected: share auto-promotes to the big stage on User B's side; User A's webcam moves to the strip.
|
||||
- User A stops the share. Expected: equal grid restores.
|
||||
- User A shares again; User B clicks User A's webcam thumb. Expected: webcam pins big, share moves to strip.
|
||||
- User B double-clicks the pinned webcam (Task 7 adds this; if not yet implemented, right-click → unpin works too). Expected: share auto-promotes again.
|
||||
|
||||
If any step fails, return to Task 4 (`stageLayout` selector) and verify the bigTile derivation is reading `tiles.find((t) => t.id === stageLayout.bigTileId)` correctly.
|
||||
|
||||
- [ ] **Step 2: No commit**
|
||||
|
||||
This task only verifies behavior introduced in earlier tasks.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Doubleclick on the focused tile clears pin
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:120-130` (root `<div>` of the tile)
|
||||
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:54-87` (ParticipantTileProps)
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `TileRender` props and the focused-tile rendering paths
|
||||
|
||||
- [ ] **Step 1: Add `onDoubleClick` prop**
|
||||
|
||||
In `CallParticipantTile.tsx`, extend `ParticipantTileProps`:
|
||||
|
||||
```tsx
|
||||
onDoubleClick?: () => void;
|
||||
```
|
||||
|
||||
In the `CallParticipantTile` body, destructure it:
|
||||
|
||||
```tsx
|
||||
const {
|
||||
// ... existing
|
||||
onDoubleClick,
|
||||
} = props;
|
||||
```
|
||||
|
||||
And add it to the root `<div>`:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Plumb `onDoubleClick` through `TileRender`**
|
||||
|
||||
In `InCallPanel.tsx`, extend the `TileRender` component props (the inline interface) with `onDoubleClick?: () => void;`. Pass it through to `CallParticipantTile` the same way `onClick` is passed:
|
||||
|
||||
```tsx
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
```
|
||||
|
||||
And on the `<div>` wrapping the screen-tile branch, add `onDoubleClick={onDoubleClick}` next to `onClick`.
|
||||
|
||||
- [ ] **Step 3: Hook doubleclick on focused tiles to clear pin**
|
||||
|
||||
In `FocusedTile`, accept and forward `onDoubleClick`:
|
||||
|
||||
```tsx
|
||||
function FocusedTile({
|
||||
tile,
|
||||
e2ee,
|
||||
activeSpeakers,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
// ... existing
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
<TileRender
|
||||
tile={tile}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In `CallStage`'s focus branch, pass an `onDoubleClick` that clears the pin:
|
||||
|
||||
```tsx
|
||||
<FocusedTile
|
||||
tile={speaker}
|
||||
e2ee={e2ee}
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
/>
|
||||
```
|
||||
|
||||
(`onFocusTile(speaker.id)` toggles — clicking the already-pinned id clears the pin per Task 5's setter.)
|
||||
|
||||
In `FullscreenCall`'s big-tile branch, pass the same `onDoubleClick` to the big tile wrapper:
|
||||
|
||||
```tsx
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Manual verification**
|
||||
|
||||
In a dev call with a pinned tile:
|
||||
|
||||
- **Doubleclick the pinned big tile** → unpins, layout falls back through StageLayout precedence (equal-grid if no shares, or share auto-promote if shares are active).
|
||||
- Single-click still toggles (no regression).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/CallParticipantTile.tsx apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): doubleclick on focused tile clears pin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Multi-share bento stage
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `CallStage` (add a bento branch) and the docked-call render to pass the `stageLayout` directly to `CallStage`
|
||||
|
||||
- [ ] **Step 1: Pass `stageLayout` to `CallStage`**
|
||||
|
||||
In `InCallPanel.tsx`, extend `StageProps`:
|
||||
|
||||
```tsx
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
/** Discriminated layout decision driven by InCallPanel's StageLayout
|
||||
* selector. Drives the bento-vs-grid-vs-focus render branch. */
|
||||
stageLayout:
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
// mode dropped — it duplicated stageLayout. callMode is still in the
|
||||
// parent for fullscreen-mode entry, just not threaded here anymore.
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
track: import('livekit-client').RemoteTrack;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
(Remove the `mode: CallMode;` field; replace with `stageLayout`.)
|
||||
|
||||
Update the `<CallStage ...>` site in `InCallPanel` to pass `stageLayout={stageLayout}` instead of `mode={...}`.
|
||||
|
||||
- [ ] **Step 2: Rewrite `CallStage` branch dispatch**
|
||||
|
||||
In `CallStage`, replace the body (currently `if (mode === 'focus' && speaker) { ... } // Grid ...`) with:
|
||||
|
||||
```tsx
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
stageLayout,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (stageLayout.kind === 'focus' && speaker) {
|
||||
// ... existing focus branch unchanged
|
||||
}
|
||||
|
||||
if (stageLayout.kind === 'bento') {
|
||||
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
|
||||
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
|
||||
const bentoCols = gridColsFor(shares.length);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
|
||||
}
|
||||
>
|
||||
{shares.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{webcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||
{webcams.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// equal-grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Apply the same layout choice inside `FullscreenCall`**
|
||||
|
||||
In `FullscreenCall`, the existing branch is `if (hasFocus) { big-stage } else { grid }`. Update the else-branch to also handle bento. The grid render path inside `FullscreenCall` currently uses `sortedGridTiles` + `gridColsFor`. Extend it:
|
||||
|
||||
After the existing `hasFocus` check and before the grid render, add:
|
||||
|
||||
```tsx
|
||||
const fsShareIds = tiles
|
||||
.filter((t) => t.kind === 'screen')
|
||||
.map((t) => t.id);
|
||||
const bentoMode = !hasFocus && fsShareIds.length >= 2;
|
||||
const bentoShares = bentoMode
|
||||
? tiles.filter((t) => fsShareIds.includes(t.id))
|
||||
: [];
|
||||
const bentoWebcams = bentoMode
|
||||
? tiles.filter((t) => !fsShareIds.includes(t.id))
|
||||
: [];
|
||||
```
|
||||
|
||||
Then wrap the existing grid-only render in:
|
||||
|
||||
```tsx
|
||||
{bentoMode ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
|
||||
{bentoShares.map((s) => (
|
||||
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{bentoWebcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2 overflow-x-auto">
|
||||
{bentoWebcams.map((w) => (
|
||||
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// ... existing grid render unchanged
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
```bash
|
||||
pnpm --filter @chatapp/desktop typecheck
|
||||
```
|
||||
|
||||
Expected: PASS. If `mode` is still referenced anywhere in `CallStage`, remove the stray reference (it's been replaced by `stageLayout.kind`).
|
||||
|
||||
- [ ] **Step 5: Manual verification — multi-share**
|
||||
|
||||
Set up a 2-share scenario (two clients sharing simultaneously):
|
||||
|
||||
- In docked mode: stage shows both shares side-by-side at equal size, webcams in the strip below.
|
||||
- In fullscreen-cinema mode: same bento, fills the screen.
|
||||
- Clicking one of the bento shares pins it → layout drops to single-stage focus on that share.
|
||||
- Stopping one share → falls back through StageLayout → rule 3 (single-share auto-promote).
|
||||
- Stopping both → equal grid.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/InCallPanel.tsx
|
||||
git commit -m "feat(call): multi-share bento layout in stage + fullscreen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: End-to-end verification pass
|
||||
|
||||
**Files:** None.
|
||||
|
||||
- [ ] **Step 1: Run all manual checks from the spec, end to end**
|
||||
|
||||
Spec section "Testing" lists six scenarios. Run all six:
|
||||
|
||||
1. Webcam-only equal grid: 4 webcams, no pin, no share → 2×2 uniform, faces cropped via cover.
|
||||
2. Pinning toggle: click webcam → big with contain (no head crop), strip below; click again → grid.
|
||||
3. Share auto-promote: start share → share is big, webcams strip, share aspect respected.
|
||||
4. Pin override during share: while share is big, click webcam → webcam pins big, share to strip; click webcam again → back to share auto-promote.
|
||||
5. Multi-share: two users share → bento stage; click one → pin that share.
|
||||
6. Active speaker: someone talks → emerald border, no reorder.
|
||||
|
||||
- [ ] **Step 2: Check no console errors**
|
||||
|
||||
Open DevTools console during the call. Expected: no warnings about React keys, missing props, or unhandled promise rejections related to the touched files.
|
||||
|
||||
- [ ] **Step 3: Final commit (if any cleanup)**
|
||||
|
||||
If you made trailing cleanup commits during the verification, push the branch. Otherwise nothing more to commit.
|
||||
|
||||
```bash
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Expected: 5–6 commits with prefixes `feat(call): ...`.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** Each section of the spec maps to a task:
|
||||
- Spec §1 (Tile aspect ratio) → Task 3
|
||||
- Spec §2 (Object-fit per mode) → Task 1
|
||||
- Spec §3 (Click-to-pin) → Task 5 + Task 7 (doubleclick)
|
||||
- Spec §4 (Layout selection precedence) → Task 4 + Task 5 + Task 6 + Task 8
|
||||
- Spec §5 (Active-speaker preserved) → no work needed; verified in Task 9 step 1.6
|
||||
- **No placeholders:** Every step has concrete code or commands.
|
||||
- **Type consistency:** `StageLayout` is named the same in spec and plan. `bigTileId`/`bigTile` naming is consistent across Tasks 4–8. `onFocusTile` signature `(id: string) => void` matches between InCallPanel callsite and CallStage prop.
|
||||
- **Reading-order safety:** Each task block re-states the file paths and the exact code being replaced — Task N doesn't assume the reader memorized Task N-1.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Discord-Style Call Tile Handling — Design Spec
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Scope:** `apps/desktop/src/components/InCallPanel.tsx`, `CallParticipantTile.tsx`, `ScreenShareViewer.tsx`
|
||||
**Goal:** Make tile rendering, click handling, and mixed share/webcam layouts behave like Discord.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Today's in-call rendering has four user-visible defects:
|
||||
|
||||
1. **Webcam tiles look stretched/cropped wrong.** `VideoStub` uses `object-cover` in every size, so when the tile aspect ratio diverges from the webcam stream, faces get cropped aggressively or distorted.
|
||||
2. **Screen-share tiles get the wrong aspect.** `ScreenShareViewer` uses `object-contain` (correct), but the grid cell that wraps it has no aspect-ratio constraint. Cells stretch tall/wide based on the grid template, leaving the share floating with large black bars on the sides.
|
||||
3. **Click-to-pin doesn't feel like Discord.** Left-click in docked grid swaps `callMode` from grid → focus, but fullscreen-grid doesn't react; pinning is right-click-only; toggling off requires another right-click.
|
||||
4. **Mixed layouts (share + webcams) treat every tile equally.** A screen share competes for space with 1:1 webcam tiles instead of dominating the stage with webcams beside it.
|
||||
|
||||
## Goals
|
||||
|
||||
- Grid renders uniform tile sizes without distorting content.
|
||||
- Single left-click on any tile pins it big; click again or click another tile swaps.
|
||||
- When at least one screen share is live and nothing is manually pinned, the share auto-promotes to the big stage spot.
|
||||
- Multiple parallel shares share the stage in a bento layout; webcams sit as a strip.
|
||||
- Active-speaker reorder stays disabled (preserves the earlier "no constant switching" fix).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No new transitions/animations beyond what's already in place.
|
||||
- No changes to context menu, volume control, screen-share picker.
|
||||
- No mobile/responsive rework — desktop only.
|
||||
- No migration of stored prefs (focused tile is session-only already).
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Tile aspect ratio
|
||||
|
||||
Every tile (webcam **and** screen) in the **grid** renders inside an `aspect-video` (16:9) box.
|
||||
|
||||
- Grid container drops `grid-rows-*` and instead lets `aspect-video` on each cell drive height.
|
||||
- `gridColsFor(n)` keeps the column count logic, just drops the row count constraint.
|
||||
- Effect: uniform tile sizes, no stretching, content sizing is per-tile not per-row.
|
||||
|
||||
```tsx
|
||||
// Today: grid h-full gap-2 grid-cols-3 grid-rows-2
|
||||
// Tomorrow: grid h-full gap-2 grid-cols-3 (each child has aspect-video)
|
||||
```
|
||||
|
||||
In fullscreen-cinema **focus** layouts (single big tile + thumbnail strip) the strip thumbs use `aspect-video` as well so they line up evenly.
|
||||
|
||||
### 2. Object-fit per mode
|
||||
|
||||
| Tile type | Grid (thumb) | Pinned/Focus (big) | Fullscreen strip |
|
||||
|--------------|--------------|--------------------|------------------|
|
||||
| Webcam | `cover` | `contain` | `cover` |
|
||||
| Screen share | `contain` | `contain` | `contain` |
|
||||
|
||||
- `VideoStub` accepts a new `fit?: 'cover' | 'contain'` prop (default `cover`). `CallParticipantTile` passes `contain` when its `focused` prop is true.
|
||||
- `ScreenShareViewer` already uses `object-contain` — no change there beyond removing the hardcoded `aspectRatio: '16/9'` on the unwatched preview button (the parent grid cell will own the ratio).
|
||||
|
||||
### 3. Click-to-pin
|
||||
|
||||
Single source of truth: `focusedId` in `CallContext`.
|
||||
|
||||
- **Left-click** on any tile: `setFocusedId(tile.id === focusedId ? null : tile.id)`.
|
||||
- In `callMode === 'grid'` and `focusedId !== null` → also `setCallMode('focus')`.
|
||||
- In `callMode === 'focus'` and `focusedId === null` → `setCallMode('grid')`.
|
||||
- In `callMode === 'fullscreen'`: only `focusedId` flips; layout reacts inside `FullscreenCall`.
|
||||
- **Doubleclick on the big/pinned tile**: clears the pin (`focusedId = null`).
|
||||
- **Right-click**: unchanged — opens existing context menu (volume / pin toggle / profile).
|
||||
- **Esc in fullscreen**: unchanged — exits fullscreen back to grid.
|
||||
|
||||
The current Stage `onClick` (`InCallPanel.tsx` around lines 578–586) already does this for docked mode; we extend the same handler to `FullscreenCall`'s tile click path (`onFocusTile`).
|
||||
|
||||
### 4. Layout selection (precedence)
|
||||
|
||||
`InCallPanel` picks one of four layouts every render. Precedence top-down — first matching rule wins:
|
||||
|
||||
| # | Condition | Layout |
|
||||
|---|-----------|--------|
|
||||
| 1 | `focusedId !== null` | Single-stage focus: the pinned tile is big, all others strip. |
|
||||
| 2 | `shareCount >= 2` and `focusedId === null` | Multi-share bento: all shares in sub-grid stage, webcams strip below. |
|
||||
| 3 | `shareCount === 1` and `focusedId === null` | Single-stage focus auto-promote: the share is big, webcams strip. |
|
||||
| 4 | `shareCount === 0` and `focusedId === null` | Equal grid (preserves the 2026-05-12 "no constant switching" fix). |
|
||||
|
||||
Where `shareCount = tiles.filter((t) => t.kind === 'screen').length`.
|
||||
|
||||
This is implemented via a derived `stageLayout` discriminated union, not a single `effectiveFocusedId`:
|
||||
|
||||
```ts
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
```
|
||||
|
||||
- Pin clearance returns control to rules 2/3/4 — Discord-style auto-fall-back.
|
||||
- Clicking a share inside the bento sets `focusedId = share.id` → drops into rule 1 (single-stage focus on that share).
|
||||
- Share ends → falls naturally from rule 3 → rule 4, or rule 2 → rule 3.
|
||||
|
||||
### 5. Active-speaker behavior (preserved)
|
||||
|
||||
No reorder. The emerald speaking border on `CallParticipantTile` stays. `prioritizeTiles` only runs when paginating (>12 tiles), as fixed in the 2026-05-12 patch.
|
||||
|
||||
---
|
||||
|
||||
## File-level changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `InCallPanel.tsx` | Compute `stageLayout` (equal-grid / focus / bento per the precedence table). Adjust grid CSS (drop `grid-rows-*`, add `aspect-video` per cell). Plumb `onClick` to `FullscreenCall` tiles. Render bento stage for layout `bento`. |
|
||||
| `CallParticipantTile.tsx` | Forward `focused` → `VideoStub.fit`. Update wrapper class to expect `aspect-video` from parent grid cell. Add `onDoubleClick` to clear pin. |
|
||||
| `ScreenShareViewer.tsx` | Remove hardcoded `aspectRatio: '16/9'` on the unwatched preview button (parent owns aspect now). |
|
||||
|
||||
No changes to `CallContext`, no changes to data model (`Tile`, `focusedId`).
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
- **Aspect-video might shrink tiles when there are many participants.** Mitigation: existing `GRID_PAGE_SIZE = 12` pagination keeps cells from collapsing to thumbnail-sized; we accept that 12 tiles at 16:9 will produce small rows just like Discord does at the same density.
|
||||
- **Auto-promote could be unexpected if a user explicitly cleared their pin.** Mitigation: pin clearance sets `focusedId = null`, then `firstShareId` takes over only if shares exist — exactly Discord's behavior. The user can stop watching shares to escape.
|
||||
- **`aspect-video` + flex children in the existing focus-mode (`FocusCall`)**: Focus mode has its own big-tile layout (`Stage` lines ~520–610). It already sets `flex h-full`; introducing `aspect-video` only on the strip thumbs is additive and won't reflow the big tile.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Manual verification with two-user dev call:
|
||||
|
||||
1. **Webcam-only equal grid:** 4 webcams, none pinned, no shares → all tiles 16:9 equal, faces visible cropped (cover), no stretching.
|
||||
2. **Pinning toggle:** click a webcam → that tile becomes big with `contain` fit (no face crop), rest strip; click again → back to grid.
|
||||
3. **Auto-promote:** start a screen share → share is big, webcams strip, share aspect respected (no stretch).
|
||||
4. **Manual override during share:** while share is big, click a webcam → webcam pins big, share moves to strip. Click webcam again → falls back to share-auto-promote.
|
||||
5. **Multi-share:** two users share → bento stage, both shares visible at equal size, webcams below.
|
||||
6. **Active speaker:** someone talks → emerald border, no tile reorder/swap.
|
||||
|
||||
No automated tests (Storybook setup not in place for in-call layouts).
|
||||
+6
-1
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user