Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 341f5f227d | |||
| 8baac2fd1e | |||
| 05870ef8fa | |||
| f9e340dbec | |||
| 91eedc0e8e | |||
| e56533918e | |||
| c87d4e82d3 | |||
| 187f8dc95a | |||
| f612c1bb50 | |||
| a9d5e2d430 | |||
| a065cc0a2c | |||
| 005aebd60b | |||
| 68bc1f76f6 | |||
| f1c7501807 | |||
| e16b248366 | |||
| 9b764053c4 | |||
| 950ef5b706 |
@@ -37,6 +37,33 @@ const __dirnameSafe = path.dirname(__filenameSafe);
|
|||||||
const DEV_URL = 'http://localhost:1420';
|
const DEV_URL = 'http://localhost:1420';
|
||||||
const WINDOW_STATE_FILE = 'window-state.json';
|
const WINDOW_STATE_FILE = 'window-state.json';
|
||||||
|
|
||||||
|
// App branding. productName in package.json drives the packaged exe name
|
||||||
|
// (Netralax.exe) and electron-builder installer title. setName + the
|
||||||
|
// AppUserModelId below 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin userData to %APPDATA%\ChatApp regardless of productName so existing
|
||||||
|
// installs keep their profile, sounds, secrets, SQLite. Electron's default
|
||||||
|
// is %APPDATA%\<productName>, which after the Netralax rename would point
|
||||||
|
// at an empty fresh dir — same painful migration as the Tauri → Electron
|
||||||
|
// cut. Anchored to `appData` (the platform-AppData root) so productName
|
||||||
|
// changes can't drag it.
|
||||||
|
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
|
||||||
|
|
||||||
|
// Run dev side-by-side with the installed packaged build by isolating the
|
||||||
|
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
|
||||||
|
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
|
||||||
|
// the single-instance lock fires, and `pnpm dev` exits immediately while
|
||||||
|
// the installed prod app holds the lock. Must run BEFORE the lock check
|
||||||
|
// below + before any other module reads `app.getPath('userData')`.
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
app.setPath('userData', app.getPath('userData') + '-Dev');
|
||||||
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
function resolvePreloadPath(): string {
|
function resolvePreloadPath(): string {
|
||||||
@@ -64,7 +91,7 @@ async function createWindow(): Promise<BrowserWindow> {
|
|||||||
const state = await loadState(WINDOW_STATE_FILE);
|
const state = await loadState(WINDOW_STATE_FILE);
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
title: 'ChatApp',
|
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
|
||||||
width: state.width,
|
width: state.width,
|
||||||
height: state.height,
|
height: state.height,
|
||||||
...(state.x !== undefined ? { x: state.x } : {}),
|
...(state.x !== undefined ? { x: state.x } : {}),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function buildOverlay(): NativeImage {
|
|||||||
export function register(mainWindow: BrowserWindow): void {
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
const icon = loadTrayIcon();
|
const icon = loadTrayIcon();
|
||||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
||||||
trayRef.setToolTip('ChatApp');
|
trayRef.setToolTip('Netralax');
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate([
|
const menu = Menu.buildFromTemplate([
|
||||||
{
|
{
|
||||||
@@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void {
|
|||||||
else mainWindow.show();
|
else mainWindow.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise<void> => {
|
ipcMain.handle(
|
||||||
|
CHANNELS.TRAY_UNREAD,
|
||||||
|
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
|
||||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||||
if (trayRef && !trayRef.isDestroyed()) {
|
if (trayRef && !trayRef.isDestroyed()) {
|
||||||
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
|
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
|
||||||
}
|
}
|
||||||
if (mainWindow.isDestroyed()) return;
|
if (mainWindow.isDestroyed()) return;
|
||||||
if (process.platform === 'win32') {
|
if (process.platform !== 'win32') return;
|
||||||
if (n > 0) {
|
if (n <= 0) {
|
||||||
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
|
|
||||||
} else {
|
|
||||||
mainWindow.setOverlayIcon(null, '');
|
mainWindow.setOverlayIcon(null, '');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Discord-style: prefer the renderer-painted badge (red circle with
|
||||||
|
// the actual unread number). Fall back to the static red dot only if
|
||||||
|
// the renderer didn't supply one or decoding failed — keeps the
|
||||||
|
// visual indicator alive even when the canvas pipeline is unavailable.
|
||||||
|
let overlay: NativeImage | null = null;
|
||||||
|
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
|
||||||
|
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
|
||||||
|
if (!decoded.isEmpty()) overlay = decoded;
|
||||||
}
|
}
|
||||||
});
|
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
|
|||||||
import { CHANNELS } from '../ipc-types';
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
export function register(mainWindow: BrowserWindow): void {
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
// Per-window maximize-before-fullscreen memo. We have to drop the
|
||||||
|
// maximized flag on Windows before setFullScreen so DWM recomposes
|
||||||
|
// cleanly (taskbar quirk), but Electron doesn't remember that the
|
||||||
|
// window WAS maximized — exiting fullscreen would leave it as a small
|
||||||
|
// floating window. Track it ourselves keyed by window-id so a future
|
||||||
|
// multi-window setup doesn't cross-pollute state.
|
||||||
|
const wasMaximized = new Map<number, boolean>();
|
||||||
|
|
||||||
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
|
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
// Prefer the BrowserWindow that issued the IPC so multi-window setups
|
// Prefer the BrowserWindow that issued the IPC so multi-window setups
|
||||||
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
|
|||||||
// registered against (matches autostart.ts's app-singleton shape).
|
// registered against (matches autostart.ts's app-singleton shape).
|
||||||
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
|
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
|
||||||
if (!win || win.isDestroyed()) return;
|
if (!win || win.isDestroyed()) return;
|
||||||
win.setFullScreen(!!enabled);
|
const id = win.id;
|
||||||
|
if (enabled) {
|
||||||
|
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
|
||||||
|
// taskbar drawn on top of the window because DWM keeps the
|
||||||
|
// maximized work-area constraints. Drop the maximize flag first
|
||||||
|
// so setFullScreen covers the whole monitor cleanly. Remember the
|
||||||
|
// pre-fullscreen state so the exit path can restore it.
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const was = win.isMaximized();
|
||||||
|
wasMaximized.set(id, was);
|
||||||
|
if (was) win.unmaximize();
|
||||||
|
}
|
||||||
|
win.setFullScreen(true);
|
||||||
|
} else {
|
||||||
|
win.setFullScreen(false);
|
||||||
|
// Restore maximize if we dropped it on entry. setFullScreen(false)
|
||||||
|
// emits 'leave-full-screen' asynchronously; maximize() needs to
|
||||||
|
// wait until the window is back in normal mode or it silently
|
||||||
|
// no-ops. The event fires same-tick in Electron 33, but we listen
|
||||||
|
// for it once just to be safe across versions.
|
||||||
|
if (process.platform === 'win32' && wasMaximized.get(id)) {
|
||||||
|
wasMaximized.delete(id);
|
||||||
|
const restore = (): void => {
|
||||||
|
if (!win.isDestroyed()) win.maximize();
|
||||||
|
};
|
||||||
|
if (win.isFullScreen()) {
|
||||||
|
win.once('leave-full-screen', restore);
|
||||||
|
} else {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('window setFullscreen failed', err);
|
console.warn('window setFullscreen failed', err);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@ export interface ElectronAPI {
|
|||||||
notify: (args: NotifyArgs) => Promise<void>;
|
notify: (args: NotifyArgs) => Promise<void>;
|
||||||
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
|
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
|
||||||
|
|
||||||
setTrayUnread: (count: number) => Promise<void>;
|
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
|
||||||
|
|
||||||
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
|
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
|
||||||
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
|
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ const api = {
|
|||||||
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||||||
|
|
||||||
// Tray -------------------------------------------------------------------
|
// Tray -------------------------------------------------------------------
|
||||||
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
|
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
|
||||||
|
// that main applies as the Windows taskbar overlay icon. We render in the
|
||||||
|
// renderer because main has no Canvas2D; passing a finished image avoids
|
||||||
|
// bundling a native canvas backend just for a 32×32 badge.
|
||||||
|
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
|
||||||
|
|
||||||
// Secure store -----------------------------------------------------------
|
// Secure store -----------------------------------------------------------
|
||||||
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.16.1",
|
"version": "0.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.meinname.chatapp",
|
"appId": "com.meinname.chatapp",
|
||||||
"productName": "ChatApp",
|
"productName": "Netralax",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
"buildResources": "resources"
|
"buildResources": "resources"
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
|
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
|
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -18,7 +22,18 @@ export function AppShell() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user.id || !device?.id) return;
|
if (!session?.user.id || !device?.id) return;
|
||||||
return startConversationKeySync(session.user.id, device.id);
|
const userId = session.user.id;
|
||||||
|
const deviceId = device.id;
|
||||||
|
const stopKeySync = startConversationKeySync(userId, deviceId);
|
||||||
|
const stopApproval = startDeviceApprovalListener({
|
||||||
|
ownUserId: userId,
|
||||||
|
ownDeviceId: deviceId,
|
||||||
|
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stopKeySync();
|
||||||
|
stopApproval();
|
||||||
|
};
|
||||||
}, [session?.user.id, device?.id]);
|
}, [session?.user.id, device?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +49,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
<BackupPromptBanner />
|
<BackupPromptBanner />
|
||||||
|
<DeviceApprovalBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export interface ParticipantTileProps {
|
|||||||
size?: 'default' | 'small';
|
size?: 'default' | 'small';
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent) => void;
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
focused = false,
|
focused = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -120,6 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
className={
|
className={
|
||||||
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
|
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
|
||||||
@@ -129,7 +132,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{video ? (
|
{video ? (
|
||||||
<VideoStub {...props} small={small} />
|
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||||
) : (
|
) : (
|
||||||
<AudioContent {...props} small={small} />
|
<AudioContent {...props} small={small} />
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +306,8 @@ function VideoStub({
|
|||||||
videoTrack,
|
videoTrack,
|
||||||
me,
|
me,
|
||||||
small,
|
small,
|
||||||
}: ParticipantTileProps & { small: boolean }) {
|
fit,
|
||||||
|
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = videoRef.current;
|
const el = videoRef.current;
|
||||||
@@ -330,7 +334,8 @@ function VideoStub({
|
|||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
className={
|
className={
|
||||||
'h-full w-full object-cover ' +
|
'h-full w-full ' +
|
||||||
|
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
|
||||||
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
approveDevice,
|
||||||
|
denyAllPending,
|
||||||
|
denyDevice,
|
||||||
|
type PendingApproval,
|
||||||
|
subscribePendingApprovals,
|
||||||
|
} from '../lib/deviceApproval';
|
||||||
|
import { LockIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
// Sticky bottom-right banner stack (Discord-style). One tile per pending
|
||||||
|
// device-approval request. Visual language deliberately mirrors
|
||||||
|
// `BackupPromptBanner` — fixed positioning, rounded panel, subtle border
|
||||||
|
// accent — but uses the brand/accent palette to distinguish "security
|
||||||
|
// decision" from the amber "you should make a backup" nudge.
|
||||||
|
//
|
||||||
|
// Data flow:
|
||||||
|
// 1. `startDeviceApprovalListener` (mounted from AppShell) seeds the
|
||||||
|
// pending list on connect + on realtime INSERTs.
|
||||||
|
// 2. This component subscribes to that module and re-renders.
|
||||||
|
// 3. On Genehmigen: calls `approveDevice` which re-uses the
|
||||||
|
// `wrapForOneDevice` helper from conversationKeySync to write conv-key
|
||||||
|
// bundles for the new device across every shared conversation.
|
||||||
|
// 4. On Ablehnen: persists the deviceId in localStorage so it doesn't
|
||||||
|
// re-surface on app reload.
|
||||||
|
export function DeviceApprovalBanner() {
|
||||||
|
const { t, i18n } = useTranslation(['app']);
|
||||||
|
const [pending, setPending] = useState<PendingApproval[]>([]);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [errorId, setErrorId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => subscribePendingApprovals(setPending), []);
|
||||||
|
|
||||||
|
const onApprove = useCallback(async (req: PendingApproval) => {
|
||||||
|
setErrorId(null);
|
||||||
|
setBusyId(req.deviceId);
|
||||||
|
try {
|
||||||
|
await approveDevice(req);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: approve failed', err);
|
||||||
|
setErrorId(req.deviceId);
|
||||||
|
} finally {
|
||||||
|
setBusyId((curr) => (curr === req.deviceId ? null : curr));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDeny = useCallback((deviceId: string) => {
|
||||||
|
denyDevice(deviceId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (pending.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex w-[min(92vw,420px)] flex-col gap-3">
|
||||||
|
{pending.length > 1 && (
|
||||||
|
<div className="pointer-events-auto flex items-center justify-between gap-3 rounded-xl border border-line bg-surface-3/80 px-4 py-2 text-xs text-fg-muted backdrop-blur-md">
|
||||||
|
<span>
|
||||||
|
{t('app:device_approval.bulk_count', {
|
||||||
|
count: pending.length,
|
||||||
|
defaultValue: '{{count}} Geräte warten auf Bestätigung',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => denyAllPending()}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
{t('app:device_approval.deny_all', { defaultValue: 'Alle ablehnen' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pending.map((req) => {
|
||||||
|
const busy = busyId === req.deviceId;
|
||||||
|
const errored = errorId === req.deviceId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={req.deviceId}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-labelledby={`device-approval-${req.deviceId}-title`}
|
||||||
|
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-line bg-surface-3 p-4 text-sm text-fg shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<LockIcon className="mt-0.5 h-5 w-5 shrink-0 text-accent" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p
|
||||||
|
id={`device-approval-${req.deviceId}-title`}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{t('app:device_approval.title', {
|
||||||
|
defaultValue: 'Neues Gerät registriert',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-fg-muted">
|
||||||
|
{formatDeviceLabel(req)} ·{' '}
|
||||||
|
{formatRelativeTime(req.createdAt, i18n.language)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:device_approval.question', {
|
||||||
|
defaultValue: 'War das du?',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{errored && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">
|
||||||
|
{t('app:device_approval.error', {
|
||||||
|
defaultValue:
|
||||||
|
'Genehmigung fehlgeschlagen. Versuch es nochmal.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void onApprove(req)}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:cursor-wait disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t('app:device_approval.approve', {
|
||||||
|
defaultValue: 'Genehmigen',
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDeny(req.deviceId)}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-transparent px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{t('app:device_approval.deny', {
|
||||||
|
defaultValue: 'Ablehnen',
|
||||||
|
})}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDeny(req.deviceId)}
|
||||||
|
aria-label={t('app:device_approval.dismiss', {
|
||||||
|
defaultValue: 'Schließen',
|
||||||
|
})}
|
||||||
|
className="cursor-pointer text-fg-muted transition hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDeviceLabel(req: PendingApproval): string {
|
||||||
|
const platform = humanPlatform(req.platform);
|
||||||
|
const name = (req.name ?? '').trim();
|
||||||
|
if (name && platform) return `${platform} · ${name}`;
|
||||||
|
if (name) return name;
|
||||||
|
if (platform) return platform;
|
||||||
|
return 'Unbekanntes Gerät';
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanPlatform(p: string): string {
|
||||||
|
switch (p) {
|
||||||
|
case 'windows':
|
||||||
|
return 'Windows';
|
||||||
|
case 'macos':
|
||||||
|
return 'macOS';
|
||||||
|
case 'linux':
|
||||||
|
return 'Linux';
|
||||||
|
case 'ios':
|
||||||
|
return 'iOS';
|
||||||
|
case 'android':
|
||||||
|
return 'Android';
|
||||||
|
default:
|
||||||
|
return p.length > 0 ? p.charAt(0).toUpperCase() + p.slice(1) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort relative-time formatter using Intl.RelativeTimeFormat.
|
||||||
|
// Falls back to absolute timestamp if anything goes sideways.
|
||||||
|
function formatRelativeTime(iso: string, locale: string): string {
|
||||||
|
try {
|
||||||
|
const ts = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ts)) return iso;
|
||||||
|
const diffSec = Math.round((ts - Date.now()) / 1000);
|
||||||
|
const abs = Math.abs(diffSec);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale || 'de', { numeric: 'auto' });
|
||||||
|
if (abs < 60) return rtf.format(diffSec, 'second');
|
||||||
|
if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute');
|
||||||
|
if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour');
|
||||||
|
return rtf.format(Math.round(diffSec / 86400), 'day');
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||||
import { RoomEvent, Track } from 'livekit-client';
|
import { RoomEvent, Track } from 'livekit-client';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -27,7 +27,7 @@ import { CallControls } from './CallControls';
|
|||||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||||
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
||||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||||
@@ -195,22 +195,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
if (soundboardCount === 0) setSoundboardOpen(false);
|
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||||
}, [soundboardCount]);
|
}, [soundboardCount]);
|
||||||
|
|
||||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
|
||||||
// rather than "exactly one speaker" — matches Discord more closely and
|
|
||||||
// handles the case where two people talk briefly without the focus
|
|
||||||
// collapsing to nobody.
|
|
||||||
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
|
||||||
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
|
||||||
useEffect(() => {
|
|
||||||
for (const id of activeSpeakers) {
|
|
||||||
if (!prevActiveSpeakersRef.current.has(id)) {
|
|
||||||
setLastStartedSpeakerId(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
|
||||||
}, [activeSpeakers]);
|
|
||||||
|
|
||||||
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||||
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||||
@@ -310,12 +294,35 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||||
: t('app:call.connected');
|
: t('app:call.connected');
|
||||||
|
|
||||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
// Discord-style precedence:
|
||||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
// 1. focusedId set → 'focus', that tile is the stage.
|
||||||
// plays. Focus falls back to the first tile so focus-mode always has
|
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||||
// something to show when no tile was explicitly picked.
|
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
// 4. no shares, no pin → 'equal-grid'.
|
||||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
type StageLayout =
|
||||||
|
| { kind: 'equal-grid' }
|
||||||
|
| { kind: 'focus'; bigTileId: string }
|
||||||
|
| { kind: 'bento'; shareIds: string[] };
|
||||||
|
|
||||||
|
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||||
|
const stageLayout: StageLayout = (() => {
|
||||||
|
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||||
|
return { kind: 'focus', bigTileId: focusedId };
|
||||||
|
}
|
||||||
|
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||||
|
if (shareIds.length === 1 && shareIds[0]) {
|
||||||
|
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||||
|
}
|
||||||
|
return { kind: 'equal-grid' };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||||
|
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||||
|
// stays consistent with the layout decision.
|
||||||
|
const bigTile =
|
||||||
|
stageLayout.kind === 'focus'
|
||||||
|
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const controls = (
|
const controls = (
|
||||||
<CallControls
|
<CallControls
|
||||||
@@ -385,24 +392,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (callMode === 'fullscreen') {
|
if (callMode === 'fullscreen') {
|
||||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
|
||||||
// the person who most recently started speaking (tracked in
|
|
||||||
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
|
||||||
// trigger; they stay as equal-size grid tiles until the user clicks
|
|
||||||
// one. "Most recent speaker" beats "exactly one currently speaking"
|
|
||||||
// because two people briefly overlapping shouldn't kick us out of
|
|
||||||
// auto-focus.
|
|
||||||
const autoSpeaker =
|
|
||||||
focusedId === null && lastStartedSpeakerId !== null
|
|
||||||
? tiles.find(
|
|
||||||
(t) =>
|
|
||||||
t.kind === 'user' &&
|
|
||||||
!t.self &&
|
|
||||||
t.userId === lastStartedSpeakerId,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
|
||||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{micError && (
|
{micError && (
|
||||||
@@ -418,7 +407,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
)}
|
)}
|
||||||
<FullscreenCall
|
<FullscreenCall
|
||||||
tiles={tiles}
|
tiles={tiles}
|
||||||
speaker={effectiveSpeaker}
|
speaker={bigTile}
|
||||||
remoteScreenShares={remoteScreenShares}
|
remoteScreenShares={remoteScreenShares}
|
||||||
conversationMembers={conversation.members}
|
conversationMembers={conversation.members}
|
||||||
activeSpeakers={activeSpeakers}
|
activeSpeakers={activeSpeakers}
|
||||||
@@ -523,10 +512,10 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
|
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
|
||||||
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
|
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
|
||||||
const sectionClass =
|
const sectionClass =
|
||||||
callMode === 'focus'
|
stageLayout.kind === 'focus'
|
||||||
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
|
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
|
||||||
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
|
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
|
||||||
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
|
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -567,23 +556,14 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
|
|
||||||
<CallStage
|
<CallStage
|
||||||
tiles={tiles}
|
tiles={tiles}
|
||||||
speaker={speaker}
|
speaker={bigTile}
|
||||||
mode={callMode}
|
stageLayout={stageLayout}
|
||||||
activeSpeakers={activeSpeakers}
|
activeSpeakers={activeSpeakers}
|
||||||
e2ee={isE2EEActive}
|
e2ee={isE2EEActive}
|
||||||
remoteScreenShares={remoteScreenShares}
|
remoteScreenShares={remoteScreenShares}
|
||||||
conversationMembers={conversation.members}
|
conversationMembers={conversation.members}
|
||||||
onFocusTile={(id) => {
|
onFocusTile={(id) => {
|
||||||
// Discord-style toggle: clicking the already-focused tile
|
setFocusedId(focusedId === id ? null : id);
|
||||||
// collapses back to grid; clicking another tile swaps focus;
|
|
||||||
// clicking any tile in grid mode focuses it.
|
|
||||||
if (callMode === 'focus' && focusedId === id) {
|
|
||||||
setFocusedId(null);
|
|
||||||
setCallMode('grid');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFocusedId(id);
|
|
||||||
if (callMode === 'grid') setCallMode('focus');
|
|
||||||
}}
|
}}
|
||||||
onTileContextMenu={openTileContextMenu}
|
onTileContextMenu={openTileContextMenu}
|
||||||
compact
|
compact
|
||||||
@@ -601,9 +581,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
y={volumeMenu.y}
|
y={volumeMenu.y}
|
||||||
pinned={focusedId === volumeMenu.tileId}
|
pinned={focusedId === volumeMenu.tileId}
|
||||||
onTogglePin={() => {
|
onTogglePin={() => {
|
||||||
const isPinned = focusedId === volumeMenu.tileId;
|
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
|
||||||
setFocusedId(isPinned ? null : volumeMenu.tileId);
|
|
||||||
if (!isPinned && callMode === 'grid') setCallMode('focus');
|
|
||||||
}}
|
}}
|
||||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||||
{...(volumeMenu.self
|
{...(volumeMenu.self
|
||||||
@@ -869,9 +847,6 @@ function ModeToggles({
|
|||||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||||
<GridIcon className="h-4 w-4" />
|
<GridIcon className="h-4 w-4" />
|
||||||
</ModeButton>
|
</ModeButton>
|
||||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
|
||||||
<FocusIcon className="h-4 w-4" />
|
|
||||||
</ModeButton>
|
|
||||||
<ModeButton
|
<ModeButton
|
||||||
active={mode === 'fullscreen'}
|
active={mode === 'fullscreen'}
|
||||||
onClick={() => onChange('fullscreen')}
|
onClick={() => onChange('fullscreen')}
|
||||||
@@ -916,7 +891,12 @@ function ModeButton({
|
|||||||
interface StageProps {
|
interface StageProps {
|
||||||
tiles: Tile[];
|
tiles: Tile[];
|
||||||
speaker: Tile | undefined;
|
speaker: Tile | undefined;
|
||||||
mode: CallMode;
|
/** Discriminated layout decision driven by InCallPanel's StageLayout
|
||||||
|
* selector. Drives the bento-vs-grid-vs-focus render branch. */
|
||||||
|
stageLayout:
|
||||||
|
| { kind: 'equal-grid' }
|
||||||
|
| { kind: 'focus'; bigTileId: string }
|
||||||
|
| { kind: 'bento'; shareIds: string[] };
|
||||||
activeSpeakers: Set<string>;
|
activeSpeakers: Set<string>;
|
||||||
e2ee: boolean;
|
e2ee: boolean;
|
||||||
remoteScreenShares: {
|
remoteScreenShares: {
|
||||||
@@ -941,7 +921,9 @@ function TileRender({
|
|||||||
conversationMembers,
|
conversationMembers,
|
||||||
size,
|
size,
|
||||||
focused,
|
focused,
|
||||||
|
cinema,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
}: {
|
}: {
|
||||||
tile: Tile;
|
tile: Tile;
|
||||||
@@ -951,7 +933,12 @@ function TileRender({
|
|||||||
conversationMembers: StageProps['conversationMembers'];
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
size?: 'default' | 'small';
|
size?: 'default' | 'small';
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
|
/** True when rendered inside FullscreenCall's big-tile slot. Drives
|
||||||
|
* chrome-suppression on the inner ScreenShareViewer so its toggle
|
||||||
|
* doesn't visually collide with the cinema-mode strip-hidden button. */
|
||||||
|
cinema?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent) => void;
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
if (tile.kind === 'screen') {
|
if (tile.kind === 'screen') {
|
||||||
@@ -964,6 +951,7 @@ function TileRender({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||||
>
|
>
|
||||||
@@ -971,6 +959,7 @@ function TileRender({
|
|||||||
share={share}
|
share={share}
|
||||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||||
|
hideFullscreenToggle={cinema === true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -994,6 +983,7 @@ function TileRender({
|
|||||||
{...(size ? { size } : {})}
|
{...(size ? { size } : {})}
|
||||||
{...(focused ? { focused } : {})}
|
{...(focused ? { focused } : {})}
|
||||||
{...(onClick ? { onClick } : {})}
|
{...(onClick ? { onClick } : {})}
|
||||||
|
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||||
{...(onContextMenu ? { onContextMenu } : {})}
|
{...(onContextMenu ? { onContextMenu } : {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1002,7 +992,7 @@ function TileRender({
|
|||||||
function CallStage({
|
function CallStage({
|
||||||
tiles,
|
tiles,
|
||||||
speaker,
|
speaker,
|
||||||
mode,
|
stageLayout,
|
||||||
activeSpeakers,
|
activeSpeakers,
|
||||||
e2ee,
|
e2ee,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
@@ -1011,7 +1001,7 @@ function CallStage({
|
|||||||
onTileContextMenu,
|
onTileContextMenu,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: StageProps) {
|
}: StageProps) {
|
||||||
if (mode === 'focus' && speaker) {
|
if (stageLayout.kind === 'focus' && speaker) {
|
||||||
const others = tiles.filter((p) => p.id !== speaker.id);
|
const others = tiles.filter((p) => p.id !== speaker.id);
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||||
@@ -1022,6 +1012,7 @@ function CallStage({
|
|||||||
activeSpeakers={activeSpeakers}
|
activeSpeakers={activeSpeakers}
|
||||||
remoteScreenShares={remoteScreenShares}
|
remoteScreenShares={remoteScreenShares}
|
||||||
conversationMembers={conversationMembers}
|
conversationMembers={conversationMembers}
|
||||||
|
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||||
{...(onTileContextMenu
|
{...(onTileContextMenu
|
||||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||||
: {})}
|
: {})}
|
||||||
@@ -1032,7 +1023,11 @@ function CallStage({
|
|||||||
{others.map((p) => (
|
{others.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
// Docked strip: thumbs grow to share the row width evenly
|
||||||
|
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
|
||||||
|
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
|
||||||
|
// with the share above; min-w keeps them readable when many.
|
||||||
|
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||||
>
|
>
|
||||||
<TileRender
|
<TileRender
|
||||||
tile={p}
|
tile={p}
|
||||||
@@ -1054,13 +1049,91 @@ function CallStage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid
|
if (stageLayout.kind === 'bento') {
|
||||||
|
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
|
||||||
|
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
|
||||||
|
const bentoCols = gridColsFor(shares.length);
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{shares.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||||
|
>
|
||||||
|
<TileRender
|
||||||
|
tile={s}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
onClick={() => onFocusTile(s.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{webcams.length > 0 && (
|
||||||
|
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||||
|
{webcams.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.id}
|
||||||
|
// Same docked-strip sizing as the focus branch above.
|
||||||
|
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||||
|
>
|
||||||
|
<TileRender
|
||||||
|
tile={w}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onFocusTile(w.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid (equal-grid fallthrough)
|
||||||
const gridClass = gridColsFor(tiles.length);
|
const gridClass = gridColsFor(tiles.length);
|
||||||
|
// aspect-video on every cell pushed the row past the section height on
|
||||||
|
// wide chat panels — a single cell at full width forced height = width
|
||||||
|
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
|
||||||
|
// below. Let cells fill grid tracks normally for n>=2, and only enforce
|
||||||
|
// a 16:9 silhouette (capped width, centred) for the solo-user case.
|
||||||
|
const isSolo = tiles.length === 1;
|
||||||
return (
|
return (
|
||||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
<div
|
||||||
|
className={
|
||||||
|
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||||
|
gridClass
|
||||||
|
}
|
||||||
|
>
|
||||||
{tiles.map((p) => (
|
{tiles.map((p) => (
|
||||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className={
|
||||||
|
isSolo
|
||||||
|
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||||
|
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||||
|
}
|
||||||
|
>
|
||||||
<TileRender
|
<TileRender
|
||||||
tile={p}
|
tile={p}
|
||||||
activeSpeakers={activeSpeakers}
|
activeSpeakers={activeSpeakers}
|
||||||
@@ -1086,6 +1159,7 @@ function FocusedTile({
|
|||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
conversationMembers,
|
conversationMembers,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
onDoubleClick,
|
||||||
}: {
|
}: {
|
||||||
tile: Tile;
|
tile: Tile;
|
||||||
e2ee: boolean;
|
e2ee: boolean;
|
||||||
@@ -1093,6 +1167,7 @@ function FocusedTile({
|
|||||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||||
conversationMembers: StageProps['conversationMembers'];
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
onContextMenu?: (e: React.MouseEvent) => void;
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full [&>div]:h-full">
|
<div className="h-full [&>div]:h-full">
|
||||||
@@ -1104,6 +1179,7 @@ function FocusedTile({
|
|||||||
conversationMembers={conversationMembers}
|
conversationMembers={conversationMembers}
|
||||||
focused
|
focused
|
||||||
{...(onContextMenu ? { onContextMenu } : {})}
|
{...(onContextMenu ? { onContextMenu } : {})}
|
||||||
|
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1112,17 +1188,16 @@ function FocusedTile({
|
|||||||
const GRID_PAGE_SIZE = 12;
|
const GRID_PAGE_SIZE = 12;
|
||||||
|
|
||||||
function gridColsFor(n: number): string {
|
function gridColsFor(n: number): string {
|
||||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||||
// space). Without this, implicit rows default to auto → they size to
|
// height follows from their width, and the container centers them
|
||||||
// content, and a video element's intrinsic size blows the tile past the
|
// vertically when the row stack is shorter than the available area.
|
||||||
// container bounds (overlapping the toolbar below).
|
if (n <= 1) return 'grid-cols-1';
|
||||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
if (n === 2) return 'grid-cols-2';
|
||||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
if (n === 3) return 'grid-cols-3';
|
||||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
if (n === 4) return 'grid-cols-2';
|
||||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
if (n <= 6) return 'grid-cols-3';
|
||||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
if (n <= 9) return 'grid-cols-3';
|
||||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
return 'grid-cols-4';
|
||||||
return 'grid-cols-4 grid-rows-3';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote self + active speakers to the front of the tile list. Stable
|
// Promote self + active speakers to the front of the tile list. Stable
|
||||||
@@ -1216,12 +1291,25 @@ function FullscreenCall({
|
|||||||
const hasFocus = speaker !== undefined;
|
const hasFocus = speaker !== undefined;
|
||||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||||
|
|
||||||
|
const fsShareIds = tiles
|
||||||
|
.filter((t) => t.kind === 'screen')
|
||||||
|
.map((t) => t.id);
|
||||||
|
const bentoMode = !hasFocus && fsShareIds.length >= 2;
|
||||||
|
const bentoShares = bentoMode
|
||||||
|
? tiles.filter((t) => fsShareIds.includes(t.id))
|
||||||
|
: [];
|
||||||
|
const bentoWebcams = bentoMode
|
||||||
|
? tiles.filter((t) => !fsShareIds.includes(t.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||||
// exist, slice them into pages. Reset to page 0 if the page count drops
|
// exist, slice them into pages and prioritize active speakers onto page 1.
|
||||||
// below the current page (someone left).
|
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
|
||||||
|
// whenever someone speaks.
|
||||||
|
const needsPagination = tiles.length > GRID_PAGE_SIZE;
|
||||||
const sortedGridTiles = useMemo(
|
const sortedGridTiles = useMemo(
|
||||||
() => prioritizeTiles(tiles, activeSpeakers),
|
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
|
||||||
[tiles, activeSpeakers],
|
[tiles, activeSpeakers, needsPagination],
|
||||||
);
|
);
|
||||||
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1235,14 +1323,18 @@ function FullscreenCall({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||||
{/* Content area. pb-24 reserves ~96px space at the bottom for the
|
{/* Content area. pb-28 reserves ~112px space at the bottom for the
|
||||||
floating controls bar so tiles never sit behind it. */}
|
floating controls bar plus extra clearance so the tiles' bottom
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
|
name-chip (positioned `bottom-2` inside each tile) doesn't sit
|
||||||
|
directly underneath the controls — pb-24 was tight enough that
|
||||||
|
on wide screens with audio-only avatars the chip got eclipsed. */}
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
|
||||||
{hasFocus ? (
|
{hasFocus ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||||
onClick={() => onFocusTile(speaker!.id)}
|
onClick={() => onFocusTile(speaker!.id)}
|
||||||
|
onDoubleClick={() => onFocusTile(speaker!.id)}
|
||||||
title="Zurück zur Übersicht"
|
title="Zurück zur Übersicht"
|
||||||
>
|
>
|
||||||
<TileRender
|
<TileRender
|
||||||
@@ -1252,6 +1344,7 @@ function FullscreenCall({
|
|||||||
remoteScreenShares={remoteScreenShares}
|
remoteScreenShares={remoteScreenShares}
|
||||||
conversationMembers={conversationMembers}
|
conversationMembers={conversationMembers}
|
||||||
focused
|
focused
|
||||||
|
cinema
|
||||||
{...(onTileContextMenu
|
{...(onTileContextMenu
|
||||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||||
: {})}
|
: {})}
|
||||||
@@ -1262,7 +1355,7 @@ function FullscreenCall({
|
|||||||
{others.map((p) => (
|
{others.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
|
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||||
>
|
>
|
||||||
<TileRender
|
<TileRender
|
||||||
tile={p}
|
tile={p}
|
||||||
@@ -1281,11 +1374,72 @@ function FullscreenCall({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : bentoMode ? (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
|
||||||
|
{bentoShares.map((s) => (
|
||||||
|
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
|
||||||
|
<TileRender
|
||||||
|
tile={s}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
onClick={() => onFocusTile(s.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bentoWebcams.length > 0 && (
|
||||||
|
<div className="flex h-[180px] gap-2 overflow-x-auto">
|
||||||
|
{bentoWebcams.map((w) => (
|
||||||
|
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
|
||||||
|
<TileRender
|
||||||
|
tile={w}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={e2ee}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onFocusTile(w.id)}
|
||||||
|
{...(onTileContextMenu
|
||||||
|
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-h-0 flex-1 p-4">
|
<div className="min-h-0 flex-1 p-4">
|
||||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
<div
|
||||||
{visibleTiles.map((p) => (
|
className={
|
||||||
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||||
|
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
|
<TileRender
|
||||||
tile={p}
|
tile={p}
|
||||||
activeSpeakers={activeSpeakers}
|
activeSpeakers={activeSpeakers}
|
||||||
@@ -1298,7 +1452,8 @@ function FullscreenCall({
|
|||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{pageCount > 1 && (
|
{pageCount > 1 && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
||||||
|
|||||||
@@ -26,13 +26,65 @@ const TABS = [
|
|||||||
];
|
];
|
||||||
type TabId = (typeof TABS)[number]['id'];
|
type TabId = (typeof TABS)[number]['id'];
|
||||||
|
|
||||||
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
|
// Resolution and framerate are picked independently. The preset table
|
||||||
|
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
|
||||||
|
// caps, so we map (res, fps) → existing preset and rely on
|
||||||
|
// `framerateOverride` for the non-default framerate combinations
|
||||||
|
// (e.g. 1440p · 30, 4K · 30).
|
||||||
|
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
|
||||||
|
type FpsChoice = 30 | 60;
|
||||||
|
|
||||||
|
const RES_PILLS: { id: ResChoice; label: string }[] = [
|
||||||
{ id: 'auto', label: 'Auto' },
|
{ id: 'auto', label: 'Auto' },
|
||||||
{ id: '720p60', label: '720p · 60' },
|
{ id: '720p', label: '720p' },
|
||||||
{ id: '1080p60', label: '1080p · 60' },
|
{ id: '1080p', label: '1080p' },
|
||||||
{ id: '1440p60', label: '1440p · 60' },
|
{ id: '1440p', label: '1440p' },
|
||||||
|
{ id: '4k', label: '4K' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
|
||||||
|
{ id: 30, label: '30 fps' },
|
||||||
|
{ id: 60, label: '60 fps' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
|
||||||
|
switch (res) {
|
||||||
|
case 'auto':
|
||||||
|
return 'auto';
|
||||||
|
case '720p':
|
||||||
|
return fps === 60 ? '720p60' : '720p30';
|
||||||
|
case '1080p':
|
||||||
|
return fps === 60 ? '1080p60' : '1080p30';
|
||||||
|
case '1440p':
|
||||||
|
return '1440p60';
|
||||||
|
case '4k':
|
||||||
|
return '4k60';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decomposePreset(
|
||||||
|
p: ScreenSharePreset,
|
||||||
|
framerateOverride: number | null,
|
||||||
|
): { res: ResChoice; fps: FpsChoice } {
|
||||||
|
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
|
||||||
|
switch (p) {
|
||||||
|
case 'auto':
|
||||||
|
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
|
||||||
|
case '720p30':
|
||||||
|
return { res: '720p', fps: 30 };
|
||||||
|
case '720p60':
|
||||||
|
return { res: '720p', fps: 60 };
|
||||||
|
case '1080p30':
|
||||||
|
return { res: '1080p', fps: 30 };
|
||||||
|
case '1080p60':
|
||||||
|
return { res: '1080p', fps: 60 };
|
||||||
|
case '1440p60':
|
||||||
|
return { res: '1440p', fps: fallback };
|
||||||
|
case '4k60':
|
||||||
|
return { res: '4k', fps: fallback };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const THUMBNAIL_REFRESH_MS = 3500;
|
const THUMBNAIL_REFRESH_MS = 3500;
|
||||||
|
|
||||||
export function ScreenSharePickerModal({ onClose }: Props) {
|
export function ScreenSharePickerModal({ onClose }: Props) {
|
||||||
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [preset, setPreset] = useState<ScreenSharePreset>(
|
const [res, setRes] = useState<ResChoice>(() => {
|
||||||
() => getScreenShareSettings().preset,
|
const s = getScreenShareSettings();
|
||||||
);
|
return decomposePreset(s.preset, s.framerateOverride).res;
|
||||||
|
});
|
||||||
|
const [fps, setFps] = useState<FpsChoice>(() => {
|
||||||
|
const s = getScreenShareSettings();
|
||||||
|
return decomposePreset(s.preset, s.framerateOverride).fps;
|
||||||
|
});
|
||||||
const [audio, setAudio] = useState<boolean>(
|
const [audio, setAudio] = useState<boolean>(
|
||||||
() => getScreenShareSettings().includeSystemAudio,
|
() => getScreenShareSettings().includeSystemAudio,
|
||||||
);
|
);
|
||||||
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
// Persist quality + audio toggle. Also force-clear any stale duck
|
const preset = presetForResFps(res, fps);
|
||||||
// setting users may have inherited from earlier builds — the
|
// Persist resolution + fps + audio. The duck flag is force-cleared
|
||||||
// native loopback addon excludes the app's own audio at OS level
|
// because the native loopback addon now excludes the app's own audio
|
||||||
// now, so JS-side ducking (which muted the user's incoming peer
|
// at OS level; JS-side ducking (which also muted incoming peer audio)
|
||||||
// audio) is no longer needed and was causing "I can't hear anyone".
|
// was causing "I can't hear anyone" on earlier builds.
|
||||||
updateScreenShareSettings({
|
updateScreenShareSettings({
|
||||||
preset,
|
preset,
|
||||||
|
framerateOverride: fps,
|
||||||
includeSystemAudio: audio,
|
includeSystemAudio: audio,
|
||||||
duckRemoteAudioWhileSharing: false,
|
duckRemoteAudioWhileSharing: false,
|
||||||
});
|
});
|
||||||
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
|||||||
await startScreenShare({
|
await startScreenShare({
|
||||||
preset,
|
preset,
|
||||||
displaySurface: tab === 'screen' ? 'monitor' : 'window',
|
displaySurface: tab === 'screen' ? 'monitor' : 'window',
|
||||||
framerate: null,
|
framerate: fps,
|
||||||
// Forward the picked source id so the native loopback path can
|
// Forward the picked source id so the native loopback path can
|
||||||
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
|
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
|
||||||
// (parses HWND from `window:<HWND>:0`). For screen-shares this
|
// (parses HWND from `window:<HWND>:0`). For screen-shares this
|
||||||
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
|||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
Qualität
|
Auflösung
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{QUALITY_PILLS.map((q) => {
|
{RES_PILLS.map((q) => {
|
||||||
const active = preset === q.id;
|
const active = res === q.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={q.id}
|
key={q.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreset(q.id)}
|
onClick={() => setRes(q.id)}
|
||||||
|
className={
|
||||||
|
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
||||||
|
(active
|
||||||
|
? 'border-accent bg-accent/10 text-fg'
|
||||||
|
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
FPS
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{FPS_PILLS.map((q) => {
|
||||||
|
const active = fps === q.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={q.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFps(q.id)}
|
||||||
className={
|
className={
|
||||||
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
||||||
(active
|
(active
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
|
|||||||
share: RemoteScreenShare;
|
share: RemoteScreenShare;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Suppress the in-share fullscreen toggle button. Used inside cinema
|
||||||
|
* mode where (a) the window is already OS-fullscreen and (b) the
|
||||||
|
* toggle collides visually with FullscreenCall's strip-hidden button
|
||||||
|
* at the same top-right corner. */
|
||||||
|
hideFullscreenToggle?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||||
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
|
|||||||
share,
|
share,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
displayName,
|
displayName,
|
||||||
|
hideFullscreenToggle = false,
|
||||||
}: ScreenShareViewerProps) {
|
}: ScreenShareViewerProps) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
|
|||||||
defaultValue: displayName + ' teilt den Bildschirm',
|
defaultValue: displayName + ' teilt den Bildschirm',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{watching && (
|
{watching && !hideFullscreenToggle && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => watchShare(share.participantId)}
|
onClick={() => watchShare(share.participantId)}
|
||||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||||
style={{ aspectRatio: '16 / 9' }}
|
|
||||||
>
|
>
|
||||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { supabase } from './supabase';
|
|||||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||||
// no other participant device was online to share the key with them.
|
// no other participant device was online to share the key with them.
|
||||||
|
|
||||||
interface SyncCtx {
|
export interface SyncCtx {
|
||||||
myUserId: string;
|
myUserId: string;
|
||||||
myDeviceId: string;
|
myDeviceId: string;
|
||||||
priv: Uint8Array;
|
priv: Uint8Array;
|
||||||
@@ -32,46 +32,55 @@ export function startConversationKeySync(
|
|||||||
ownUserId: string,
|
ownUserId: string,
|
||||||
ownDeviceId: string,
|
ownDeviceId: string,
|
||||||
): () => void {
|
): () => void {
|
||||||
let cancelled = false;
|
// DISABLED: auto-share of conversation keys to newly-registered devices
|
||||||
let priv: Uint8Array | null = null;
|
// is gone. Without it, account takeover (stolen password / new device
|
||||||
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
// registered by attacker) no longer automatically grants history access
|
||||||
|
// — an attacker would have a working device-key but no conv-key wraps.
|
||||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
//
|
||||||
if (cancelled) return;
|
// History access paths still supported:
|
||||||
priv = pk;
|
// 1. Backup-Restore — restores the OLD device-id + privkey, so the
|
||||||
if (!priv) return;
|
// server-side wraps for that device-id are accessible as before.
|
||||||
if (backfilledKey.has(dedupeKey)) return;
|
// 2. (Planned) Approval flow — existing device or conversation peer
|
||||||
backfilledKey.add(dedupeKey);
|
// explicitly approves a new device, then conv-keys are wrapped
|
||||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
// for it. Until that ships, fresh-login-without-backup means old
|
||||||
});
|
// conversations stay encrypted.
|
||||||
|
//
|
||||||
const channel = supabase
|
// For NEW conversations: the key is generated at conv-creation time
|
||||||
.channel('device-key-sync:' + ownDeviceId)
|
// and includes all current devices of all members, so a freshly-logged-
|
||||||
.on(
|
// in device CAN still participate in newly-created conversations. It
|
||||||
'postgres_changes',
|
// just can't read the back-history of conversations it wasn't a member
|
||||||
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
// of when those messages were sealed.
|
||||||
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
//
|
||||||
if (cancelled) return;
|
// We deliberately keep the helper functions below (syncAllExistingGaps,
|
||||||
const row = payload.new;
|
// wrapForOneDevice, …) intact so the upcoming approval flow can wire
|
||||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
// them to user-driven triggers without rebuilding from scratch.
|
||||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
void ownUserId;
|
||||||
if (!priv) return; // backfill on mount will catch it later
|
void ownDeviceId;
|
||||||
void wrapForOneDevice(
|
void backfilledKey;
|
||||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
void loadDevicePrivateKey;
|
||||||
row.id,
|
void devLocalSecretStore;
|
||||||
row.user_id,
|
void supabase;
|
||||||
row.public_key,
|
return () => {};
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
void supabase.removeChannel(channel);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep helpers alive across the auto-sync hibernation window so the
|
||||||
|
// upcoming approval flow can re-wire them. Without this no-op reference
|
||||||
|
// `tsc --noEmit` flags them as unused (TS6133).
|
||||||
|
//
|
||||||
|
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||||
|
// the device-approval module — once the user explicitly approves a new
|
||||||
|
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||||
|
// that specific deviceId.
|
||||||
|
void (() => {
|
||||||
|
void listMyConversationIds;
|
||||||
|
void listConversationDevices;
|
||||||
|
void listExistingKeyRecipients;
|
||||||
|
void getActiveKeyVersion;
|
||||||
|
void syncAllExistingGaps;
|
||||||
|
void isExpectedShareFailure;
|
||||||
|
void rawFrom;
|
||||||
|
});
|
||||||
|
|
||||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
@@ -149,7 +158,7 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||||
const version = await getActiveKeyVersion(convId);
|
const version = await getActiveKeyVersion(convId);
|
||||||
const devices = await listConversationDevices(convId);
|
const devices = await listConversationDevices(convId);
|
||||||
if (devices.length === 0) return;
|
if (devices.length === 0) return;
|
||||||
@@ -200,7 +209,7 @@ function isExpectedShareFailure(err: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wrapForOneDevice(
|
export async function wrapForOneDevice(
|
||||||
ctx: SyncCtx,
|
ctx: SyncCtx,
|
||||||
newDeviceId: string,
|
newDeviceId: string,
|
||||||
newDeviceUserId: string,
|
newDeviceUserId: string,
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||||
|
|
||||||
|
import { type SyncCtx, wrapForOneDevice } from './conversationKeySync';
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
// Device-approval flow (Phase 1).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// When the user registers a brand-new device on top of an existing one, the
|
||||||
|
// existing device must explicitly approve it before any conv-key wraps are
|
||||||
|
// created. This module:
|
||||||
|
//
|
||||||
|
// 1. On startup, fetches every device row owned by the user and surfaces
|
||||||
|
// the ones that aren't this device, aren't already approved, and aren't
|
||||||
|
// dismissed. Covers the "I was offline when the new device registered"
|
||||||
|
// case.
|
||||||
|
// 2. Subscribes to realtime INSERTs on `devices` for the user's id, so a
|
||||||
|
// device that registers WHILE this client is online raises a banner
|
||||||
|
// immediately.
|
||||||
|
// 3. Persists approve/deny decisions in localStorage so a reload doesn't
|
||||||
|
// ask again for a device the user already answered for.
|
||||||
|
//
|
||||||
|
// Approval call: re-uses `wrapForOneDevice` from conversationKeySync — that
|
||||||
|
// helper already walks every shared conversation and writes the key bundle
|
||||||
|
// for the target device.
|
||||||
|
|
||||||
|
export interface PendingApproval {
|
||||||
|
deviceId: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
platform: DevicePlatform | string;
|
||||||
|
createdAt: string;
|
||||||
|
publicKey: string; // pg-hex-encoded bytea
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceApprovalListenerCtx {
|
||||||
|
ownUserId: string;
|
||||||
|
ownDeviceId: string;
|
||||||
|
// Lazy getter so we never hold the privkey in memory for longer than the
|
||||||
|
// approve action that needs it. Returns null if the key isn't loadable
|
||||||
|
// (e.g. fresh-restored device that hasn't unsealed yet).
|
||||||
|
getPriv: () => Promise<Uint8Array | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVED_KEY = 'chatapp.approvedDeviceIds';
|
||||||
|
const DISMISSED_KEY = 'chatapp.dismissedDeviceIds';
|
||||||
|
|
||||||
|
// In-process state. Module-level so the banner component and the listener
|
||||||
|
// share one source of truth without prop-drilling through context.
|
||||||
|
let pending: PendingApproval[] = [];
|
||||||
|
const subscribers = new Set<(list: PendingApproval[]) => void>();
|
||||||
|
let listenerCtx: DeviceApprovalListenerCtx | null = null;
|
||||||
|
|
||||||
|
function readIdSet(key: string): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) return new Set();
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIdSet(key: string, set: Set<string>): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify([...set]));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — non-fatal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistApproved(deviceId: string): void {
|
||||||
|
const s = readIdSet(APPROVED_KEY);
|
||||||
|
s.add(deviceId);
|
||||||
|
writeIdSet(APPROVED_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDismissed(deviceId: string): void {
|
||||||
|
const s = readIdSet(DISMISSED_KEY);
|
||||||
|
s.add(deviceId);
|
||||||
|
writeIdSet(DISMISSED_KEY, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
const snapshot = [...pending];
|
||||||
|
for (const cb of subscribers) {
|
||||||
|
try {
|
||||||
|
cb(snapshot);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: subscriber threw', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingApprovals(): PendingApproval[] {
|
||||||
|
return [...pending];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribePendingApprovals(
|
||||||
|
cb: (list: PendingApproval[]) => void,
|
||||||
|
): () => void {
|
||||||
|
subscribers.add(cb);
|
||||||
|
// Fire once with current state so the consumer can initialise without
|
||||||
|
// waiting for the next change.
|
||||||
|
try {
|
||||||
|
cb([...pending]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: initial subscriber call threw', err);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSurface(deviceId: string, ownDeviceId: string): boolean {
|
||||||
|
if (deviceId === ownDeviceId) return false;
|
||||||
|
const approved = readIdSet(APPROVED_KEY);
|
||||||
|
if (approved.has(deviceId)) return false;
|
||||||
|
const dismissed = readIdSet(DISMISSED_KEY);
|
||||||
|
if (dismissed.has(deviceId)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceRowLite {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
platform: DevicePlatform | string;
|
||||||
|
created_at: string;
|
||||||
|
public_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToPending(row: DeviceRowLite): PendingApproval {
|
||||||
|
return {
|
||||||
|
deviceId: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
platform: row.platform,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPending(req: PendingApproval): void {
|
||||||
|
if (pending.some((p) => p.deviceId === req.deviceId)) return;
|
||||||
|
pending = [...pending, req];
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePending(deviceId: string): void {
|
||||||
|
const next = pending.filter((p) => p.deviceId !== deviceId);
|
||||||
|
if (next.length === pending.length) return;
|
||||||
|
pending = next;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitialPending(ctx: DeviceApprovalListenerCtx): Promise<void> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('devices')
|
||||||
|
.select('id, user_id, name, platform, created_at, public_key')
|
||||||
|
.eq('user_id', ctx.ownUserId);
|
||||||
|
if (error) {
|
||||||
|
console.warn('deviceApproval: initial devices lookup failed', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = (data ?? []) as DeviceRowLite[];
|
||||||
|
|
||||||
|
// Pre-filter: anything already in approved/dismissed sets, plus own
|
||||||
|
// device, never surfaces.
|
||||||
|
const candidates = rows.filter((r) => shouldSurface(r.id, ctx.ownDeviceId));
|
||||||
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
// Semantic skip: any candidate that already has a `conversation_keys`
|
||||||
|
// wrap somewhere is by definition legit — it was either auto-shared
|
||||||
|
// back when that path was enabled (pre-0.16.2) or explicitly approved
|
||||||
|
// earlier. Surfacing it now would just nag the user about something
|
||||||
|
// already taken care of. Bulk query against `conversation_keys` for
|
||||||
|
// all candidate device IDs at once; cheap, avoids N+1.
|
||||||
|
const candidateIds = candidates.map((c) => c.id);
|
||||||
|
const wrappedIds = new Set<string>();
|
||||||
|
try {
|
||||||
|
const { data: keyRows, error: kErr } = await (
|
||||||
|
supabase as unknown as {
|
||||||
|
from: (t: string) => {
|
||||||
|
select: (cols: string) => {
|
||||||
|
in: (
|
||||||
|
col: string,
|
||||||
|
vals: string[],
|
||||||
|
) => Promise<{ data: { recipient_device_id: string }[] | null; error: unknown }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.from('conversation_keys')
|
||||||
|
.select('recipient_device_id')
|
||||||
|
.in('recipient_device_id', candidateIds);
|
||||||
|
if (!kErr && keyRows) {
|
||||||
|
for (const r of keyRows) wrappedIds.add(r.recipient_device_id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('deviceApproval: conv-key existence probe failed', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense in depth: also keep the created_at guard. Devices older than
|
||||||
|
// own can't reasonably need approval — when own came online a fanout
|
||||||
|
// pass already covered them. Helps when the conv_keys probe returns
|
||||||
|
// partial data due to RLS.
|
||||||
|
const own = rows.find((r) => r.id === ctx.ownDeviceId);
|
||||||
|
const ownCreatedAt = own ? Date.parse(own.created_at) : Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const row of candidates) {
|
||||||
|
if (wrappedIds.has(row.id)) {
|
||||||
|
persistApproved(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rowCreatedAt = Date.parse(row.created_at);
|
||||||
|
if (Number.isFinite(rowCreatedAt) && rowCreatedAt <= ownCreatedAt) {
|
||||||
|
persistApproved(row.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
upsertPending(rowToPending(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts the approval listener for the given user/device. Returns an
|
||||||
|
// unsubscribe function — call it on shell unmount to tear down the realtime
|
||||||
|
// channel and clear in-memory state.
|
||||||
|
export function startDeviceApprovalListener(
|
||||||
|
ctx: DeviceApprovalListenerCtx,
|
||||||
|
): () => void {
|
||||||
|
listenerCtx = ctx;
|
||||||
|
|
||||||
|
void loadInitialPending(ctx);
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`device-approval:${ctx.ownUserId}`)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'INSERT',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'devices',
|
||||||
|
filter: `user_id=eq.${ctx.ownUserId}`,
|
||||||
|
},
|
||||||
|
(payload: { new: Record<string, unknown> }) => {
|
||||||
|
const row = payload.new as unknown as DeviceRowLite;
|
||||||
|
if (!row?.id) return;
|
||||||
|
if (!shouldSurface(row.id, ctx.ownDeviceId)) return;
|
||||||
|
upsertPending(rowToPending(row));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void supabase.removeChannel(channel).catch(() => {
|
||||||
|
/* ignore — channel might already be gone */
|
||||||
|
});
|
||||||
|
pending = [];
|
||||||
|
listenerCtx = null;
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approves a pending device: walks every conversation the current user is
|
||||||
|
// in and writes a conv-key bundle for the new device. On success the request
|
||||||
|
// is removed from the pending list and the deviceId is persisted in
|
||||||
|
// localStorage so a reload doesn't re-prompt.
|
||||||
|
export async function approveDevice(req: PendingApproval): Promise<void> {
|
||||||
|
const ctx = listenerCtx;
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('deviceApproval: listener not started');
|
||||||
|
}
|
||||||
|
if (req.userId !== ctx.ownUserId) {
|
||||||
|
// Phase 1 only handles same-user approvals (own new device). Friend-side
|
||||||
|
// approval is a later phase.
|
||||||
|
throw new Error('deviceApproval: cross-user approval not supported yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const priv = await ctx.getPriv();
|
||||||
|
if (!priv) {
|
||||||
|
throw new Error('deviceApproval: own private key unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync: SyncCtx = {
|
||||||
|
myUserId: ctx.ownUserId,
|
||||||
|
myDeviceId: ctx.ownDeviceId,
|
||||||
|
priv,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wrapForOneDevice(sync, req.deviceId, req.userId, req.publicKey);
|
||||||
|
} finally {
|
||||||
|
// Wipe the priv copy we asked for. The original lives in the secret
|
||||||
|
// store; this is the transient working copy.
|
||||||
|
for (let i = 0; i < priv.length; i++) priv[i] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistApproved(req.deviceId);
|
||||||
|
removePending(req.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Denies a pending device: just remembers the deviceId in the dismissed-set
|
||||||
|
// and removes the request. No server-side change — the new device simply
|
||||||
|
// stays without any conv-key wraps until the user changes their mind (e.g.
|
||||||
|
// from a settings screen later).
|
||||||
|
export function denyDevice(deviceId: string): void {
|
||||||
|
persistDismissed(deviceId);
|
||||||
|
removePending(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk-deny: persists every currently-pending deviceId into the dismissed
|
||||||
|
// set and clears the in-memory list in one notify. Useful when a user has
|
||||||
|
// accumulated stale entries from old test devices / migrations.
|
||||||
|
export function denyAllPending(): void {
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
const dismissed = readIdSet(DISMISSED_KEY);
|
||||||
|
for (const p of pending) dismissed.add(p.deviceId);
|
||||||
|
writeIdSet(DISMISSED_KEY, dismissed);
|
||||||
|
pending = [];
|
||||||
|
notify();
|
||||||
|
}
|
||||||
@@ -1,14 +1,63 @@
|
|||||||
import { isTauriRuntime } from './globalShortcut';
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
// Pushes the current aggregate unread count to main, which updates the
|
// Pushes the current aggregate unread count to main, which updates the
|
||||||
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
|
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
|
||||||
// outside the Electron runtime (e.g. browser dev preview) so no guards
|
// badge here in the renderer because main has no Canvas2D — painting a
|
||||||
// needed at call-sites.
|
// red bubble with the count, encoding to PNG, and handing the buffer
|
||||||
|
// to main keeps the implementation free of a native canvas dependency.
|
||||||
|
//
|
||||||
|
// No-op outside the Electron runtime (e.g. browser dev preview) so no
|
||||||
|
// guards needed at call-sites.
|
||||||
export async function updateTrayUnread(count: number): Promise<void> {
|
export async function updateTrayUnread(count: number): Promise<void> {
|
||||||
if (!isTauriRuntime()) return;
|
if (!isTauriRuntime()) return;
|
||||||
|
const n = Math.max(0, Math.floor(count));
|
||||||
|
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
|
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('updateTrayUnread failed', err);
|
console.warn('updateTrayUnread failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discord-style red bubble with white count.
|
||||||
|
//
|
||||||
|
// Source is rendered at 64×64 so Windows' high-quality downsample to the
|
||||||
|
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
|
||||||
|
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
|
||||||
|
// pixel at the target size, and the bigger font fell into AA mush. Now
|
||||||
|
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
|
||||||
|
function renderBadgePng(count: number): string | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
const size = 64;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
// Solid red bubble, full bleed.
|
||||||
|
const center = size / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(center, center, center, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
|
||||||
|
// Sizes are tuned per glyph count so each variant fills the bubble
|
||||||
|
// without clipping when Windows downsamples to 16×16.
|
||||||
|
const label = count > 99 ? '99+' : String(count);
|
||||||
|
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
// Segoe UI is the Windows system font; explicit weight 800 keeps the
|
||||||
|
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
|
||||||
|
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
// +2 vertical nudge: most system fonts render numerals visually high
|
||||||
|
// relative to the baseline mid-point; the offset re-centres them.
|
||||||
|
ctx.fillText(label, center, center + 2);
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
+29
-1
@@ -89,7 +89,12 @@ execSync('pnpm --filter @chat-app/desktop run build:win', {
|
|||||||
// --- Locate artifacts -----------------------------------------------------
|
// --- Locate artifacts -----------------------------------------------------
|
||||||
|
|
||||||
const releaseDir = join(ROOT, 'apps/desktop/release');
|
const releaseDir = join(ROOT, 'apps/desktop/release');
|
||||||
const exeName = `ChatApp Setup ${versionArg}.exe`;
|
// productName lives in the electron-builder block of the desktop package.
|
||||||
|
// Read it back from disk (post-bump) so installer filenames stay in sync
|
||||||
|
// after a rebrand (e.g. ChatApp → Netralax) without manual script edits.
|
||||||
|
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
|
||||||
|
const productName = pkgJson?.build?.productName ?? 'ChatApp';
|
||||||
|
const exeName = `${productName} Setup ${versionArg}.exe`;
|
||||||
const blockmapName = `${exeName}.blockmap`;
|
const blockmapName = `${exeName}.blockmap`;
|
||||||
const latestYml = 'latest.yml';
|
const latestYml = 'latest.yml';
|
||||||
|
|
||||||
@@ -104,6 +109,29 @@ for (const p of [exePath, blockmapPath, latestYmlPath]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Inject releaseNotes into latest.yml ----------------------------------
|
||||||
|
//
|
||||||
|
// electron-builder doesn't write the CLI-supplied notes into the
|
||||||
|
// manifest by default — clients then see an empty body in the
|
||||||
|
// UpdateToast. Patch the YAML in place: append a block scalar
|
||||||
|
// (`releaseNotes: |-`) so multi-line content survives intact.
|
||||||
|
// Idempotent — skip if a `releaseNotes:` entry is already present
|
||||||
|
// (covers reruns / hand-edited manifests).
|
||||||
|
{
|
||||||
|
let yml = readFileSync(latestYmlPath, 'utf8');
|
||||||
|
if (!/^releaseNotes:/m.test(yml)) {
|
||||||
|
const indented = notes
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => ' ' + l)
|
||||||
|
.join('\n');
|
||||||
|
yml = yml.replace(/\s*$/, '') + `\nreleaseNotes: |-\n${indented}\n`;
|
||||||
|
writeFileSync(latestYmlPath, yml, 'utf8');
|
||||||
|
console.log('Injected releaseNotes into latest.yml');
|
||||||
|
} else {
|
||||||
|
console.log('latest.yml already has releaseNotes — skipping injection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- scp helpers ----------------------------------------------------------
|
// --- scp helpers ----------------------------------------------------------
|
||||||
|
|
||||||
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
|
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user