feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
@@ -31,6 +31,9 @@ const FriendsPage = lazy(() =>
|
||||
const SettingsPage = lazy(() =>
|
||||
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||
);
|
||||
const ChangelogPage = lazy(() =>
|
||||
import('./pages/ChangelogPage').then((m) => ({ default: m.ChangelogPage })),
|
||||
);
|
||||
|
||||
function RouteSuspense({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -64,7 +67,7 @@ export function App() {
|
||||
<FriendshipsProvider>
|
||||
<ConversationsProvider>
|
||||
<CallProvider>
|
||||
<BrowserRouter
|
||||
<HashRouter
|
||||
future={{
|
||||
// Opt into v7 behaviour early so the upgrade is a no-op:
|
||||
// - `v7_startTransition` wraps navigations in startTransition
|
||||
@@ -136,6 +139,16 @@ export function App() {
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RouteBoundary scope="changelog" />}>
|
||||
<Route
|
||||
path="/changelog"
|
||||
element={
|
||||
<RouteSuspense>
|
||||
<ChangelogPage />
|
||||
</RouteSuspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route element={<RouteBoundary scope="admin" />}>
|
||||
<Route
|
||||
@@ -155,7 +168,7 @@ export function App() {
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
<CrashToast />
|
||||
</BrowserRouter>
|
||||
</HashRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
</FriendshipsProvider>
|
||||
|
||||
@@ -38,15 +38,13 @@ export function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
// Subtle ambient background — only renders in dark mode where the app's
|
||||
// visual language expects depth. Light mode stays clean and flat.
|
||||
// Subtle desktop texture for dark mode. The panels carry the depth; the
|
||||
// background stays quiet so chat content remains the focus.
|
||||
function ShellBackground() {
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
|
||||
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
|
||||
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
|
||||
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.6)_100%)]" />
|
||||
<div className="bg-grid absolute inset-0 opacity-[0.08]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(30,31,34,0.94),rgba(17,18,20,0.98))]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useCall } from '../context/CallContext';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const STALE_AFTER_MS = 5000;
|
||||
|
||||
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
|
||||
* call surface; renders the most recent caption per participant, fading
|
||||
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
|
||||
* too so the speaker can sanity-check what's being broadcast. */
|
||||
export function CallCaptionsOverlay({ conversation }: Props) {
|
||||
const { captions } = useCall();
|
||||
// Re-render every second so stale entries fade without needing the data
|
||||
// channel to fire — captions module just stores timestamps.
|
||||
const [, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const now = Date.now();
|
||||
const visible = Object.entries(captions)
|
||||
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
|
||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
|
||||
{visible.map(([identity, c]) => {
|
||||
const member = conversation.members.find((m) => m.userId === identity);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
const age = now - c.timestamp;
|
||||
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
|
||||
return (
|
||||
<div
|
||||
key={identity}
|
||||
style={{ opacity: Math.max(0, opacity) }}
|
||||
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
|
||||
>
|
||||
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
|
||||
{name}
|
||||
</span>
|
||||
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
CaptionsIcon,
|
||||
HeadphonesIcon,
|
||||
HeadphonesOffIcon,
|
||||
MicIcon,
|
||||
@@ -31,6 +32,10 @@ interface Props {
|
||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||
onToggleSoundboard?: () => void;
|
||||
soundboardOpen?: boolean;
|
||||
/** Discord-style live-captions toggle. Optional — pages that don't support
|
||||
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
|
||||
onToggleCaptions?: () => void;
|
||||
captionsOn?: boolean;
|
||||
participantsOpen?: boolean;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
@@ -54,6 +59,8 @@ export function CallControls({
|
||||
onOpenParticipants,
|
||||
onToggleSoundboard,
|
||||
soundboardOpen = false,
|
||||
onToggleCaptions,
|
||||
captionsOn = false,
|
||||
participantsOpen = false,
|
||||
compact = false,
|
||||
glass = false,
|
||||
@@ -141,6 +148,22 @@ export function CallControls({
|
||||
<MusicIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onToggleCaptions && (
|
||||
<CallButton
|
||||
label={
|
||||
captionsOn
|
||||
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
|
||||
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
|
||||
}
|
||||
active={captionsOn}
|
||||
activeTone="accent"
|
||||
onClick={onToggleCaptions}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<CaptionsIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||
import {
|
||||
CrownIcon,
|
||||
HeadphonesOffIcon,
|
||||
LockIcon,
|
||||
MicOffIcon,
|
||||
PinIcon,
|
||||
WifiLowIcon,
|
||||
WifiOffIcon,
|
||||
} from './icons';
|
||||
|
||||
/** Discord-style connection-quality badge state. Matches LiveKit's
|
||||
* ConnectionQuality enum values so consumers can pass the value through
|
||||
* without conversion. `unknown` and `excellent`/`good` render nothing — the
|
||||
* badge only surfaces when the user actually has degraded media. */
|
||||
export type TileConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
|
||||
|
||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||
|
||||
@@ -43,16 +57,29 @@ export interface ParticipantTileProps {
|
||||
avatarUrl: string | null;
|
||||
me: boolean;
|
||||
muted: boolean;
|
||||
/** Local-only: true when THIS user has muted everyone else via the deafen
|
||||
* toggle. Remote deafen state is not propagated, so only the own tile
|
||||
* ever carries a truthy value. */
|
||||
/** True when the participant has deafened. Propagated from peers via the
|
||||
* LiveKit data channel, so remote tiles surface the headphones-off badge
|
||||
* the same way the local tile does. */
|
||||
deafened: boolean;
|
||||
/** Discord-style: true if this participant is the call host (initiator).
|
||||
* Drives the crown badge. Hidden in 1:1 calls — set false there. */
|
||||
isHost?: boolean;
|
||||
/** True iff the user has pinned this tile via right-click → "Anpinnen".
|
||||
* Renders a small pin badge top-left and locks the focus mode to this
|
||||
* tile regardless of who is currently speaking. */
|
||||
pinned?: boolean;
|
||||
/** Discord-style "LIVE" pill — true iff this participant is currently
|
||||
* publishing a screen share. Shown on the user tile so observers know
|
||||
* the share-tile next to it belongs to this person. */
|
||||
streaming?: boolean;
|
||||
speaking: boolean;
|
||||
video: boolean;
|
||||
e2ee: boolean;
|
||||
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||
* true. When null the tile falls back to the avatar placeholder. */
|
||||
videoTrack?: MediaStreamTrack | null;
|
||||
/** SFU-measured connection quality. Only `poor` and `lost` show a badge. */
|
||||
connectionQuality?: TileConnectionQuality;
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -68,6 +95,10 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
speaking,
|
||||
video,
|
||||
e2ee,
|
||||
connectionQuality,
|
||||
isHost = false,
|
||||
pinned = false,
|
||||
streaming = false,
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
@@ -75,14 +106,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
// Split the speaking indicator per-mode so we don't stack a tile border
|
||||
// + inset glow on top of the avatar pulse (visual double-chrome). Video
|
||||
// tiles get the border (the avatar is hidden behind the stream so the
|
||||
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
|
||||
// pulse rendered inside AudioContent.
|
||||
const videoSpeaking = speaking && video;
|
||||
const borderClass = videoSpeaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||
// Discord-style: full tile border switches to emerald the whole time the
|
||||
// user is speaking. Same treatment for audio + video tiles so the visual
|
||||
// is consistent regardless of camera state. The inner-glow span on top of
|
||||
// it adds a subtle inset highlight that reads even when the underlying
|
||||
// tile is bright (e.g. a video stream).
|
||||
const borderClass = speaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.35)] dark:border-emerald-400'
|
||||
: focused
|
||||
? 'border-accent'
|
||||
: 'border-line hover:border-accent';
|
||||
@@ -92,7 +122,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
|
||||
(onClick ? 'cursor-pointer ' : '') +
|
||||
borderClass +
|
||||
(small ? ' min-w-[140px]' : '')
|
||||
@@ -104,19 +134,55 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
|
||||
{/* Video-only speaking indicator. Audio tiles use the avatar pulse
|
||||
from AudioContent so we don't double-render chrome. z-10 keeps
|
||||
it above the video element. */}
|
||||
{videoSpeaking && (
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-[12px] shadow-[inset_0_0_18px_rgba(34,197,94,0.45)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||
<ConnectionQualityBadge quality={connectionQuality} small={small} />
|
||||
|
||||
{pinned && (
|
||||
<span
|
||||
aria-label="Angepinnt"
|
||||
title="Angepinnt"
|
||||
className={
|
||||
'pointer-events-none absolute left-2 top-2 z-20 flex items-center justify-center rounded-md bg-accent text-accent-fg ' +
|
||||
(small ? 'h-5 w-5' : 'h-6 w-6')
|
||||
}
|
||||
>
|
||||
<PinIcon className={small ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{streaming && (
|
||||
<span
|
||||
aria-label="Streamt gerade"
|
||||
title="Streamt gerade"
|
||||
className={
|
||||
'pointer-events-none absolute z-20 flex items-center gap-1 rounded-md bg-rose-600 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white shadow ' +
|
||||
(pinned ? 'left-9 top-2' : 'left-2 top-2')
|
||||
}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white motion-safe:animate-live-dot" />
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Reserve enough vertical space so the chip's height stays constant
|
||||
whether or not the muted/deafened badges are present. Without
|
||||
`min-h-5` on the right slot the chip grows by ~4px the moment a
|
||||
badge appears, which makes the dock visibly jiggle when somebody
|
||||
mutes mid-call. */}
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex min-h-[32px] items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||
{isHost && (
|
||||
<CrownIcon
|
||||
aria-label="Anrufgründer"
|
||||
className="h-3 w-3 shrink-0 text-amber-300"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{me ? ' (du)' : ''}
|
||||
@@ -131,7 +197,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex min-h-5 shrink-0 items-center gap-1">
|
||||
{muted && (
|
||||
<span
|
||||
aria-label="Mikro stumm"
|
||||
@@ -156,11 +222,45 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Discord-style: hidden when excellent/good/unknown, amber when poor,
|
||||
* red with a struck-through Wifi when lost. Anchored top-right above the
|
||||
* speaking-glow so it stays readable on a bright video stream. */
|
||||
function ConnectionQualityBadge({
|
||||
quality,
|
||||
small,
|
||||
}: {
|
||||
quality: TileConnectionQuality | undefined;
|
||||
small: boolean;
|
||||
}) {
|
||||
if (!quality || quality === 'excellent' || quality === 'good' || quality === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
const isLost = quality === 'lost';
|
||||
const Icon = isLost ? WifiOffIcon : WifiLowIcon;
|
||||
const label = isLost ? 'Verbindung verloren' : 'Schlechte Verbindung';
|
||||
const tone = isLost
|
||||
? 'bg-rose-500/85 text-white'
|
||||
: 'bg-amber-400/90 text-amber-950 dark:text-amber-950';
|
||||
const sz = small ? 'h-5 w-5' : 'h-6 w-6';
|
||||
const iconSz = small ? 'h-3 w-3' : 'h-3.5 w-3.5';
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'pointer-events-none absolute right-2 top-2 z-20 flex items-center justify-center rounded-md ' +
|
||||
sz + ' ' + tone
|
||||
}
|
||||
>
|
||||
<Icon className={iconSz} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioContent({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
speaking,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
const key = colorKeyFor(userId);
|
||||
@@ -174,12 +274,6 @@ function AudioContent({
|
||||
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
||||
}
|
||||
>
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||
/>
|
||||
)}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
getAudioSettings,
|
||||
subscribeAudioSettings,
|
||||
updateAudioSettings,
|
||||
} from '../lib/audioSettings';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { MicIcon, VideoIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
/** Title shown at the top — e.g. peer's display name. */
|
||||
title: string;
|
||||
/** Called when the user confirms — Modal closes itself first. */
|
||||
onJoin: () => void;
|
||||
/** Called when the user cancels or hits Esc. Modal closes itself first. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord-style "before-you-join" preview for video calls. Shows the local
|
||||
* camera stream + mic/camera device selectors so the user sees what their
|
||||
* peers will see (and can fix backlight, etc.) before the call actually
|
||||
* starts. Resources are released on unmount so we never leak getUserMedia
|
||||
* tracks if the user backs out.
|
||||
*/
|
||||
export function CallPreviewModal({ title, onJoin, onCancel }: Props) {
|
||||
const { setVideoInputDevice, setAudioInputDevice } = useCall();
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
||||
const [mics, setMics] = useState<MediaDeviceInfo[]>([]);
|
||||
const [cameraId, setCameraId] = useState<string | null>(
|
||||
() => getAudioSettings().videoInputDeviceId,
|
||||
);
|
||||
const [micId, setMicId] = useState<string | null>(
|
||||
() => getAudioSettings().inputDeviceId,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Subscribe to settings so device-changes from another window propagate.
|
||||
useEffect(() => {
|
||||
return subscribeAudioSettings((s) => {
|
||||
setCameraId(s.videoInputDeviceId);
|
||||
setMicId(s.inputDeviceId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Acquire / re-acquire the preview stream when devices change. Each
|
||||
// re-acquire stops the previous stream so the camera light goes off
|
||||
// between switches and the new device actually starts streaming.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const stopCurrent = () => {
|
||||
const cur = streamRef.current;
|
||||
if (cur) {
|
||||
cur.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
const acquire = async () => {
|
||||
stopCurrent();
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: cameraId ? { deviceId: { exact: cameraId } } : true,
|
||||
audio: micId ? { deviceId: { exact: micId } } : true,
|
||||
});
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) videoRef.current.srcObject = stream;
|
||||
// After the first successful getUserMedia, enumerateDevices returns
|
||||
// labels — refresh so the dropdowns show real device names.
|
||||
try {
|
||||
const list = await navigator.mediaDevices.enumerateDevices();
|
||||
if (cancelled) return;
|
||||
setCameras(list.filter((d) => d.kind === 'videoinput'));
|
||||
setMics(list.filter((d) => d.kind === 'audioinput'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Kamera nicht verfügbar');
|
||||
}
|
||||
};
|
||||
void acquire();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopCurrent();
|
||||
};
|
||||
}, [cameraId, micId]);
|
||||
|
||||
// Esc cancels.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleJoin = () => {
|
||||
// Stop the preview stream BEFORE joining — LiveKit will start its own
|
||||
// capture, and on some devices the camera can't be opened twice.
|
||||
const cur = streamRef.current;
|
||||
if (cur) {
|
||||
cur.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
onJoin();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm motion-safe:animate-fade-in">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={'Vorschau für ' + title}
|
||||
className="relative w-full max-w-[720px] motion-safe:animate-slide-up overflow-hidden rounded-2xl border border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
Bereit für den Anruf?
|
||||
</p>
|
||||
<h2 className="mt-0.5 truncate font-display text-lg font-bold text-fg">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-black">
|
||||
{error ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-6 text-center text-sm text-rose-400">
|
||||
<p className="font-semibold">Kein Kamerabild</p>
|
||||
<p className="text-xs text-fg-muted">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full scale-x-[-1] object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
<VideoIcon className="h-3.5 w-3.5" />
|
||||
Kamera
|
||||
</label>
|
||||
<select
|
||||
value={cameraId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value === '' ? null : e.target.value;
|
||||
setCameraId(id);
|
||||
updateAudioSettings({ videoInputDeviceId: id });
|
||||
void setVideoInputDevice(id);
|
||||
}}
|
||||
className="mt-1 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
<option value="">Systemstandard</option>
|
||||
{cameras.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || 'Unbenannt'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
<MicIcon className="h-3.5 w-3.5" />
|
||||
Mikrofon
|
||||
</label>
|
||||
<select
|
||||
value={micId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value === '' ? null : e.target.value;
|
||||
setMicId(id);
|
||||
updateAudioSettings({ inputDeviceId: id });
|
||||
void setAudioInputDevice(id);
|
||||
}}
|
||||
className="mt-1 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
<option value="">Systemstandard</option>
|
||||
{mics.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || 'Unbenannt'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-line bg-surface-3/40 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer rounded-lg border border-line bg-transparent px-4 py-2 text-sm font-semibold text-fg-muted transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJoin}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
<VideoIcon className="h-4 w-4" />
|
||||
Mit Video beitreten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ConnectionQuality, type Room } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type ParticipantStats,
|
||||
makeSampleCache,
|
||||
sampleStats,
|
||||
} from '../lib/callStats';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
members: { userId: string; profile?: { displayName?: string | null } | null }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
|
||||
const QUALITY_LABEL: Record<ConnectionQuality, string> = {
|
||||
[ConnectionQuality.Excellent]: 'Sehr gut',
|
||||
[ConnectionQuality.Good]: 'Gut',
|
||||
[ConnectionQuality.Poor]: 'Schlecht',
|
||||
[ConnectionQuality.Lost]: 'Verloren',
|
||||
[ConnectionQuality.Unknown]: 'Unbekannt',
|
||||
};
|
||||
|
||||
const QUALITY_TONE: Record<ConnectionQuality, string> = {
|
||||
[ConnectionQuality.Excellent]: 'text-emerald-400',
|
||||
[ConnectionQuality.Good]: 'text-emerald-400',
|
||||
[ConnectionQuality.Poor]: 'text-amber-400',
|
||||
[ConnectionQuality.Lost]: 'text-rose-400',
|
||||
[ConnectionQuality.Unknown]: 'text-fg-muted',
|
||||
};
|
||||
|
||||
/**
|
||||
* Discord-style debug overlay (Ctrl+Shift+S). Polls WebRTC getStats() every
|
||||
* 1.5s and renders bitrate/loss/jitter/RTT per participant + audio + video.
|
||||
* Pin to corner; designed to stay readable on top of any video stream.
|
||||
*/
|
||||
export function CallStatsOverlay({ room, members, onClose }: Props) {
|
||||
const { connectionQualities } = useCall();
|
||||
const [stats, setStats] = useState<ParticipantStats[]>([]);
|
||||
const cacheRef = useRef(makeSampleCache());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const next = await sampleStats(room, cacheRef.current);
|
||||
if (!cancelled) setStats(next);
|
||||
} catch {
|
||||
/* ignore — getStats can throw mid-reconnect */
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => {
|
||||
void tick();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
const nameFor = (identity: string): string => {
|
||||
const m = members.find((mm) => mm.userId === identity);
|
||||
return m?.profile?.displayName ?? identity.slice(0, 8);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Call-Statistiken"
|
||||
className="fixed right-4 top-4 z-[110] w-[320px] overflow-hidden rounded-xl border border-line bg-black/85 text-[11px] text-white shadow-2xl backdrop-blur-md"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-white/10 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||
Debug
|
||||
</div>
|
||||
<div className="font-display text-sm font-bold">Call-Stats</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Stats schließen"
|
||||
className="cursor-pointer rounded-md p-1 text-white/70 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="max-h-[60vh] overflow-y-auto px-3 py-2 space-y-2 font-mono">
|
||||
{stats.map((p) => {
|
||||
const cq = connectionQualities[p.identity] ?? ConnectionQuality.Unknown;
|
||||
return (
|
||||
<div
|
||||
key={p.identity}
|
||||
className="rounded-md border border-white/10 bg-white/5 p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="truncate font-semibold">
|
||||
{nameFor(p.identity)}
|
||||
{p.isLocal && <span className="ml-1 text-white/50">(du)</span>}
|
||||
</span>
|
||||
<span className={'text-[10px] tabular-nums ' + QUALITY_TONE[cq]}>
|
||||
{QUALITY_LABEL[cq]}
|
||||
</span>
|
||||
</div>
|
||||
{p.isLocal ? (
|
||||
<Row
|
||||
label="Audio out"
|
||||
values={[fmtKbps(p.audio.audioOutKbps)]}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Audio in"
|
||||
values={[
|
||||
fmtKbps(p.audio.audioInKbps),
|
||||
fmtPct(p.audio.packetLossPct, 'loss'),
|
||||
fmtMs(p.audio.jitterMs, 'jit'),
|
||||
fmtMs(p.audio.rttMs, 'rtt'),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{(p.video.videoInKbps ?? p.video.videoOutKbps ?? 0) > 0 && (
|
||||
p.isLocal ? (
|
||||
<Row
|
||||
label="Video out"
|
||||
values={[fmtKbps(p.video.videoOutKbps)]}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Video in"
|
||||
values={[
|
||||
fmtKbps(p.video.videoInKbps),
|
||||
fmtPct(p.video.packetLossPct, 'loss'),
|
||||
fmtMs(p.video.jitterMs, 'jit'),
|
||||
fmtMs(p.video.rttMs, 'rtt'),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.length === 0 && (
|
||||
<p className="px-1 py-1.5 text-white/60">Sammle Stats …</p>
|
||||
)}
|
||||
</div>
|
||||
<footer className="border-t border-white/10 px-3 py-1.5 text-[10px] text-white/50">
|
||||
Aktualisiert alle 1,5 s · Strg+Shift+S zum Schließen
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, values }: { label: string; values: (string | null)[] }) {
|
||||
const visible = values.filter(Boolean) as string[];
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="w-[58px] shrink-0 text-white/50">{label}</span>
|
||||
<span className="flex flex-wrap gap-x-2 tabular-nums text-white/90">
|
||||
{visible.length === 0 ? '—' : visible.map((v, i) => <span key={i}>{v}</span>)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtKbps(v: number | undefined): string | null {
|
||||
if (v === undefined) return null;
|
||||
return v.toLocaleString('de') + ' kbps';
|
||||
}
|
||||
|
||||
function fmtPct(v: number | undefined, prefix: string): string | null {
|
||||
if (v === undefined) return null;
|
||||
return prefix + ' ' + v.toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function fmtMs(v: number | undefined, prefix: string): string | null {
|
||||
if (v === undefined) return null;
|
||||
return prefix + ' ' + v + 'ms';
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { ringtone } from '../lib/ringtone';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { Avatar } from './Avatar';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
@@ -16,19 +18,23 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
// (clicking the toast routes to the docked IncomingCallPanel).
|
||||
// - PiP widget when an active call is live but the user is looking at a
|
||||
// different route.
|
||||
// - Top-center pending-incoming toast for Discord-style second-call ringer.
|
||||
export function CallUI() {
|
||||
const { state } = useCall();
|
||||
const { state, pendingIncoming } = useCall();
|
||||
const { profile } = useAuth();
|
||||
const dnd = profile?.presenceState === 'dnd';
|
||||
|
||||
useEffect(() => {
|
||||
// DND silences only the *incoming* ring — outgoing stays audible because
|
||||
// the user initiated that call themselves. The incoming-call panel still
|
||||
// appears visually; only the audible ring is suppressed.
|
||||
// appears visually; only the audible ring is suppressed. The pending
|
||||
// second-call ringer plays the same incoming sound at the same volume:
|
||||
// user explicitly asked for parity with the normal ring.
|
||||
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
||||
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
|
||||
else if (pendingIncoming && !dnd) ringtone.start('incoming');
|
||||
else ringtone.stop();
|
||||
}, [state.kind, dnd]);
|
||||
}, [state.kind, pendingIncoming, dnd]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => ringtone.stop();
|
||||
@@ -37,6 +43,7 @@ export function CallUI() {
|
||||
return (
|
||||
<>
|
||||
<IncomingCallToast />
|
||||
<PendingIncomingToast />
|
||||
<PipCall />
|
||||
</>
|
||||
);
|
||||
@@ -138,6 +145,99 @@ function IncomingCallToast() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discord-style "second-call" ringer — pops at the top centre while we're
|
||||
// already in another call. Accepting hangs up the active call (handled in
|
||||
// CallContext.acceptPendingIncoming) then routes to the new conversation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PendingIncomingToast() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { pendingIncoming, acceptPendingIncoming, rejectPendingIncoming } = useCall();
|
||||
const { friendships } = useFriendshipsContext();
|
||||
const { conversations } = useConversationsContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!pendingIncoming) return null;
|
||||
|
||||
const conv = conversations.find((c) => c.id === pendingIncoming.conversationId) ?? null;
|
||||
const callerName =
|
||||
conv?.members.find((m) => m.userId === pendingIncoming.fromUserId)?.profile?.displayName ??
|
||||
friendships.find((f) => f.peer.userId === pendingIncoming.fromUserId)?.peer.displayName ??
|
||||
'?';
|
||||
const isGroup = conv?.type === 'group';
|
||||
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
|
||||
const title = isGroup ? groupName ?? callerName : callerName;
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const color = colorKeyFor(pendingIncoming.fromUserId);
|
||||
const targetConversationId = pendingIncoming.conversationId;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={t('app:call.incoming_title')}
|
||||
className="pointer-events-none fixed inset-x-0 top-5 z-[70] flex justify-center px-4"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-[420px] animate-slide-down overflow-hidden rounded-2xl border border-emerald-500/50 bg-surface-3/95 shadow-call-card-dark backdrop-blur-md">
|
||||
<div className="flex items-center gap-3 px-5 pt-4">
|
||||
<div
|
||||
className={
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-semibold ' +
|
||||
TOAST_AVATAR[color]
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-500 dark:text-emerald-400">
|
||||
{t('app:call.incoming_while_busy', {
|
||||
defaultValue: 'Anderer Anruf eingehend',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate font-display text-base font-semibold text-fg">
|
||||
{title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isGroup
|
||||
? t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft Gruppe',
|
||||
})
|
||||
: t('app:call.incoming_from', { name: callerName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 px-5 pb-4 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={rejectPendingIncoming}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.decline')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Route first so the user lands on the new conversation
|
||||
// before the connecting state resolves — feels snappier than
|
||||
// waiting for the join to complete. acceptPendingIncoming
|
||||
// tears down the old call internally.
|
||||
navigate('/chats/' + targetConversationId);
|
||||
void acceptPendingIncoming();
|
||||
}}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.accept_and_switch', { defaultValue: 'Wechseln & Annehmen' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PiP widget — shown when the user has an active call but is browsing
|
||||
// somewhere else. Clicking expands back to the call's conversation.
|
||||
@@ -147,6 +247,7 @@ function PipCall() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
state,
|
||||
room,
|
||||
remoteParticipants,
|
||||
remoteScreenShares,
|
||||
hangup,
|
||||
@@ -154,6 +255,10 @@ function PipCall() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { conversations } = useConversationsContext();
|
||||
const { profile } = useAuth();
|
||||
// Active-speakers hook drives the green ring on mini-avatars so the user
|
||||
// can spot who's talking from the PiP without expanding back to the call.
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
|
||||
const active =
|
||||
state.kind === 'connected' ||
|
||||
@@ -171,8 +276,19 @@ function PipCall() {
|
||||
conv?.type === 'group'
|
||||
? conv?.name ?? t('app:chats.new_group')
|
||||
: conv?.peer?.displayName ?? '—';
|
||||
const participantCount = 1 + remoteParticipants.length;
|
||||
const someoneSharing = remoteScreenShares.length > 0;
|
||||
// Discord-style mini-grid: collect actual present participants (self +
|
||||
// joined remotes), match with profile data from the conversation roster
|
||||
// for avatars/displayName. Show up to MAX, plus a "+N" overflow chip.
|
||||
const MAX_TILES = 4;
|
||||
const myId = profile?.userId ?? null;
|
||||
const presentIds: string[] = [];
|
||||
if (myId) presentIds.push(myId);
|
||||
for (const rp of remoteParticipants) {
|
||||
if (rp.identity && !presentIds.includes(rp.identity)) presentIds.push(rp.identity);
|
||||
}
|
||||
const tileIds = presentIds.slice(0, MAX_TILES);
|
||||
const overflow = Math.max(0, presentIds.length - MAX_TILES);
|
||||
// Duration ticks while connected or reconnecting (LiveKit holds the room
|
||||
// across reconnects, so the timer shouldn't reset on a wobble). Absent
|
||||
// on outgoing/connecting where the call hasn't started yet.
|
||||
@@ -186,42 +302,74 @@ function PipCall() {
|
||||
role="dialog"
|
||||
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
|
||||
onClick={() => navigate('/chats/' + state.conversationId)}
|
||||
className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
|
||||
className="fixed bottom-5 right-5 z-40 flex w-[300px] motion-safe:animate-slide-in-call cursor-pointer flex-col gap-2 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
|
||||
{someoneSharing ? (
|
||||
<MonitorShareIcon className="h-5 w-5 text-accent" />
|
||||
) : (
|
||||
<PhoneIcon className="h-5 w-5 text-accent" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-2">
|
||||
{someoneSharing ? (
|
||||
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||
) : (
|
||||
<PhoneIcon className="h-4 w-4 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-fg">{title}</p>
|
||||
<p className="flex items-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-rose-500 motion-safe:animate-live-dot"
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{startedAt
|
||||
? <PipDuration startedAt={startedAt} />
|
||||
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void hangup();
|
||||
}}
|
||||
aria-label={t('app:call.hangup')}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tileIds.map((id) => {
|
||||
const member = conv?.members.find((m) => m.userId === id);
|
||||
const isMe = id === myId;
|
||||
const name = isMe
|
||||
? profile?.displayName ?? '?'
|
||||
: member?.profile?.displayName ?? '?';
|
||||
const avatarUrl = isMe
|
||||
? profile?.avatarUrl ?? null
|
||||
: member?.profile?.avatarUrl ?? null;
|
||||
const speaking = activeSpeakers.has(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={name + (isMe ? ' (du)' : '')}
|
||||
className={
|
||||
'relative h-8 w-8 shrink-0 overflow-hidden rounded-full ring-2 transition-shadow ' +
|
||||
(speaking
|
||||
? 'ring-emerald-500 shadow-[0_0_0_2px_rgba(34,197,94,0.35)]'
|
||||
: 'ring-line')
|
||||
}
|
||||
>
|
||||
<Avatar url={avatarUrl} displayName={name} className="h-full w-full text-xs" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<span className="ml-0.5 inline-flex h-8 min-w-[2rem] items-center justify-center rounded-full bg-surface-2 px-2 text-[11px] font-semibold text-fg-muted">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-fg">
|
||||
{title} · {participantCount}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{startedAt
|
||||
? <PipDuration startedAt={startedAt} />
|
||||
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void hangup();
|
||||
}}
|
||||
aria-label={t('app:call.hangup')}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import {
|
||||
GridIcon,
|
||||
InfoIcon,
|
||||
PhoneIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
@@ -29,12 +26,21 @@ interface Props {
|
||||
conversation: ConversationSummary | null;
|
||||
peerPresence: PeerPresence | null;
|
||||
onInfoClick?: () => void;
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
|
||||
export function ConversationHeader({
|
||||
conversation,
|
||||
peerPresence,
|
||||
onInfoClick,
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
}: Props) {
|
||||
if (!conversation) {
|
||||
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,9 +49,12 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(onInfoClick ? { onInfoClick } : {})}
|
||||
{...(onMediaClick ? { onMediaClick } : {})}
|
||||
{...(onProfileClick ? { onProfileClick } : {})}
|
||||
{...(onSearchClick ? { onSearchClick } : {})}
|
||||
/>
|
||||
<ActiveCallBanner conversationId={conversation.id} />
|
||||
{/* Voice-channel rail rendered separately in ConversationPage so it
|
||||
can sit between the message surface and the in-call dock. */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -54,16 +63,27 @@ interface HeaderBarProps {
|
||||
conversation: ConversationSummary;
|
||||
peerPresence: PeerPresence | null;
|
||||
onInfoClick?: () => void;
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
|
||||
function HeaderBar({
|
||||
conversation,
|
||||
peerPresence,
|
||||
onInfoClick,
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
}: HeaderBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
const isDm = conversation.type === 'dm';
|
||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||
const peerAvatar = isDm
|
||||
? (conversation.peer?.avatarUrl ?? null)
|
||||
: (conversation.avatarUrl ?? null);
|
||||
|
||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||
const peerState = peerPresence?.state ?? null;
|
||||
@@ -80,8 +100,16 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
})();
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||
<div className="relative">
|
||||
<header className="discord-chat-surface flex min-h-[65px] items-center gap-3 border-b border-line bg-surface-3 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
data-user-popover-trigger={isDm ? true : undefined}
|
||||
onClick={isDm ? onProfileClick : undefined}
|
||||
disabled={!isDm || !onProfileClick}
|
||||
aria-label={isDm ? 'Profil öffnen' : title}
|
||||
title={isDm ? 'Profil öffnen' : title}
|
||||
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
||||
>
|
||||
{isDm ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
) : peerAvatar ? (
|
||||
@@ -100,9 +128,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isDm ? (
|
||||
<>
|
||||
@@ -127,6 +158,11 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
icon={SearchIcon}
|
||||
{...(onSearchClick ? { onClick: onSearchClick } : {})}
|
||||
/>
|
||||
<HeaderActionButton
|
||||
label="Medien & Dateien"
|
||||
icon={GridIcon}
|
||||
{...(onMediaClick ? { onClick: onMediaClick } : {})}
|
||||
/>
|
||||
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||
{!isDm && onInfoClick && (
|
||||
@@ -158,7 +194,7 @@ function HeaderActionButton({
|
||||
const toneClass =
|
||||
tone === 'accent'
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg dark:hover:bg-[#383a40]';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -201,62 +237,3 @@ function CallHeaderButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
|
||||
const active = useCallPresence(conversationId);
|
||||
|
||||
const myId = session?.user.id;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversationId;
|
||||
|
||||
const othersIn = active.filter((u) => u !== myId);
|
||||
// Fallback signal: I just left this conv with peers still inside. Covers the
|
||||
// brief window after hangup where presence may not have re-synced yet (the
|
||||
// realtime channel can churn while ConversationHeader remounts).
|
||||
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
||||
|
||||
// Once presence confirms the room is empty, drop the "just left" hint so the
|
||||
// banner hides cleanly instead of sticking forever. Grace window handles the
|
||||
// brief gap between hangup and presence re-sync so we don't flicker.
|
||||
useEffect(() => {
|
||||
if (!justLeft) return;
|
||||
if (othersIn.length > 0) return; // still live — keep banner
|
||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [justLeft, othersIn.length, dismissLastCall]);
|
||||
|
||||
if (iAmIn) return null;
|
||||
if (othersIn.length === 0 && !justLeft) return null;
|
||||
|
||||
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
|
||||
const busy = state.kind !== 'idle';
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
|
||||
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
|
||||
<span className="flex-1">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Active call · {{count}} in room',
|
||||
count,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PhoneIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{t('app:call.join', { defaultValue: 'Join' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,11 +137,7 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
try {
|
||||
await setConversationMutedUntil(
|
||||
supabase,
|
||||
conversationId,
|
||||
muteDurationToIso(minutes),
|
||||
);
|
||||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
||||
} catch (err: unknown) {
|
||||
console.error('mute toggle failed', err);
|
||||
}
|
||||
@@ -161,7 +157,7 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -173,7 +169,7 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl dark:bg-[#313338]"
|
||||
>
|
||||
<MenuItem
|
||||
icon={<ArchiveIcon className="h-4 w-4" />}
|
||||
@@ -187,11 +183,7 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
<MenuItem
|
||||
ref={muteItemRef}
|
||||
icon={
|
||||
isMuted ? (
|
||||
<BellOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<BellIcon className="h-4 w-4" />
|
||||
)
|
||||
isMuted ? <BellOffIcon className="h-4 w-4" /> : <BellIcon className="h-4 w-4" />
|
||||
}
|
||||
label={
|
||||
isMuted
|
||||
@@ -215,7 +207,7 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
<div
|
||||
role="menu"
|
||||
style={{ top: submenuPos.top, left: submenuPos.left }}
|
||||
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl dark:bg-[#313338]"
|
||||
>
|
||||
{MUTE_OPTIONS.map((opt) => (
|
||||
<MenuItem
|
||||
@@ -253,7 +245,7 @@ const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
||||
onClick();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
|
||||
@@ -296,7 +296,12 @@ export function EmojiPicker({ open, onPick, onClose }: Props) {
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
|
||||
if (
|
||||
rootRef.current &&
|
||||
t &&
|
||||
!rootRef.current.contains(t) &&
|
||||
!t.closest('[data-emoji-trigger]')
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
@@ -340,7 +345,7 @@ export function EmojiPicker({ open, onPick, onClose }: Props) {
|
||||
ref={rootRef}
|
||||
role="dialog"
|
||||
aria-label="Emoji auswählen"
|
||||
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl dark:bg-[#2b2d31]"
|
||||
>
|
||||
<div className="border-b border-line p-2">
|
||||
<input
|
||||
@@ -349,7 +354,7 @@ export function EmojiPicker({ open, onPick, onClose }: Props) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Suchen…"
|
||||
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto p-2">
|
||||
@@ -397,7 +402,7 @@ function CategoryBlock({
|
||||
type="button"
|
||||
onClick={() => onPick(entry.e)}
|
||||
aria-label={entry.k[0] ?? entry.e}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
{entry.e}
|
||||
</button>
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
const preview = useMemo(() => {
|
||||
if (!message?.plaintext) return '';
|
||||
const p = parseMessagePayload(message.plaintext);
|
||||
if (p.kind === 'poll') return 'Umfrage: ' + p.question;
|
||||
if (p.kind !== 'text') return '';
|
||||
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
|
||||
}, [message]);
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Cropper, { type Area } from 'react-easy-crop';
|
||||
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface Props {
|
||||
/** When true, the dialog is mounted. Always pair with a `key` change on
|
||||
* the source file so a fresh open re-runs the loader. */
|
||||
open: boolean;
|
||||
/** Source image. Object URL is created/revoked internally. */
|
||||
file: File | null;
|
||||
/** width:height ratio of the crop box. 1 for avatar, 3 for banner. */
|
||||
aspect: number;
|
||||
/** Output image dimensions in px. The crop area is scaled to this. */
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
title: string;
|
||||
/** Called with the encoded blob when the user confirms. The dialog stays
|
||||
* open until the parent flips `open` back off (typically after upload
|
||||
* completes) — gives the parent a chance to show errors inline. */
|
||||
onConfirm: (blob: Blob) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ENCODE_QUALITY = 0.85;
|
||||
|
||||
// Crop dialog used by both avatar (1:1) and banner (3:1) flows. Wraps
|
||||
// react-easy-crop in our standard modal chrome and runs the canvas crop
|
||||
// inline on confirm so the upload helpers receive a ready-to-store Blob.
|
||||
export function ImageCropDialog({
|
||||
open,
|
||||
file,
|
||||
aspect,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
title,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [areaPixels, setAreaPixels] = useState<Area | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Resolve the file into an object URL exactly once per file. Revoked on
|
||||
// unmount or when the file changes so a long crop session doesn't leak.
|
||||
const objectUrl = useMemo(() => {
|
||||
if (!file) return null;
|
||||
return URL.createObjectURL(file);
|
||||
}, [file]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [objectUrl]);
|
||||
|
||||
// Reset transient UI state every time a new file is loaded so reopens
|
||||
// don't carry over the previous session's zoom/offset.
|
||||
useEffect(() => {
|
||||
if (!file) return;
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setAreaPixels(null);
|
||||
setError(null);
|
||||
}, [file]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
const submitting = useRef(false);
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (submitting.current || !objectUrl || !areaPixels) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await renderCrop({
|
||||
srcUrl: objectUrl,
|
||||
area: areaPixels,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
});
|
||||
onConfirm(blob);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'crop failed');
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}, [objectUrl, areaPixels, outputWidth, outputHeight, onConfirm]);
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={busy ? () => undefined : onClose} size="lg">
|
||||
<div className="flex flex-col gap-3 p-5">
|
||||
{/* Cropper canvas. Fixed height (320px) so the layout stays stable
|
||||
regardless of the source image dimensions; react-easy-crop fits
|
||||
the image into the box and the user pans/zooms inside. */}
|
||||
<div className="relative h-[320px] w-full overflow-hidden rounded-xl bg-black">
|
||||
{objectUrl && (
|
||||
<Cropper
|
||||
image={objectUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
cropShape={aspect === 1 ? 'round' : 'rect'}
|
||||
showGrid={false}
|
||||
objectFit="contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<label className="text-xs font-medium text-fg-muted" htmlFor="crop-zoom">
|
||||
Zoom
|
||||
</label>
|
||||
<input
|
||||
id="crop-zoom"
|
||||
type="range"
|
||||
min={1}
|
||||
max={4}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-accent"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-rose-500 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={busy || !areaPixels}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderArgs {
|
||||
srcUrl: string;
|
||||
area: Area;
|
||||
outputWidth: number;
|
||||
outputHeight: number;
|
||||
}
|
||||
|
||||
// Off-thread canvas would be nicer but the input is bounded (single image,
|
||||
// max ~12MP after react-easy-crop's clamp) so the main-thread cost is in
|
||||
// the tens of ms. Keeping it inline avoids the OffscreenCanvas + Worker
|
||||
// plumbing for a one-shot operation.
|
||||
async function renderCrop({
|
||||
srcUrl,
|
||||
area,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
}: RenderArgs): Promise<Blob> {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error('image load failed'));
|
||||
i.src = srcUrl;
|
||||
});
|
||||
|
||||
// Don't upscale beyond the source crop. A 200x200 selection stays at
|
||||
// 200x200 instead of inflating to outputWidth — saves bytes and avoids
|
||||
// the soft look of canvas-resampled enlargement.
|
||||
const targetWidth = Math.min(outputWidth, Math.round(area.width));
|
||||
const targetHeight = Math.min(outputHeight, Math.round(area.height));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
ctx.drawImage(
|
||||
img,
|
||||
area.x,
|
||||
area.y,
|
||||
area.width,
|
||||
area.height,
|
||||
0,
|
||||
0,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', ENCODE_QUALITY),
|
||||
);
|
||||
if (blob) return blob;
|
||||
|
||||
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', ENCODE_QUALITY),
|
||||
);
|
||||
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||||
return jpeg;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||
import { RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,16 +15,25 @@ import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import {
|
||||
getLiveCaptionsSettings,
|
||||
isLiveCaptionsSupported,
|
||||
subscribeLiveCaptionsSettings,
|
||||
updateLiveCaptionsSettings,
|
||||
} from '../lib/liveCaptions';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||
import { ScreenSourcePicker } from './ScreenSourcePicker';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
import { SoundboardPanel } from './SoundboardPanel';
|
||||
import { UserProfilePopover } from './UserProfilePopover';
|
||||
|
||||
// Discord-style in-call dock rendered above the message list. Renders three
|
||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||
@@ -54,6 +63,19 @@ interface Tile {
|
||||
sharing: boolean;
|
||||
// True iff this is a remote user's screen-share tile.
|
||||
remoteSharing: boolean;
|
||||
// LiveKit-measured connection quality for this participant. Drives the
|
||||
// Discord-style Wifi badge on user-tiles. Undefined for screen-tiles.
|
||||
connectionQuality?: TileConnectionQuality;
|
||||
// Discord-style host marker — only true for the call initiator's tile in
|
||||
// group calls. Drives the crown.
|
||||
isHost: boolean;
|
||||
// True iff the user pinned this tile via right-click → "Anpinnen".
|
||||
// Mirrors the value of CallContext.focusedId for this tile id.
|
||||
pinned: boolean;
|
||||
// True iff this user-tile's owner is currently publishing a screen share.
|
||||
// Drives the LIVE pill on the avatar tile so the share-tile next to it
|
||||
// visually belongs to the same person. Always false on screen-kind tiles.
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
export function InCallPanel({ conversation }: Props) {
|
||||
@@ -73,7 +95,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
callMode,
|
||||
focusedId,
|
||||
toggleMute,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
toggleCamera,
|
||||
toggleDeafen,
|
||||
@@ -84,19 +105,65 @@ export function InCallPanel({ conversation }: Props) {
|
||||
clearMicError,
|
||||
retryMic,
|
||||
dismissedShareUserIds,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
} = useCall();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||
const [participantsOpen, setParticipantsOpen] = useState(false);
|
||||
// Discord-style screen-share configure modal. Opens when the user clicks
|
||||
// the Share button while not yet sharing — collects FPS/resolution/audio
|
||||
// before triggering the OS source picker.
|
||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
||||
// controls bar can show an "active" state without polling. Captions
|
||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
||||
// tracks the toggle state for the button.
|
||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
||||
() => getLiveCaptionsSettings().enabled,
|
||||
);
|
||||
useEffect(
|
||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
e.shiftKey &&
|
||||
!e.altKey &&
|
||||
e.code === 'KeyS'
|
||||
) {
|
||||
e.preventDefault();
|
||||
setStatsOverlayOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
const [volumeMenu, setVolumeMenu] = useState<
|
||||
{ userId: string; displayName: string; x: number; y: number } | null
|
||||
{
|
||||
userId: string;
|
||||
displayName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
tileId: string;
|
||||
self: boolean;
|
||||
} | null
|
||||
>(null);
|
||||
const [shareMenu, setShareMenu] = useState<
|
||||
{ userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null
|
||||
>(null);
|
||||
// Discord-style profile popover triggered from the right-click context menu
|
||||
// on a participant tile. Anchored at the same coords as the volume menu.
|
||||
const [profileMenu, setProfileMenu] = useState<
|
||||
{ userId: string; x: number; y: number } | null
|
||||
>(null);
|
||||
// Soundboard-count so the in-call bar only surfaces the music button when
|
||||
// the user actually has something to play. Matches Discord's "hide soundboard
|
||||
// when empty" behaviour — no point dangling a button that opens to a blank
|
||||
@@ -149,18 +216,22 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||
// stop your own share from the control bar.
|
||||
const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => {
|
||||
if (tile.self) return;
|
||||
e.preventDefault();
|
||||
if (tile.kind === 'user') {
|
||||
// Self-tile gets the pin row only — there's no remote volume to control.
|
||||
setShareMenu(null);
|
||||
setVolumeMenu({
|
||||
userId: tile.userId,
|
||||
displayName: tile.displayName,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
tileId: tile.id,
|
||||
self: tile.self,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Self screen-tiles: same — pin row is still useful, share menu is not.
|
||||
if (tile.self) return;
|
||||
// Screen-tile. Check whether the participant has a published screen-share
|
||||
// audio track so the menu can hide the volume/mute rows when there's
|
||||
// nothing to control.
|
||||
@@ -212,6 +283,12 @@ export function InCallPanel({ conversation }: Props) {
|
||||
.map((s) => s.participantId)
|
||||
.filter((id) => !dismissedShareUserIds.has(id)),
|
||||
),
|
||||
connectionQualities,
|
||||
// Discord-style host crown — only show in group calls (>2 members).
|
||||
// 1:1s have no concept of "host" so the crown stays hidden.
|
||||
hostUserId: conversation.members.length > 2 ? callHostId : null,
|
||||
// Pin marker — focusedId === tile.id means the user explicitly pinned.
|
||||
pinnedTileId: focusedId,
|
||||
});
|
||||
|
||||
// Duration keeps ticking during reconnecting so the user sees the call is
|
||||
@@ -254,12 +331,15 @@ export function InCallPanel({ conversation }: Props) {
|
||||
if (isScreenSharing) {
|
||||
void stopScreenShare();
|
||||
} else {
|
||||
setPickerOpen(true);
|
||||
// Discord-parity: open the configure-then-share modal instead of
|
||||
// jumping straight into the OS picker. Modal calls
|
||||
// startScreenShare with the chosen overrides on confirm.
|
||||
setSharePickerOpen(true);
|
||||
}
|
||||
}}
|
||||
onShareContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isScreenSharing) setPickerOpen(true);
|
||||
if (!isScreenSharing) setSharePickerOpen(true);
|
||||
}}
|
||||
onToggleVideo={() => void toggleCamera()}
|
||||
onToggleDeafen={toggleDeafen}
|
||||
@@ -275,6 +355,15 @@ export function InCallPanel({ conversation }: Props) {
|
||||
soundboardOpen,
|
||||
}
|
||||
: {})}
|
||||
// Live-Captions only when SpeechRecognition is available in the
|
||||
// runtime — Firefox lacks it, would just show a dead button.
|
||||
{...(isLiveCaptionsSupported()
|
||||
? {
|
||||
onToggleCaptions: () =>
|
||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
||||
captionsOn: captionsEnabled,
|
||||
}
|
||||
: {})}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -358,6 +447,25 @@ export function InCallPanel({ conversation }: Props) {
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
// We're already in fullscreen here — toggling focusedId is enough,
|
||||
// no mode-switch needed.
|
||||
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
|
||||
}}
|
||||
// Self-tiles get no slider — the volume control would adjust the
|
||||
// remote volume of someone the local user isn't hearing.
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
? {}
|
||||
: {
|
||||
onShowProfile: () =>
|
||||
setProfileMenu({
|
||||
userId: volumeMenu.userId,
|
||||
x: volumeMenu.x,
|
||||
y: volumeMenu.y,
|
||||
}),
|
||||
})}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -381,6 +489,28 @@ export function InCallPanel({ conversation }: Props) {
|
||||
activeSpeakers={activeSpeakers}
|
||||
onClose={() => setParticipantsOpen(false)}
|
||||
/>
|
||||
{profileMenu && (
|
||||
<UserProfilePopover
|
||||
userId={profileMenu.userId}
|
||||
profile={
|
||||
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
|
||||
}
|
||||
x={profileMenu.x}
|
||||
y={profileMenu.y}
|
||||
onClose={() => setProfileMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{sharePickerOpen && (
|
||||
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
|
||||
)}
|
||||
{statsOverlayOpen && room && (
|
||||
<CallStatsOverlay
|
||||
room={room}
|
||||
members={conversation.members}
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -444,6 +574,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile
|
||||
// collapses back to grid; clicking another tile swaps focus;
|
||||
// clicking any tile in grid mode focuses it.
|
||||
if (callMode === 'focus' && focusedId === id) {
|
||||
setFocusedId(null);
|
||||
setCallMode('grid');
|
||||
return;
|
||||
}
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
@@ -455,24 +593,45 @@ export function InCallPanel({ conversation }: Props) {
|
||||
|
||||
<PttHint />
|
||||
|
||||
<ScreenSourcePicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStart={async (opts) => {
|
||||
await startScreenShare(opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
userId={volumeMenu.userId}
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
const isPinned = focusedId === volumeMenu.tileId;
|
||||
setFocusedId(isPinned ? null : volumeMenu.tileId);
|
||||
if (!isPinned && callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
? {}
|
||||
: {
|
||||
onShowProfile: () =>
|
||||
setProfileMenu({
|
||||
userId: volumeMenu.userId,
|
||||
x: volumeMenu.x,
|
||||
y: volumeMenu.y,
|
||||
}),
|
||||
})}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileMenu && (
|
||||
<UserProfilePopover
|
||||
userId={profileMenu.userId}
|
||||
profile={
|
||||
conversation.members.find((m) => m.userId === profileMenu.userId)?.profile ?? null
|
||||
}
|
||||
x={profileMenu.x}
|
||||
y={profileMenu.y}
|
||||
onClose={() => setProfileMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareMenu && (
|
||||
<ScreenShareContextMenu
|
||||
userId={shareMenu.userId}
|
||||
@@ -495,6 +654,20 @@ export function InCallPanel({ conversation }: Props) {
|
||||
activeSpeakers={activeSpeakers}
|
||||
onClose={() => setParticipantsOpen(false)}
|
||||
/>
|
||||
|
||||
{sharePickerOpen && (
|
||||
<ScreenSharePickerModal onClose={() => setSharePickerOpen(false)} />
|
||||
)}
|
||||
|
||||
{statsOverlayOpen && room && (
|
||||
<CallStatsOverlay
|
||||
room={room}
|
||||
members={conversation.members}
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -528,6 +701,13 @@ interface BuildArgs {
|
||||
isScreenSharing: boolean;
|
||||
isCameraEnabled: boolean;
|
||||
remoteSharerIds: Set<string>;
|
||||
connectionQualities: Record<string, ConnectionQuality>;
|
||||
/** Host userId (call initiator). Null in re-joined calls and 1:1 calls.
|
||||
* Used to mark exactly one user's tile with the crown. */
|
||||
hostUserId: string | null;
|
||||
/** Tile id (e.g. "user:abc") the user has pinned via right-click. Null
|
||||
* while no pin is active or while auto-tracking the active speaker. */
|
||||
pinnedTileId: string | null;
|
||||
}
|
||||
|
||||
function cameraTrackFor(
|
||||
@@ -554,6 +734,9 @@ function buildTiles({
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
remoteSharerIds,
|
||||
connectionQualities,
|
||||
hostUserId,
|
||||
pinnedTileId,
|
||||
}: BuildArgs): Tile[] {
|
||||
const remoteById = new Map<string, RemoteParticipant>();
|
||||
for (const p of remoteParticipants) {
|
||||
@@ -567,6 +750,7 @@ function buildTiles({
|
||||
// device / permission denied) the toggle stays false while the real
|
||||
// state is "muted". Combine both so the badge always matches reality.
|
||||
const micLive = room?.localParticipant?.isMicrophoneEnabled ?? false;
|
||||
const myQuality = connectionQualities[myId] as TileConnectionQuality | undefined;
|
||||
out.push({
|
||||
kind: 'user',
|
||||
id: 'user:' + myId,
|
||||
@@ -580,6 +764,10 @@ function buildTiles({
|
||||
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
|
||||
sharing: false,
|
||||
remoteSharing: false,
|
||||
...(myQuality ? { connectionQuality: myQuality } : {}),
|
||||
isHost: hostUserId === myId,
|
||||
pinned: pinnedTileId === 'user:' + myId,
|
||||
streaming: isScreenSharing,
|
||||
});
|
||||
if (isScreenSharing) {
|
||||
out.push({
|
||||
@@ -595,6 +783,9 @@ function buildTiles({
|
||||
videoTrack: null,
|
||||
sharing: true,
|
||||
remoteSharing: false,
|
||||
isHost: false,
|
||||
pinned: pinnedTileId === 'screen:' + myId,
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -602,6 +793,7 @@ function buildTiles({
|
||||
if (m.userId === myId) continue;
|
||||
const rp = remoteById.get(m.userId);
|
||||
if (!rp) continue;
|
||||
const peerQuality = connectionQualities[m.userId] as TileConnectionQuality | undefined;
|
||||
out.push({
|
||||
kind: 'user',
|
||||
id: 'user:' + m.userId,
|
||||
@@ -619,6 +811,10 @@ function buildTiles({
|
||||
videoTrack: cameraTrackFor(rp),
|
||||
sharing: false,
|
||||
remoteSharing: false,
|
||||
...(peerQuality ? { connectionQuality: peerQuality } : {}),
|
||||
isHost: hostUserId === m.userId,
|
||||
pinned: pinnedTileId === 'user:' + m.userId,
|
||||
streaming: remoteSharerIds.has(m.userId),
|
||||
});
|
||||
if (remoteSharerIds.has(m.userId)) {
|
||||
out.push({
|
||||
@@ -634,6 +830,9 @@ function buildTiles({
|
||||
videoTrack: null,
|
||||
sharing: false,
|
||||
remoteSharing: true,
|
||||
isHost: false,
|
||||
pinned: pinnedTileId === 'screen:' + m.userId,
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -757,25 +956,7 @@ function TileRender({
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
if (tile.self) {
|
||||
// Local screenshare preview — we don't mirror a copy of the outgoing
|
||||
// stream. Render a labelled placeholder card so the user knows their
|
||||
// share is live without double-encoding the stream.
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={
|
||||
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
|
||||
(onClick ? 'cursor-pointer' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-4 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider">
|
||||
Live
|
||||
</div>
|
||||
<div className="text-sm">{tile.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <LocalSharePreview displayName={tile.displayName} {...(onClick ? { onClick } : {})} />;
|
||||
}
|
||||
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
||||
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
||||
@@ -806,6 +987,10 @@ function TileRender({
|
||||
video={tile.video}
|
||||
videoTrack={tile.videoTrack}
|
||||
e2ee={e2ee}
|
||||
isHost={tile.isHost}
|
||||
pinned={tile.pinned}
|
||||
streaming={tile.streaming}
|
||||
{...(tile.connectionQuality ? { connectionQuality: tile.connectionQuality } : {})}
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
@@ -837,6 +1022,9 @@ function CallStage({
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
@@ -897,12 +1085,14 @@ function FocusedTile({
|
||||
activeSpeakers,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
activeSpeakers: Set<string>;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
@@ -913,6 +1103,7 @@ function FocusedTile({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1268,6 +1459,130 @@ function MicErrorBanner({
|
||||
);
|
||||
}
|
||||
|
||||
// Preview card for the local sharer's screen-tile. Shows a "Live" label and,
|
||||
// when the share carries a ScreenShareAudio track, a small overlay button to
|
||||
// mute that outgoing audio publication without tearing down the share. The
|
||||
// mute state lives in CallContext so the toggle survives re-renders and
|
||||
// resets cleanly when the share ends.
|
||||
function LocalSharePreview({
|
||||
displayName,
|
||||
onClick,
|
||||
}: {
|
||||
displayName: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const { room, outgoingShareAudioMuted, toggleOutgoingShareAudioMute } = useCall();
|
||||
const [hasShareAudio, setHasShareAudio] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setHasShareAudio(false);
|
||||
return;
|
||||
}
|
||||
const compute = () => {
|
||||
const lp = room.localParticipant;
|
||||
let found = false;
|
||||
for (const pub of lp.audioTrackPublications.values()) {
|
||||
if (pub.source === Track.Source.ScreenShareAudio) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHasShareAudio(found);
|
||||
};
|
||||
compute();
|
||||
const onPub = () => compute();
|
||||
room.on(RoomEvent.LocalTrackPublished, onPub);
|
||||
room.on(RoomEvent.LocalTrackUnpublished, onPub);
|
||||
return () => {
|
||||
room.off(RoomEvent.LocalTrackPublished, onPub);
|
||||
room.off(RoomEvent.LocalTrackUnpublished, onPub);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={
|
||||
'relative flex h-full w-full items-center justify-center overflow-hidden rounded-[14px] border border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-200 ' +
|
||||
(onClick ? 'cursor-pointer' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-4 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider">Live</div>
|
||||
<div className="text-sm">{displayName}</div>
|
||||
</div>
|
||||
{hasShareAudio && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleOutgoingShareAudioMute();
|
||||
}}
|
||||
aria-pressed={outgoingShareAudioMuted}
|
||||
aria-label={
|
||||
outgoingShareAudioMuted
|
||||
? 'Sound der Übertragung wieder senden'
|
||||
: 'Sound der Übertragung stumm'
|
||||
}
|
||||
title={
|
||||
outgoingShareAudioMuted
|
||||
? 'Sound der Übertragung wieder senden'
|
||||
: 'Sound der Übertragung stumm'
|
||||
}
|
||||
className={
|
||||
'absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border backdrop-blur-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||
(outgoingShareAudioMuted
|
||||
? 'border-rose-500/60 bg-rose-500/20 text-rose-600 hover:bg-rose-500/30 dark:text-rose-300'
|
||||
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-200')
|
||||
}
|
||||
>
|
||||
{outgoingShareAudioMuted ? <SpeakerOffIconInline /> : <SpeakerOnIconInline />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeakerOnIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeakerOffIconInline() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<line x1="23" y1="9" x2="17" y2="15" />
|
||||
<line x1="17" y1="9" x2="23" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Tiny inline variant so we don't pull MicOffIcon's default sizing.
|
||||
function MicOffIconInline() {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { CallPreviewModal } from './CallPreviewModal';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
||||
|
||||
@@ -24,6 +26,7 @@ export function IncomingCallPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const { friendships } = useFriendshipsContext();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
||||
|
||||
@@ -56,13 +59,15 @@ export function IncomingCallPanel({ conversation }: Props) {
|
||||
{t('app:call.incoming_title')}
|
||||
</p>
|
||||
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
||||
{/* Discord-style: animated pulse rings when motion is OK; one
|
||||
static ring + a faint outer glow when prefers-reduced-motion. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent motion-safe:animate-pulse-ring motion-reduce:opacity-70"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent motion-safe:animate-pulse-ring motion-reduce:hidden"
|
||||
style={{ animationDelay: '1s' }}
|
||||
/>
|
||||
{avatarUrl ? (
|
||||
@@ -123,7 +128,7 @@ export function IncomingCallPanel({ conversation }: Props) {
|
||||
{state.mediaKind === 'video' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void acceptIncoming('video')}
|
||||
onClick={() => setShowPreview(true)}
|
||||
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
<VideoIcon className="h-4 w-4" />
|
||||
@@ -132,6 +137,17 @@ export function IncomingCallPanel({ conversation }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<CallPreviewModal
|
||||
title={title}
|
||||
onJoin={() => {
|
||||
setShowPreview(false);
|
||||
void acceptIncoming('video');
|
||||
}}
|
||||
onCancel={() => setShowPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
ConversationAttachmentIndex,
|
||||
ConversationAttachmentItem,
|
||||
} from '../lib/conversationFeatures';
|
||||
import { AttachmentGeneric } from './AttachmentGeneric';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { ArrowRightIcon, FileIcon, ImageIcon, MicIcon, VideoIcon, XIcon } from './icons';
|
||||
|
||||
type DrawerTab = 'media' | 'files' | 'audio';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
index: ConversationAttachmentIndex;
|
||||
senderNameFor: (senderId: string) => string;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TAB_LABELS: Record<DrawerTab, string> = {
|
||||
media: 'Medien',
|
||||
files: 'Dateien',
|
||||
audio: 'Audio',
|
||||
};
|
||||
|
||||
export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage, onClose }: Props) {
|
||||
const [tab, setTab] = useState<DrawerTab>('media');
|
||||
const items = index[tab];
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
|
||||
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">Medien & Dateien</p>
|
||||
<p className="text-xs text-fg-muted">{index.all.length} Elemente im geladenen Verlauf</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Drawer schließen"
|
||||
title="Drawer schließen"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-3 gap-1 border-b border-line p-2">
|
||||
{(['media', 'files', 'audio'] as DrawerTab[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setTab(item)}
|
||||
className={
|
||||
'cursor-pointer rounded-lg px-2 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(tab === item
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
{TAB_LABELS[item]} · {index[item].length}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState tab={tab} />
|
||||
) : tab === 'media' ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{items.map((item) => (
|
||||
<MediaTile
|
||||
key={item.handle.id}
|
||||
item={item}
|
||||
senderName={senderNameFor(item.senderId)}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<FileRow
|
||||
key={item.handle.id}
|
||||
item={item}
|
||||
senderName={senderNameFor(item.senderId)}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaTile({
|
||||
item,
|
||||
senderName,
|
||||
onJumpToMessage,
|
||||
}: {
|
||||
item: ConversationAttachmentItem;
|
||||
senderName: string;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
}) {
|
||||
const isImage = item.handle.mimeType.startsWith('image/');
|
||||
const isVideo = item.handle.mimeType.startsWith('video/');
|
||||
return (
|
||||
<article className="overflow-hidden rounded-lg border border-line bg-surface-3 dark:bg-[#313338]">
|
||||
<div className="flex aspect-square items-center justify-center overflow-hidden bg-black/20">
|
||||
{isImage ? (
|
||||
<div className="flex max-h-full max-w-full items-center justify-center p-1 [&_button]:m-0 [&_img]:max-h-36">
|
||||
<AttachmentImage handle={item.handle} />
|
||||
</div>
|
||||
) : isVideo ? (
|
||||
<div className="flex flex-col items-center gap-2 text-fg-muted">
|
||||
<VideoIcon className="h-7 w-7" />
|
||||
<span className="text-xs">Video</span>
|
||||
</div>
|
||||
) : (
|
||||
<ImageIcon className="h-7 w-7 text-fg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<AttachmentMeta item={item} senderName={senderName} onJumpToMessage={onJumpToMessage} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
item,
|
||||
senderName,
|
||||
onJumpToMessage,
|
||||
}: {
|
||||
item: ConversationAttachmentItem;
|
||||
senderName: string;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
}) {
|
||||
const isAudio = item.handle.mimeType.startsWith('audio/');
|
||||
return (
|
||||
<article className="rounded-lg border border-line bg-surface-3 p-2 dark:bg-[#313338]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||
{isAudio ? <MicIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-fg">{labelFor(item.handle.mimeType)}</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{formatSize(item.handle.sizeBytes)} · {senderName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isAudio && <AttachmentGeneric handle={item.handle} />}
|
||||
<AttachmentMeta
|
||||
item={item}
|
||||
senderName={senderName}
|
||||
onJumpToMessage={onJumpToMessage}
|
||||
compact
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentMeta({
|
||||
item,
|
||||
senderName,
|
||||
onJumpToMessage,
|
||||
compact = false,
|
||||
}: {
|
||||
item: ConversationAttachmentItem;
|
||||
senderName: string;
|
||||
onJumpToMessage: (messageId: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const date = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(item.createdAt)),
|
||||
[item.createdAt],
|
||||
);
|
||||
return (
|
||||
<div className={(compact ? 'pt-2' : 'p-2') + ' flex items-center gap-2'}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{!compact && <p className="truncate text-xs font-semibold text-fg">{senderName}</p>}
|
||||
<p className="truncate text-[11px] text-fg-muted">{date}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJumpToMessage(item.messageId)}
|
||||
aria-label="Zur Nachricht springen"
|
||||
title="Zur Nachricht springen"
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ tab }: { tab: DrawerTab }) {
|
||||
const Icon = tab === 'media' ? ImageIcon : tab === 'audio' ? MicIcon : FileIcon;
|
||||
return (
|
||||
<div className="flex h-full min-h-[280px] flex-col items-center justify-center gap-3 text-center text-fg-muted">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-surface-3 dark:bg-[#313338]">
|
||||
<Icon className="h-6 w-6" />
|
||||
</span>
|
||||
<p className="max-w-48 text-sm">
|
||||
Noch keine {TAB_LABELS[tab].toLowerCase()} im geladenen Verlauf.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function labelFor(mimeType: string): string {
|
||||
if (mimeType.startsWith('audio/')) return 'Audiodatei';
|
||||
if (mimeType === 'application/pdf') return 'PDF-Datei';
|
||||
if (mimeType.startsWith('text/')) return 'Textdatei';
|
||||
return mimeType || 'Datei';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
}
|
||||
@@ -14,13 +14,7 @@ interface Props {
|
||||
// Dropdown shown above the composer when the user has typed `@` followed
|
||||
// by the start of a member name. Keyboard-first — arrow keys move through,
|
||||
// enter/tab commits, escape cancels.
|
||||
export function MentionAutocomplete({
|
||||
members,
|
||||
query,
|
||||
excludeUserId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
export function MentionAutocomplete({ members, query, excludeUserId, onSelect, onClose }: Props) {
|
||||
const q = query.toLowerCase();
|
||||
const matches = members
|
||||
.filter((m) => m.userId !== excludeUserId)
|
||||
@@ -67,7 +61,7 @@ export function MentionAutocomplete({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mitglieder"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
|
||||
>
|
||||
{matches.map((m, idx) => {
|
||||
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||
@@ -85,7 +79,9 @@ export function MentionAutocomplete({
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { summarizePollVotes } from '../lib/conversationFeatures';
|
||||
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||
import { AttachmentAudio } from './AttachmentAudio';
|
||||
import { AttachmentGeneric } from './AttachmentGeneric';
|
||||
@@ -21,7 +23,19 @@ import { AttachmentPdf } from './AttachmentPdf';
|
||||
import { AttachmentVideo } from './AttachmentVideo';
|
||||
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
import {
|
||||
CopyIcon,
|
||||
ForwardIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
ReplyIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from './icons';
|
||||
|
||||
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
|
||||
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -45,6 +59,7 @@ interface Props {
|
||||
conversationId: string;
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||
@@ -72,6 +87,7 @@ export function MessageBubble({
|
||||
conversationId,
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
onVotePoll,
|
||||
showSeen = false,
|
||||
deliveryState,
|
||||
quoted = null,
|
||||
@@ -93,6 +109,11 @@ export function MessageBubble({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
source: 'context' | 'more';
|
||||
} | null>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const createdAt = new Date(message.createdAt);
|
||||
@@ -100,6 +121,12 @@ export function MessageBubble({
|
||||
const withinEditWindow = age < EDIT_WINDOW_MS;
|
||||
const bodyText = initialText;
|
||||
const attachments = initialAttachments;
|
||||
const pollOptionEmojis =
|
||||
parsed.kind === 'poll' ? parsed.options.map((option) => option.emoji) : [];
|
||||
const visibleReactions =
|
||||
pollOptionEmojis.length === 0
|
||||
? reactions
|
||||
: reactions.filter((reaction) => !pollOptionEmojis.includes(reaction.emoji));
|
||||
|
||||
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
|
||||
// fires the soft-delete when the clock runs out. Receivers just watch
|
||||
@@ -112,9 +139,7 @@ export function MessageBubble({
|
||||
return () => window.clearInterval(id);
|
||||
}, [expireMs]);
|
||||
const msLeft =
|
||||
expireMs !== undefined
|
||||
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
|
||||
: null;
|
||||
expireMs !== undefined ? Math.max(0, expireMs - (tickNow - createdAt.getTime())) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mine) return;
|
||||
@@ -131,9 +156,7 @@ export function MessageBubble({
|
||||
|
||||
// Receiver-side auto-hide when the expiry window elapses even if the
|
||||
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
|
||||
const [localExpired, setLocalExpired] = useState<boolean>(
|
||||
msLeft !== null && msLeft <= 0,
|
||||
);
|
||||
const [localExpired, setLocalExpired] = useState<boolean>(msLeft !== null && msLeft <= 0);
|
||||
useEffect(() => {
|
||||
if (expireMs === undefined) return;
|
||||
if (localExpired) return;
|
||||
@@ -147,7 +170,10 @@ export function MessageBubble({
|
||||
withinEditWindow &&
|
||||
!message.deletedAt &&
|
||||
attachments.length === 0;
|
||||
const canDelete = parsed.kind === 'text' && mine && !message.deletedAt;
|
||||
const canDelete =
|
||||
(parsed.kind === 'text' || parsed.kind === 'poll') && mine && !message.deletedAt;
|
||||
const canCopy = (parsed.kind === 'text' && bodyText.length > 0) || parsed.kind === 'poll';
|
||||
const hasMoreMenuActions = canCopy || canEdit || canDelete;
|
||||
|
||||
const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(
|
||||
createdAt,
|
||||
@@ -225,9 +251,33 @@ export function MessageBubble({
|
||||
[onToggleReaction],
|
||||
);
|
||||
|
||||
const copyableText =
|
||||
parsed.kind === 'poll'
|
||||
? [parsed.question, ...parsed.options.map((option) => '- ' + option.text)].join('\n')
|
||||
: bodyText;
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!copyableText.trim()) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyableText);
|
||||
} catch (err: unknown) {
|
||||
console.warn('copy message failed', err);
|
||||
}
|
||||
}, [copyableText]);
|
||||
|
||||
const openContextMenu = useCallback((ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
setContextMenu({ x: ev.clientX, y: ev.clientY, source: 'context' });
|
||||
}, []);
|
||||
|
||||
if (message.deletedAt || localExpired) {
|
||||
return (
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<div
|
||||
className={
|
||||
'-mx-2 flex items-end gap-2 rounded-xl px-2 py-0.5 transition hover:bg-surface-2/45 dark:hover:bg-[#2e3035] ' +
|
||||
(mine ? 'flex-row-reverse' : '')
|
||||
}
|
||||
>
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
@@ -236,7 +286,7 @@ export function MessageBubble({
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
/>
|
||||
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
||||
<div className="max-w-[72%] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted dark:bg-[#383a40]">
|
||||
{t('app:chats.deleted')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,19 +298,24 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<div
|
||||
onContextMenu={openContextMenu}
|
||||
className={
|
||||
'-mx-2 flex items-end gap-2 rounded-xl px-2 py-0.5 transition hover:bg-surface-2/45 dark:hover:bg-[#2e3035] ' +
|
||||
(mine ? 'flex-row-reverse' : '')
|
||||
}
|
||||
>
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
||||
}
|
||||
>
|
||||
<div className={'group relative max-w-[72%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')}>
|
||||
{editing ? (
|
||||
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
|
||||
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm dark:bg-[#383a40]">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
@@ -276,7 +331,7 @@ export function MessageBubble({
|
||||
}}
|
||||
rows={2}
|
||||
autoFocus
|
||||
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
|
||||
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50 dark:bg-[#313338]"
|
||||
/>
|
||||
{editError && (
|
||||
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
|
||||
@@ -290,7 +345,7 @@ export function MessageBubble({
|
||||
setEditing(false);
|
||||
setEditError(null);
|
||||
}}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:friends.action_cancel')}
|
||||
</button>
|
||||
@@ -309,11 +364,11 @@ export function MessageBubble({
|
||||
<div
|
||||
data-message-id={message.id}
|
||||
className={
|
||||
'break-words px-3.5 py-2 text-sm transition ' +
|
||||
'break-words px-3.5 py-2 text-sm shadow-sm transition ' +
|
||||
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
|
||||
(mine
|
||||
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
|
||||
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
|
||||
: 'rounded-[18px_18px_18px_4px] bg-surface-2/90 text-fg dark:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
{quoted && (
|
||||
@@ -324,14 +379,13 @@ export function MessageBubble({
|
||||
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
|
||||
(mine
|
||||
? 'bg-white/10 text-accent-fg/90'
|
||||
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
|
||||
: 'bg-surface-3/80 text-fg-muted ring-1 ring-inset ring-line dark:bg-[#313338]')
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'-ml-1 w-1 shrink-0 rounded-full ' +
|
||||
(mine ? 'bg-white/70' : 'bg-accent')
|
||||
'-ml-1 w-1 shrink-0 rounded-full ' + (mine ? 'bg-white/70' : 'bg-accent')
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 pl-1">
|
||||
@@ -356,6 +410,16 @@ export function MessageBubble({
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
options={parsed.options}
|
||||
reactions={reactions}
|
||||
mine={mine}
|
||||
onVote={(emoji) =>
|
||||
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
|
||||
@@ -413,12 +477,10 @@ export function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reactions.length > 0 && !editing && (
|
||||
{visibleReactions.length > 0 && !editing && (
|
||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
{reactions.map((r) => (
|
||||
{visibleReactions.map((r) => (
|
||||
<button
|
||||
// Keying by emoji+count makes React remount the chip when the
|
||||
// count flips, replaying the pop animation. Cheap visual cue.
|
||||
key={r.emoji + ':' + r.count}
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
@@ -426,7 +488,7 @@ export function MessageBubble({
|
||||
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(r.mine
|
||||
? 'border-accent/40 bg-accent/20 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3 dark:bg-[#383a40] dark:hover:bg-[#404249]')
|
||||
}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
@@ -439,11 +501,14 @@ export function MessageBubble({
|
||||
{!editing && (
|
||||
<div
|
||||
className={
|
||||
'pointer-events-none absolute top-0 z-20 opacity-0 transition group-hover:pointer-events-auto group-hover:opacity-100 ' +
|
||||
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
||||
'absolute bottom-full pb-1 z-20 transition ' +
|
||||
(pickerOpen || contextMenu
|
||||
? 'pointer-events-auto opacity-100 '
|
||||
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 ') +
|
||||
(mine ? 'right-2' : 'left-2')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg dark:bg-[#313338]">
|
||||
<ActionButton
|
||||
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
@@ -463,22 +528,18 @@ export function MessageBubble({
|
||||
icon={<ForwardIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
{hasMoreMenuActions && (
|
||||
<ActionButton
|
||||
label="Edit"
|
||||
onClick={() => {
|
||||
setEditText(message.plaintext ?? '');
|
||||
setEditing(true);
|
||||
label="Mehr"
|
||||
onClick={(ev) => {
|
||||
const rect = ev.currentTarget.getBoundingClientRect();
|
||||
setContextMenu({
|
||||
x: rect.left,
|
||||
y: rect.bottom + 8,
|
||||
source: 'more',
|
||||
});
|
||||
}}
|
||||
icon={<PencilIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ActionButton
|
||||
label="Delete"
|
||||
onClick={() => void handleDelete()}
|
||||
icon={<TrashIcon className="h-4 w-4" />}
|
||||
tone="danger"
|
||||
icon={<MoreVerticalIcon className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -488,7 +549,7 @@ export function MessageBubble({
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
className={
|
||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
|
||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl dark:bg-[#313338] ' +
|
||||
(mine ? 'right-0' : 'left-0')
|
||||
}
|
||||
>
|
||||
@@ -497,7 +558,7 @@ export function MessageBubble({
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => void handlePickEmoji(e)}
|
||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
@@ -505,7 +566,7 @@ export function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
|
||||
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -513,11 +574,264 @@ export function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<MessageContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
canCopy={canCopy}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
showQuickActions={contextMenu.source === 'context'}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onReact={() => {
|
||||
setContextMenu(null);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
{...(onReply
|
||||
? {
|
||||
onReply: () => {
|
||||
setContextMenu(null);
|
||||
onReply(message);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
{...(onForward
|
||||
? {
|
||||
onForward: () => {
|
||||
setContextMenu(null);
|
||||
onForward(message);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
onEdit={() => {
|
||||
setContextMenu(null);
|
||||
setEditText(message.plaintext ?? '');
|
||||
setEditing(true);
|
||||
}}
|
||||
onCopy={() => {
|
||||
setContextMenu(null);
|
||||
void handleCopy();
|
||||
}}
|
||||
onDelete={() => {
|
||||
setContextMenu(null);
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PollCard({
|
||||
question,
|
||||
options,
|
||||
reactions,
|
||||
onVote,
|
||||
mine,
|
||||
}: {
|
||||
question: string;
|
||||
options: { id: string; emoji: string; text: string }[];
|
||||
reactions: AggregatedReaction[];
|
||||
onVote: (emoji: string) => Promise<void>;
|
||||
mine: boolean;
|
||||
}) {
|
||||
const summary = summarizePollVotes(options, reactions);
|
||||
|
||||
const rowBase = mine
|
||||
? 'border-white/25 bg-white/10 hover:bg-white/15'
|
||||
: 'border-line bg-surface-3 hover:bg-surface-3/80 dark:bg-[#2b2d31] dark:hover:bg-[#313338]';
|
||||
const rowVoted = mine ? 'border-white/60 bg-white/25' : 'border-accent/60 bg-accent/20';
|
||||
const progressTint = mine ? 'bg-white/25' : 'bg-accent/25';
|
||||
const chipStyle = mine
|
||||
? 'bg-white/20 text-accent-fg'
|
||||
: 'bg-surface-2 text-fg dark:bg-[#1e1f22]';
|
||||
const textMain = mine ? 'text-accent-fg' : 'text-fg';
|
||||
const textCount = mine ? 'text-accent-fg/90' : 'text-fg';
|
||||
const textFooter = mine ? 'text-accent-fg/75' : 'text-fg-muted';
|
||||
const focusRing = mine ? 'focus-visible:ring-white/60' : 'focus-visible:ring-accent/40';
|
||||
|
||||
return (
|
||||
<div className="min-w-[260px] max-w-[360px]">
|
||||
<p className={'mb-3 text-sm font-semibold leading-snug ' + textMain}>
|
||||
{question || 'Umfrage'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{summary.options.map((option, idx) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => void onVote(option.emoji)}
|
||||
className={
|
||||
'relative flex w-full cursor-pointer items-center gap-2.5 overflow-hidden rounded-lg border px-3 py-2 text-left text-sm transition focus:outline-none focus-visible:ring-2 ' +
|
||||
focusRing +
|
||||
' ' +
|
||||
textMain +
|
||||
' ' +
|
||||
(option.mine ? rowVoted : rowBase)
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ width: option.percent + '%' }}
|
||||
className={'absolute inset-y-0 left-0 transition-[width] ' + progressTint}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-sm font-bold tabular-nums ' +
|
||||
chipStyle
|
||||
}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="relative min-w-0 flex-1 break-words font-medium">{option.text}</span>
|
||||
<span className={'relative shrink-0 text-xs font-semibold tabular-nums ' + textCount}>
|
||||
{option.count} · {option.percent}%
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={'mt-2 text-[11px] ' + textFooter}>
|
||||
{summary.totalVotes === 1 ? '1 Stimme' : summary.totalVotes + ' Stimmen'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContextMenu({
|
||||
x,
|
||||
y,
|
||||
canCopy,
|
||||
canEdit,
|
||||
canDelete,
|
||||
showQuickActions,
|
||||
onClose,
|
||||
onReact,
|
||||
onReply,
|
||||
onForward,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
canCopy: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
showQuickActions: boolean;
|
||||
onClose: () => void;
|
||||
onReact: () => void;
|
||||
onReply?: () => void;
|
||||
onForward?: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
function onDown(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-message-context-menu]')) return;
|
||||
onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const left = Math.min(Math.max(8, x), window.innerWidth - 232);
|
||||
const top = Math.min(Math.max(8, y), window.innerHeight - 280);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-message-context-menu
|
||||
role="menu"
|
||||
style={{ left, top, width: 224 }}
|
||||
className="fixed z-[90] rounded-xl border border-line bg-surface-3 p-1.5 shadow-2xl dark:bg-[#313338]"
|
||||
>
|
||||
{showQuickActions && (
|
||||
<>
|
||||
<MenuItem
|
||||
label="Reaktion hinzufügen"
|
||||
icon={<SmileIcon className="h-4 w-4" />}
|
||||
onClick={onReact}
|
||||
/>
|
||||
{onReply && (
|
||||
<MenuItem
|
||||
label="Antworten"
|
||||
icon={<ReplyIcon className="h-4 w-4" />}
|
||||
onClick={onReply}
|
||||
/>
|
||||
)}
|
||||
{onForward && (
|
||||
<MenuItem
|
||||
label="Weiterleiten"
|
||||
icon={<ForwardIcon className="h-4 w-4" />}
|
||||
onClick={onForward}
|
||||
/>
|
||||
)}
|
||||
{(canCopy || canEdit || canDelete) && <MenuSeparator />}
|
||||
</>
|
||||
)}
|
||||
{canCopy && (
|
||||
<MenuItem label="Text kopieren" icon={<CopyIcon className="h-4 w-4" />} onClick={onCopy} />
|
||||
)}
|
||||
{canEdit && (
|
||||
<MenuItem label="Bearbeiten" icon={<PencilIcon className="h-4 w-4" />} onClick={onEdit} />
|
||||
)}
|
||||
{canDelete && (
|
||||
<MenuItem
|
||||
label="Löschen"
|
||||
icon={<TrashIcon className="h-4 w-4" />}
|
||||
onClick={onDelete}
|
||||
tone="danger"
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSeparator() {
|
||||
return <div aria-hidden="true" className="my-1 h-px bg-line" />;
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tone?: 'danger';
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(tone === 'danger'
|
||||
? 'text-rose-500 hover:bg-rose-500/15 dark:text-rose-300'
|
||||
: 'text-fg hover:bg-surface-2 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<span className="shrink-0 text-fg-muted">{icon}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarSlot({
|
||||
show,
|
||||
url,
|
||||
@@ -545,13 +859,7 @@ function AvatarSlot({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={displayName ?? ''}
|
||||
className="h-8 w-8 text-xs"
|
||||
/>
|
||||
);
|
||||
return <Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />;
|
||||
}
|
||||
|
||||
function CallEventRow({
|
||||
@@ -608,10 +916,6 @@ function formatDuration(totalSec: number): string {
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
// Splits body text on `@username` tokens, rendering matches as highlighted
|
||||
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
|
||||
// + underscores, length 1..32 (we don't bound here — regex is permissive
|
||||
// and keys off a leading `@` with an alnum/underscore follow).
|
||||
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
|
||||
|
||||
function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||
@@ -624,7 +928,7 @@ function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||
out.push(
|
||||
<span
|
||||
key={m.index + ':' + m[1]}
|
||||
className="rounded bg-accent/20 px-1 text-accent"
|
||||
className="rounded bg-accent/20 px-1 font-medium text-inherit ring-1 ring-inset ring-accent/25"
|
||||
>
|
||||
{m[0]}
|
||||
</span>,
|
||||
@@ -636,31 +940,24 @@ function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||
}
|
||||
|
||||
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||
const color =
|
||||
state === 'read'
|
||||
? 'text-sky-500 dark:text-sky-400'
|
||||
: 'text-fg-muted';
|
||||
const color = state === 'read' ? 'text-sky-500 dark:text-sky-400' : 'text-fg-muted';
|
||||
return (
|
||||
<span
|
||||
aria-label={
|
||||
state === 'read'
|
||||
? 'Gelesen'
|
||||
: state === 'delivered'
|
||||
? 'Zugestellt'
|
||||
: 'Gesendet'
|
||||
}
|
||||
title={
|
||||
state === 'read'
|
||||
? 'Gelesen'
|
||||
: state === 'delivered'
|
||||
? 'Zugestellt'
|
||||
: 'Gesendet'
|
||||
}
|
||||
aria-label={state === 'read' ? 'Gelesen' : state === 'delivered' ? 'Zugestellt' : 'Gesendet'}
|
||||
title={state === 'read' ? 'Gelesen' : state === 'delivered' ? 'Zugestellt' : 'Gesendet'}
|
||||
className={'flex items-center ' + color}
|
||||
>
|
||||
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<svg
|
||||
viewBox="0 0 16 12"
|
||||
width="14"
|
||||
height="10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{state === 'sent' ? (
|
||||
<polyline points="2 7 6 11 14 1" />
|
||||
) : (
|
||||
@@ -681,7 +978,7 @@ function ActionButton({
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClick: (ev: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
icon: React.ReactNode;
|
||||
tone?: 'danger';
|
||||
}) {
|
||||
@@ -695,7 +992,7 @@ function ActionButton({
|
||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(tone === 'danger'
|
||||
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getAudioSettings, subscribeAudioSettings } from '../lib/audioSettings';
|
||||
|
||||
type Phase = 'idle' | 'live' | 'recording' | 'playback';
|
||||
|
||||
const RECORD_DURATION_MS = 4000;
|
||||
|
||||
/**
|
||||
* Discord-style "Let's Check" mic test panel. Two parts:
|
||||
* 1. Live bargraph driven by AnalyserNode on the user's selected mic so the
|
||||
* user sees their RMS in real time and can sanity-check the
|
||||
* voice-threshold slider above.
|
||||
* 2. Mic-Test button: records ~4s into a MediaRecorder, then plays it back
|
||||
* so the user hears exactly what their peers will hear.
|
||||
*
|
||||
* Resources are released aggressively — every state change tears down the
|
||||
* audio graph + media stream so the camera/mic light is honest about
|
||||
* activity.
|
||||
*/
|
||||
export function MicTestSection() {
|
||||
const [phase, setPhase] = useState<Phase>('idle');
|
||||
const [level, setLevel] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [threshold, setThreshold] = useState<number>(
|
||||
() => getAudioSettings().voiceThreshold,
|
||||
);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const playbackUrlRef = useRef<string | null>(null);
|
||||
const playbackElRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Pull the threshold live so the bargraph's threshold marker updates
|
||||
// while the user is dragging the slider above.
|
||||
useEffect(() => {
|
||||
return subscribeAudioSettings((s) => setThreshold(s.voiceThreshold));
|
||||
}, []);
|
||||
|
||||
const teardownGraph = useCallback(() => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const stream = streamRef.current;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
const ctx = audioContextRef.current;
|
||||
if (ctx) {
|
||||
void ctx.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
|
||||
const teardownPlayback = useCallback(() => {
|
||||
const el = playbackElRef.current;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.src = '';
|
||||
playbackElRef.current = null;
|
||||
}
|
||||
const url = playbackUrlRef.current;
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
playbackUrlRef.current = null;
|
||||
}
|
||||
chunksRef.current = [];
|
||||
}, []);
|
||||
|
||||
// Final cleanup on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
teardownGraph();
|
||||
teardownPlayback();
|
||||
};
|
||||
}, [teardownGraph, teardownPlayback]);
|
||||
|
||||
const acquireMic = useCallback(async (): Promise<MediaStream> => {
|
||||
const settings = getAudioSettings();
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: settings.inputDeviceId
|
||||
? { deviceId: { exact: settings.inputDeviceId } }
|
||||
: true,
|
||||
};
|
||||
return navigator.mediaDevices.getUserMedia(constraints);
|
||||
}, []);
|
||||
|
||||
const startLive = useCallback(async () => {
|
||||
setError(null);
|
||||
teardownPlayback();
|
||||
teardownGraph();
|
||||
try {
|
||||
const stream = await acquireMic();
|
||||
streamRef.current = stream;
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) throw new Error('AudioContext nicht verfügbar');
|
||||
const ctx = new AudioCtx();
|
||||
audioContextRef.current = ctx;
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
src.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const buffer = new Float32Array(analyser.fftSize);
|
||||
const tick = () => {
|
||||
const a = analyserRef.current;
|
||||
if (!a) return;
|
||||
a.getFloatTimeDomainData(buffer);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const s = buffer[i] ?? 0;
|
||||
sum += s * s;
|
||||
}
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
setLevel(rms);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
setPhase('live');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Mikrofon nicht verfügbar');
|
||||
setPhase('idle');
|
||||
}
|
||||
}, [acquireMic, teardownGraph, teardownPlayback]);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
const rec = recorderRef.current;
|
||||
if (rec && rec.state !== 'inactive') {
|
||||
try {
|
||||
rec.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
recorderRef.current = null;
|
||||
teardownGraph();
|
||||
teardownPlayback();
|
||||
setLevel(0);
|
||||
setPhase('idle');
|
||||
}, [teardownGraph, teardownPlayback]);
|
||||
|
||||
const startTest = useCallback(async () => {
|
||||
setError(null);
|
||||
teardownPlayback();
|
||||
teardownGraph();
|
||||
try {
|
||||
const stream = await acquireMic();
|
||||
streamRef.current = stream;
|
||||
// Build the same analyser graph as live so the bargraph stays alive
|
||||
// while recording — gives the user feedback that they're being heard.
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
const ctx = new AudioCtx();
|
||||
audioContextRef.current = ctx;
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 1024;
|
||||
src.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
const buf = new Float32Array(analyser.fftSize);
|
||||
const tick = () => {
|
||||
const a = analyserRef.current;
|
||||
if (!a) return;
|
||||
a.getFloatTimeDomainData(buf);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const s = buf[i] ?? 0;
|
||||
sum += s * s;
|
||||
}
|
||||
setLevel(Math.sqrt(sum / buf.length));
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const rec = new MediaRecorder(stream);
|
||||
recorderRef.current = rec;
|
||||
chunksRef.current = [];
|
||||
rec.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
rec.onstop = () => {
|
||||
// Stop the analyser/stream first so the mic light goes off while the
|
||||
// playback-only phase is active. Keep playback on its own element.
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
const stream2 = streamRef.current;
|
||||
if (stream2) {
|
||||
stream2.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
const ctx2 = audioContextRef.current;
|
||||
if (ctx2) {
|
||||
void ctx2.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
setLevel(0);
|
||||
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
playbackUrlRef.current = url;
|
||||
const el = new Audio(url);
|
||||
playbackElRef.current = el;
|
||||
el.onended = () => {
|
||||
teardownPlayback();
|
||||
setPhase('idle');
|
||||
};
|
||||
setPhase('playback');
|
||||
void el.play().catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Wiedergabe fehlgeschlagen');
|
||||
setPhase('idle');
|
||||
});
|
||||
};
|
||||
rec.start();
|
||||
setPhase('recording');
|
||||
window.setTimeout(() => {
|
||||
const cur = recorderRef.current;
|
||||
if (cur && cur.state === 'recording') {
|
||||
try {
|
||||
cur.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, RECORD_DURATION_MS);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Mikrofon nicht verfügbar');
|
||||
stopAll();
|
||||
}
|
||||
}, [acquireMic, stopAll, teardownGraph, teardownPlayback]);
|
||||
|
||||
// Bar fill 0..1 — clamp to 1 so very loud sources still fit.
|
||||
const fill = Math.min(1, level / 0.2);
|
||||
const thresholdMark = Math.min(1, threshold / 0.2);
|
||||
const speaking = level > threshold;
|
||||
|
||||
const buttonLabel: Record<Phase, string> = {
|
||||
idle: 'Live-Pegel zeigen',
|
||||
live: 'Live stoppen',
|
||||
recording: 'Aufnahme stoppen',
|
||||
playback: 'Wiedergabe',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">Mikrofon-Test</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Sieh deinen Live-Pegel (grün = du wirst als sprechend erkannt) und nimm einen 4-Sekunden-Test
|
||||
auf, um zu hören, wie deine Peers dich hören.
|
||||
</p>
|
||||
|
||||
<div className="mt-2 space-y-3 rounded-lg border border-line bg-surface-3 p-3">
|
||||
<div className="relative h-3 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className={
|
||||
'h-full transition-[width] duration-75 ease-out ' +
|
||||
(speaking ? 'bg-emerald-500' : 'bg-accent/70')
|
||||
}
|
||||
style={{ width: (fill * 100).toFixed(2) + '%' }}
|
||||
/>
|
||||
<div
|
||||
aria-label="Schwelle"
|
||||
title="Schwelle"
|
||||
className="pointer-events-none absolute top-0 h-full w-0.5 bg-amber-400"
|
||||
style={{ left: 'calc(' + (thresholdMark * 100).toFixed(2) + '% - 1px)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{phase === 'idle' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startLive()}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-1.5 text-xs font-semibold text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
{buttonLabel.idle}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startTest()}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110"
|
||||
>
|
||||
Mic-Test (4s)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{phase === 'live' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-400"
|
||||
>
|
||||
{buttonLabel.live}
|
||||
</button>
|
||||
)}
|
||||
{phase === 'recording' && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-semibold text-rose-600 dark:text-rose-400">
|
||||
<span className="h-2 w-2 rounded-full bg-rose-500 motion-safe:animate-live-dot" />
|
||||
Aufnahme läuft …
|
||||
</span>
|
||||
)}
|
||||
{phase === 'playback' && (
|
||||
<span className="inline-flex items-center gap-2 rounded-lg bg-accent/15 px-3 py-1.5 text-xs font-semibold text-accent">
|
||||
Spielt deine Aufnahme zurück …
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { playNotificationTone } from '../lib/notificationSound';
|
||||
import {
|
||||
clearCustomNotificationSound,
|
||||
getCustomNotificationSoundMeta,
|
||||
MAX_NOTIFICATION_SOUND_BYTES,
|
||||
type NotificationSoundEntry,
|
||||
setCustomNotificationSound,
|
||||
subscribeNotificationSoundChanges,
|
||||
} from '../lib/notificationSoundStorage';
|
||||
import { SpinnerIcon, TrashIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
|
||||
// UI for the custom new-message notification chime. Single file slot.
|
||||
// Preview button calls playNotificationTone() directly so the user
|
||||
// hears exactly what a real incoming message will sound like (same
|
||||
// AudioContext, same volume curve).
|
||||
export function NotificationSoundSettings({ disabled = false }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [current, setCurrent] = useState<NotificationSoundEntry | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const meta = await getCustomNotificationSoundMeta();
|
||||
setCurrent(meta);
|
||||
} catch (err: unknown) {
|
||||
console.error('getCustomNotificationSoundMeta failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
return subscribeNotificationSoundChanges(() => {
|
||||
void refresh();
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
async function handleFile(file: File): Promise<void> {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await setCustomNotificationSound(file);
|
||||
// refresh fires via subscribeNotificationSoundChanges
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Error ? err.message : 'upload_failed';
|
||||
if (code === 'sound_too_large') {
|
||||
setError(
|
||||
t('app:settings.notif_sound_error_too_large', {
|
||||
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||||
max: MAX_NOTIFICATION_SOUND_BYTES / BYTES_PER_MB,
|
||||
}),
|
||||
);
|
||||
} else if (code === 'sound_not_audio') {
|
||||
setError(
|
||||
t('app:settings.notif_sound_error_not_audio', {
|
||||
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||
}),
|
||||
);
|
||||
} else if (code === 'empty_file') {
|
||||
setError(
|
||||
t('app:settings.notif_sound_error_empty', {
|
||||
defaultValue: 'Leere Datei.',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
t('app:settings.notif_sound_error_generic', {
|
||||
defaultValue: 'Ton konnte nicht gespeichert werden.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearCustomNotificationSound();
|
||||
// refresh fires via subscribeNotificationSoundChanges
|
||||
} catch (err: unknown) {
|
||||
console.error('clearCustomNotificationSound failed', err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview(): void {
|
||||
// Same code path as a real notification — user hears exactly what
|
||||
// peers triggering a message will cause.
|
||||
playNotificationTone();
|
||||
}
|
||||
|
||||
const hasCustom = current !== null;
|
||||
const sizeMb = current ? (current.size / BYTES_PER_MB).toFixed(2) : null;
|
||||
const interactionsDisabled = disabled || busy;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.notif_sound_title', { defaultValue: 'Benachrichtigungston' })}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-fg-muted">
|
||||
{hasCustom && current
|
||||
? t('app:settings.notif_sound_custom_active', {
|
||||
defaultValue: '{{name}} · {{size}} MB',
|
||||
name: current.filename,
|
||||
size: sizeMb,
|
||||
})
|
||||
: t('app:settings.notif_sound_default_active', {
|
||||
defaultValue: 'Standard (Zwei-Ton-Chime)',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={interactionsDisabled}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.notif_sound_preview', { defaultValue: 'Probe hören' })}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={interactionsDisabled}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||
<span>
|
||||
{hasCustom
|
||||
? t('app:settings.notif_sound_replace', { defaultValue: 'Ersetzen' })
|
||||
: t('app:settings.notif_sound_upload', { defaultValue: 'Hochladen' })}
|
||||
</span>
|
||||
</button>
|
||||
{hasCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleReset()}
|
||||
disabled={interactionsDisabled}
|
||||
aria-label={t('app:settings.notif_sound_reset', { defaultValue: 'Zurücksetzen' })}
|
||||
title={t('app:settings.notif_sound_reset', { defaultValue: 'Zurücksetzen' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t('app:settings.notif_sound_hint', {
|
||||
defaultValue:
|
||||
'MP3, WAV, OGG oder M4A bis 1 MB. Spielt bei neuen Nachrichten in Chats die du nicht aktiv ansiehst.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
@@ -6,26 +6,52 @@ import {
|
||||
setParticipantVolume,
|
||||
subscribeParticipantVolumes,
|
||||
} from '../lib/participantVolumes';
|
||||
import {
|
||||
AtIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Discord-style pin toggle row at the top of the menu. When undefined,
|
||||
* the row is hidden — used for tiles that don't make sense to pin. */
|
||||
pinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
/** Hide the volume slider — for self-tiles where the slider would adjust
|
||||
* the local user's own playback gain (which we don't expose). Defaults to
|
||||
* true so existing call sites keep their behaviour. */
|
||||
renderVolume?: boolean;
|
||||
/** Discord-parity: open this user's profile card from the menu. When
|
||||
* undefined the row is hidden (e.g. for self-tiles where it'd just open
|
||||
* your own profile). */
|
||||
onShowProfile?: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MENU_W = 240;
|
||||
const MENU_H = 96;
|
||||
const MENU_H = 138;
|
||||
|
||||
export function ParticipantVolumeMenu({
|
||||
userId,
|
||||
displayName,
|
||||
x,
|
||||
y,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
renderVolume = true,
|
||||
onShowProfile,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
|
||||
// Remembers the volume before "Für mich stummschalten" so unmute restores
|
||||
// it instead of snapping back to 100%.
|
||||
const preMuteRef = useRef<number | null>(volume > 0 ? volume : null);
|
||||
|
||||
// Re-sync from store in case another menu instance changed the same user.
|
||||
useEffect(() => subscribeParticipantVolumes(() => {
|
||||
@@ -63,35 +89,99 @@ export function ParticipantVolumeMenu({
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||
<span
|
||||
{renderVolume && (
|
||||
<span
|
||||
className={
|
||||
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{onShowProfile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onShowProfile();
|
||||
onClose();
|
||||
}}
|
||||
className="mb-1.5 flex w-full cursor-pointer items-center gap-2 rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-xs font-medium text-fg transition hover:border-accent hover:bg-accent/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<AtIcon className="h-3.5 w-3.5" />
|
||||
<span>Profil anzeigen</span>
|
||||
</button>
|
||||
)}
|
||||
{renderVolume && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Discord-parity "Mute for me": toggle local playback gain.
|
||||
// Storing the pre-mute level lets unmute restore the user's
|
||||
// chosen volume (e.g. 130% → 0 → 130%) instead of snapping to 100.
|
||||
if (volume === 0) {
|
||||
const restored = preMuteRef.current ?? 1;
|
||||
setVolume(restored);
|
||||
setParticipantVolume(userId, restored);
|
||||
} else {
|
||||
preMuteRef.current = volume;
|
||||
setVolume(0);
|
||||
setParticipantVolume(userId, 0);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||
'mb-1.5 flex w-full cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(volume === 0
|
||||
? 'border-rose-500/40 bg-rose-500/15 text-rose-600 hover:bg-rose-500/20 dark:text-rose-300'
|
||||
: 'border-line bg-surface-3 text-fg hover:border-accent hover:bg-accent/10')
|
||||
}
|
||||
>
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
|
||||
mics — the amber count-up hints at that without a verbose warning. */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
setParticipantVolume(userId, v);
|
||||
}}
|
||||
aria-label={'Lautstärke ' + displayName}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
|
||||
<span>0%</span>
|
||||
<span className="tabular-nums">100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
{volume === 0 ? (
|
||||
<MicIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<MicOffIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{volume === 0 ? 'Lokalen Ton aktivieren' : 'Für mich stummschalten'}</span>
|
||||
</button>
|
||||
)}
|
||||
{onTogglePin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTogglePin();
|
||||
onClose();
|
||||
}}
|
||||
className="mb-2 flex w-full cursor-pointer items-center gap-2 rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-xs font-medium text-fg transition hover:border-accent hover:bg-accent/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{pinned ? <PinOffIcon className="h-3.5 w-3.5" /> : <PinIcon className="h-3.5 w-3.5" />}
|
||||
<span>{pinned ? 'Anpinnen aufheben' : 'Anpinnen'}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderVolume && (
|
||||
<>
|
||||
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
|
||||
mics — the amber count-up hints at that without a verbose warning. */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
setParticipantVolume(userId, v);
|
||||
}}
|
||||
aria-label={'Lautstärke ' + displayName}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
|
||||
<span>0%</span>
|
||||
<span className="tabular-nums">100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { PollIcon, PlusIcon, SpinnerIcon, TrashIcon } from './icons';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (question: string, options: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PollComposerDialog({ open, sending, error, onClose, onSubmit }: Props) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState(['', '']);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuestion('');
|
||||
setOptions(['', '']);
|
||||
}, [open]);
|
||||
|
||||
const filledOptions = options.map((option) => option.trim()).filter(Boolean);
|
||||
const canSubmit = question.trim().length > 0 && filledOptions.length >= 2 && !sending;
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Umfrage erstellen" onClose={onClose} size="md">
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
void onSubmit(question, options);
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="font-semibold text-fg">Frage</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
maxLength={140}
|
||||
placeholder="Worüber sollen wir abstimmen?"
|
||||
className="rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg outline-none transition placeholder:text-fg-muted focus:border-accent/60 focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-fg">Antworten</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions((prev) => [...prev, ''])}
|
||||
disabled={options.length >= 10}
|
||||
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
aria-label="Antwort hinzufügen"
|
||||
title="Antwort hinzufügen"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{options.map((option, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-xs font-semibold text-accent">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<input
|
||||
value={option}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setOptions((prev) =>
|
||||
prev.map((item, itemIdx) => (itemIdx === idx ? value : item)),
|
||||
);
|
||||
}}
|
||||
maxLength={80}
|
||||
placeholder={'Antwort ' + (idx + 1)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg outline-none transition placeholder:text-fg-muted focus:border-accent/60 focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptions((prev) => prev.filter((_, itemIdx) => itemIdx !== idx))}
|
||||
disabled={options.length <= 2}
|
||||
aria-label="Antwort entfernen"
|
||||
title="Antwort entfernen"
|
||||
className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-rose-500/15 hover:text-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/30 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{sending ? <SpinnerIcon className="h-4 w-4" /> : <PollIcon className="h-4 w-4" />}
|
||||
<span>Senden</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
setScreenShareVolume,
|
||||
subscribeScreenShareVolumes,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
|
||||
import { EyeOffIcon, HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
/** Participant whose screen share the user right-clicked. */
|
||||
@@ -23,8 +23,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const MENU_W = 260;
|
||||
const MENU_H_WITH_AUDIO = 200;
|
||||
const MENU_H_NO_AUDIO = 96;
|
||||
// Heights bumped to fit the new "Tile ausblenden" row (38px each).
|
||||
const MENU_H_WITH_AUDIO = 240;
|
||||
const MENU_H_NO_AUDIO = 134;
|
||||
|
||||
// Context menu surfaced on right-click of a remote screen share. Mirrors
|
||||
// Discord's stream menu: volume slider, audio mute toggle (independent of
|
||||
@@ -41,9 +42,12 @@ export function ScreenShareContextMenu({
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
dismissShare,
|
||||
stopWatchingShare,
|
||||
watchingShareUserIds,
|
||||
screenShareAudioMutedIds,
|
||||
setScreenShareAudioMuted,
|
||||
} = useCall();
|
||||
const watching = watchingShareUserIds.has(userId);
|
||||
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
|
||||
|
||||
useEffect(
|
||||
@@ -151,6 +155,25 @@ export function ScreenShareContextMenu({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Discord-parity: "Stop watching" only ends the local subscription;
|
||||
the tile stays so the user can re-click to watch again. The full
|
||||
dismiss path (kill the tile until the sharer restarts) is the
|
||||
second row below. */}
|
||||
{watching && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stopWatchingShare(userId);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4 text-fg-muted" />
|
||||
<span>
|
||||
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -159,9 +182,9 @@ export function ScreenShareContextMenu({
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
|
||||
{t('app:call.dismiss_tile', { defaultValue: 'Stream-Tile ausblenden' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { listScreenSources, type ScreenSource } from '../lib/screenSources';
|
||||
import {
|
||||
type ScreenSharePreset,
|
||||
getScreenShareSettings,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { MonitorShareIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Discord trims the picker to two questions: which source, and a couple of
|
||||
// quality knobs. Anything else lives in Settings → Bildschirmfreigabe (it
|
||||
// already does in this app). So the modal here mirrors that — tabs to switch
|
||||
// between screens and windows, thumbnail grid, and a compact footer with
|
||||
// quality + audio.
|
||||
|
||||
const TABS = [
|
||||
{ id: 'screen' as const, label: 'Bildschirme' },
|
||||
{ id: 'window' as const, label: 'Anwendungen' },
|
||||
];
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
||||
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
|
||||
{ id: 'auto', label: 'Auto' },
|
||||
{ id: '720p60', label: '720p · 60' },
|
||||
{ id: '1080p60', label: '1080p · 60' },
|
||||
{ id: '1440p60', label: '1440p · 60' },
|
||||
];
|
||||
|
||||
const THUMBNAIL_REFRESH_MS = 3500;
|
||||
|
||||
export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
const { startScreenShare } = useCall();
|
||||
|
||||
const [tab, setTab] = useState<TabId>('screen');
|
||||
const [sources, setSources] = useState<ScreenSource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(
|
||||
() => getScreenShareSettings().preset,
|
||||
);
|
||||
const [audio, setAudio] = useState<boolean>(
|
||||
() => getScreenShareSettings().includeSystemAudio,
|
||||
);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load + refresh thumbnails. Local `cancelled` flag is the single source
|
||||
// of mount-state truth; we deliberately do NOT use a mountedRef pattern
|
||||
// because React 18 strict-mode runs effects twice and a ref set to false
|
||||
// in cleanup never gets re-set on remount, leaving `Lade …` hanging.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
try {
|
||||
const list = await listScreenSources();
|
||||
if (cancelled) return;
|
||||
setSources(list);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.warn('listScreenSources failed', err);
|
||||
setLoading(false);
|
||||
}
|
||||
if (cancelled) return;
|
||||
timer = setTimeout(() => {
|
||||
if (!cancelled && !busy) void tick();
|
||||
}, THUMBNAIL_REFRESH_MS);
|
||||
};
|
||||
void tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [busy]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !busy) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, busy]);
|
||||
|
||||
const visibleSources = sources.filter((s) => s.kind === tab);
|
||||
|
||||
const handleClose = () => {
|
||||
if (busy) return;
|
||||
void window.electronAPI.setPendingShareSource(null).catch(() => {});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!selectedId) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// Persist quality + audio toggle. Also force-clear any stale duck
|
||||
// setting users may have inherited from earlier builds — the
|
||||
// native loopback addon excludes the app's own audio at OS level
|
||||
// now, so JS-side ducking (which muted the user's incoming peer
|
||||
// audio) is no longer needed and was causing "I can't hear anyone".
|
||||
updateScreenShareSettings({
|
||||
preset,
|
||||
includeSystemAudio: audio,
|
||||
duckRemoteAudioWhileSharing: false,
|
||||
});
|
||||
// Stage the picked source id for main BEFORE getDisplayMedia. Main
|
||||
// reads + clears it on the next display-media request.
|
||||
await window.electronAPI.setPendingShareSource(selectedId);
|
||||
await startScreenShare({
|
||||
preset,
|
||||
displaySurface: tab === 'screen' ? 'monitor' : 'window',
|
||||
framerate: null,
|
||||
// Forward the picked source id so the native loopback path can
|
||||
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
|
||||
// (parses HWND from `window:<HWND>:0`). For screen-shares this
|
||||
// is just informational — the EXCLUDE-self path stays in play.
|
||||
pickedSourceId: selectedId,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
try {
|
||||
await window.electronAPI.setPendingShareSource(null);
|
||||
} catch {
|
||||
/* main may already be torn down */
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (/cancel|abort|user/i.test(msg)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setError(msg || 'Bildschirm-Quelle konnte nicht geladen werden');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm motion-safe:animate-fade-in"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Bildschirmfreigabe"
|
||||
className="relative flex max-h-[88vh] w-full max-w-[680px] motion-safe:animate-slide-up flex-col overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MonitorShareIcon className="h-5 w-5 text-fg-muted" />
|
||||
<h2 className="text-base font-semibold text-fg">Bildschirmfreigabe</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-line px-3">
|
||||
{TABS.map((t) => {
|
||||
const active = tab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTab(t.id);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
className={
|
||||
'relative cursor-pointer px-4 py-2.5 text-sm transition focus:outline-none ' +
|
||||
(active
|
||||
? 'font-semibold text-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
{active && (
|
||||
<span className="absolute inset-x-3 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Source grid */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{loading && visibleSources.length === 0 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-video animate-pulse rounded-lg border border-line bg-surface-3"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : visibleSources.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-line bg-surface-3/30 px-4 py-10 text-center text-xs text-fg-muted">
|
||||
{tab === 'screen' ? 'Keine Bildschirme gefunden.' : 'Keine offenen Anwendungen.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{visibleSources.map((src) => {
|
||||
const active = selectedId === src.id;
|
||||
return (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(src.id)}
|
||||
className={
|
||||
'group flex flex-col overflow-hidden rounded-lg border text-left transition focus:outline-none ' +
|
||||
(active
|
||||
? 'border-accent ring-2 ring-accent/40 bg-accent/5'
|
||||
: 'border-line bg-surface-3 hover:border-accent/50')
|
||||
}
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden bg-black/40">
|
||||
{src.thumbnailDataUrl ? (
|
||||
<img
|
||||
src={src.thumbnailDataUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-fg-muted">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
{src.iconDataUrl && (
|
||||
<img
|
||||
src={src.iconDataUrl}
|
||||
alt=""
|
||||
className="h-4 w-4 flex-none"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="truncate text-xs font-medium text-fg"
|
||||
title={src.name}
|
||||
>
|
||||
{src.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer config */}
|
||||
<div className="border-t border-line bg-surface-3/30 px-5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
Qualität
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{QUALITY_PILLS.map((q) => {
|
||||
const active = preset === q.id;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
onClick={() => setPreset(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>
|
||||
|
||||
<label className="ml-auto flex cursor-pointer items-center gap-2 text-xs text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={audio}
|
||||
onChange={(e) => setAudio(e.target.checked)}
|
||||
className="h-3.5 w-3.5 cursor-pointer accent-accent"
|
||||
/>
|
||||
<span>Sound mitstreamen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded border border-rose-500/30 bg-rose-500/10 px-2.5 py-1.5 text-[11px] text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={handleClose}
|
||||
className="cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium text-fg-muted transition hover:text-fg disabled:opacity-50 focus:outline-none"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !selectedId}
|
||||
onClick={() => void handleStart()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-4 py-1.5 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
>
|
||||
<MonitorShareIcon className="h-4 w-4" />
|
||||
{busy ? 'Starte …' : 'Live gehen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,13 @@ export function ScreenShareViewer({
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
// Suppress the browser's built-in <video> context menu
|
||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||
// to the wrapping tile div in InCallPanel — that's where the
|
||||
// app's volume / mute menu is wired up. Without this, the
|
||||
// native menu opens on top of ours in focus + fullscreen
|
||||
// modes (where the video covers the whole tile).
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
||||
/>
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
getScreenShareSettings,
|
||||
PRESET_ORDER,
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { MonitorShareIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Parent starts the actual share. The OS picker runs afterwards; the
|
||||
* dialog only gathers quality + audio settings. */
|
||||
onStart: (opts: {
|
||||
preset: ScreenSharePreset;
|
||||
displaySurface: DisplaySurfaceHint;
|
||||
framerate: number | null;
|
||||
includeAudio: boolean;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
// Quality + audio chooser shown before "Bildschirm teilen" opens the OS
|
||||
// source picker. We can't substitute sources in WebView2 — the
|
||||
// `chromeMediaSource: 'desktop'` constraint is extension-only and
|
||||
// ScreenCaptureStarting offers allow/deny, not source-injection — so a
|
||||
// custom thumbnail grid would just double-pick (user picks here, then
|
||||
// picks again in the OS dialog). Dropping the grid keeps the smooth
|
||||
// Chromium-native capture pipeline and narrows the UX to the one
|
||||
// decision that still matters at share-time: quality + audio.
|
||||
export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const initial = getScreenShareSettings();
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function handleStart(): Promise<void> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
updateScreenShareSettings({ preset, includeSystemAudio: includeAudio });
|
||||
await onStart({
|
||||
preset,
|
||||
displaySurface: null,
|
||||
framerate: null,
|
||||
includeAudio,
|
||||
});
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-[420px] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col gap-4 px-5 py-5">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:call.share_os_picker_hint', {
|
||||
defaultValue:
|
||||
'Nach dem Klick auf „Teilen" wählst du im System-Dialog den Bildschirm oder das Fenster aus.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 text-xs text-fg">
|
||||
<span className="font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||
</span>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||
className="flex-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-xs text-fg focus:border-accent focus:outline-none"
|
||||
>
|
||||
{PRESET_ORDER.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAudio}
|
||||
onChange={(e) => setIncludeAudio(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span>
|
||||
{t('app:call.share_system_audio', {
|
||||
defaultValue: 'System-Sound mit übertragen',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStart()}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>{t('app:call.share_start', { defaultValue: 'Teilen' })}</span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,13 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
ChatBubbleIcon,
|
||||
GearIcon,
|
||||
LogoMark,
|
||||
MoonIcon,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
SunIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
} from './icons';
|
||||
|
||||
@@ -41,29 +39,27 @@ export function Sidebar() {
|
||||
const { signOut, profile } = useAuth();
|
||||
const { incomingCount } = useFriendshipsContext();
|
||||
const { totalUnread } = useConversationsContext();
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
|
||||
className="discord-rail flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2 py-3"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
|
||||
<LogoMark className="h-8 w-8" />
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent text-accent-fg shadow-[0_10px_26px_rgba(88,101,242,0.32)]"
|
||||
title="Netralax"
|
||||
>
|
||||
<LogoMark className="h-10 w-10" />
|
||||
</div>
|
||||
|
||||
<div className="my-3 h-px w-8 bg-line" aria-hidden="true" />
|
||||
<div className="my-3 h-0.5 w-8 rounded-full bg-line" aria-hidden="true" />
|
||||
|
||||
<nav className="flex flex-col items-center gap-2">
|
||||
<nav className="flex flex-col items-center gap-2.5">
|
||||
{navItems.map((item) => {
|
||||
const badgeCount =
|
||||
item.badge === 'friends'
|
||||
? incomingCount
|
||||
: item.badge === 'chats'
|
||||
? totalUnread
|
||||
: 0;
|
||||
item.badge === 'friends' ? incomingCount : item.badge === 'chats' ? totalUnread : 0;
|
||||
return (
|
||||
<RailNavLink
|
||||
key={item.to}
|
||||
@@ -78,17 +74,13 @@ export function Sidebar() {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<RailNavLink
|
||||
to="/settings"
|
||||
label={t('app:nav.settings')}
|
||||
icon={GearIcon}
|
||||
/>
|
||||
<RailIconButton
|
||||
label={theme === 'dark' ? t('app:theme.light', { defaultValue: 'Light mode' }) : t('app:theme.dark', { defaultValue: 'Dark mode' })}
|
||||
onClick={toggle}
|
||||
icon={theme === 'dark' ? SunIcon : MoonIcon}
|
||||
to="/changelog"
|
||||
label={t('app:nav.changelog', { defaultValue: 'Was ist neu' })}
|
||||
icon={SparklesIcon}
|
||||
/>
|
||||
<RailNavLink to="/settings" label={t('app:nav.settings')} icon={GearIcon} />
|
||||
<RailIconButton
|
||||
label={t('app:sidebar.sign_out')}
|
||||
onClick={() => void signOut()}
|
||||
@@ -119,8 +111,8 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
||||
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
|
||||
? 'bg-accent text-accent-fg shadow-[0_8px_22px_rgba(88,101,242,0.34)]'
|
||||
: 'text-fg-muted hover:rounded-2xl hover:bg-surface-3/80 hover:text-fg dark:hover:bg-[#313338]',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
@@ -129,7 +121,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -left-4 h-6 w-1 rounded-r-full bg-accent"
|
||||
className="absolute -left-[15px] h-7 w-1 rounded-r-full bg-fg"
|
||||
/>
|
||||
)}
|
||||
<Icon style={{ width: '20px', height: '20px' }} />
|
||||
@@ -150,8 +142,8 @@ interface RailIconButtonProps {
|
||||
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'text-fg-muted hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
|
||||
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg focus-visible:ring-accent/50';
|
||||
? 'text-fg-muted hover:rounded-2xl hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
|
||||
: 'text-fg-muted hover:rounded-2xl hover:bg-surface-3/80 hover:text-fg focus-visible:ring-accent/50 dark:hover:bg-[#313338]';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -159,7 +151,7 @@ function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIc
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition focus:outline-none focus-visible:ring-2 ' +
|
||||
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition-all duration-150 focus:outline-none focus-visible:ring-2 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
@@ -173,7 +165,7 @@ function RailBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2"
|
||||
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2 dark:ring-[#1e1f22]"
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
||||
: t('app:chats.typing_many', { count: typingUserIds.length });
|
||||
|
||||
return (
|
||||
<div className="bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
|
||||
<div className="discord-chat-surface bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<TypingDots />
|
||||
<span>{text}</span>
|
||||
|
||||
@@ -69,7 +69,9 @@ export function UpdateToast() {
|
||||
{state.version ? ' · v' + state.version : ''}
|
||||
</p>
|
||||
{state.notes && (
|
||||
<p className="mt-0.5 line-clamp-3 text-xs text-fg-muted">{state.notes}</p>
|
||||
<p className="mt-0.5 line-clamp-6 whitespace-pre-line text-xs text-fg-muted">
|
||||
{state.notes}
|
||||
</p>
|
||||
)}
|
||||
{state.error && (
|
||||
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{state.error}</p>
|
||||
|
||||
@@ -83,13 +83,13 @@ export function UserBar() {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
className="h-10 w-10 text-sm"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
@@ -99,9 +99,7 @@ export function UserBar() {
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-fg">
|
||||
{profile?.displayName ?? '—'}
|
||||
</p>
|
||||
<p className="truncate text-sm font-medium text-fg">{profile?.displayName ?? '—'}</p>
|
||||
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
@@ -112,7 +110,7 @@ export function UserBar() {
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl dark:bg-[#313338]"
|
||||
>
|
||||
<div className="border-b border-line p-2">
|
||||
<input
|
||||
@@ -135,7 +133,7 @@ export function UserBar() {
|
||||
defaultValue: 'Status setzen…',
|
||||
})}
|
||||
maxLength={STATUS_MAX}
|
||||
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
/>
|
||||
</div>
|
||||
{PRESENCE_OPTIONS.map((opt) => (
|
||||
@@ -146,13 +144,11 @@ export function UserBar() {
|
||||
aria-checked={opt === presence}
|
||||
onClick={() => void changePresence(opt)}
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50"
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
|
||||
<span className="flex-1">{t('app:presence.' + opt)}</span>
|
||||
{opt === presence && (
|
||||
<span className="text-xs text-accent">●</span>
|
||||
)}
|
||||
{opt === presence && <span className="text-xs text-accent">●</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ProfileBrief } from '@chat-app/shared/friends';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getProfileAvatarPreviewUrl, getProfileCardStatusText } from '../lib/profileCard';
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { CopyIcon, MailIcon, SparklesIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
@@ -22,27 +24,38 @@ const PRESENCE_DOT: Record<string, string> = {
|
||||
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||
};
|
||||
|
||||
const CARD_W = 280;
|
||||
const CARD_H = 180;
|
||||
const PRESENCE_LABEL: Record<string, string> = {
|
||||
online: 'Online',
|
||||
idle: 'Abwesend',
|
||||
dnd: 'Nicht stören',
|
||||
invisible: 'Offline',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
const CARD_W = 320;
|
||||
// Banner is now aspect-[3/1] (≈107px tall at 320 wide) instead of the old
|
||||
// fixed 80px → ~27px taller. Keep CARD_H in sync so the y-clamp doesn't run
|
||||
// the card off the bottom of the viewport.
|
||||
const CARD_H = 297;
|
||||
|
||||
// Hover/click card surfaced from avatars around the app. Shows display name,
|
||||
// @handle, presence state + status message, plus a DM-start button when
|
||||
// the clicked profile isn't the caller.
|
||||
export function UserProfilePopover({
|
||||
userId,
|
||||
profile,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onStartDm,
|
||||
}: Props) {
|
||||
export function UserProfilePopover({ userId, profile, x, y, onClose, onStartDm }: Props) {
|
||||
// Subscribe to the same presence feed that the conversation header uses,
|
||||
// so status updates flow in live.
|
||||
const presence = usePeerPresence(userId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key !== 'Escape') return;
|
||||
if (avatarPreviewOpen) {
|
||||
setAvatarPreviewOpen(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
@@ -56,7 +69,7 @@ export function UserProfilePopover({
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
}, [avatarPreviewOpen, onClose]);
|
||||
|
||||
const left = Math.min(Math.max(8, x), window.innerWidth - CARD_W - 8);
|
||||
const top = Math.min(Math.max(8, y), window.innerHeight - CARD_H - 8);
|
||||
@@ -66,6 +79,20 @@ export function UserProfilePopover({
|
||||
const state = presence?.state ?? 'offline';
|
||||
const showPresence = state !== 'invisible';
|
||||
const statusMessage = presence?.statusMessage?.trim() ?? '';
|
||||
const profileStatusText = getProfileCardStatusText(statusMessage);
|
||||
const avatarPreviewUrl = getProfileAvatarPreviewUrl(profile?.avatarUrl);
|
||||
const presenceLabel = PRESENCE_LABEL[state] ?? PRESENCE_LABEL.offline;
|
||||
|
||||
async function copyHandle() {
|
||||
const value = username ? '@' + username : userId;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
} catch (err: unknown) {
|
||||
console.warn('copy profile handle failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -73,54 +100,168 @@ export function UserProfilePopover({
|
||||
role="dialog"
|
||||
aria-label={displayName}
|
||||
style={{ left, top, width: CARD_W }}
|
||||
className="fixed z-[80] flex flex-col gap-3 rounded-xl border border-line bg-surface-2/95 p-4 shadow-xl backdrop-blur-md"
|
||||
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 shadow-2xl backdrop-blur-md dark:bg-[#2b2d31]/95"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={displayName}
|
||||
className="h-14 w-14 text-lg"
|
||||
/>
|
||||
{showPresence && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-surface-2 ' +
|
||||
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
|
||||
}
|
||||
<div
|
||||
className={
|
||||
'aspect-[3/1] w-full bg-cover bg-center ' +
|
||||
(profile?.bannerUrl
|
||||
? ''
|
||||
: 'bg-gradient-to-br from-accent/40 via-accent/15 to-surface-3')
|
||||
}
|
||||
style={
|
||||
profile?.bannerUrl
|
||||
? { backgroundImage: 'url("' + profile.bannerUrl + '")' }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="-mt-8 flex items-end justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (avatarPreviewUrl) setAvatarPreviewOpen(true);
|
||||
}}
|
||||
disabled={!avatarPreviewUrl}
|
||||
className="relative rounded-full bg-surface-2 p-1 transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default dark:bg-[#2b2d31]"
|
||||
aria-label={avatarPreviewUrl ? 'Profilbild groß anzeigen' : displayName}
|
||||
title={avatarPreviewUrl ? 'Profilbild groß anzeigen' : displayName}
|
||||
>
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 text-xl"
|
||||
/>
|
||||
{showPresence && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute bottom-1 right-1 h-4 w-4 rounded-full ring-2 ring-surface-2 dark:ring-[#2b2d31] ' +
|
||||
(PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyHandle()}
|
||||
className="mb-1 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||||
aria-label="Handle kopieren"
|
||||
title="Handle kopieren"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 min-w-0">
|
||||
<p className="truncate font-display text-lg font-semibold text-fg">{displayName}</p>
|
||||
{username && <p className="truncate text-sm text-fg-muted">@{username}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 py-1 text-xs font-semibold text-fg dark:bg-[#383a40]">
|
||||
{showPresence && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={'h-2 w-2 rounded-full ' + (PRESENCE_DOT[state] ?? PRESENCE_DOT.offline)}
|
||||
/>
|
||||
)}
|
||||
{presenceLabel}
|
||||
</span>
|
||||
{copied && (
|
||||
<span className="rounded-full bg-emerald-500/15 px-2.5 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-200">
|
||||
Kopiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">
|
||||
{displayName}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-line bg-surface-3 p-3 dark:bg-[#313338]">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
<span>Custom Status</span>
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
'mt-1.5 break-words text-sm ' +
|
||||
(statusMessage ? 'text-fg' : 'italic text-fg-muted')
|
||||
}
|
||||
>
|
||||
{profileStatusText}
|
||||
</p>
|
||||
{username && (
|
||||
<p className="truncate text-xs text-fg-muted">@{username}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onStartDm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onStartDm(userId);
|
||||
onClose();
|
||||
}}
|
||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
<MailIcon className="h-4 w-4" />
|
||||
<span>Nachricht senden</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{statusMessage && state !== 'offline' && (
|
||||
<p className="rounded-md bg-surface-3 px-2.5 py-1.5 text-xs italic text-fg-muted">
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onStartDm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onStartDm(userId);
|
||||
onClose();
|
||||
}}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
Nachricht senden
|
||||
</button>
|
||||
{avatarPreviewOpen && avatarPreviewUrl && (
|
||||
<AvatarPreviewDialog
|
||||
url={avatarPreviewUrl}
|
||||
displayName={displayName}
|
||||
onClose={() => setAvatarPreviewOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarPreviewDialog({
|
||||
url,
|
||||
displayName,
|
||||
onClose,
|
||||
}: {
|
||||
url: string;
|
||||
displayName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-user-popover
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={'Profilbild von ' + displayName}
|
||||
onMouseDown={(e) => {
|
||||
if (e.currentTarget === e.target) onClose();
|
||||
}}
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
title="Schließen"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-surface-3/90 text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:bg-[#313338]/90 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={url}
|
||||
alt={'Profilbild von ' + displayName}
|
||||
draggable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="max-h-[82vh] max-w-[82vw] rounded-2xl object-contain shadow-2xl ring-1 ring-white/10"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
/**
|
||||
* Discord-style persistent voice-channel band rendered at the top of the
|
||||
* message surface. Always visible for groups so any member can pop in
|
||||
* without an invite-ring; for 1:1 conversations only when someone's already
|
||||
* in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall
|
||||
* path which sends no invite signals.
|
||||
*/
|
||||
export function VoiceChannelRail({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
const isGroup = conversation.type === 'group';
|
||||
|
||||
// For groups: persistent channel feel — show even when empty.
|
||||
// For DMs: only when somebody is already in (matches Discord-DM behaviour).
|
||||
const visible = !iAmIn && (isGroup || others.length > 0);
|
||||
if (!visible) return null;
|
||||
|
||||
const showAvatars = others.slice(0, MAX_AVATARS);
|
||||
const overflow = Math.max(0, others.length - MAX_AVATARS);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleJoin}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleJoin();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(others.length > 0
|
||||
? 'bg-emerald-500/10 hover:bg-emerald-500/15'
|
||||
: 'bg-surface-3/70 hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
|
||||
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
|
||||
}
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg">
|
||||
{others.length === 0
|
||||
? t('app:call.voice_empty', {
|
||||
defaultValue: 'Niemand drin — sei der Erste.',
|
||||
})
|
||||
: t('app:call.voice_count', {
|
||||
defaultValue: '{{count}} im Channel',
|
||||
count: others.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{showAvatars.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{showAvatars.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={member?.profile?.displayName ?? '?'}
|
||||
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
|
||||
>
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? '?'}
|
||||
className="h-full w-full text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleJoin();
|
||||
}}
|
||||
disabled={busy}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PhoneIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{others.length === 0
|
||||
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
|
||||
: t('app:call.join', { defaultValue: 'Beitreten' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,8 +87,10 @@ export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||
streamRef.current = stream;
|
||||
|
||||
try {
|
||||
const ctx = new (window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const ctx = new (
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
)();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
@@ -166,7 +168,7 @@ export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||
disabled={disabled}
|
||||
aria-label="Sprachnachricht aufnehmen"
|
||||
title="Sprachnachricht aufnehmen"
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<MicIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -175,7 +177,7 @@ export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||
|
||||
if (state === 'finalizing') {
|
||||
return (
|
||||
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
|
||||
<div className="inline-flex h-10 items-center gap-2 rounded-lg px-3 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>Wird gesendet…</span>
|
||||
</div>
|
||||
@@ -188,7 +190,7 @@ export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
|
||||
|
||||
return (
|
||||
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
|
||||
<div className="inline-flex h-10 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
|
||||
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
|
||||
<div
|
||||
@@ -226,12 +228,7 @@ export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||
}
|
||||
|
||||
function pickMime(): string | null {
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
];
|
||||
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4'];
|
||||
for (const c of candidates) {
|
||||
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,71 @@ export function LockIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function WifiLowIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
||||
<path d="M8.5 16a6 6 0 0 1 7 0" opacity="0.55" />
|
||||
<path d="M12 20h.01" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function WifiOffIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
<path d="M8.5 16a6 6 0 0 1 7 0" />
|
||||
<path d="M2 8.82A15 15 0 0 1 6 7" />
|
||||
<path d="M10.66 5c4.01-.36 8.14.9 11.34 3.76" />
|
||||
<path d="M16.85 11.25a10 10 0 0 1 2.22 1.68" />
|
||||
<path d="M5 12.55a11 11 0 0 1 5.17-2.39" />
|
||||
<path d="M12 20h.01" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function PinIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function EyeOffIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
<path d="M6.71 6.71A13 13 0 0 0 2 12s3 7 10 7a10 10 0 0 0 4.29-1.29" />
|
||||
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
|
||||
<path d="M9.88 5.09A11 11 0 0 1 12 5c7 0 10 7 10 7a13 13 0 0 1-2.16 3.19" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaptionsIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function PinOffIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h7" />
|
||||
<path d="M15 13.24V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparklesIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
@@ -364,6 +429,36 @@ export function GridIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<circle cx="8.5" cy="10" r="1.5" />
|
||||
<path d="m21 15-4.5-4.5a2 2 0 0 0-2.8 0L6 18" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v5h5" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function PollIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 19V9" />
|
||||
<path d="M12 19V5" />
|
||||
<path d="M19 19v-7" />
|
||||
<path d="M3 19h18" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function FocusIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getOwnProfile,
|
||||
signOut as supabaseSignOut,
|
||||
type Profile,
|
||||
touchDeviceLastSeen,
|
||||
updateOwnProfile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
@@ -14,11 +15,13 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { registerWebPush } from '../lib/webPush';
|
||||
@@ -46,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
||||
const autoOnlineUserRef = useRef<string | null>(null);
|
||||
|
||||
// Initial session + auth subscription. We verify the cached JWT against the
|
||||
// server (via getUser) once on mount. Only purge the session on an
|
||||
@@ -154,8 +158,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// request usually slips through; the next page load corrects state if it
|
||||
// didn't.
|
||||
useEffect(() => {
|
||||
if (!session || !profile) return;
|
||||
if (profile.presenceState === 'offline') {
|
||||
if (!session) {
|
||||
autoOnlineUserRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!profile) return;
|
||||
const shouldApplyInitialAutoOnline = autoOnlineUserRef.current !== session.user.id;
|
||||
autoOnlineUserRef.current = session.user.id;
|
||||
if (profile.presenceState === 'offline' && shouldApplyInitialAutoOnline) {
|
||||
void updateOwnProfile(supabase, { presenceState: 'online' })
|
||||
.then(() => refreshProfile())
|
||||
.catch((err: unknown) => {
|
||||
@@ -165,10 +175,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const onLeave = () => {
|
||||
// Skip if user explicitly chose a non-online state — they probably
|
||||
// want to look unavailable on next reconnect too.
|
||||
if (
|
||||
profile.presenceState !== 'online' &&
|
||||
profile.presenceState !== 'offline'
|
||||
) {
|
||||
if (profile.presenceState !== 'online' && profile.presenceState !== 'offline') {
|
||||
return;
|
||||
}
|
||||
void updateOwnProfile(supabase, { presenceState: 'offline' }).catch(() => {});
|
||||
@@ -181,7 +188,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [session, profile, refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !device?.id) return;
|
||||
|
||||
const touch = () => {
|
||||
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
|
||||
console.warn('presence heartbeat failed', err);
|
||||
});
|
||||
};
|
||||
const touchWhenVisible = () => {
|
||||
if (document.visibilityState === 'visible') touch();
|
||||
};
|
||||
|
||||
touch();
|
||||
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
|
||||
window.addEventListener('focus', touch);
|
||||
window.addEventListener('online', touch);
|
||||
document.addEventListener('visibilitychange', touchWhenVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
window.removeEventListener('focus', touch);
|
||||
window.removeEventListener('online', touch);
|
||||
document.removeEventListener('visibilitychange', touchWhenVisible);
|
||||
};
|
||||
}, [session, device?.id]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||
console.warn('offline update before sign-out failed', err);
|
||||
});
|
||||
await supabaseSignOut(supabase);
|
||||
}, []);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import { playNotificationTone } from '../lib/notificationSound';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { updateTrayUnread } from '../lib/trayBadge';
|
||||
import { getIsWindowFocused, subscribeWindowFocus } from '../lib/windowFocus';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
|
||||
@@ -69,6 +70,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
const lastReadRef = useRef<LastReadMap>(loadLastReadMap());
|
||||
const activeConvIdRef = useRef<string | null>(null);
|
||||
const presenceRef = useRef(profile?.presenceState ?? 'offline');
|
||||
// Ref-mirrored window-focus state so the realtime message handler can
|
||||
// read it synchronously without triggering a re-render per focus flip.
|
||||
// "App focused" = our window is the foreground window in the OS,
|
||||
// which is what we actually care about when deciding whether an
|
||||
// arriving message was seen by the user or should ping.
|
||||
const isWindowFocusedRef = useRef<boolean>(getIsWindowFocused());
|
||||
const conversationsRef = useRef<ConversationSummary[]>([]);
|
||||
conversationsRef.current = conversations;
|
||||
|
||||
@@ -76,6 +83,26 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
presenceRef.current = profile?.presenceState ?? 'offline';
|
||||
}, [profile?.presenceState]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeWindowFocus((focused) => {
|
||||
isWindowFocusedRef.current = focused;
|
||||
// Regaining focus on the active conversation: the user is back
|
||||
// and looking at those messages, so flip the unread counter to
|
||||
// zero and persist the last-read stamp. Without this, opening
|
||||
// the app after being away leaves a stale badge until the user
|
||||
// re-clicks the conversation.
|
||||
if (focused) {
|
||||
const active = activeConvIdRef.current;
|
||||
if (active) {
|
||||
const now = new Date().toISOString();
|
||||
lastReadRef.current[active] = now;
|
||||
saveLastReadMap(lastReadRef.current);
|
||||
setUnread((prev) => (prev[active] ? { ...prev, [active]: 0 } : prev));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const computeUnreadForConv = useCallback(
|
||||
async (convId: string): Promise<number> => {
|
||||
if (!myId) return 0;
|
||||
@@ -174,10 +201,16 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
const row = payload.new as unknown as { conversation_id: string; sender_id: string };
|
||||
const fromSelf = row.sender_id === myId;
|
||||
const active = row.conversation_id === activeConvIdRef.current;
|
||||
const appFocused = isWindowFocusedRef.current;
|
||||
// Only suppress sound + unread bump when the user is *actually*
|
||||
// looking at the conversation — i.e. the app window is in the
|
||||
// foreground AND the active conversation matches. Having the
|
||||
// app parked on monitor 2 while the user is in-game still
|
||||
// counts as "they didn't see it."
|
||||
const seenByUser = active && appFocused;
|
||||
|
||||
if (!fromSelf) {
|
||||
if (active) {
|
||||
// Viewing this conversation — implicit read.
|
||||
if (seenByUser) {
|
||||
markRead(row.conversation_id);
|
||||
} else {
|
||||
setUnread((prev) => ({
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { check as checkUpdate } from '@tauri-apps/plugin-updater';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export interface UpdateState {
|
||||
@@ -20,38 +18,34 @@ export const IDLE_UPDATE_STATE: UpdateState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
type UpdateHandle = Awaited<ReturnType<typeof checkUpdate>>;
|
||||
|
||||
let cachedUpdate: UpdateHandle | null = null;
|
||||
// Module-local flag: main owns the real "pending update" state via
|
||||
// electron-updater, but the renderer also needs to refuse installUpdate()
|
||||
// calls that weren't preceded by a successful checkForUpdate().
|
||||
let hasPendingUpdate = false;
|
||||
|
||||
export async function checkForUpdate(): Promise<UpdateState> {
|
||||
if (!isTauriRuntime()) {
|
||||
return { ...IDLE_UPDATE_STATE };
|
||||
}
|
||||
try {
|
||||
const update = await checkUpdate();
|
||||
if (!update) {
|
||||
cachedUpdate = null;
|
||||
const result = await window.electronAPI.checkForUpdate();
|
||||
if (!result.available || !result.info) {
|
||||
hasPendingUpdate = false;
|
||||
return { ...IDLE_UPDATE_STATE };
|
||||
}
|
||||
cachedUpdate = update;
|
||||
hasPendingUpdate = true;
|
||||
return {
|
||||
available: true,
|
||||
version: update.version ?? null,
|
||||
notes: update.body ?? null,
|
||||
version: result.info.version ?? null,
|
||||
notes: result.info.releaseNotes ?? null,
|
||||
downloading: false,
|
||||
downloaded: false,
|
||||
error: null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
|
||||
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
|
||||
// repo or offline machine produces a generic "Could not fetch a valid
|
||||
// release JSON" error that has no actionable information for the user —
|
||||
// logging it on every launch just pollutes the console.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const benign =
|
||||
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
|
||||
/could not fetch a valid release json|network|timed? out|failed to fetch|connection|enotfound|econnreset|etimedout|404/i.test(
|
||||
msg,
|
||||
);
|
||||
if (!benign) {
|
||||
@@ -64,25 +58,25 @@ export async function checkForUpdate(): Promise<UpdateState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Downloads + installs the previously checked update. On Windows the app
|
||||
// quits during install (passive installer); on macOS/Linux tauri triggers
|
||||
// a relaunch automatically.
|
||||
// Downloads + installs the previously checked update. On Windows the
|
||||
// app quits during install (passive installer); electron-updater
|
||||
// triggers a relaunch on macOS/Linux.
|
||||
export async function installUpdate(
|
||||
onProgress?: (downloaded: number, total: number | null) => void,
|
||||
): Promise<void> {
|
||||
if (!cachedUpdate) {
|
||||
if (!hasPendingUpdate) {
|
||||
throw new Error('no pending update — call checkForUpdate() first');
|
||||
}
|
||||
let total: number | null = null;
|
||||
let downloaded = 0;
|
||||
await cachedUpdate.downloadAndInstall((event) => {
|
||||
if (event.event === 'Started') {
|
||||
total = event.data.contentLength ?? null;
|
||||
downloaded = 0;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data.chunkLength;
|
||||
}
|
||||
onProgress?.(downloaded, total);
|
||||
});
|
||||
cachedUpdate = null;
|
||||
let unsub: (() => void) | null = null;
|
||||
if (onProgress) {
|
||||
unsub = window.electronAPI.onUpdaterProgress((p) => {
|
||||
onProgress(p.transferred, p.total || null);
|
||||
});
|
||||
}
|
||||
try {
|
||||
await window.electronAPI.downloadInstallUpdate();
|
||||
} finally {
|
||||
unsub?.();
|
||||
hasPendingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface AudioSettings {
|
||||
// @livekit/track-processors + its MediaPipe selfie-segmentation model
|
||||
// (~1.5MB) which downloads on first activation.
|
||||
videoBackgroundBlur: boolean;
|
||||
// Preferred camera deviceId from enumerateDevices. null = use browser
|
||||
// default. Persisted across sessions; applied when toggleCamera publishes
|
||||
// a new track. Discord-parity: lets users with multiple cams pin one.
|
||||
videoInputDeviceId: string | null;
|
||||
// Ringtone volume for both the generated oscillator fallback and the
|
||||
// custom incoming-call audio file. 0..1; applied on top of the base
|
||||
// oscillator gain so the fallback stays audible at 100% without being
|
||||
@@ -46,6 +50,7 @@ const DEFAULTS: AudioSettings = {
|
||||
// Users who want it enable it explicitly in Settings → Sprache.
|
||||
noiseSuppression: false,
|
||||
videoBackgroundBlur: false,
|
||||
videoInputDeviceId: null,
|
||||
ringtoneVolume: 0.9,
|
||||
};
|
||||
|
||||
@@ -128,6 +133,10 @@ function read(): AudioSettings {
|
||||
typeof parsed.videoBackgroundBlur === 'boolean'
|
||||
? parsed.videoBackgroundBlur
|
||||
: DEFAULTS.videoBackgroundBlur,
|
||||
videoInputDeviceId:
|
||||
typeof parsed.videoInputDeviceId === 'string' && parsed.videoInputDeviceId.length > 0
|
||||
? parsed.videoInputDeviceId
|
||||
: DEFAULTS.videoInputDeviceId,
|
||||
ringtoneVolume:
|
||||
typeof parsed.ringtoneVolume === 'number' &&
|
||||
Number.isFinite(parsed.ringtoneVolume) &&
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Renderer-side autostart adapter. Replaces the Tauri version, which
|
||||
// imported from `@tauri-apps/plugin-autostart`. We route through the
|
||||
// Electron preload bridge to `app.setLoginItemSettings()` in the main
|
||||
// process — the OS-native login-items mechanism (Windows registry Run
|
||||
// key, macOS LaunchAgent, Linux .desktop entry).
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export async function isAutoStartEnabled(): Promise<boolean> {
|
||||
if (!isTauriRuntime()) return false;
|
||||
try {
|
||||
return await window.electronAPI.isAutoStartEnabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAutoStart(enabled: boolean): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
await window.electronAPI.setAutoStart(enabled);
|
||||
}
|
||||
@@ -48,6 +48,13 @@ export async function uploadAvatar(userId: string, file: File): Promise<string>
|
||||
throw new Error('only image files are accepted');
|
||||
}
|
||||
const blob = await resizeToSquare(file);
|
||||
return uploadAvatarBlob(userId, blob);
|
||||
}
|
||||
|
||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
// Random filename so old uploads don't get overwritten before we update
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
@@ -64,6 +71,8 @@ export async function uploadAvatar(userId: string, file: File): Promise<string>
|
||||
return pub.publicUrl;
|
||||
}
|
||||
|
||||
export const AVATAR_TARGET_DIM = MAX_DIM;
|
||||
|
||||
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
|
||||
// Public URLs look like
|
||||
// https://<host>/storage/v1/object/public/profile-avatars/<path>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const BUCKET = 'profile-banners';
|
||||
// 3:1 hero crop. 1500x500 hits the sweet spot between crisp on a wide
|
||||
// settings preview and a payload that stays well under the 8 MB pre-encode
|
||||
// budget we accept from the user (post-WebP that's typically ~150–300 KB).
|
||||
const TARGET_WIDTH = 1500;
|
||||
const TARGET_HEIGHT = 500;
|
||||
const QUALITY = 0.82;
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
// Resizes the source image to a centred 3:1 crop at TARGET_WIDTH x TARGET_HEIGHT
|
||||
// and re-encodes as WebP (JPEG fallback if WebP encode unsupported).
|
||||
async function resizeToBanner(file: File): Promise<Blob> {
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error('image load failed'));
|
||||
i.src = url;
|
||||
});
|
||||
|
||||
const srcRatio = img.naturalWidth / img.naturalHeight;
|
||||
const targetRatio = TARGET_WIDTH / TARGET_HEIGHT;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = img.naturalWidth;
|
||||
let sh = img.naturalHeight;
|
||||
if (srcRatio > targetRatio) {
|
||||
// Source is wider than 3:1 — crop horizontally, keep full height.
|
||||
sw = Math.round(img.naturalHeight * targetRatio);
|
||||
sx = Math.round((img.naturalWidth - sw) / 2);
|
||||
} else if (srcRatio < targetRatio) {
|
||||
// Source is taller than 3:1 — crop vertically, keep full width.
|
||||
sh = Math.round(img.naturalWidth / targetRatio);
|
||||
sy = Math.round((img.naturalHeight - sh) / 2);
|
||||
}
|
||||
|
||||
// Don't upscale — if the source crop is smaller than the target, render
|
||||
// at the source crop size so we don't waste bytes on synthetic detail.
|
||||
const outWidth = Math.min(TARGET_WIDTH, sw);
|
||||
const outHeight = Math.min(TARGET_HEIGHT, sh);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outWidth;
|
||||
canvas.height = outHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, outWidth, outHeight);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', QUALITY),
|
||||
);
|
||||
if (blob) return blob;
|
||||
|
||||
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
|
||||
);
|
||||
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||||
return jpeg;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadBanner(userId: string, file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('only image files are accepted');
|
||||
}
|
||||
if (file.size > MAX_INPUT_BYTES) {
|
||||
throw new Error('image must be 8 MB or smaller');
|
||||
}
|
||||
const blob = await resizeToBanner(file);
|
||||
return uploadBannerBlob(userId, blob);
|
||||
}
|
||||
|
||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog). Skips the
|
||||
// legacy center-crop path so the user-chosen framing is preserved.
|
||||
export async function uploadBannerBlob(userId: string, blob: Blob): Promise<string> {
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
// Random filename so old uploads don't get overwritten before we update
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
// also forces clients to fetch the new image.
|
||||
const name =
|
||||
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
|
||||
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
|
||||
contentType: blob.type,
|
||||
cacheControl: '604800',
|
||||
upsert: false,
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
|
||||
return pub.publicUrl;
|
||||
}
|
||||
|
||||
export const BANNER_TARGET_WIDTH = TARGET_WIDTH;
|
||||
export const BANNER_TARGET_HEIGHT = TARGET_HEIGHT;
|
||||
export const BANNER_MAX_INPUT_BYTES = MAX_INPUT_BYTES;
|
||||
|
||||
export async function deleteBannerObject(publicUrl: string): Promise<void> {
|
||||
// Public URLs look like
|
||||
// https://<host>/storage/v1/object/public/profile-banners/<path>
|
||||
// Extract <path> and remove.
|
||||
const marker = '/object/public/' + BUCKET + '/';
|
||||
const idx = publicUrl.indexOf(marker);
|
||||
if (idx === -1) return;
|
||||
const path = publicUrl.slice(idx + marker.length);
|
||||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// binary assets needed. Intentionally short + low-volume — these fire
|
||||
// multiple times per call and shouldn't feel intrusive.
|
||||
|
||||
type Sfx = 'join' | 'leave' | 'end';
|
||||
type Sfx = 'join' | 'leave' | 'end' | 'mute' | 'unmute' | 'deafen' | 'undeafen';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
@@ -59,6 +59,28 @@ export async function playSfx(kind: Sfx): Promise<void> {
|
||||
beep(440, 0.18, 0, 0.14); // A4
|
||||
beep(293.66, 0.26, 0.14, 0.14); // D4
|
||||
break;
|
||||
case 'mute':
|
||||
// Discord-style: short downward blip when mic goes silent. Quick, low
|
||||
// volume so it doesn't fight whatever the user is listening to.
|
||||
beep(880, 0.06, 0, 0.1); // A5
|
||||
beep(660, 0.08, 0.04, 0.1); // E5
|
||||
break;
|
||||
case 'unmute':
|
||||
// Mirror: upward blip when mic comes back.
|
||||
beep(660, 0.06, 0, 0.1);
|
||||
beep(880, 0.08, 0.04, 0.1);
|
||||
break;
|
||||
case 'deafen':
|
||||
// Lower + slightly longer than mute — Discord uses a deeper tone for
|
||||
// deafen so the user can tell the two states apart without looking.
|
||||
beep(660, 0.07, 0, 0.1);
|
||||
beep(392, 0.12, 0.05, 0.1); // G4
|
||||
break;
|
||||
case 'undeafen':
|
||||
// Mirror of deafen: low → mid.
|
||||
beep(392, 0.07, 0, 0.1);
|
||||
beep(660, 0.12, 0.05, 0.1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +93,15 @@ export function playLeaveBeep(): Promise<void> {
|
||||
export function playEndBeep(): Promise<void> {
|
||||
return playSfx('end');
|
||||
}
|
||||
export function playMuteBeep(): Promise<void> {
|
||||
return playSfx('mute');
|
||||
}
|
||||
export function playUnmuteBeep(): Promise<void> {
|
||||
return playSfx('unmute');
|
||||
}
|
||||
export function playDeafenBeep(): Promise<void> {
|
||||
return playSfx('deafen');
|
||||
}
|
||||
export function playUndeafenBeep(): Promise<void> {
|
||||
return playSfx('undeafen');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Per-participant call-stats polling via WebRTC's getStats() API.
|
||||
// Used by the Discord-style debug overlay (Ctrl+Shift+S). Computes bitrate
|
||||
// deltas across two consecutive samples so the displayed kbps tracks the
|
||||
// live stream rather than the cumulative byte count.
|
||||
|
||||
import type { Participant, RemoteParticipant, Room } from 'livekit-client';
|
||||
|
||||
interface TrackWithStats {
|
||||
getRTCStatsReport?: () => Promise<RTCStatsReport | undefined>;
|
||||
}
|
||||
|
||||
export interface ParticipantTrackStats {
|
||||
audioInKbps?: number;
|
||||
audioOutKbps?: number;
|
||||
videoInKbps?: number;
|
||||
videoOutKbps?: number;
|
||||
packetLossPct?: number;
|
||||
jitterMs?: number;
|
||||
rttMs?: number;
|
||||
}
|
||||
|
||||
export interface ParticipantStats {
|
||||
identity: string;
|
||||
isLocal: boolean;
|
||||
audio: ParticipantTrackStats;
|
||||
video: ParticipantTrackStats;
|
||||
}
|
||||
|
||||
interface ByteSample {
|
||||
bytes: number;
|
||||
timeMs: number;
|
||||
}
|
||||
|
||||
interface SampleCache {
|
||||
// key = `${identity}/${kind}/${dir}` — kind in {audio,video}, dir in {in,out}
|
||||
bytes: Map<string, ByteSample>;
|
||||
// packets cumulative for loss-pct delta calc.
|
||||
pkts: Map<string, { recv: number; lost: number }>;
|
||||
}
|
||||
|
||||
export function makeSampleCache(): SampleCache {
|
||||
return { bytes: new Map(), pkts: new Map() };
|
||||
}
|
||||
|
||||
/** Pull a single stats sample from every participant in the room. Returns
|
||||
* one ParticipantStats entry per participant (local + remote). bitrates are
|
||||
* computed against the previous sample stored in `cache`, so the first
|
||||
* call returns 0 kbps everywhere — call again 1s later for real numbers. */
|
||||
export async function sampleStats(
|
||||
room: Room,
|
||||
cache: SampleCache,
|
||||
): Promise<ParticipantStats[]> {
|
||||
const out: ParticipantStats[] = [];
|
||||
|
||||
const localStats = await collectForParticipant(
|
||||
room.localParticipant,
|
||||
true,
|
||||
cache,
|
||||
);
|
||||
out.push(localStats);
|
||||
|
||||
for (const rp of room.remoteParticipants.values()) {
|
||||
const s = await collectForParticipant(rp, false, cache);
|
||||
out.push(s);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function collectForParticipant(
|
||||
participant: Participant,
|
||||
isLocal: boolean,
|
||||
cache: SampleCache,
|
||||
): Promise<ParticipantStats> {
|
||||
const audio: ParticipantTrackStats = {};
|
||||
const video: ParticipantTrackStats = {};
|
||||
|
||||
// Local participant publishes — pull outbound-rtp from audio + video sender.
|
||||
if (isLocal) {
|
||||
for (const pub of participant.audioTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/audio/out';
|
||||
const r = readOutbound(stats);
|
||||
audio.audioOutKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
}
|
||||
}
|
||||
for (const pub of participant.videoTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/video/out';
|
||||
const r = readOutbound(stats);
|
||||
video.videoOutKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remote participant — pull inbound-rtp from each subscribed track.
|
||||
const rp = participant as RemoteParticipant;
|
||||
for (const pub of rp.audioTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/audio/in';
|
||||
const r = readInbound(stats);
|
||||
audio.audioInKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
const lossPct = computeLossPct(cache.pkts, k, r.recv, r.lost);
|
||||
if (lossPct !== undefined) audio.packetLossPct = lossPct;
|
||||
if (r.jitter !== undefined) audio.jitterMs = Math.round(r.jitter * 1000);
|
||||
if (r.rttMs !== undefined) audio.rttMs = r.rttMs;
|
||||
}
|
||||
}
|
||||
for (const pub of rp.videoTrackPublications.values()) {
|
||||
const track = pub.track;
|
||||
if (!track) continue;
|
||||
const stats = await safeGetStats(track);
|
||||
if (stats) {
|
||||
const k = participant.identity + '/video/in';
|
||||
const r = readInbound(stats);
|
||||
video.videoInKbps = bitrateKbps(cache.bytes, k, r.bytes);
|
||||
const lossPct = computeLossPct(cache.pkts, k, r.recv, r.lost);
|
||||
if (lossPct !== undefined) video.packetLossPct = lossPct;
|
||||
if (r.jitter !== undefined) video.jitterMs = Math.round(r.jitter * 1000);
|
||||
if (r.rttMs !== undefined) video.rttMs = r.rttMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { identity: participant.identity, isLocal, audio, video };
|
||||
}
|
||||
|
||||
async function safeGetStats(track: unknown): Promise<RTCStatsReport | null> {
|
||||
const t = track as TrackWithStats;
|
||||
if (typeof t.getRTCStatsReport !== 'function') return null;
|
||||
try {
|
||||
const stats = await t.getRTCStatsReport();
|
||||
return stats ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface InboundRead {
|
||||
bytes: number;
|
||||
recv: number;
|
||||
lost: number;
|
||||
jitter?: number;
|
||||
rttMs?: number;
|
||||
}
|
||||
|
||||
function readInbound(stats: RTCStatsReport): InboundRead {
|
||||
let bytes = 0;
|
||||
let recv = 0;
|
||||
let lost = 0;
|
||||
let jitter: number | undefined;
|
||||
let rttMs: number | undefined;
|
||||
stats.forEach((report: { type?: string; [key: string]: unknown }) => {
|
||||
if (report.type === 'inbound-rtp') {
|
||||
const r = report as unknown as {
|
||||
bytesReceived?: number;
|
||||
packetsReceived?: number;
|
||||
packetsLost?: number;
|
||||
jitter?: number;
|
||||
};
|
||||
bytes += r.bytesReceived ?? 0;
|
||||
recv += r.packetsReceived ?? 0;
|
||||
lost += r.packetsLost ?? 0;
|
||||
if (r.jitter !== undefined) jitter = r.jitter;
|
||||
}
|
||||
if (report.type === 'remote-inbound-rtp') {
|
||||
const r = report as unknown as { roundTripTime?: number };
|
||||
if (r.roundTripTime !== undefined) {
|
||||
rttMs = Math.round(r.roundTripTime * 1000);
|
||||
}
|
||||
}
|
||||
if (report.type === 'candidate-pair') {
|
||||
const r = report as unknown as {
|
||||
nominated?: boolean;
|
||||
currentRoundTripTime?: number;
|
||||
};
|
||||
if (r.nominated && r.currentRoundTripTime !== undefined && rttMs === undefined) {
|
||||
rttMs = Math.round(r.currentRoundTripTime * 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
const result: InboundRead = { bytes, recv, lost };
|
||||
if (jitter !== undefined) result.jitter = jitter;
|
||||
if (rttMs !== undefined) result.rttMs = rttMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
function readOutbound(stats: RTCStatsReport): { bytes: number } {
|
||||
let bytes = 0;
|
||||
stats.forEach((report: { type?: string; [key: string]: unknown }) => {
|
||||
if (report.type === 'outbound-rtp') {
|
||||
const r = report as unknown as { bytesSent?: number };
|
||||
bytes += r.bytesSent ?? 0;
|
||||
}
|
||||
});
|
||||
return { bytes };
|
||||
}
|
||||
|
||||
function bitrateKbps(
|
||||
cache: Map<string, ByteSample>,
|
||||
key: string,
|
||||
bytes: number,
|
||||
): number {
|
||||
const now = performance.now();
|
||||
const prev = cache.get(key);
|
||||
cache.set(key, { bytes, timeMs: now });
|
||||
if (!prev) return 0;
|
||||
const dtSec = (now - prev.timeMs) / 1000;
|
||||
if (dtSec <= 0) return 0;
|
||||
const dBytes = Math.max(0, bytes - prev.bytes);
|
||||
// bytes -> bits -> kbps.
|
||||
return Math.round((dBytes * 8) / 1000 / dtSec);
|
||||
}
|
||||
|
||||
function computeLossPct(
|
||||
cache: Map<string, { recv: number; lost: number }>,
|
||||
key: string,
|
||||
recv: number,
|
||||
lost: number,
|
||||
): number | undefined {
|
||||
const prev = cache.get(key);
|
||||
cache.set(key, { recv, lost });
|
||||
if (!prev) return undefined;
|
||||
const dRecv = Math.max(0, recv - prev.recv);
|
||||
const dLost = Math.max(0, lost - prev.lost);
|
||||
const total = dRecv + dLost;
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((dLost / total) * 100 * 10) / 10;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// In-app changelog feed.
|
||||
//
|
||||
// The release script (`scripts/release.mjs`) maintains a single
|
||||
// `changelog.json` file alongside `latest.json` on update.netralax.cloud.
|
||||
// The list is newest-first, capped at 200 entries server-side, and rewritten
|
||||
// after every release.
|
||||
|
||||
const CHANGELOG_URL = 'https://update.netralax.cloud/windows/changelog.json';
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
pub_date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function isEntry(v: unknown): v is ChangelogEntry {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const e = v as Record<string, unknown>;
|
||||
return (
|
||||
typeof e.version === 'string' &&
|
||||
typeof e.pub_date === 'string' &&
|
||||
typeof e.notes === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchChangelog(): Promise<ChangelogEntry[]> {
|
||||
const res = await fetch(CHANGELOG_URL, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
throw new Error('changelog request failed: ' + res.status);
|
||||
}
|
||||
const data: unknown = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.filter(isEntry);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
parseMessagePayload,
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectConversationAttachments,
|
||||
createPollPayload,
|
||||
summarizePollVotes,
|
||||
} from './conversationFeatures';
|
||||
|
||||
function handle(id: string, mimeType: string): AttachmentHandle {
|
||||
return {
|
||||
id,
|
||||
storagePath: 'conversation/' + id + '.bin',
|
||||
mimeType,
|
||||
sizeBytes: 2048,
|
||||
keyB64: 'key',
|
||||
nonceB64: 'nonce',
|
||||
};
|
||||
}
|
||||
|
||||
function message(id: string, createdAt: string, attachments: AttachmentHandle[]): DecryptedMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: 'conv-1',
|
||||
senderId: 'sender-1',
|
||||
senderDeviceId: 'device-1',
|
||||
replyToId: null,
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt,
|
||||
plaintext: JSON.stringify({ v: 1, type: 'text', text: '', attachments }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('collectConversationAttachments', () => {
|
||||
it('indexes media, audio, and files newest first', () => {
|
||||
const first = message('m1', '2026-04-24T09:00:00.000Z', [
|
||||
handle('img-1', 'image/png'),
|
||||
handle('file-1', 'application/pdf'),
|
||||
]);
|
||||
const second = message('m2', '2026-04-24T10:00:00.000Z', [
|
||||
handle('audio-1', 'audio/webm'),
|
||||
handle('video-1', 'video/mp4'),
|
||||
]);
|
||||
|
||||
const index = collectConversationAttachments([first, second]);
|
||||
|
||||
expect(index.media.map((item) => item.handle.id)).toEqual(['video-1', 'img-1']);
|
||||
expect(index.audio.map((item) => item.handle.id)).toEqual(['audio-1']);
|
||||
expect(index.files.map((item) => item.handle.id)).toEqual(['file-1']);
|
||||
expect(index.all.map((item) => item.messageId)).toEqual(['m2', 'm2', 'm1', 'm1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPollPayload', () => {
|
||||
it('creates a parseable encrypted-message poll payload', () => {
|
||||
const payload = createPollPayload(' Lieblings Feature? ', [' Media Drawer ', '', 'Polls']);
|
||||
const parsed = parseMessagePayload(payload);
|
||||
|
||||
expect(parsed.kind).toBe('poll');
|
||||
if (parsed.kind !== 'poll') throw new Error('expected poll payload');
|
||||
expect(parsed.question).toBe('Lieblings Feature?');
|
||||
expect(parsed.options).toEqual([
|
||||
{ id: 'option-1', emoji: '1️⃣', text: 'Media Drawer' },
|
||||
{ id: 'option-2', emoji: '2️⃣', text: 'Polls' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a question and at least two non-empty options', () => {
|
||||
expect(() => createPollPayload('', ['A', 'B'])).toThrow('question');
|
||||
expect(() => createPollPayload('Feature?', ['A', ''])).toThrow('options');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizePollVotes', () => {
|
||||
it('counts only configured poll options and marks the current user vote', () => {
|
||||
const summary = summarizePollVotes(
|
||||
[
|
||||
{ id: 'option-1', emoji: '1️⃣', text: 'Media Drawer' },
|
||||
{ id: 'option-2', emoji: '2️⃣', text: 'Polls' },
|
||||
],
|
||||
[
|
||||
{ emoji: '1️⃣', count: 3, mine: false },
|
||||
{ emoji: '2️⃣', count: 1, mine: true },
|
||||
{ emoji: '🔥', count: 99, mine: true },
|
||||
],
|
||||
);
|
||||
|
||||
expect(summary.totalVotes).toBe(4);
|
||||
expect(summary.options.map((option) => option.percent)).toEqual([75, 25]);
|
||||
expect(summary.options.map((option) => option.mine)).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
parseMessagePayload,
|
||||
serializeMessagePayload,
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
type PollOption,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||
|
||||
export interface ConversationAttachmentItem {
|
||||
messageId: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
handle: AttachmentHandle;
|
||||
bucket: AttachmentBucket;
|
||||
}
|
||||
|
||||
export interface ConversationAttachmentIndex {
|
||||
all: ConversationAttachmentItem[];
|
||||
media: ConversationAttachmentItem[];
|
||||
audio: ConversationAttachmentItem[];
|
||||
files: ConversationAttachmentItem[];
|
||||
}
|
||||
|
||||
interface ReactionSummaryInput {
|
||||
emoji: string;
|
||||
count: number;
|
||||
mine: boolean;
|
||||
}
|
||||
|
||||
export interface PollVoteOptionSummary extends PollOption {
|
||||
count: number;
|
||||
mine: boolean;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface PollVoteSummary {
|
||||
totalVotes: number;
|
||||
options: PollVoteOptionSummary[];
|
||||
}
|
||||
|
||||
export const POLL_OPTION_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'];
|
||||
|
||||
export function collectConversationAttachments(
|
||||
messages: DecryptedMessage[],
|
||||
): ConversationAttachmentIndex {
|
||||
const all: ConversationAttachmentItem[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.deletedAt || !message.plaintext) continue;
|
||||
const parsed = parseMessagePayload(message.plaintext);
|
||||
if (parsed.kind !== 'text') continue;
|
||||
|
||||
for (const handle of parsed.attachments) {
|
||||
all.push({
|
||||
messageId: message.id,
|
||||
senderId: message.senderId,
|
||||
createdAt: message.createdAt,
|
||||
handle,
|
||||
bucket: bucketForMime(handle.mimeType),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
all.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||
|
||||
return {
|
||||
all,
|
||||
media: all.filter((item) => item.bucket === 'media'),
|
||||
audio: all.filter((item) => item.bucket === 'audio'),
|
||||
files: all.filter((item) => item.bucket === 'files'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPollPayload(question: string, optionTexts: string[]): string {
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) throw new Error('poll question is required');
|
||||
|
||||
const options: PollOption[] = optionTexts
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, POLL_OPTION_EMOJIS.length)
|
||||
.map((text, idx) => ({
|
||||
id: 'option-' + (idx + 1),
|
||||
emoji: POLL_OPTION_EMOJIS[idx]!,
|
||||
text,
|
||||
}));
|
||||
|
||||
if (options.length < 2) throw new Error('poll requires at least two options');
|
||||
|
||||
return serializeMessagePayload({
|
||||
v: 1,
|
||||
type: 'poll',
|
||||
question: trimmedQuestion,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
export function summarizePollVotes(
|
||||
options: PollOption[],
|
||||
reactions: ReactionSummaryInput[],
|
||||
): PollVoteSummary {
|
||||
const reactionByEmoji = new Map(reactions.map((reaction) => [reaction.emoji, reaction]));
|
||||
const totalVotes = options.reduce(
|
||||
(sum, option) => sum + (reactionByEmoji.get(option.emoji)?.count ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalVotes,
|
||||
options: options.map((option) => {
|
||||
const reaction = reactionByEmoji.get(option.emoji);
|
||||
const count = reaction?.count ?? 0;
|
||||
return {
|
||||
...option,
|
||||
count,
|
||||
mine: reaction?.mine ?? false,
|
||||
percent: totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function bucketForMime(mimeType: string): AttachmentBucket {
|
||||
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) return 'media';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
return 'files';
|
||||
}
|
||||
@@ -1,145 +1,169 @@
|
||||
import {
|
||||
isRegistered,
|
||||
register,
|
||||
type ShortcutEvent,
|
||||
unregister,
|
||||
} from '@tauri-apps/plugin-global-shortcut';
|
||||
// Global shortcut bindings. Routes all calls through the Electron
|
||||
// preload bridge (`window.electronAPI`). The exported surface is stable
|
||||
// across the Tauri → Electron migration so no calling code changes.
|
||||
//
|
||||
// `isTauriRuntime()` is kept as a named export for back-compat — many
|
||||
// callers import it. The implementation now checks for the Electron
|
||||
// preload marker instead. See `isNativeRuntime` for a forward-looking
|
||||
// name.
|
||||
|
||||
// Maps a KeyboardEvent.code (what our PTT settings store) into the shortcut
|
||||
// string accepted by tauri-plugin-global-shortcut. The plugin follows the
|
||||
// [keyboard-types] crate naming which mostly matches DOM `event.code`, but
|
||||
// single-key aliases (e.g. "Space", "F5") work as-is.
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
export function isNativeRuntime(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.electronAPI !== 'undefined' &&
|
||||
window.electronAPI.platform === 'electron-chatapp-v1'
|
||||
);
|
||||
}
|
||||
|
||||
// Back-compat alias. New code should prefer `isNativeRuntime`.
|
||||
export const isTauriRuntime = isNativeRuntime;
|
||||
|
||||
// Converts a KeyboardEvent.code (what PTT settings store) into an
|
||||
// Electron accelerator token. Electron's accelerator syntax is close
|
||||
// enough to the DOM `.code` naming for most keys; single-key strips the
|
||||
// "Key"/"Digit" prefix that DOM adds.
|
||||
export function codeToShortcut(code: string): string {
|
||||
if (code.startsWith('Key')) return code.slice(3); // KeyV -> V
|
||||
if (code.startsWith('Digit')) return code.slice(5); // Digit1 -> 1
|
||||
// Space, F1..F24, Escape, Enter, Tab, Arrow*, etc. pass through unchanged.
|
||||
if (code.startsWith('Key')) return code.slice(3);
|
||||
if (code.startsWith('Digit')) return code.slice(5);
|
||||
return code;
|
||||
}
|
||||
|
||||
// ---- Single-shortcut event router ----------------------------------------
|
||||
//
|
||||
// Electron's preload delivers all fired/released events on one channel;
|
||||
// we fan out to per-id callbacks here so each caller can register
|
||||
// independently without re-subscribing at the IPC layer.
|
||||
|
||||
interface EventEntry {
|
||||
onPress: () => void;
|
||||
onRelease?: () => void;
|
||||
}
|
||||
|
||||
const handlers = new Map<string, EventEntry>();
|
||||
let firedUnsub: Unsubscribe | null = null;
|
||||
let releasedUnsub: Unsubscribe | null = null;
|
||||
|
||||
function ensureSubscribed(): void {
|
||||
if (!firedUnsub) {
|
||||
firedUnsub = window.electronAPI.onShortcutFired((evt) => {
|
||||
const entry = handlers.get(evt.id);
|
||||
entry?.onPress();
|
||||
});
|
||||
}
|
||||
if (!releasedUnsub) {
|
||||
releasedUnsub = window.electronAPI.onShortcutReleased((evt) => {
|
||||
const entry = handlers.get(evt.id);
|
||||
entry?.onRelease?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerShortcut(
|
||||
id: string,
|
||||
accelerator: string,
|
||||
kind: 'press' | 'ptt',
|
||||
onPress: () => void,
|
||||
onRelease?: () => void,
|
||||
): Promise<boolean> {
|
||||
if (!isNativeRuntime()) return false;
|
||||
ensureSubscribed();
|
||||
if (handlers.has(id)) {
|
||||
try {
|
||||
await window.electronAPI.unregisterShortcut(id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
handlers.delete(id);
|
||||
}
|
||||
try {
|
||||
const ok = await window.electronAPI.registerShortcut({ id, accelerator, kind });
|
||||
if (!ok) return false;
|
||||
handlers.set(id, onRelease ? { onPress, onRelease } : { onPress });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerShortcut failed', { id, accelerator, err });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unregisterShortcutById(id: string): Promise<void> {
|
||||
if (!isNativeRuntime()) {
|
||||
handlers.delete(id);
|
||||
return;
|
||||
}
|
||||
handlers.delete(id);
|
||||
try {
|
||||
await window.electronAPI.unregisterShortcut(id);
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterShortcut failed', { id, err });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PTT (press + release) ------------------------------------------------
|
||||
//
|
||||
// PTT release semantics under Electron's globalShortcut are simulated —
|
||||
// main auto-fires SHORTCUT_EVT_RELEASED 200ms after each press. See the
|
||||
// main-side shortcuts module for the limitation.
|
||||
|
||||
export async function registerPttShortcut(
|
||||
code: string,
|
||||
onPress: () => void,
|
||||
onRelease: () => void,
|
||||
): Promise<boolean> {
|
||||
const shortcut = codeToShortcut(code);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
else if (event.state === 'Released') onRelease();
|
||||
});
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerPttShortcut failed', { code, err });
|
||||
return false;
|
||||
}
|
||||
return registerShortcut('ptt', codeToShortcut(code), 'ptt', onPress, onRelease);
|
||||
}
|
||||
|
||||
export async function unregisterPttShortcut(code: string): Promise<void> {
|
||||
const shortcut = codeToShortcut(code);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterPttShortcut failed', { code, err });
|
||||
}
|
||||
export async function unregisterPttShortcut(_code: string): Promise<void> {
|
||||
await unregisterShortcutById('ptt');
|
||||
}
|
||||
|
||||
// Press-only global shortcut (for toggles like Mute/Deafen). Accepts an
|
||||
// already-formatted accelerator string (e.g. "CommandOrControl+Shift+M")
|
||||
// since these bindings may include modifier chords — the KeyboardEvent.code
|
||||
// variant used by PTT can't express that.
|
||||
// ---- Press-only (toggles like mute/deafen) -------------------------------
|
||||
|
||||
export async function registerGlobalShortcutPress(
|
||||
shortcut: string,
|
||||
onPress: () => void,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
});
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerGlobalShortcutPress failed', { shortcut, err });
|
||||
return false;
|
||||
}
|
||||
return registerShortcut(`press:${shortcut}`, shortcut, 'press', onPress);
|
||||
}
|
||||
|
||||
export async function unregisterGlobalShortcut(shortcut: string): Promise<void> {
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterGlobalShortcut failed', { shortcut, err });
|
||||
}
|
||||
await unregisterShortcutById(`press:${shortcut}`);
|
||||
}
|
||||
|
||||
// Detects whether we're running under Tauri. When running in a pure web
|
||||
// preview (vite dev in a browser without Tauri), importing the plugin still
|
||||
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
|
||||
// exist — use this guard to skip registration cleanly.
|
||||
export function isTauriRuntime(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
// --- Soundboard shortcuts --------------------------------------------------
|
||||
//
|
||||
// Separate from the single PTT shortcut: the soundboard needs to register
|
||||
// many fire-and-forget press bindings at once, keep track of which ids own
|
||||
// which accelerators so we can unregister just one, and expose conflict
|
||||
// detection for the settings UI.
|
||||
// ---- Soundboard shortcuts ------------------------------------------------
|
||||
|
||||
interface SoundShortcutRegistration {
|
||||
shortcut: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// Map of logical id (sound uuid) -> registration.
|
||||
const soundRegistry = new Map<string, SoundShortcutRegistration>();
|
||||
|
||||
function soundId(id: string): string {
|
||||
return `sound:${id}`;
|
||||
}
|
||||
|
||||
export async function registerSoundShortcut(
|
||||
id: string,
|
||||
code: string,
|
||||
onPress: () => void,
|
||||
): Promise<boolean> {
|
||||
if (!isTauriRuntime()) return false;
|
||||
if (!isNativeRuntime()) return false;
|
||||
const shortcut = codeToShortcut(code);
|
||||
// Unregister any previous binding for this id first — caller may be
|
||||
// re-registering after the user changed the hotkey for the same sound.
|
||||
await unregisterSoundShortcut(id);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
});
|
||||
soundRegistry.set(id, { shortcut, onPress });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerSoundShortcut failed', { id, code, err });
|
||||
return false;
|
||||
}
|
||||
const ok = await registerShortcut(soundId(id), shortcut, 'press', onPress);
|
||||
if (ok) soundRegistry.set(id, { shortcut, onPress });
|
||||
return ok;
|
||||
}
|
||||
|
||||
export async function unregisterSoundShortcut(id: string): Promise<void> {
|
||||
const reg = soundRegistry.get(id);
|
||||
if (!reg) return;
|
||||
soundRegistry.delete(id);
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
if (await isRegistered(reg.shortcut)) {
|
||||
await unregister(reg.shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterSoundShortcut failed', { id, err });
|
||||
}
|
||||
if (!isNativeRuntime()) return;
|
||||
await unregisterShortcutById(soundId(id));
|
||||
}
|
||||
|
||||
export async function unregisterAllSoundShortcuts(): Promise<void> {
|
||||
@@ -147,8 +171,6 @@ export async function unregisterAllSoundShortcuts(): Promise<void> {
|
||||
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
|
||||
}
|
||||
|
||||
// Resolve a DOM code to the registry's current owner (if any). Used by the
|
||||
// settings UI to surface conflicts before saving a new hotkey.
|
||||
export function soundShortcutOwnerFor(code: string): string | null {
|
||||
const shortcut = codeToShortcut(code);
|
||||
for (const [id, reg] of soundRegistry) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
||||
//
|
||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
||||
// We ship a hard off switch and require an explicit user toggle.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
||||
|
||||
export interface LiveCaptionsSettings {
|
||||
enabled: boolean;
|
||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: LiveCaptionsSettings = {
|
||||
enabled: false,
|
||||
lang: null,
|
||||
};
|
||||
|
||||
type Listener = (s: LiveCaptionsSettings) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let cached: LiveCaptionsSettings | null = null;
|
||||
|
||||
function read(): LiveCaptionsSettings {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
||||
cached = {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
||||
lang:
|
||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
||||
? parsed.lang
|
||||
: DEFAULTS.lang,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
function write(s: LiveCaptionsSettings): void {
|
||||
cached = s;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
for (const l of listeners) l(s);
|
||||
}
|
||||
|
||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
||||
return read();
|
||||
}
|
||||
|
||||
export function updateLiveCaptionsSettings(
|
||||
patch: Partial<LiveCaptionsSettings>,
|
||||
): LiveCaptionsSettings {
|
||||
const next = { ...read(), ...patch };
|
||||
write(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
||||
// outright. Returns the constructor or null.
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
interface SpeechRecognitionLike extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
interface SpeechRecognitionEventLike {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{
|
||||
isFinal: boolean;
|
||||
[index: number]: { transcript: string };
|
||||
length: number;
|
||||
}>;
|
||||
}
|
||||
interface SpeechRecognitionErrorLike {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function isLiveCaptionsSupported(): boolean {
|
||||
return getSpeechRecognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type {
|
||||
SpeechRecognitionLike,
|
||||
SpeechRecognitionEventLike,
|
||||
SpeechRecognitionErrorLike,
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
// Native-loopback renderer sink. Pairs with
|
||||
// electron/modules/audio-loopback.ts and the @chatapp/audio-loopback-native
|
||||
// addon: receives interleaved f32 stereo PCM chunks at 48kHz over IPC,
|
||||
// pushes them into an AudioWorklet ring buffer, and exposes the result
|
||||
// as a real `MediaStreamTrack` that LiveKit can publish as a
|
||||
// ScreenShareAudio publication.
|
||||
//
|
||||
// The WASAPI pipeline excludes our own process tree, so peers in a
|
||||
// video call don't hear themselves echoed back when the user shares
|
||||
// system audio. That's the whole reason we route around Chromium's
|
||||
// 'loopback' source on Windows.
|
||||
//
|
||||
// Strategy: AudioWorklet + MediaStreamAudioDestinationNode is the
|
||||
// portable path that works in every Electron Chromium build we ship
|
||||
// against. MediaStreamTrackGenerator (a newer alternative) was
|
||||
// considered but is gated behind a flag in stable Chromium and isn't
|
||||
// reliable across Electron versions, so we don't take that branch.
|
||||
|
||||
interface AudioLoopbackChunkPayload {
|
||||
captureId: number;
|
||||
samples: Float32Array;
|
||||
}
|
||||
|
||||
export interface LoopbackTrackHandle {
|
||||
/** The audio MediaStreamTrack to publish via LiveKit. */
|
||||
track: MediaStreamTrack;
|
||||
/** Teardown — stops the addon-side capture, disconnects the audio
|
||||
* graph, ends the track. Idempotent. */
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class LoopbackAudioUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super('loopback audio unavailable: ' + reason);
|
||||
this.name = 'LoopbackAudioUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
// AudioWorklet processor source. Identical ring-buffer shape to the
|
||||
// Tauri renderer: a pair of per-channel ring buffers that the main
|
||||
// thread fills as samples arrive; `process()` drains into the output
|
||||
// quantum. Underrun emits silence (a glitch is better than a freeze
|
||||
// for LiveKit's Opus encoder). Hard cap at 300ms keeps a stalled-then-
|
||||
// resumed pipeline from playing back minutes of stale audio.
|
||||
const WORKLET_SOURCE = `
|
||||
class LoopbackAudioProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.bufferSize = 48000 * 0.3 | 0;
|
||||
this.targetFrames = 48000 * 0.08 | 0;
|
||||
this.bufL = new Float32Array(this.bufferSize);
|
||||
this.bufR = new Float32Array(this.bufferSize);
|
||||
this.writePos = 0;
|
||||
this.readPos = 0;
|
||||
this.available = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const { left, right } = e.data;
|
||||
const len = left.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
this.bufL[this.writePos] = left[i];
|
||||
this.bufR[this.writePos] = right[i];
|
||||
this.writePos = (this.writePos + 1) % this.bufferSize;
|
||||
if (this.available < this.bufferSize) {
|
||||
this.available++;
|
||||
} else {
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
}
|
||||
}
|
||||
if (this.available > this.targetFrames * 3) {
|
||||
const drop = this.available - this.targetFrames;
|
||||
this.readPos = (this.readPos + drop) % this.bufferSize;
|
||||
this.available -= drop;
|
||||
}
|
||||
};
|
||||
}
|
||||
process(_inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || output.length === 0) return true;
|
||||
const out0 = output[0];
|
||||
const out1 = output[1] || output[0];
|
||||
const n = out0.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.available > 0) {
|
||||
out0[i] = this.bufL[this.readPos];
|
||||
if (out1 !== out0) out1[i] = this.bufR[this.readPos];
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
this.available--;
|
||||
} else {
|
||||
out0[i] = 0;
|
||||
if (out1 !== out0) out1[i] = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('loopback-audio-processor', LoopbackAudioProcessor);
|
||||
`;
|
||||
|
||||
let workletModuleUrl: string | null = null;
|
||||
function getWorkletModuleUrl(): string {
|
||||
if (workletModuleUrl) return workletModuleUrl;
|
||||
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
|
||||
workletModuleUrl = URL.createObjectURL(blob);
|
||||
return workletModuleUrl;
|
||||
}
|
||||
|
||||
export interface StartLoopbackTrackOptions {
|
||||
/** When set, the capture targets only the picked window's process
|
||||
* tree (INCLUDE_TARGET_PROCESS_TREE) — Discord-parity behaviour for
|
||||
* window-shares. The HWND is the decimal handle parsed from
|
||||
* desktopCapturer's `window:<HWND>:0` source id. When unset, falls
|
||||
* back to the EXCLUDE-self path that captures the whole OS mixer
|
||||
* minus our own PID tree (default for full-screen shares). */
|
||||
windowHwnd?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a native WASAPI process-loopback capture and surface the
|
||||
* result as a `MediaStreamTrack`. Throws `LoopbackAudioUnavailable` if
|
||||
* the platform / build doesn't ship the addon — callers fall through
|
||||
* to the cross-platform getUserMedia path.
|
||||
*
|
||||
* Pass `{ windowHwnd }` to capture only that window's app audio
|
||||
* (window-share path). Omit to capture the whole OS mixer minus our
|
||||
* own PID tree (full-screen-share path).
|
||||
*/
|
||||
export async function startLoopbackTrack(
|
||||
opts?: StartLoopbackTrackOptions,
|
||||
): Promise<LoopbackTrackHandle> {
|
||||
if (typeof window === 'undefined' || !window.electronAPI?.audioLoopback) {
|
||||
throw new LoopbackAudioUnavailable('not an electron runtime with audio-loopback bridge');
|
||||
}
|
||||
const AudioCtor: typeof AudioContext | undefined =
|
||||
typeof window !== 'undefined'
|
||||
? (window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext)
|
||||
: undefined;
|
||||
if (!AudioCtor) {
|
||||
throw new LoopbackAudioUnavailable('WebAudio unavailable');
|
||||
}
|
||||
|
||||
// Pin the context to 48kHz so the worklet's input rate matches the
|
||||
// addon's output rate. If the OS forces a different rate the
|
||||
// constructor throws on some browsers; we surface as Unavailable so
|
||||
// the caller can fall back.
|
||||
let ctx: AudioContext;
|
||||
try {
|
||||
ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||
} catch (err: unknown) {
|
||||
throw new LoopbackAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(getWorkletModuleUrl());
|
||||
} catch (err: unknown) {
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new LoopbackAudioUnavailable(
|
||||
'audioWorklet load failed: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
const node = new AudioWorkletNode(ctx, 'loopback-audio-processor', {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
node.connect(dest);
|
||||
|
||||
// Kick the AudioContext out of `suspended` before any samples arrive
|
||||
// — the share is triggered from a user click so autoplay policy
|
||||
// allows this. An un-resumed context would buffer everything the
|
||||
// addon produces until the context eventually runs, giving seconds
|
||||
// of initial latency.
|
||||
if (ctx.state !== 'running') {
|
||||
try {
|
||||
await ctx.resume();
|
||||
} catch (err: unknown) {
|
||||
console.warn('loopback ctx.resume failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe BEFORE starting the capture so we don't drop any of the
|
||||
// initial chunks that race the start() promise.
|
||||
let captureIdResolved: number | null = null;
|
||||
const unsubscribe = window.electronAPI.audioLoopback.onChunk(
|
||||
(payload: AudioLoopbackChunkPayload) => {
|
||||
if (captureIdResolved !== null && payload.captureId !== captureIdResolved) {
|
||||
return;
|
||||
}
|
||||
const interleaved = payload.samples;
|
||||
const frames = interleaved.length >> 1;
|
||||
if (frames === 0) return;
|
||||
const left = new Float32Array(frames);
|
||||
const right = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
left[i] = interleaved[i * 2] ?? 0;
|
||||
right[i] = interleaved[i * 2 + 1] ?? 0;
|
||||
}
|
||||
node.port.postMessage({ left, right }, [left.buffer, right.buffer]);
|
||||
},
|
||||
);
|
||||
|
||||
let startResult: { captureId: number };
|
||||
try {
|
||||
if (typeof opts?.windowHwnd === 'number' && Number.isFinite(opts.windowHwnd)) {
|
||||
// INCLUDE_TARGET_PROCESS_TREE — capture only the picked window's
|
||||
// app. Falls back via the catch below if the addon predates the
|
||||
// startForWindow surface (older .node binary).
|
||||
const startForWindow = window.electronAPI.audioLoopback.startForWindow;
|
||||
if (typeof startForWindow !== 'function') {
|
||||
throw new LoopbackAudioUnavailable(
|
||||
'audioLoopback.startForWindow not exposed — preload + native addon need rebuild',
|
||||
);
|
||||
}
|
||||
startResult = await startForWindow(opts.windowHwnd);
|
||||
} else {
|
||||
startResult = await window.electronAPI.audioLoopback.start();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
unsubscribe();
|
||||
node.disconnect();
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new LoopbackAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
captureIdResolved = startResult.captureId;
|
||||
|
||||
const tracks = dest.stream.getAudioTracks();
|
||||
const track = tracks[0];
|
||||
if (!track) {
|
||||
unsubscribe();
|
||||
node.disconnect();
|
||||
await ctx.close().catch(() => undefined);
|
||||
await window.electronAPI.audioLoopback
|
||||
.stop(startResult.captureId)
|
||||
.catch(() => undefined);
|
||||
throw new LoopbackAudioUnavailable('MediaStreamDestination produced no track');
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
try {
|
||||
await window.electronAPI.audioLoopback.stop(startResult.captureId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('audioLoopback.stop failed', err);
|
||||
}
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
await ctx.close().catch(() => undefined);
|
||||
};
|
||||
|
||||
return { track, stop };
|
||||
}
|
||||
Binary file not shown.
@@ -12,10 +12,20 @@
|
||||
// All fall back to WASM when the Tauri runtime isn't present (browser
|
||||
// preview, dev server) so the same code paths keep working.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import _sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
// NOTE: After the Tauri → Electron migration the native Rust crypto
|
||||
// commands are no longer available — we always fall through to the
|
||||
// WASM path below. The `invoke`-based call-sites are kept in this file
|
||||
// as reference / future re-enable points once we expose main-process
|
||||
// crypto accelerators via IPC again, but `nativeAvailable()` now
|
||||
// unconditionally returns false so they never run.
|
||||
|
||||
// Local shim so the unreachable `invoke` references still type-check
|
||||
// without a dependency on the deleted Tauri package.
|
||||
const invoke = async <T>(_cmd: string, _args?: unknown): Promise<T> => {
|
||||
throw new Error('native crypto invoke disabled post-Electron-migration');
|
||||
};
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
@@ -41,7 +51,11 @@ const flagEnabled = (() => {
|
||||
})();
|
||||
|
||||
function nativeAvailable(): boolean {
|
||||
return flagEnabled && isTauriRuntime();
|
||||
// Crypto native commands deferred for post-Electron-migration. We
|
||||
// run everything through the WASM path until there's a measured
|
||||
// reason to re-introduce a main-process accelerator.
|
||||
void flagEnabled;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// Thin JS wrapper around the Rust LiveKit bridge commands + events.
|
||||
// Mirrors the subset of `livekit-client` that CallContext actually uses
|
||||
// so the adapter can be swapped behind the `VITE_USE_RUST_LIVEKIT` flag.
|
||||
//
|
||||
// Phase B.1 — only connect/disconnect/data-channel/state events wired.
|
||||
// Mic, camera, screen-share, active-speakers, video rendering ship in
|
||||
// later phases. Components that call missing methods get a typed
|
||||
// "not implemented" error so regressions surface immediately.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export const rustLivekitFlag = (() => {
|
||||
const raw = (import.meta as unknown as { env?: { VITE_USE_RUST_LIVEKIT?: string } })
|
||||
.env?.VITE_USE_RUST_LIVEKIT;
|
||||
return raw === 'true' || raw === '1';
|
||||
})();
|
||||
|
||||
export function isRustLivekitAvailable(): boolean {
|
||||
return rustLivekitFlag && isTauriRuntime();
|
||||
}
|
||||
|
||||
export type NativeRoomState =
|
||||
| { state: 'connecting' }
|
||||
| { state: 'connected' }
|
||||
| { state: 'disconnected' };
|
||||
|
||||
export interface NativeParticipantEvent {
|
||||
identity: string;
|
||||
name?: string | undefined;
|
||||
}
|
||||
|
||||
export interface NativeDataEvent {
|
||||
identity: string;
|
||||
payloadB64: string;
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
type Listener<T> = (payload: T) => void;
|
||||
|
||||
export class NativeRoom {
|
||||
private unlistens: UnlistenFn[] = [];
|
||||
private stateListeners = new Set<Listener<NativeRoomState>>();
|
||||
private joinListeners = new Set<Listener<NativeParticipantEvent>>();
|
||||
private leaveListeners = new Set<Listener<NativeParticipantEvent>>();
|
||||
private dataListeners = new Set<Listener<NativeDataEvent>>();
|
||||
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (!isRustLivekitAvailable()) {
|
||||
throw new Error('Rust LiveKit backend not available');
|
||||
}
|
||||
await this.subscribeEvents();
|
||||
try {
|
||||
await invoke('livekit_connect', { args: { url, token } });
|
||||
} catch (err: unknown) {
|
||||
await this.teardown();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
try {
|
||||
await invoke('livekit_disconnect');
|
||||
} finally {
|
||||
await this.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
async sendData(payload: Uint8Array, reliable: boolean): Promise<void> {
|
||||
await invoke('livekit_send_data', {
|
||||
payloadB64: bytesToB64(payload),
|
||||
reliable,
|
||||
});
|
||||
}
|
||||
|
||||
onRoomState(fn: Listener<NativeRoomState>): () => void {
|
||||
this.stateListeners.add(fn);
|
||||
return () => this.stateListeners.delete(fn);
|
||||
}
|
||||
onParticipantJoined(fn: Listener<NativeParticipantEvent>): () => void {
|
||||
this.joinListeners.add(fn);
|
||||
return () => this.joinListeners.delete(fn);
|
||||
}
|
||||
onParticipantLeft(fn: Listener<NativeParticipantEvent>): () => void {
|
||||
this.leaveListeners.add(fn);
|
||||
return () => this.leaveListeners.delete(fn);
|
||||
}
|
||||
onDataReceived(fn: Listener<NativeDataEvent>): () => void {
|
||||
this.dataListeners.add(fn);
|
||||
return () => this.dataListeners.delete(fn);
|
||||
}
|
||||
|
||||
private async subscribeEvents(): Promise<void> {
|
||||
// Room state — connected / disconnected.
|
||||
const uState = await listen<NativeRoomState>('livekit:room_state', (evt) => {
|
||||
for (const fn of this.stateListeners) fn(evt.payload);
|
||||
});
|
||||
const uJoined = await listen<NativeParticipantEvent>('livekit:participant_joined', (evt) => {
|
||||
for (const fn of this.joinListeners) fn(evt.payload);
|
||||
});
|
||||
const uLeft = await listen<NativeParticipantEvent>('livekit:participant_left', (evt) => {
|
||||
for (const fn of this.leaveListeners) fn(evt.payload);
|
||||
});
|
||||
const uData = await listen<NativeDataEvent>('livekit:data_received', (evt) => {
|
||||
for (const fn of this.dataListeners) fn(evt.payload);
|
||||
});
|
||||
this.unlistens.push(uState, uJoined, uLeft, uData);
|
||||
}
|
||||
|
||||
private async teardown(): Promise<void> {
|
||||
for (const unlisten of this.unlistens) {
|
||||
try {
|
||||
unlisten();
|
||||
} catch {
|
||||
/* unlistens become no-op after first call */
|
||||
}
|
||||
}
|
||||
this.unlistens = [];
|
||||
this.stateListeners.clear();
|
||||
this.joinListeners.clear();
|
||||
this.leaveListeners.clear();
|
||||
this.dataListeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToB64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
@@ -1,21 +1,90 @@
|
||||
// Two-tone notification chime generated via WebAudio. No asset file needed.
|
||||
// Throttled so a burst of messages doesn't turn into a machine gun.
|
||||
// Notification chime. Plays either a user-uploaded custom sound (if one
|
||||
// exists in IndexedDB) or the built-in two-tone synth chime. Throttled
|
||||
// so a burst of messages doesn't turn into a machine gun.
|
||||
//
|
||||
// Uses a single persistent AudioContext — a fresh one per notification
|
||||
// lands in the `suspended` state under Chromium's autoplay policy
|
||||
// whenever the user hasn't interacted recently, so the tone silently
|
||||
// never plays.
|
||||
//
|
||||
// The custom AudioBuffer is decoded eagerly: on module init and on any
|
||||
// upload/reset, kick the load so it's ready before the next incoming
|
||||
// message. Playback always awaits `ctx.resume()` before starting the
|
||||
// source so we don't race an async resume in a realtime-event context
|
||||
// that has no prior user gesture.
|
||||
|
||||
import {
|
||||
getCustomNotificationSound,
|
||||
subscribeNotificationSoundChanges,
|
||||
} from './notificationSoundStorage';
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
let lastPlay = 0;
|
||||
|
||||
export function playNotificationTone(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastPlay < 800) return;
|
||||
lastPlay = now;
|
||||
// Cached decoded custom sound. `null` = no custom upload (fall back to
|
||||
// synth). `undefined` = not yet loaded. `false` = decode failed.
|
||||
let customBuffer: AudioBuffer | null | undefined | false = undefined;
|
||||
let customLoadInFlight: Promise<void> | null = null;
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (ctx) return ctx;
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
if (!AudioCtx) return null;
|
||||
ctx = new AudioCtx();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const ctx = new AudioCtx();
|
||||
const master = ctx.createGain();
|
||||
master.connect(ctx.destination);
|
||||
async function loadCustomBuffer(c: AudioContext): Promise<void> {
|
||||
try {
|
||||
const record = await getCustomNotificationSound();
|
||||
if (!record) {
|
||||
customBuffer = null;
|
||||
return;
|
||||
}
|
||||
const arr = await record.blob.arrayBuffer();
|
||||
const buf = await c.decodeAudioData(arr.slice(0));
|
||||
customBuffer = buf;
|
||||
} catch (err: unknown) {
|
||||
console.warn('notification: custom sound decode failed', err);
|
||||
customBuffer = false;
|
||||
}
|
||||
}
|
||||
|
||||
function kickLoad(): void {
|
||||
if (customLoadInFlight) return;
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
customLoadInFlight = loadCustomBuffer(c).finally(() => {
|
||||
customLoadInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
subscribeNotificationSoundChanges(() => {
|
||||
customBuffer = undefined;
|
||||
customLoadInFlight = null;
|
||||
kickLoad();
|
||||
});
|
||||
|
||||
// Eager-load at module init so the buffer is ready before the first
|
||||
// notification fires. Safe to call at top level — decodeAudioData
|
||||
// doesn't need the context running.
|
||||
kickLoad();
|
||||
|
||||
function playCustom(c: AudioContext, buffer: AudioBuffer): void {
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
const gain = c.createGain();
|
||||
gain.gain.value = 1;
|
||||
src.connect(gain);
|
||||
gain.connect(c.destination);
|
||||
src.start();
|
||||
}
|
||||
|
||||
function playSynthTone(c: AudioContext): void {
|
||||
const master = c.createGain();
|
||||
master.connect(c.destination);
|
||||
master.gain.value = 0.12;
|
||||
|
||||
const tones: { freq: number; delay: number }[] = [
|
||||
@@ -23,23 +92,51 @@ export function playNotificationTone(): void {
|
||||
{ freq: 1320, delay: 0.08 },
|
||||
];
|
||||
for (const { freq, delay } of tones) {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const osc = c.createOscillator();
|
||||
const gain = c.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
osc.connect(gain);
|
||||
gain.connect(master);
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const t0 = c.currentTime + delay;
|
||||
gain.gain.setValueAtTime(0, t0);
|
||||
gain.gain.linearRampToValueAtTime(1, t0 + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + 0.3);
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
void ctx.close().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
export function playNotificationTone(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastPlay < 800) return;
|
||||
lastPlay = now;
|
||||
|
||||
void (async () => {
|
||||
const c = getCtx();
|
||||
if (!c) return;
|
||||
if (c.state === 'suspended') {
|
||||
try {
|
||||
await c.resume();
|
||||
} catch {
|
||||
/* autoplay denial — silent this round */
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the load hasn't finished yet (first notification after a cold
|
||||
// start, before the eager load completed) wait briefly for it so
|
||||
// we don't emit the synth tone over a user-configured custom clip.
|
||||
// Cap the wait so a never-resolving decode doesn't block a message.
|
||||
if (customBuffer === undefined && customLoadInFlight) {
|
||||
const timeout = new Promise<void>((r) => window.setTimeout(r, 500));
|
||||
await Promise.race([customLoadInFlight, timeout]);
|
||||
}
|
||||
|
||||
if (customBuffer && typeof customBuffer !== 'boolean') {
|
||||
playCustom(c, customBuffer);
|
||||
return;
|
||||
}
|
||||
playSynthTone(c);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// IndexedDB-backed custom notification sound. Single-row object store —
|
||||
// user either has one uploaded clip overriding the built-in two-tone
|
||||
// chime, or doesn't and we fall back to the synthesized tone.
|
||||
//
|
||||
// Separate from `soundboardStorage.ts` because the soundboard is a
|
||||
// multi-entry user library with categories + hotkeys + per-clip gain;
|
||||
// this module only needs "one blob, replace-on-upload, wipe-on-reset".
|
||||
|
||||
export interface NotificationSoundEntry {
|
||||
filename: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
uploadedAt: number;
|
||||
}
|
||||
|
||||
interface StoredNotificationSound extends NotificationSoundEntry {
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
// 1 MB cap — notification sounds should be short (<5s), and we don't
|
||||
// want an IDB quota bust from a 200MB MP3.
|
||||
export const MAX_NOTIFICATION_SOUND_BYTES = 1 * 1024 * 1024;
|
||||
|
||||
// --- Change observer — the notificationSound module subscribes to
|
||||
// invalidate its cached AudioBuffer when upload/reset happens, so the
|
||||
// next ping uses the fresh sound without an app restart.
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function subscribeNotificationSoundChanges(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyChange(): void {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (err: unknown) {
|
||||
console.warn('notification-sound change listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DB_NAME = 'netralax-notification';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'sound';
|
||||
const KEY = 'current';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function getCustomNotificationSound(): Promise<
|
||||
{ blob: Blob; entry: NotificationSoundEntry } | null
|
||||
> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readonly');
|
||||
const req = t.objectStore(STORE).get(KEY);
|
||||
req.onsuccess = () => {
|
||||
const stored = req.result as StoredNotificationSound | undefined;
|
||||
if (!stored) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const { blob, ...entry } = stored;
|
||||
resolve({ blob, entry });
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomNotificationSoundMeta(): Promise<NotificationSoundEntry | null> {
|
||||
const res = await getCustomNotificationSound();
|
||||
return res ? res.entry : null;
|
||||
}
|
||||
|
||||
export async function setCustomNotificationSound(file: File): Promise<NotificationSoundEntry> {
|
||||
if (file.size === 0) throw new Error('empty_file');
|
||||
if (file.size > MAX_NOTIFICATION_SOUND_BYTES) throw new Error('sound_too_large');
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');
|
||||
|
||||
const stored: StoredNotificationSound = {
|
||||
filename: file.name || 'notification.audio',
|
||||
mime,
|
||||
size: file.size,
|
||||
uploadedAt: Date.now(),
|
||||
blob: file,
|
||||
};
|
||||
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readwrite');
|
||||
const req = t.objectStore(STORE).put(stored, KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
|
||||
notifyChange();
|
||||
const { blob: _blob, ...entry } = stored;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function clearCustomNotificationSound(): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(STORE, 'readwrite');
|
||||
const req = t.objectStore(STORE).delete(KEY);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
notifyChange();
|
||||
}
|
||||
@@ -1,29 +1,17 @@
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
} from '@tauri-apps/plugin-notification';
|
||||
// OS notifications via the Electron preload bridge. The exported API
|
||||
// stays identical to the Tauri-era version so callers don't change.
|
||||
//
|
||||
// Permission under Electron is implicit ('granted' always). The legacy
|
||||
// ASKED marker in localStorage is kept so we don't re-prompt after a
|
||||
// previously-denied run; we just fast-succeed now.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Tracks whether permission has already been requested this session so we
|
||||
// don't spam the OS prompt. Actual permission state lives in the OS, but we
|
||||
// also persist a "we've asked" marker in localStorage so reloads don't
|
||||
// re-request (OS would block anyway after denial, but calling it every reload
|
||||
// triggers noisy plugin warnings on some platforms).
|
||||
let permissionChecked = false;
|
||||
let permissionGranted = false;
|
||||
|
||||
const ASKED_KEY = 'chatapp.notif.asked';
|
||||
|
||||
function readAskedMarker(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(ASKED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAskedMarker(): void {
|
||||
try {
|
||||
window.localStorage.setItem(ASKED_KEY, '1');
|
||||
@@ -36,21 +24,13 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
if (permissionChecked) return permissionGranted;
|
||||
permissionChecked = true;
|
||||
if (!isTauriRuntime()) {
|
||||
// Web preview / Chrome — Tauri notification plugin not available.
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted && !readAskedMarker()) {
|
||||
// First-install: prompt the user once. After this we remember via the
|
||||
// marker and never re-prompt — the user can re-enable later via OS
|
||||
// system settings if they change their mind.
|
||||
const result = await requestPermission();
|
||||
granted = result === 'granted';
|
||||
writeAskedMarker();
|
||||
}
|
||||
permissionGranted = granted;
|
||||
const state = await window.electronAPI.getNotificationPermission();
|
||||
permissionGranted = state === 'granted';
|
||||
writeAskedMarker();
|
||||
} catch (err: unknown) {
|
||||
permissionGranted = false;
|
||||
console.warn('notification permission check failed', err);
|
||||
@@ -65,7 +45,7 @@ export function isAppFocused(): boolean {
|
||||
interface NotifyOpts {
|
||||
title: string;
|
||||
body?: string;
|
||||
// Force notification even when app is focused. Default: suppress if focused.
|
||||
/** Force notification even when app is focused. Default: suppress if focused. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
@@ -75,8 +55,12 @@ export async function notify({ title, body, force = false }: NotifyOpts): Promis
|
||||
const granted = await ensureNotificationPermission();
|
||||
if (!granted) return;
|
||||
try {
|
||||
sendNotification({ title, ...(body ? { body } : {}) });
|
||||
await window.electronAPI.notify({
|
||||
title,
|
||||
body: body ?? '',
|
||||
silent: true,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('sendNotification failed', err);
|
||||
console.error('notify failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
|
||||
export const PRESENCE_HEARTBEAT_MS = 30_000;
|
||||
export const PRESENCE_DEVICE_STALE_MS = 90_000;
|
||||
|
||||
export function createPeerPresenceChannelName(userId: string, subscriptionId: string): string {
|
||||
return 'peer-presence:' + userId + ':' + subscriptionId;
|
||||
}
|
||||
|
||||
export function getEffectivePresenceState(
|
||||
profileState: PresenceState,
|
||||
latestDeviceSeenAt: string | null,
|
||||
nowMs = Date.now(),
|
||||
): PresenceState {
|
||||
if (profileState === 'offline' || profileState === 'invisible') return profileState;
|
||||
if (!latestDeviceSeenAt) return 'offline';
|
||||
|
||||
const seenMs = Date.parse(latestDeviceSeenAt);
|
||||
if (!Number.isFinite(seenMs)) return 'offline';
|
||||
|
||||
return nowMs - seenMs <= PRESENCE_DEVICE_STALE_MS ? profileState : 'offline';
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getProfileAvatarPreviewUrl, getProfileCardStatusText } from './profileCard';
|
||||
|
||||
describe('getProfileCardStatusText', () => {
|
||||
it('uses the trimmed custom status when one is set', () => {
|
||||
expect(getProfileCardStatusText(' am Coden ')).toBe('am Coden');
|
||||
});
|
||||
|
||||
it('falls back to a neutral empty-status label', () => {
|
||||
expect(getProfileCardStatusText('')).toBe('Kein Status gesetzt');
|
||||
expect(getProfileCardStatusText(null)).toBe('Kein Status gesetzt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfileAvatarPreviewUrl', () => {
|
||||
it('returns a trimmed avatar url when one is available', () => {
|
||||
expect(getProfileAvatarPreviewUrl(' https://example.com/avatar.png ')).toBe(
|
||||
'https://example.com/avatar.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when no avatar image can be opened', () => {
|
||||
expect(getProfileAvatarPreviewUrl(null)).toBeNull();
|
||||
expect(getProfileAvatarPreviewUrl('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
const EMPTY_STATUS_TEXT = 'Kein Status gesetzt';
|
||||
|
||||
export function getProfileCardStatusText(statusMessage: string | null | undefined): string {
|
||||
const trimmed = statusMessage?.trim() ?? '';
|
||||
return trimmed || EMPTY_STATUS_TEXT;
|
||||
}
|
||||
|
||||
export function getProfileAvatarPreviewUrl(avatarUrl: string | null | undefined): string | null {
|
||||
const trimmed = avatarUrl?.trim() ?? '';
|
||||
return trimmed || null;
|
||||
}
|
||||
@@ -39,6 +39,22 @@ export function createPipeline(
|
||||
if (!AudioCtx) return null;
|
||||
try {
|
||||
const ctx = new AudioCtx();
|
||||
// A fresh AudioContext under Chromium's autoplay policy starts in
|
||||
// `suspended` state when no recent user gesture is in scope —
|
||||
// attachTrack fires from a LiveKit event, not the call-start click,
|
||||
// so we can't rely on the gesture crossing the async boundary. Kick
|
||||
// resume() immediately and retry on any statechange so a later
|
||||
// suspension (window backgrounding, device change) doesn't leave
|
||||
// the remote peer silent permanently.
|
||||
const tryResume = () => {
|
||||
if (ctx.state === 'suspended') {
|
||||
void ctx.resume().catch(() => {
|
||||
/* ignore — will retry on next statechange */
|
||||
});
|
||||
}
|
||||
};
|
||||
tryResume();
|
||||
ctx.addEventListener('statechange', tryResume);
|
||||
const source = ctx.createMediaElementSource(audio);
|
||||
const gain = ctx.createGain();
|
||||
// Start silent; the caller (CallContext) applies the correct effective
|
||||
@@ -46,11 +62,14 @@ export function createPipeline(
|
||||
gain.gain.value = 0;
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
// createMediaElementSource diverts the element's direct output through
|
||||
// the audio graph. Muting the element is then a double-guard — if the
|
||||
// diversion ever fails (older WebKit), the element stays silent instead
|
||||
// of bypassing the gain chain entirely.
|
||||
audio.muted = true;
|
||||
// Do NOT set `audio.muted = true` here. Chromium gates the
|
||||
// media-element's internal sample production behind the muted flag,
|
||||
// and that gate sits *before* the MediaElementAudioSourceNode tap —
|
||||
// a muted element feeds zero samples into the WebAudio graph, which
|
||||
// silences the peer even though createMediaElementSource already
|
||||
// diverts the element's direct playback path. The diversion itself
|
||||
// is sufficient to stop the element from double-playing to the
|
||||
// default output; explicit muting is the bug.
|
||||
const pipeline: RemoteAudioPipeline = {
|
||||
trackSid: info.trackSid,
|
||||
participantId: info.participantId,
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
// Frontend side of the native system-audio pipeline. Pairs with the Rust
|
||||
// `screen_audio` module: it opens a Tauri Channel, receives interleaved
|
||||
// f32 stereo samples at 48kHz (base64-encoded), and surfaces them as a
|
||||
// real `MediaStream` that LiveKit can publish as a `ScreenShareAudio`
|
||||
// track. An AudioWorklet does the heavy lifting so the render thread is
|
||||
// never the bottleneck — the main thread just pushes decoded samples
|
||||
// across a port; the worklet copies them into its output buffer which
|
||||
// feeds a `MediaStreamDestination`.
|
||||
// System-audio loopback. Under Electron this is renderer-driven: we
|
||||
// ask main for the primary screen's capturer id, then call
|
||||
// getUserMedia with Chromium's `chromeMediaSource: 'desktop'`
|
||||
// constraint to obtain the OS-mixer MediaStream directly. The whole
|
||||
// Tauri WASAPI + AudioWorklet base64 pipeline is gone.
|
||||
//
|
||||
// Windows-only right now. On other platforms `startSystemAudioCapture`
|
||||
// throws `SystemAudioUnavailable` and the caller is expected to fall
|
||||
// back to the browser's getDisplayMedia path.
|
||||
// Windows-only in practice (loopback audio is a Windows feature of
|
||||
// Chromium's desktop source). On other platforms `startSystemAudioCapture`
|
||||
// throws `SystemAudioUnavailable`; callers are expected to fall back
|
||||
// to the standard getDisplayMedia flow.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export interface SystemAudioHandle {
|
||||
/** Rust-side capture id. Pass to the Rust stop command via `stop()`. */
|
||||
/** Monotonic id, used by callers to correlate stop() with start. */
|
||||
captureId: number;
|
||||
/** MediaStream carrying a single audio track at 48kHz stereo. */
|
||||
/** MediaStream with a single audio track carrying the OS mixer. */
|
||||
stream: MediaStream;
|
||||
/** Teardown — stops the Rust thread, closes the AudioContext, ends the
|
||||
* MediaStreamDestination track. Idempotent. */
|
||||
/** Teardown — stops the MediaStreamTrack. Idempotent. */
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Thrown when the platform can't deliver native system-audio (non-Tauri
|
||||
* runtime, non-Windows host, WebAudio unavailable, COM init failure). */
|
||||
export class SystemAudioUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super('system audio unavailable: ' + reason);
|
||||
@@ -32,211 +27,58 @@ export class SystemAudioUnavailable extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
interface AudioFramePayload {
|
||||
captureId: number;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
samplesBase64: string;
|
||||
}
|
||||
|
||||
// AudioWorklet source embedded as a string. The worklet keeps a pair of
|
||||
// ring buffers (one per channel) that the main thread appends to as
|
||||
// samples arrive. `process()` drains the ring buffers into the output
|
||||
// blocks; an underrun emits silence instead of propagating the stall
|
||||
// upwards (a glitch is better than a freeze for LiveKit's Opus encoder).
|
||||
//
|
||||
// The worklet runs at AudioContext sample rate, which we pin to 48kHz via
|
||||
// the AudioContext constructor. That matches what the Rust side already
|
||||
// resamples to, so no further rate conversion is needed here.
|
||||
const WORKLET_SOURCE = `
|
||||
class LoopbackAudioProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
// Ring buffer sized for latency, not for "never drop". 300ms hard cap,
|
||||
// 80ms target — we aim for ~one WASAPI packet of headroom above the
|
||||
// render quantum and drop excess whenever the producer gets ahead.
|
||||
// Keeping the target small is the difference between "feels live" and
|
||||
// "laggy" for screen-share audio.
|
||||
this.bufferSize = 48000 * 0.3 | 0;
|
||||
this.targetFrames = 48000 * 0.08 | 0;
|
||||
this.bufL = new Float32Array(this.bufferSize);
|
||||
this.bufR = new Float32Array(this.bufferSize);
|
||||
this.writePos = 0;
|
||||
this.readPos = 0;
|
||||
this.available = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const { left, right } = e.data;
|
||||
const len = left.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
this.bufL[this.writePos] = left[i];
|
||||
this.bufR[this.writePos] = right[i];
|
||||
this.writePos = (this.writePos + 1) % this.bufferSize;
|
||||
if (this.available < this.bufferSize) {
|
||||
this.available++;
|
||||
} else {
|
||||
// Buffer full — advance the read cursor to keep writing.
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
}
|
||||
}
|
||||
// Hard cap: if we're this far behind the producer, skip ahead to
|
||||
// the target latency instead of playing out minutes of stale audio.
|
||||
// Happens on: AudioContext resume after suspend, tab throttle
|
||||
// recovery, any hiccup that left samples piling up.
|
||||
if (this.available > this.targetFrames * 3) {
|
||||
const drop = this.available - this.targetFrames;
|
||||
this.readPos = (this.readPos + drop) % this.bufferSize;
|
||||
this.available -= drop;
|
||||
}
|
||||
};
|
||||
}
|
||||
process(_inputs, outputs) {
|
||||
const output = outputs[0];
|
||||
if (!output || output.length === 0) return true;
|
||||
const out0 = output[0];
|
||||
const out1 = output[1] || output[0];
|
||||
const n = out0.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.available > 0) {
|
||||
out0[i] = this.bufL[this.readPos];
|
||||
if (out1 !== out0) out1[i] = this.bufR[this.readPos];
|
||||
this.readPos = (this.readPos + 1) % this.bufferSize;
|
||||
this.available--;
|
||||
} else {
|
||||
out0[i] = 0;
|
||||
if (out1 !== out0) out1[i] = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('screen-audio-loopback', LoopbackAudioProcessor);
|
||||
`;
|
||||
|
||||
let workletModuleUrl: string | null = null;
|
||||
function getWorkletModuleUrl(): string {
|
||||
if (workletModuleUrl) return workletModuleUrl;
|
||||
const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
|
||||
workletModuleUrl = URL.createObjectURL(blob);
|
||||
return workletModuleUrl;
|
||||
interface ChromiumAudioConstraint {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop';
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
|
||||
if (!isTauriRuntime()) {
|
||||
throw new SystemAudioUnavailable('not a tauri runtime');
|
||||
throw new SystemAudioUnavailable('not an electron runtime');
|
||||
}
|
||||
const AudioCtor: typeof AudioContext | undefined =
|
||||
typeof window !== 'undefined'
|
||||
? (window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext)
|
||||
: undefined;
|
||||
if (!AudioCtor) {
|
||||
throw new SystemAudioUnavailable('WebAudio unavailable');
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
|
||||
throw new SystemAudioUnavailable('mediaDevices unavailable');
|
||||
}
|
||||
|
||||
// Pin to 48kHz so the worklet's input rate matches the Rust-side
|
||||
// output rate. If the OS forces a different rate the constructor
|
||||
// throws on some browsers; we catch and surface as Unavailable so the
|
||||
// caller can fall back.
|
||||
let ctx: AudioContext;
|
||||
try {
|
||||
ctx = new AudioCtor({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||
} catch (err: unknown) {
|
||||
throw new SystemAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
const resolved = await window.electronAPI.resolveLoopbackSource();
|
||||
if (!resolved) {
|
||||
throw new SystemAudioUnavailable('no screen source available');
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(getWorkletModuleUrl());
|
||||
} catch (err: unknown) {
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new SystemAudioUnavailable(
|
||||
'audioWorklet load failed: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
const node = new AudioWorkletNode(ctx, 'screen-audio-loopback', {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
node.connect(dest);
|
||||
|
||||
// Kick the AudioContext out of `suspended` before any samples arrive —
|
||||
// the share is triggered from a user click so autoplay policy allows
|
||||
// this, and an un-resumed context would buffer everything the Rust
|
||||
// side produces until the context eventually runs, giving seconds of
|
||||
// initial latency.
|
||||
if (ctx.state !== 'running') {
|
||||
try {
|
||||
await ctx.resume();
|
||||
} catch (err: unknown) {
|
||||
console.warn('system-audio ctx.resume failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
||||
const channel = new Channel<AudioFramePayload>();
|
||||
|
||||
channel.onmessage = (frame: AudioFramePayload) => {
|
||||
const bytes = base64ToBytes(frame.samplesBase64);
|
||||
// Re-view the bytes as f32 little-endian. The byteLength is always
|
||||
// a multiple of 8 (f32 stereo pairs) — if not, drop the trailing
|
||||
// partial frame rather than risk a truncation artifact.
|
||||
const sampleCount = Math.floor(bytes.byteLength / 4);
|
||||
if (sampleCount < 2) return;
|
||||
const floats = new Float32Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
sampleCount,
|
||||
);
|
||||
// Interleaved L/R → deinterleaved for the worklet. Copying out of
|
||||
// the base64 view also ensures the Float32Arrays we postMessage are
|
||||
// owned (the underlying buffer is about to be garbage-collected).
|
||||
const frames = floats.length >> 1;
|
||||
const left = new Float32Array(frames);
|
||||
const right = new Float32Array(frames);
|
||||
for (let i = 0; i < frames; i++) {
|
||||
left[i] = floats[i * 2] ?? 0;
|
||||
right[i] = floats[i * 2 + 1] ?? 0;
|
||||
}
|
||||
// Transfer the buffers so postMessage is zero-copy.
|
||||
node.port.postMessage(
|
||||
{ left, right },
|
||||
[left.buffer, right.buffer],
|
||||
);
|
||||
const audioConstraint: ChromiumAudioConstraint = {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: resolved.sourceId,
|
||||
},
|
||||
};
|
||||
|
||||
let captureId: number;
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
captureId = await invoke<number>('start_system_audio_capture', { channel });
|
||||
// Cast: the Chromium `mandatory` constraint is non-standard and
|
||||
// not covered by lib.dom.d.ts typings.
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: audioConstraint as unknown as MediaTrackConstraints,
|
||||
video: false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
node.disconnect();
|
||||
await ctx.close().catch(() => undefined);
|
||||
throw new SystemAudioUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
const stream = dest.stream;
|
||||
const tracks = stream.getAudioTracks();
|
||||
if (tracks.length === 0) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw new SystemAudioUnavailable('no audio track in returned stream');
|
||||
}
|
||||
|
||||
const captureId = Date.now();
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
await invoke('stop_system_audio_capture', { captureId });
|
||||
} catch (err: unknown) {
|
||||
console.warn('stop_system_audio_capture failed', err);
|
||||
}
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
for (const track of stream.getTracks()) {
|
||||
try {
|
||||
track.stop();
|
||||
@@ -244,17 +86,7 @@ export async function startSystemAudioCapture(): Promise<SystemAudioHandle> {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
await ctx.close().catch(() => undefined);
|
||||
};
|
||||
|
||||
return { captureId, stream, stop };
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// Frontend side of the native screen-capture pipeline. Starts a Rust-side
|
||||
// capture thread via `start_screen_capture` and streams JPEG frames back
|
||||
// through a Tauri Channel. Each frame is decoded into an ImageBitmap,
|
||||
// drawn onto an offscreen canvas, and the canvas' captureStream() is
|
||||
// returned as a MediaStream that LiveKit can publishTrack() directly —
|
||||
// no OS/browser screen picker is involved.
|
||||
//
|
||||
// Video-only: system audio would require WASAPI / ScreenCaptureKit hooks
|
||||
// that xcap doesn't provide. Callers that request shared audio must
|
||||
// either fall back to the browser picker or accept video-without-audio.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export interface NativeCaptureHandle {
|
||||
/** Rust-side capture id. Pass to `stopNativeCapture` to tear down. */
|
||||
captureId: number;
|
||||
/** MediaStream fed by a canvas that's drawing each incoming frame. */
|
||||
stream: MediaStream;
|
||||
/** Cleanup — stops the Rust thread, closes channels, revokes the canvas
|
||||
* stream. Idempotent. */
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface FramePayload {
|
||||
captureId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
jpegBase64: string;
|
||||
}
|
||||
|
||||
/** Returned when the runtime can't support native capture (no Tauri, no
|
||||
* WebAudio, source vanished between enumeration and start, etc.). The
|
||||
* caller is expected to fall back to the browser's getDisplayMedia path. */
|
||||
export class NativeCaptureUnavailable extends Error {
|
||||
constructor(reason: string) {
|
||||
super('native capture unavailable: ' + reason);
|
||||
this.name = 'NativeCaptureUnavailable';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startNativeCapture(opts: {
|
||||
sourceId: string;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
fps: number;
|
||||
}): Promise<NativeCaptureHandle> {
|
||||
if (!isTauriRuntime()) {
|
||||
throw new NativeCaptureUnavailable('not a tauri runtime');
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = opts.maxWidth;
|
||||
canvas.height = opts.maxHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new NativeCaptureUnavailable('canvas 2d context unavailable');
|
||||
}
|
||||
|
||||
// Track whether we got the first frame so we can fail fast if Rust
|
||||
// reports "found" but then produces no output (e.g. screen was locked).
|
||||
let firstFrameResolved = false;
|
||||
let firstFrameResolve!: () => void;
|
||||
let firstFrameReject!: (err: Error) => void;
|
||||
const firstFramePromise = new Promise<void>((resolve, reject) => {
|
||||
firstFrameResolve = resolve;
|
||||
firstFrameReject = reject;
|
||||
});
|
||||
|
||||
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
||||
const channel = new Channel<FramePayload>();
|
||||
|
||||
// Latest-wins frame queue: if the JS side falls behind the Rust producer,
|
||||
// we drop stale frames rather than queue them. Keeps memory flat and
|
||||
// latency sensible for live screenshare.
|
||||
let pendingFrame: FramePayload | null = null;
|
||||
let decoding = false;
|
||||
|
||||
const drainQueue = async () => {
|
||||
if (decoding) return;
|
||||
decoding = true;
|
||||
try {
|
||||
while (pendingFrame) {
|
||||
const frame = pendingFrame;
|
||||
pendingFrame = null;
|
||||
const bytes = base64ToBytes(frame.jpegBase64);
|
||||
// Uint8Array's buffer type is `ArrayBufferLike` (could be a
|
||||
// SharedArrayBuffer in theory); Blob wants plain ArrayBuffer.
|
||||
// Pass the underlying buffer explicitly so the type narrows.
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
} catch (err: unknown) {
|
||||
console.warn('createImageBitmap failed', err);
|
||||
continue;
|
||||
}
|
||||
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
if (!firstFrameResolved) {
|
||||
firstFrameResolved = true;
|
||||
firstFrameResolve();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
decoding = false;
|
||||
}
|
||||
};
|
||||
|
||||
channel.onmessage = (frame: FramePayload) => {
|
||||
pendingFrame = frame;
|
||||
void drainQueue();
|
||||
};
|
||||
|
||||
let captureId: number;
|
||||
try {
|
||||
captureId = await invoke<number>('start_screen_capture', {
|
||||
sourceId: opts.sourceId,
|
||||
maxWidth: opts.maxWidth,
|
||||
maxHeight: opts.maxHeight,
|
||||
fps: opts.fps,
|
||||
channel,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
throw new NativeCaptureUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
// Bound the wait: the capture thread may fail silently on some sources
|
||||
// (locked screens, protected windows). Fall back to getDisplayMedia in
|
||||
// that case rather than hang the user.
|
||||
const firstFrameTimeout = window.setTimeout(() => {
|
||||
firstFrameReject(new Error('first frame timed out (3s)'));
|
||||
}, 3000);
|
||||
try {
|
||||
await firstFramePromise;
|
||||
} catch (err: unknown) {
|
||||
window.clearTimeout(firstFrameTimeout);
|
||||
try {
|
||||
await invoke('stop_screen_capture', { captureId });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new NativeCaptureUnavailable(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
window.clearTimeout(firstFrameTimeout);
|
||||
|
||||
const stream = canvas.captureStream(opts.fps);
|
||||
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
await invoke('stop_screen_capture', { captureId });
|
||||
} catch (err: unknown) {
|
||||
console.warn('stop_screen_capture failed', err);
|
||||
}
|
||||
for (const track of stream.getTracks()) {
|
||||
try {
|
||||
track.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { captureId, stream, stop };
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -29,6 +29,16 @@ export interface ScreenShareSettings {
|
||||
// Linux setups). If the browser ignores the `audio: true` request we
|
||||
// silently fall through to a video-only share.
|
||||
includeSystemAudio: boolean;
|
||||
// While system audio is being shared, mute the local playback of remote
|
||||
// call audio to prevent peers from hearing themselves echoed back via the
|
||||
// loopback capture. Defaults to true — Chromium's loopback grant in
|
||||
// Electron *should* exclude the app's own render output, but the
|
||||
// process-tree exclusion isn't always watertight (WebView2/Chromium audio
|
||||
// sessions can render in process owners outside the tree). The user can
|
||||
// opt out (e.g. when they route call audio to a separate output device
|
||||
// with setSinkId, in which case the default-render-endpoint capture
|
||||
// never sees it).
|
||||
duckRemoteAudioWhileSharing: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: ScreenShareSettings = {
|
||||
@@ -36,6 +46,11 @@ const DEFAULTS: ScreenShareSettings = {
|
||||
displaySurface: null,
|
||||
framerateOverride: null,
|
||||
includeSystemAudio: false,
|
||||
// Default off — we use Electron's `loopback` (not `loopbackWithMute`)
|
||||
// so the user keeps local audio while sharing. Auto-ducking remote
|
||||
// mic audio for echo prevention also kills the user's ability to
|
||||
// hear peers, which most users don't want. Opt-in only.
|
||||
duckRemoteAudioWhileSharing: false,
|
||||
};
|
||||
|
||||
export interface PresetParams {
|
||||
@@ -131,6 +146,10 @@ function read(): ScreenShareSettings {
|
||||
typeof parsed.includeSystemAudio === 'boolean'
|
||||
? parsed.includeSystemAudio
|
||||
: DEFAULTS.includeSystemAudio,
|
||||
duckRemoteAudioWhileSharing:
|
||||
typeof parsed.duckRemoteAudioWhileSharing === 'boolean'
|
||||
? parsed.duckRemoteAudioWhileSharing
|
||||
: DEFAULTS.duckRemoteAudioWhileSharing,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -1,150 +1,78 @@
|
||||
// Frontend wrapper for the Rust `enumerate_screen_sources` command. Falls
|
||||
// back to an empty list outside the Tauri runtime so a browser-only dev
|
||||
// build (pnpm vite:dev in Chrome without Tauri) degrades gracefully to
|
||||
// "nothing to show" rather than throwing.
|
||||
// Frontend wrapper for the main-process screen-source enumerator.
|
||||
// Pre-migration this called into a Rust command that captured JPEG
|
||||
// thumbnails via xcap; Electron's desktopCapturer returns thumbnails
|
||||
// inline as data URLs so there's no binary/base64 dual-path to juggle.
|
||||
//
|
||||
// The return shape here matches the ipc-types `ScreenSource` contract.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export type ScreenSourceKind = 'screen' | 'window';
|
||||
|
||||
export interface ScreenSource {
|
||||
/** Chromium-format source id ("screen:<id>:0" / "window:<hwnd>:0"). */
|
||||
/** Chromium desktopCapturer id; feed unchanged to getUserMedia's
|
||||
* chromeMediaSourceId constraint when capturing this source. */
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ScreenSourceKind;
|
||||
/** Base64-encoded JPEG without a data-URL prefix. Null when capture failed.
|
||||
* Kept under `thumbnailPng` key for rollout stability — the server-side
|
||||
* format switched from PNG to JPEG for payload size, but the field name
|
||||
* preserves the wire contract during the transition. */
|
||||
thumbnailPng: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Thumbnail as a ready-to-use data URL (image/png). Null when
|
||||
* desktopCapturer returned an empty buffer. */
|
||||
thumbnailDataUrl: string | null;
|
||||
/** App icon as a data URL for window sources; null for screens. */
|
||||
iconDataUrl: string | null;
|
||||
displayId: number | null;
|
||||
}
|
||||
|
||||
// Fast, metadata-only list. The picker uses this first so names show up
|
||||
// immediately; thumbnails stream in via captureScreenSourceThumbnail below.
|
||||
export async function listScreenSources(): Promise<ScreenSource[]> {
|
||||
if (!isTauriRuntime()) return [];
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<ScreenSource[]>('list_screen_sources');
|
||||
return raw ?? [];
|
||||
return await window.electronAPI.getScreenSources();
|
||||
} catch (err: unknown) {
|
||||
console.warn('list_screen_sources failed', err);
|
||||
console.warn('getScreenSources failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Single-source thumbnail capture (legacy base64 variant). Callers should
|
||||
// prefer `captureScreenSourceThumbnailBytes` below — it ships raw JPEG
|
||||
// bytes over IPC so the main thread avoids both the base64 decode AND
|
||||
// the JSON parse overhead of a long string result. Kept for fallback.
|
||||
// Single-source high-res refresh. Re-queries desktopCapturer at 640x360
|
||||
// so a detail view looks crisp without paying the full enumeration cost
|
||||
// more than once per hover-debounce.
|
||||
export async function captureScreenSourceThumbnail(
|
||||
sourceId: string,
|
||||
): Promise<string | null> {
|
||||
if (!isTauriRuntime()) return null;
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const result = await invoke<string | null>('capture_screen_source_thumbnail', {
|
||||
sourceId,
|
||||
});
|
||||
return result ?? null;
|
||||
return await window.electronAPI.getScreenThumbnail(sourceId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('capture_screen_source_thumbnail failed', { sourceId, err });
|
||||
console.warn('getScreenThumbnail failed', { sourceId, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail fetch with a two-tier fallback:
|
||||
// 1. binary-IPC path (`..._bytes`) — ArrayBuffer over Tauri's raw channel
|
||||
// 2. base64 path (legacy) — same command minus the ArrayBuffer wrapper
|
||||
//
|
||||
// The binary path can come through in several shapes depending on the
|
||||
// Tauri / WebView2 version combo: a real ArrayBuffer, a Uint8Array, or
|
||||
// occasionally a plain number[] when the response got re-serialised.
|
||||
// We normalise all three into an ArrayBuffer before handing it to Blob.
|
||||
// If the binary path returns nothing usable we retry once on the base64
|
||||
// command — keeps thumbnails visible while the binary contract settles.
|
||||
let warnedBinaryShape = false;
|
||||
// Legacy name retained for call-sites that expected a Blob. desktopCapturer
|
||||
// already gives us a data URL — callers that need a Blob can fetch() the
|
||||
// URL. This helper keeps the old signature so nothing breaks during the
|
||||
// migration.
|
||||
export async function captureScreenSourceThumbnailBytes(
|
||||
sourceId: string,
|
||||
): Promise<Blob | null> {
|
||||
if (!isTauriRuntime()) return null;
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
|
||||
// ---- Tier 1: binary IPC ---------------------------------------------
|
||||
const url = await captureScreenSourceThumbnail(sourceId);
|
||||
if (!url) return null;
|
||||
try {
|
||||
const result = await invoke<ArrayBuffer | Uint8Array | number[] | null>(
|
||||
'capture_screen_source_thumbnail_bytes',
|
||||
{ sourceId },
|
||||
);
|
||||
let bytes: Uint8Array | null = null;
|
||||
if (result instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(result);
|
||||
} else if (result instanceof Uint8Array) {
|
||||
bytes = result;
|
||||
} else if (Array.isArray(result) && result.length > 0) {
|
||||
bytes = new Uint8Array(result);
|
||||
} else if (result && typeof result === 'object') {
|
||||
// One-time diagnostic so we can see the unexpected shape in the
|
||||
// console if WebView2 de-serialises the Response body into a bag
|
||||
// of properties instead of a transferable binary buffer.
|
||||
if (!warnedBinaryShape) {
|
||||
warnedBinaryShape = true;
|
||||
console.warn(
|
||||
'capture_screen_source_thumbnail_bytes: unexpected shape, falling back',
|
||||
result,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (bytes && bytes.byteLength > 0) {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return new Blob([copy.buffer], { type: 'image/jpeg' });
|
||||
}
|
||||
const res = await fetch(url);
|
||||
return await res.blob();
|
||||
} catch (err: unknown) {
|
||||
console.warn('binary thumbnail path threw, trying base64 fallback', {
|
||||
sourceId,
|
||||
err,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Tier 2: base64 fallback ----------------------------------------
|
||||
try {
|
||||
const b64 = await invoke<string | null>('capture_screen_source_thumbnail', {
|
||||
sourceId,
|
||||
});
|
||||
if (!b64) return null;
|
||||
const bin = atob(b64);
|
||||
const fallbackBytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) fallbackBytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([fallbackBytes.buffer], { type: 'image/jpeg' });
|
||||
} catch (err: unknown) {
|
||||
console.warn('base64 thumbnail fallback failed', { sourceId, err });
|
||||
console.warn('thumbnail fetch failed', { sourceId, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy single-shot variant. Captures everything serially on the Rust side
|
||||
// before returning. Prefer listScreenSources + captureScreenSourceThumbnail
|
||||
// for user-facing flows — they feel 5–10× more responsive in practice.
|
||||
// Legacy single-shot API — now just delegates to listScreenSources.
|
||||
export async function enumerateScreenSources(): Promise<ScreenSource[]> {
|
||||
if (!isTauriRuntime()) return [];
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<ScreenSource[]>('enumerate_screen_sources');
|
||||
return raw ?? [];
|
||||
} catch (err: unknown) {
|
||||
console.warn('enumerate_screen_sources failed', err);
|
||||
return [];
|
||||
}
|
||||
return listScreenSources();
|
||||
}
|
||||
|
||||
// Data-URL helper — picker tiles bind `src={thumbnailDataUrl(src)}` so the
|
||||
// thumbnail bytes never leave the component's render pass. Rust encodes
|
||||
// JPEG now (smaller payload, faster decode); the mime type here must
|
||||
// match or the <img> element silently fails to paint.
|
||||
// Picker tiles bind `src={thumbnailDataUrl(src)}`; we already receive a
|
||||
// data URL so this is just a pass-through for API compatibility.
|
||||
export function thumbnailDataUrl(src: ScreenSource): string | null {
|
||||
if (!src.thumbnailPng) return null;
|
||||
return 'data:image/jpeg;base64,' + src.thumbnailPng;
|
||||
return src.thumbnailDataUrl;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||
import {
|
||||
makeStrongholdStore,
|
||||
migrateLocalStorageToStronghold,
|
||||
} from './strongholdStore';
|
||||
|
||||
// Two-tier SecretStore:
|
||||
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||
// - Electron runtime: safeStorage-backed store in `<userData>`
|
||||
// (`strongholdStore`). DPAPI on Windows / Keychain on macOS /
|
||||
// libsecret on Linux seals the per-user blob. Survives app
|
||||
// reinstalls when the OS preserves the data dir.
|
||||
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||
//
|
||||
@@ -38,20 +42,20 @@ export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
activeUserId = userId;
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const fileStore = makeSecureFileStore(userId);
|
||||
const store = makeStrongholdStore(userId);
|
||||
try {
|
||||
// Probe write/read to confirm the vault is usable on this machine.
|
||||
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||
// localStorage so the rest of the app keeps working.
|
||||
await fileStore.getSecret('__probe');
|
||||
activeBackend = fileStore;
|
||||
// Probe read to confirm the store is usable on this machine. If
|
||||
// anything throws, fall back to localStorage so the rest of the
|
||||
// app keeps working.
|
||||
await store.getSecret('__probe');
|
||||
activeBackend = store;
|
||||
try {
|
||||
await migrateLocalStorageToVault(userId, PREFIX);
|
||||
await migrateLocalStorageToStronghold(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('vault migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||
console.warn('secure store init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
// sumo variant ships crypto_pwhash (Argon2id). Standard `libsodium-wrappers`
|
||||
// is the compact build without Argon2 — vault KDF would error otherwise.
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
//
|
||||
// File layout (binary, little-endian):
|
||||
// bytes 0..7 magic: ASCII "CHATVLT1"
|
||||
// bytes 8..23 salt for KDF (16 bytes)
|
||||
// bytes 24..47 XSalsa20-Poly1305 nonce (24 bytes)
|
||||
// bytes 48.. secretbox(plaintext_json, key, nonce)
|
||||
//
|
||||
// `plaintext_json` is a UTF-8 JSON object { [key: string]: base64url(value) }.
|
||||
//
|
||||
// Key derivation: Argon2id (libsodium MODERATE ops/mem) over a passphrase
|
||||
// derived from the authenticated user-id + a constant. Same userId on the
|
||||
// same machine after re-install ⇒ same key ⇒ vault recovers automatically.
|
||||
//
|
||||
// Atomic writes: serialised vault is first written to `<file>.tmp` then
|
||||
// renamed onto `<file>` so an interrupted write never corrupts the existing
|
||||
// vault.
|
||||
|
||||
// Per-user vault filename so multiple accounts on the same machine each get
|
||||
// their own file (and Argon2 derives a different key per user, so cross-user
|
||||
// decrypt is also blocked even if filenames collided).
|
||||
async function vaultFileName(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode('chatapp-vault-name:' + userId));
|
||||
const hex = Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return 'chatapp-vault-' + hex.slice(0, 16) + '.bin';
|
||||
}
|
||||
const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes
|
||||
const SALT_LEN = 16;
|
||||
const NONCE_LEN = 24;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
interface VaultState {
|
||||
path: string;
|
||||
tmpPath: string;
|
||||
key: Uint8Array; // derived encryption key
|
||||
data: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<VaultState> | null = null;
|
||||
let vault: VaultState | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
return dir + sep + name;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(userId: string, salt: Uint8Array, _s: typeof sodium): Promise<Uint8Array> {
|
||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
const s = await ensureSodium();
|
||||
const dir = await appLocalDataDir();
|
||||
const fileName = await vaultFileName(userId);
|
||||
const path = joinPath(dir, fileName);
|
||||
const tmpPath = path + '.tmp';
|
||||
|
||||
// First-run: AppLocalData dir may not exist yet. `mkdir(recursive)` is
|
||||
// idempotent on macOS/Linux, but we need to surface genuine permission
|
||||
// errors (silent catch masked a previous bug where the dir was never
|
||||
// created and every subsequent writeFile failed with ENOENT).
|
||||
const dirExists = await exists(dir).catch(() => false);
|
||||
if (!dirExists) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileExists = await exists(path).catch(() => false);
|
||||
if (!fileExists) {
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
const state: VaultState = { path, tmpPath, key, data: new Map() };
|
||||
await persist(state, salt, s);
|
||||
return state;
|
||||
}
|
||||
|
||||
const raw = await readFile(path);
|
||||
if (raw.length < MAGIC.length + SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('vault file too short');
|
||||
}
|
||||
for (let i = 0; i < MAGIC.length; i++) {
|
||||
if (raw[i] !== MAGIC[i]) throw new Error('vault magic mismatch');
|
||||
}
|
||||
const salt = raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
const nonce = raw.slice(MAGIC.length + SALT_LEN, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = raw.slice(MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
let plain: Uint8Array;
|
||||
try {
|
||||
plain = s.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
'vault decrypt failed (wrong user / corrupted file): ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
const json = new TextDecoder().decode(plain) || '{}';
|
||||
const obj = JSON.parse(json) as Record<string, string>;
|
||||
const data = new Map<string, Uint8Array>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
try {
|
||||
data.set(k, unb64url(v));
|
||||
} catch {
|
||||
/* skip malformed entries */
|
||||
}
|
||||
}
|
||||
return { path, tmpPath, key, data };
|
||||
}
|
||||
|
||||
async function persist(state: VaultState, salt: Uint8Array, s: typeof sodium): Promise<void> {
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of state.data) obj[k] = b64url(v);
|
||||
const plain = new TextEncoder().encode(JSON.stringify(obj));
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const ciphertext = s.crypto_secretbox_easy(plain, nonce, state.key);
|
||||
|
||||
const out = new Uint8Array(MAGIC.length + SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
out.set(MAGIC, 0);
|
||||
out.set(salt, MAGIC.length);
|
||||
out.set(nonce, MAGIC.length + SALT_LEN);
|
||||
out.set(ciphertext, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
// Atomic write: tmp → rename. `rename` on the same filesystem is atomic
|
||||
// on macOS, Linux, and Windows (NTFS).
|
||||
await writeFile(state.tmpPath, out);
|
||||
await rename(state.tmpPath, state.path);
|
||||
}
|
||||
|
||||
// Re-derives the salt by reading the existing file header so persist() can
|
||||
// keep using the same KDF salt across writes (we don't rotate KDF on every
|
||||
// save — only on initial vault creation).
|
||||
async function readSalt(state: VaultState): Promise<Uint8Array> {
|
||||
const raw = await readFile(state.path);
|
||||
return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<VaultState> {
|
||||
if (initializedFor === userId && vault) return vault;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = loadOrCreateVault(userId)
|
||||
.then((v) => {
|
||||
vault = v;
|
||||
initializedFor = userId;
|
||||
return v;
|
||||
})
|
||||
.finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeSecureFileStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const v = await ensureInit(userId);
|
||||
const found = v.data.get(key);
|
||||
return found ? new Uint8Array(found) : null;
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.set(key, new Uint8Array(value));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.delete(key);
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Migrates legacy localStorage entries (chatapp.secret:*) into the encrypted
|
||||
// vault on first init. Idempotent — checks for marker key.
|
||||
export async function migrateLocalStorageToVault(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
if (v.data.has('__migrated_from_localstorage')) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
v.data.set(shortKey, decoded);
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
v.data.set('__migrated_from_localstorage', new Uint8Array([1]));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
}
|
||||
@@ -1,91 +1,91 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import { type Client, type Store, Stronghold } from '@tauri-apps/plugin-stronghold';
|
||||
|
||||
// Stronghold-backed SecretStore. Vault file lives in Tauri's
|
||||
// `appLocalDataDir/chatapp.stronghold` and survives app re-installs (the
|
||||
// directory is preserved by the OS on macOS/Windows/Linux unless the user
|
||||
// manually removes it). Vault password is derived from the Supabase user-id
|
||||
// so the same user re-installing the app on the same machine recovers their
|
||||
// device key automatically.
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
const VAULT_NAME = 'chatapp.stronghold';
|
||||
const CLIENT_NAME = 'chatapp';
|
||||
// Secret store backed by Electron's safeStorage (DPAPI / Keychain /
|
||||
// libsecret) via the main-process secure-store IPC. Opens a per-user
|
||||
// handle on first access and caches it for the lifetime of the session.
|
||||
//
|
||||
// Values are Uint8Array at the SecretStore interface level; we encode
|
||||
// them as base64 on the wire since the preload bridge is stringly-typed.
|
||||
|
||||
let strongholdRef: Stronghold | null = null;
|
||||
let storeRef: Store | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
let handlePromise: Promise<string | null> | null = null;
|
||||
let openedFor: string | null = null;
|
||||
|
||||
async function derivePassword(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
enc.encode('chatapp-stronghold-v1:' + userId),
|
||||
);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
async function ensureOpen(userId: string): Promise<string | null> {
|
||||
if (openedFor === userId && handlePromise) return handlePromise;
|
||||
if (!isTauriRuntime()) {
|
||||
handlePromise = Promise.resolve(null);
|
||||
return handlePromise;
|
||||
}
|
||||
openedFor = userId;
|
||||
handlePromise = (async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await window.electronAPI.secureStoreOpen({ userId });
|
||||
if (!res.encrypted) {
|
||||
console.warn(
|
||||
'secure-store: safeStorage unavailable, using plaintext fallback',
|
||||
);
|
||||
}
|
||||
return res.handle;
|
||||
} catch (err: unknown) {
|
||||
console.warn('secure-store open failed', err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return handlePromise;
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<void> {
|
||||
if (initializedFor === userId && storeRef) return;
|
||||
if (initPromise) return initPromise;
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const dir = await appLocalDataDir();
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
const vaultPath = dir + sep + VAULT_NAME;
|
||||
const password = await derivePassword(userId);
|
||||
|
||||
strongholdRef = await Stronghold.load(vaultPath, password);
|
||||
let client: Client;
|
||||
try {
|
||||
client = await strongholdRef.loadClient(CLIENT_NAME);
|
||||
} catch {
|
||||
client = await strongholdRef.createClient(CLIENT_NAME);
|
||||
}
|
||||
storeRef = client.getStore();
|
||||
initializedFor = userId;
|
||||
})().finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function makeStrongholdStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
await ensureInit(userId);
|
||||
const val = await storeRef!.get(key);
|
||||
if (!val) return null;
|
||||
return val instanceof Uint8Array ? val : new Uint8Array(val);
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return null;
|
||||
const raw = await window.electronAPI.secureStoreGet(handle, key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return base64ToBytes(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.insert(key, Array.from(value));
|
||||
await strongholdRef!.save();
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return;
|
||||
await window.electronAPI.secureStoreSet(handle, key, bytesToBase64(value));
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.remove(key);
|
||||
await strongholdRef!.save();
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return;
|
||||
await window.electronAPI.secureStoreRemove(handle, key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// One-time migration: copies any keys we find in localStorage (the legacy
|
||||
// dev store) into Stronghold so a user who upgrades from a localStorage-only
|
||||
// build doesn't lose their device key. Safe to call multiple times — no-op
|
||||
// once the marker key is present.
|
||||
// Back-compat migration helper. Legacy localStorage entries under the
|
||||
// caller-supplied prefix are copied into the safeStorage-backed store on
|
||||
// first run. A marker key prevents repeated work across launches.
|
||||
export async function migrateLocalStorageToStronghold(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
if (!storeRef) return;
|
||||
const handle = await ensureOpen(userId);
|
||||
if (!handle) return;
|
||||
const markerKey = '__migrated_from_localstorage';
|
||||
const already = await storeRef.get(markerKey);
|
||||
const already = await window.electronAPI.secureStoreGet(handle, markerKey);
|
||||
if (already) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
@@ -93,14 +93,9 @@ export async function migrateLocalStorageToStronghold(
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
await storeRef.insert(shortKey, Array.from(decoded));
|
||||
} catch {
|
||||
// Skip malformed entries.
|
||||
}
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
// Legacy payloads are already base64 — store as-is.
|
||||
await window.electronAPI.secureStoreSet(handle, shortKey, raw);
|
||||
}
|
||||
await storeRef.insert(markerKey, [1]);
|
||||
if (strongholdRef) await strongholdRef.save();
|
||||
await window.electronAPI.secureStoreSet(handle, markerKey, '1');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { emit } from '@tauri-apps/api/event';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Pushes the current aggregate unread count to the Rust-side tray listener.
|
||||
// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the
|
||||
// browser/dev preview where the Tauri runtime isn't present.
|
||||
// Pushes the current aggregate unread count to main, which updates the
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
|
||||
// outside the Electron runtime (e.g. browser dev preview) so no guards
|
||||
// needed at call-sites.
|
||||
export async function updateTrayUnread(count: number): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
|
||||
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
||||
|
||||
import type { Room } from 'livekit-client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LiveCaptionsSettings,
|
||||
getLiveCaptionsSettings,
|
||||
getSpeechRecognitionCtor,
|
||||
type SpeechRecognitionEventLike,
|
||||
type SpeechRecognitionLike,
|
||||
subscribeLiveCaptionsSettings,
|
||||
} from './liveCaptions';
|
||||
|
||||
interface Args {
|
||||
room: Room | null;
|
||||
/** True while we're connected and want captions to flow. */
|
||||
active: boolean;
|
||||
/** Callback fired locally for our own captions so the overlay can show
|
||||
* them without going through the SFU round-trip. */
|
||||
onLocalCaption: (text: string, final: boolean) => void;
|
||||
}
|
||||
|
||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeLiveCaptionsSettings((s) => {
|
||||
settingsRef.current = s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return; // unsupported runtime
|
||||
if (!active || !room) return;
|
||||
if (!getLiveCaptionsSettings().enabled) return;
|
||||
|
||||
const send = (text: string, final: boolean) => {
|
||||
onLocalCaption(text, final);
|
||||
try {
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
||||
);
|
||||
// Reliable channel — captions are infrequent enough to afford it,
|
||||
// and dropping interims looks worse than slight lag.
|
||||
void room.localParticipant.publishData(payload, { reliable: true });
|
||||
} catch {
|
||||
/* ignore — best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const r = new Ctor();
|
||||
r.continuous = true;
|
||||
r.interimResults = true;
|
||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
||||
r.lang = lang;
|
||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
||||
// Pull whichever results arrived since last fire. Interim fires
|
||||
// many times per second; the final one is sticky and persists.
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const result = e.results[i];
|
||||
if (!result || result.length === 0) continue;
|
||||
const alt = result[0];
|
||||
if (!alt) continue;
|
||||
const transcript = alt.transcript.trim();
|
||||
if (!transcript) continue;
|
||||
send(transcript, result.isFinal);
|
||||
}
|
||||
};
|
||||
r.onerror = () => {
|
||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
||||
// `service-not-allowed` are permission-permanent — bail.
|
||||
try {
|
||||
r.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
r.onend = () => {
|
||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
||||
// want captions, restart it. Guard against tear-down race.
|
||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
||||
try {
|
||||
r.start();
|
||||
} catch {
|
||||
/* already running or browser refused */
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
// Some browsers throw when start() is called too soon after a
|
||||
// previous abort — wait a tick and retry.
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
/* give up */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
||||
const cur = recognitionRef.current;
|
||||
if (!s.enabled && cur) {
|
||||
recognitionRef.current = null;
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (s.enabled && !cur) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
const cur = recognitionRef.current;
|
||||
recognitionRef.current = null;
|
||||
if (cur) {
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [active, room, onLocalCaption]);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export interface AggregatedReaction {
|
||||
export interface UseMessageReactionsResult {
|
||||
byMessage: Map<string, AggregatedReaction[]>;
|
||||
toggle: (messageId: string, emoji: string) => Promise<void>;
|
||||
voteExclusive: (messageId: string, emoji: string, exclusiveEmojis: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// Batch-fetches reactions for the given message ids + subscribes to the
|
||||
@@ -106,5 +107,27 @@ export function useMessageReactions(
|
||||
[byMessage, myId, refresh],
|
||||
);
|
||||
|
||||
return { byMessage, toggle };
|
||||
const voteExclusive = useCallback(
|
||||
async (messageId: string, emoji: string, exclusiveEmojis: string[]) => {
|
||||
if (!myId) return;
|
||||
const allowed = new Set(exclusiveEmojis);
|
||||
const current = (byMessage.get(messageId) ?? []).filter((reaction) =>
|
||||
allowed.has(reaction.emoji),
|
||||
);
|
||||
const selectedMine = current.some((reaction) => reaction.emoji === emoji && reaction.mine);
|
||||
|
||||
for (const reaction of current) {
|
||||
if (reaction.mine) {
|
||||
await removeReaction(supabase, messageId, reaction.emoji);
|
||||
}
|
||||
}
|
||||
if (!selectedMine) {
|
||||
await addReaction(supabase, messageId, emoji);
|
||||
}
|
||||
await refresh();
|
||||
},
|
||||
[byMessage, myId, refresh],
|
||||
);
|
||||
|
||||
return { byMessage, toggle, voteExclusive };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createPeerPresenceChannelName,
|
||||
getEffectivePresenceState,
|
||||
PRESENCE_DEVICE_STALE_MS,
|
||||
} from './presence';
|
||||
|
||||
describe('getEffectivePresenceState', () => {
|
||||
const now = Date.parse('2026-04-24T12:00:00.000Z');
|
||||
|
||||
it('keeps a live profile state when a device checked in recently', () => {
|
||||
const seenAt = new Date(now - PRESENCE_DEVICE_STALE_MS + 1_000).toISOString();
|
||||
|
||||
expect(getEffectivePresenceState('online', seenAt, now)).toBe('online');
|
||||
expect(getEffectivePresenceState('idle', seenAt, now)).toBe('idle');
|
||||
expect(getEffectivePresenceState('dnd', seenAt, now)).toBe('dnd');
|
||||
});
|
||||
|
||||
it('falls back to offline when the latest device check-in is stale', () => {
|
||||
const seenAt = new Date(now - PRESENCE_DEVICE_STALE_MS - 1_000).toISOString();
|
||||
|
||||
expect(getEffectivePresenceState('online', seenAt, now)).toBe('offline');
|
||||
});
|
||||
|
||||
it('respects explicit offline and invisible states', () => {
|
||||
const seenAt = new Date(now).toISOString();
|
||||
|
||||
expect(getEffectivePresenceState('offline', seenAt, now)).toBe('offline');
|
||||
expect(getEffectivePresenceState('invisible', seenAt, now)).toBe('invisible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPeerPresenceChannelName', () => {
|
||||
it('keeps simultaneous subscriptions for the same user isolated', () => {
|
||||
const userId = '48c2959f-1a21-4de4-a69b-7481a3e68fbd';
|
||||
|
||||
expect(createPeerPresenceChannelName(userId, 'header')).not.toBe(
|
||||
createPeerPresenceChannelName(userId, 'profile-card'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
createPeerPresenceChannelName,
|
||||
getEffectivePresenceState,
|
||||
PRESENCE_HEARTBEAT_MS,
|
||||
} from './presence';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface PeerPresence {
|
||||
@@ -8,56 +13,92 @@ export interface PeerPresence {
|
||||
statusMessage: string | null;
|
||||
}
|
||||
|
||||
// Subscribe to a single peer's presence_state + status_message via Supabase
|
||||
// realtime. Returns null until the first row arrives, or when userId is
|
||||
// undefined.
|
||||
let peerPresenceSubscriptionCounter = 0;
|
||||
|
||||
function nextPeerPresenceSubscriptionId(): string {
|
||||
peerPresenceSubscriptionCounter += 1;
|
||||
return String(peerPresenceSubscriptionCounter);
|
||||
}
|
||||
|
||||
// Subscribe to a single peer's stored presence via Supabase realtime, then
|
||||
// treat it as live only while one of their devices has checked in recently.
|
||||
// Returns null until the first profile row arrives, or when userId is undefined.
|
||||
export function usePeerPresence(userId: string | undefined): PeerPresence | null {
|
||||
const [presence, setPresence] = useState<PeerPresence | null>(null);
|
||||
const [profilePresence, setProfilePresence] = useState<PeerPresence | null>(null);
|
||||
const [latestDeviceSeenAt, setLatestDeviceSeenAt] = useState<string | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setPresence(null);
|
||||
setProfilePresence(null);
|
||||
setLatestDeviceSeenAt(null);
|
||||
return;
|
||||
}
|
||||
const peerUserId = userId;
|
||||
let cancelled = false;
|
||||
|
||||
async function refreshLatestDeviceSeenAt() {
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.select('last_seen_at')
|
||||
.eq('user_id', peerUserId)
|
||||
.order('last_seen_at', { ascending: false })
|
||||
.limit(1);
|
||||
if (cancelled) return;
|
||||
if (error) {
|
||||
console.warn('peer latest device lookup failed', error);
|
||||
return;
|
||||
}
|
||||
setLatestDeviceSeenAt(data?.[0]?.last_seen_at ?? null);
|
||||
setNowMs(Date.now());
|
||||
}
|
||||
|
||||
void supabase
|
||||
.from('profiles')
|
||||
.select('presence_state, status_message')
|
||||
.eq('user_id', userId)
|
||||
.eq('user_id', peerUserId)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (cancelled || !data) return;
|
||||
setPresence({
|
||||
setProfilePresence({
|
||||
state: (data.presence_state as PresenceState | null) ?? 'offline',
|
||||
statusMessage: data.status_message ?? null,
|
||||
});
|
||||
});
|
||||
void refreshLatestDeviceSeenAt();
|
||||
const heartbeatPoll = window.setInterval(
|
||||
() => void refreshLatestDeviceSeenAt(),
|
||||
PRESENCE_HEARTBEAT_MS,
|
||||
);
|
||||
|
||||
const channelName = createPeerPresenceChannelName(
|
||||
peerUserId,
|
||||
nextPeerPresenceSubscriptionId(),
|
||||
);
|
||||
const channel = supabase
|
||||
.channel('peer-presence:' + userId)
|
||||
.channel(channelName)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'profiles',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
filter: 'user_id=eq.' + peerUserId,
|
||||
},
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const nextState = payload.new['presence_state'];
|
||||
const nextMsg = payload.new['status_message'];
|
||||
setPresence((prev) => {
|
||||
setProfilePresence((prev) => {
|
||||
const state =
|
||||
typeof nextState === 'string'
|
||||
? (nextState as PresenceState)
|
||||
: prev?.state ?? 'offline';
|
||||
: (prev?.state ?? 'offline');
|
||||
const statusMessage =
|
||||
nextMsg === null
|
||||
? null
|
||||
: typeof nextMsg === 'string'
|
||||
? nextMsg
|
||||
: prev?.statusMessage ?? null;
|
||||
: (prev?.statusMessage ?? null);
|
||||
return { state, statusMessage };
|
||||
});
|
||||
},
|
||||
@@ -66,9 +107,15 @@ export function usePeerPresence(userId: string | undefined): PeerPresence | null
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(heartbeatPoll);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
return presence;
|
||||
if (!profilePresence) return null;
|
||||
|
||||
return {
|
||||
state: getEffectivePresenceState(profilePresence.state, latestDeviceSeenAt, nowMs),
|
||||
statusMessage: profilePresence.statusMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface VoiceHotkeyBinding {
|
||||
export interface VoiceHotkeys {
|
||||
mute: VoiceHotkeyBinding;
|
||||
deafen: VoiceHotkeyBinding;
|
||||
/** Hang up the active call. Discord uses no default — easy to mis-fire. */
|
||||
hangup: VoiceHotkeyBinding;
|
||||
/** Toggle outgoing screen share. */
|
||||
screenShare: VoiceHotkeyBinding;
|
||||
/** Toggle outgoing camera. */
|
||||
video: VoiceHotkeyBinding;
|
||||
}
|
||||
|
||||
export type VoiceHotkeyKind = keyof VoiceHotkeys;
|
||||
@@ -46,6 +52,30 @@ const DEFAULTS: VoiceHotkeys = {
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
hangup: {
|
||||
key: 'KeyH',
|
||||
keyLabel: 'Ctrl+Shift+H',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
screenShare: {
|
||||
key: 'KeyE',
|
||||
keyLabel: 'Ctrl+Shift+E',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
video: {
|
||||
key: 'KeyV',
|
||||
keyLabel: 'Ctrl+Shift+V',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
type Listener = (s: VoiceHotkeys) => void;
|
||||
@@ -78,6 +108,9 @@ function read(): VoiceHotkeys {
|
||||
cached = {
|
||||
mute: validateBinding(parsed.mute, DEFAULTS.mute),
|
||||
deafen: validateBinding(parsed.deafen, DEFAULTS.deafen),
|
||||
hangup: validateBinding(parsed.hangup, DEFAULTS.hangup),
|
||||
screenShare: validateBinding(parsed.screenShare, DEFAULTS.screenShare),
|
||||
video: validateBinding(parsed.video, DEFAULTS.video),
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Window-focus tracking. Under Electron the renderer's `window` matches
|
||||
// a single BrowserWindow, and Chromium's focus/blur events fire when
|
||||
// that window gains/loses OS-foreground status, so DOM events are
|
||||
// authoritative — no IPC bridge needed.
|
||||
//
|
||||
// One synchronous getter + one subscriber so callers can mirror the
|
||||
// value into a ref for fast-path reads inside realtime callbacks.
|
||||
|
||||
type Listener = (focused: boolean) => void;
|
||||
|
||||
// Optimistic default — if we don't know yet, assume focused so the
|
||||
// first few events after app start behave like "user is here" rather
|
||||
// than spamming sounds during the init window.
|
||||
let cached = true;
|
||||
let initialized = false;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function emit(next: boolean): void {
|
||||
if (next === cached) return;
|
||||
cached = next;
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l(next);
|
||||
} catch (err: unknown) {
|
||||
console.warn('windowFocus listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInit(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
if (typeof window === 'undefined') return;
|
||||
cached = typeof document !== 'undefined' ? document.hasFocus() : true;
|
||||
window.addEventListener('focus', () => emit(true));
|
||||
window.addEventListener('blur', () => emit(false));
|
||||
}
|
||||
|
||||
ensureInit();
|
||||
|
||||
export function getIsWindowFocused(): boolean {
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function subscribeWindowFocus(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Renderer-side wrapper for the OS-level window fullscreen flag. Replaces
|
||||
// the Tauri call `getCurrentWindow().setFullscreen(...)` from
|
||||
// `@tauri-apps/api/window`. We route through the Electron preload bridge
|
||||
// to `BrowserWindow.setFullScreen()` in the main process so the Windows
|
||||
// taskbar / macOS menubar stay covered while in cinema mode.
|
||||
//
|
||||
// Mirrors the shape of `lib/autoStart.ts` — runtime guard against the
|
||||
// Electron preload marker, swallow errors so renderer logic never breaks
|
||||
// when the host is not the desktop app (e.g. web build / Storybook).
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
export async function setWindowFullscreen(enabled: boolean): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
await window.electronAPI.setFullscreen(enabled);
|
||||
} catch (err: unknown) {
|
||||
// setFullscreen can reject if the window is minimised or focus is
|
||||
// gone — both recoverable noise, matches the Tauri callsite which
|
||||
// also `.catch(() => undefined)`s the promise.
|
||||
console.warn('setWindowFullscreen failed', err);
|
||||
}
|
||||
}
|
||||
@@ -181,7 +181,10 @@ function BrandSection() {
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-between gap-3 text-xs text-fg-muted">
|
||||
<span className="font-mono">v0.1.0 · {t('common:dev_build')}</span>
|
||||
<span className="font-mono">
|
||||
v{window.electronAPI?.appVersion ?? '0.0.0'}
|
||||
{import.meta.env.DEV ? ' · ' + t('common:dev_build') : ''}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function ChangelogPage() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await fetchChangelog();
|
||||
if (!cancelled) setEntries(list);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Konnte Changelog nicht laden.');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="mb-2 flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-fg-muted">
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-line bg-surface-2 p-6 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
<span>Lade Changelog…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-sm text-rose-600 dark:text-rose-200">
|
||||
<p className="font-semibold">Fehler beim Laden</p>
|
||||
<p className="mt-1 text-xs opacity-90">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries !== null && entries.length === 0 && !error && (
|
||||
<div className="rounded-xl border border-line bg-surface-2 p-8 text-center text-sm text-fg-muted">
|
||||
Noch keine Einträge vorhanden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries && entries.length > 0 && (
|
||||
<ol className="flex flex-col gap-4">
|
||||
{entries.slice(0, visible).map((entry) => (
|
||||
<li
|
||||
key={entry.version}
|
||||
className="rounded-2xl border border-line bg-surface-2 p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 className="font-display text-lg font-semibold tracking-tight text-fg">
|
||||
v{entry.version}
|
||||
</h2>
|
||||
<time
|
||||
dateTime={entry.pub_date}
|
||||
className="text-xs tabular-nums text-fg-muted"
|
||||
>
|
||||
{formatDate(entry.pub_date)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-line break-words text-sm leading-relaxed text-fg">
|
||||
{entry.notes}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{visible < entries.length && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((n) => n + PAGE_SIZE)}
|
||||
className="inline-flex w-full cursor-pointer items-center justify-center rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm font-semibold text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Mehr laden ({entries.length - visible} verbleibend)
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
acceptDm,
|
||||
type ConversationSummary,
|
||||
isConversationMuted,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { acceptDm, type ConversationSummary, isConversationMuted } from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,12 +11,14 @@ import {
|
||||
ArchiveIcon,
|
||||
BellOffIcon,
|
||||
ChatBubbleIcon,
|
||||
PhoneIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
} from '../components/icons';
|
||||
import { UserBar } from '../components/UserBar';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function ChatsPage() {
|
||||
@@ -43,22 +41,13 @@ export function ChatsPage() {
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((c) => {
|
||||
const title = (c.type === 'dm' ? c.peer?.displayName : c.name) ?? '';
|
||||
const handle = c.type === 'dm' ? c.peer?.username ?? '' : '';
|
||||
const handle = c.type === 'dm' ? (c.peer?.username ?? '') : '';
|
||||
return title.toLowerCase().includes(q) || handle.toLowerCase().includes(q);
|
||||
});
|
||||
}, [sorted, query]);
|
||||
|
||||
// Split into active vs archived — the user toggles which bucket shows in the
|
||||
// main list. Archived conversations with unread messages still surface so
|
||||
// the user can't accidentally silence an ongoing conversation permanently.
|
||||
const activeItems = useMemo(
|
||||
() => queryFiltered.filter((c) => !c.archived),
|
||||
[queryFiltered],
|
||||
);
|
||||
const archivedItems = useMemo(
|
||||
() => queryFiltered.filter((c) => c.archived),
|
||||
[queryFiltered],
|
||||
);
|
||||
const activeItems = useMemo(() => queryFiltered.filter((c) => !c.archived), [queryFiltered]);
|
||||
const archivedItems = useMemo(() => queryFiltered.filter((c) => c.archived), [queryFiltered]);
|
||||
|
||||
const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0);
|
||||
|
||||
@@ -85,8 +74,10 @@ export function ChatsPage() {
|
||||
showArchived={showArchived}
|
||||
onToggleArchived={() => setShowArchived((v) => !v)}
|
||||
archivedUnread={archivedUnread}
|
||||
activeCount={activeItems.length}
|
||||
archivedCount={archivedItems.length}
|
||||
/>
|
||||
<div className="flex-1 border-l border-line bg-surface-3">
|
||||
<div className="discord-chat-surface flex-1 border-l border-line bg-surface-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
@@ -107,6 +98,8 @@ interface ConversationListProps {
|
||||
showArchived: boolean;
|
||||
onToggleArchived: () => void;
|
||||
archivedUnread: number;
|
||||
activeCount: number;
|
||||
archivedCount: number;
|
||||
}
|
||||
|
||||
function ConversationList({
|
||||
@@ -122,62 +115,49 @@ function ConversationList({
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
archivedUnread,
|
||||
activeCount,
|
||||
archivedCount,
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Conversations"
|
||||
className="flex h-full w-[320px] shrink-0 flex-col bg-surface-2"
|
||||
className="discord-chat-panel flex h-full w-[312px] shrink-0 flex-col bg-surface-2"
|
||||
>
|
||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||
<h2 className="font-display text-base font-semibold tracking-tight text-fg">
|
||||
{showArchived
|
||||
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
|
||||
: t('app:nav.chats')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleArchived}
|
||||
aria-label={
|
||||
showArchived
|
||||
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||
}
|
||||
title={
|
||||
showArchived
|
||||
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||
}
|
||||
className={
|
||||
'relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(showArchived
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
{!showArchived && archivedUnread > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-accent px-1 text-[9px] font-bold leading-tight text-accent-fg"
|
||||
<header className="px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-base font-semibold text-fg">
|
||||
{showArchived
|
||||
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
|
||||
: t('app:nav.chats')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{showArchived
|
||||
? t('app:chats.archived_count', {
|
||||
count: archivedCount,
|
||||
defaultValue: archivedCount + ' archiviert',
|
||||
})
|
||||
: t('app:chats.active_count', {
|
||||
count: activeCount,
|
||||
defaultValue: activeCount + ' aktiv',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!showArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{archivedUnread > 9 ? '9+' : archivedUnread}
|
||||
</span>
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
{!showArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -193,11 +173,49 @@ function ConversationList({
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder={t('app:chats.search_placeholder', { defaultValue: 'Suche…' })}
|
||||
className="w-full rounded-lg border border-line bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
className="discord-composer w-full rounded-lg border border-transparent bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-surface/70 p-1 dark:bg-[#232428]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showArchived) onToggleArchived();
|
||||
}}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-2 py-1.5 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(!showArchived
|
||||
? 'bg-surface-3 text-fg shadow-sm dark:bg-[#383a40]'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t('app:chats.active_filter', { defaultValue: 'Aktiv' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showArchived) onToggleArchived();
|
||||
}}
|
||||
className={
|
||||
'relative cursor-pointer rounded-md px-2 py-1.5 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(showArchived
|
||||
? 'bg-surface-3 text-fg shadow-sm dark:bg-[#383a40]'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t('app:chats.archive_filter', { defaultValue: 'Archiv' })}
|
||||
{archivedUnread > 0 && (
|
||||
<span className="ml-1 rounded-full bg-rose-500 px-1.5 py-0.5 text-[9px] font-bold text-white">
|
||||
{archivedUnread > 9 ? '9+' : archivedUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
@@ -242,10 +260,6 @@ function ConversationList({
|
||||
);
|
||||
}
|
||||
|
||||
// Windowed list: renders the first N rows and expands by N whenever a
|
||||
// bottom sentinel scrolls into view. Under the threshold we skip the
|
||||
// machinery entirely because rendering 50 rows costs less than the
|
||||
// overhead of observers + state updates.
|
||||
const VLIST_INITIAL = 40;
|
||||
const VLIST_STEP = 40;
|
||||
|
||||
@@ -320,20 +334,20 @@ function ConversationRow({
|
||||
onAccept: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const title =
|
||||
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||
const title = item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
|
||||
const avatarUrl =
|
||||
item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
|
||||
const avatarUrl = item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
|
||||
const preview =
|
||||
item.type === 'dm' ? handle : t('app:chats.group_preview', {
|
||||
count: item.members.length,
|
||||
defaultValue: `${item.members.length} Mitglieder`,
|
||||
});
|
||||
item.type === 'dm'
|
||||
? handle
|
||||
: t('app:chats.group_preview', {
|
||||
count: item.members.length,
|
||||
defaultValue: `${item.members.length} Mitglieder`,
|
||||
});
|
||||
|
||||
if (!item.acceptedByMe) {
|
||||
return (
|
||||
<div className="my-1 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div className="my-1 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -359,29 +373,30 @@ function ConversationRow({
|
||||
<NavLink
|
||||
to={'/chats/' + item.id}
|
||||
className={
|
||||
'group relative my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
'group relative my-0.5 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent/15 text-fg'
|
||||
: 'text-fg hover:bg-surface-3/70')
|
||||
? 'bg-surface-3 text-fg shadow-sm dark:bg-[#404249]'
|
||||
: 'text-fg hover:bg-surface-3/70 dark:hover:bg-[#35373c]')
|
||||
}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-1/2 h-7 w-1 -translate-y-1/2 rounded-r-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={
|
||||
'truncate text-sm ' +
|
||||
(unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
|
||||
'truncate text-sm ' + (unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{muted && (
|
||||
<BellOffIcon
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
{muted && <BellOffIcon aria-hidden="true" className="h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
<ConversationVoiceDot conversationId={item.id} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">{preview}</p>
|
||||
</div>
|
||||
@@ -390,9 +405,7 @@ function ConversationRow({
|
||||
aria-label={'Unread: ' + unreadCount}
|
||||
className={
|
||||
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold leading-tight ' +
|
||||
(muted
|
||||
? 'bg-fg-muted/30 text-fg-muted'
|
||||
: 'bg-accent text-accent-fg')
|
||||
(muted ? 'bg-fg-muted/30 text-fg-muted' : 'bg-rose-500 text-white')
|
||||
}
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
@@ -407,6 +420,23 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Tiny green phone-icon next to the conversation title when somebody is
|
||||
* currently in voice for this conversation. Discord-parity: surfaces voice
|
||||
* activity in the chat list so users can hop in without opening the chat. */
|
||||
function ConversationVoiceDot({ conversationId }: { conversationId: string }) {
|
||||
const present = useCallPresence(conversationId);
|
||||
if (present.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
aria-label={'Sprach-Channel aktiv: ' + present.length}
|
||||
title={'Sprach-Channel aktiv: ' + present.length}
|
||||
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500"
|
||||
>
|
||||
<PhoneIcon className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConvAvatar({
|
||||
url,
|
||||
title,
|
||||
@@ -421,21 +451,21 @@ function ConvAvatar({
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isGroup) {
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent dark:bg-accent/25 dark:text-white">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent dark:bg-accent/25 dark:text-white">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
@@ -449,7 +479,7 @@ export function ChatsEmptyState() {
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
|
||||
<ChatBubbleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
<h2 className="font-display text-2xl font-semibold text-fg">
|
||||
{t('app:chats.select_prompt')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('app:chats.select_subtitle')}</p>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
PollIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SmileIcon,
|
||||
@@ -22,8 +23,11 @@ import {
|
||||
} from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { VoiceChannelRail } from '../components/VoiceChannelRail';
|
||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { PollComposerDialog } from '../components/PollComposerDialog';
|
||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||
@@ -31,6 +35,7 @@ import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
@@ -64,14 +69,14 @@ export function ConversationPage() {
|
||||
deviceId: device?.id,
|
||||
});
|
||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
|
||||
messageIds,
|
||||
session?.user.id,
|
||||
);
|
||||
const {
|
||||
byMessage: reactionsByMessage,
|
||||
toggle: toggleReaction,
|
||||
voteExclusive: votePoll,
|
||||
} = useMessageReactions(messageIds, session?.user.id);
|
||||
|
||||
const myId = session?.user.id;
|
||||
|
||||
// Peer read tracking — only for 1:1 DMs.
|
||||
const ownMessageIds = useMemo(
|
||||
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
|
||||
[messages, myId],
|
||||
@@ -79,18 +84,17 @@ export function ConversationPage() {
|
||||
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
|
||||
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
|
||||
|
||||
// Group receipts: only meaningful when conversation is a group. We feed it
|
||||
// ownMessageIds since we only render delivery state on the sender side.
|
||||
const isGroup = conversation?.type === 'group';
|
||||
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
|
||||
useGroupReceipts(ownMessageIds, myId, !!isGroup);
|
||||
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } = useGroupReceipts(
|
||||
ownMessageIds,
|
||||
myId,
|
||||
!!isGroup,
|
||||
);
|
||||
const groupRecipientCount = useMemo(() => {
|
||||
if (!isGroup || !conversation) return 0;
|
||||
return conversation.members.filter((m) => m.userId !== myId).length;
|
||||
}, [isGroup, conversation, myId]);
|
||||
|
||||
// Mark every peer-authored message as delivered on our side. Idempotent,
|
||||
// so rerunning for already-acknowledged ids is a no-op server-side.
|
||||
const deliveredTrackedRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!myId || messages.length === 0) return;
|
||||
@@ -116,11 +120,8 @@ export function ConversationPage() {
|
||||
return null;
|
||||
}, [messages, peerReadSet, myId]);
|
||||
|
||||
// Typing channel.
|
||||
const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);
|
||||
|
||||
// Mark incoming messages as read (server-side, visible to peer if both sides
|
||||
// have receipts on). Runs whenever new messages arrive or id changes.
|
||||
useEffect(() => {
|
||||
if (!id || messages.length === 0 || !myId) return;
|
||||
const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
|
||||
@@ -136,6 +137,10 @@ export function ConversationPage() {
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
||||
const [pollSending, setPollSending] = useState(false);
|
||||
const [pollError, setPollError] = useState<string | null>(null);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
@@ -148,54 +153,64 @@ export function ConversationPage() {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
// Snapshot of the "first-unread-message" id captured once the very first
|
||||
// render of this conversation lands. Stays fixed until the user switches
|
||||
// away so the divider doesn't jump around while new messages arrive.
|
||||
const firstUnreadRef = useRef<string | null>(null);
|
||||
const [firstUnreadId, setFirstUnreadId] = useState<string | null>(null);
|
||||
const [firstUnreadJumpDismissed, setFirstUnreadJumpDismissed] = useState(false);
|
||||
const [newMessagesWhileAway, setNewMessagesWhileAway] = useState(0);
|
||||
const firstUnreadComputedRef = useRef<boolean>(false);
|
||||
const [profilePopover, setProfilePopover] = useState<
|
||||
{ userId: string; x: number; y: number } | null
|
||||
>(null);
|
||||
const [mentionState, setMentionState] = useState<
|
||||
{ query: string; start: number } | null
|
||||
>(null);
|
||||
const previousMessageIdsRef = useRef<Set<string>>(new Set());
|
||||
const [profilePopover, setProfilePopover] = useState<{
|
||||
userId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Drop reply-to / clear search state when switching conversation.
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setMediaDrawerOpen(false);
|
||||
setPollDialogOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
firstUnreadRef.current = null;
|
||||
setFirstUnreadId(null);
|
||||
setFirstUnreadJumpDismissed(false);
|
||||
setNewMessagesWhileAway(0);
|
||||
previousMessageIdsRef.current = new Set();
|
||||
firstUnreadComputedRef.current = false;
|
||||
}, [id]);
|
||||
|
||||
// On first message-list populate for this conversation, pin the divider
|
||||
// above the oldest-unread message. We only compute once — subsequent
|
||||
// inserts push the divider "further back" visually, which matches
|
||||
// Discord's behaviour.
|
||||
useEffect(() => {
|
||||
if (firstUnreadComputedRef.current) return;
|
||||
if (!id || messages.length === 0) return;
|
||||
const count = unread[id] ?? 0;
|
||||
firstUnreadComputedRef.current = true;
|
||||
if (count === 0 || count > messages.length) {
|
||||
firstUnreadRef.current = null;
|
||||
setFirstUnreadId(null);
|
||||
return;
|
||||
}
|
||||
const boundary = messages[messages.length - count];
|
||||
firstUnreadRef.current = boundary ? boundary.id : null;
|
||||
setFirstUnreadId(boundary ? boundary.id : null);
|
||||
}, [id, messages, unread]);
|
||||
|
||||
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
||||
// effective window on each trigger so scrolling up quickly converges to
|
||||
// rendering everything.
|
||||
useEffect(() => {
|
||||
const previous = previousMessageIdsRef.current;
|
||||
if (previous.size > 0 && !stickToBottom) {
|
||||
const addedIncoming = messages.filter(
|
||||
(message) => !previous.has(message.id) && message.senderId !== myId,
|
||||
).length;
|
||||
if (addedIncoming > 0) {
|
||||
setNewMessagesWhileAway((count) => count + addedIncoming);
|
||||
}
|
||||
}
|
||||
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
|
||||
}, [messages, myId, stickToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = loadMoreSentinelRef.current;
|
||||
if (!el) return;
|
||||
@@ -218,12 +233,14 @@ export function ConversationPage() {
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
const attachmentIndex = useMemo(() => collectConversationAttachments(messages), [messages]);
|
||||
|
||||
const senderNameFor = useCallback(
|
||||
(senderId: string): string => {
|
||||
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
|
||||
const profile =
|
||||
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
|
||||
(senderId !== myId ? conversation?.peer ?? null : null);
|
||||
(senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
return profile?.displayName ?? '?';
|
||||
},
|
||||
[conversation, myId, t],
|
||||
@@ -243,7 +260,12 @@ export function ConversationPage() {
|
||||
};
|
||||
}
|
||||
const parsed = parseMessagePayload(target.plaintext);
|
||||
const text = parsed.kind === 'text' ? parsed.text : '';
|
||||
const text =
|
||||
parsed.kind === 'text'
|
||||
? parsed.text
|
||||
: parsed.kind === 'poll'
|
||||
? 'Umfrage: ' + parsed.question
|
||||
: '';
|
||||
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
|
||||
return {
|
||||
id: target.id,
|
||||
@@ -275,9 +297,6 @@ export function ConversationPage() {
|
||||
setForwardTarget(m);
|
||||
}, []);
|
||||
|
||||
// Search matches: messages matching query + filters. Empty query is allowed
|
||||
// when filters are active, so users can e.g. show "all attachments from
|
||||
// alice in the last week" without a text query.
|
||||
const searchActive = useMemo(
|
||||
() =>
|
||||
searchQuery.trim().length > 0 ||
|
||||
@@ -288,10 +307,6 @@ export function ConversationPage() {
|
||||
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
|
||||
);
|
||||
|
||||
// FTS5-backed supplementary results: covers cached messages that aren't in
|
||||
// the currently-loaded window (`messages`). Runs only when there's a text
|
||||
// query — filters alone stay in-memory because they depend on already-
|
||||
// decrypted payload state.
|
||||
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
@@ -317,12 +332,7 @@ export function ConversationPage() {
|
||||
if (!searchActive) return [] as DecryptedMessage[];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
|
||||
// Date inputs cover whole days — bump 'to' to end-of-day.
|
||||
const toTs = searchDateTo
|
||||
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
||||
: null;
|
||||
// Union the live `messages` array with any FTS5-only rows not yet
|
||||
// loaded into memory, keyed by id so we don't double-count.
|
||||
const toTs = searchDateTo ? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1 : null;
|
||||
const seen = new Set<string>();
|
||||
const pool: DecryptedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
@@ -337,9 +347,7 @@ export function ConversationPage() {
|
||||
pool.push(m);
|
||||
}
|
||||
}
|
||||
pool.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
pool.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
return pool.filter((m) => {
|
||||
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
||||
const created = new Date(m.createdAt).getTime();
|
||||
@@ -363,7 +371,6 @@ export function ConversationPage() {
|
||||
searchDateTo,
|
||||
]);
|
||||
|
||||
// Reset/clamp the active match index when the match set changes.
|
||||
useEffect(() => {
|
||||
if (searchMatches.length === 0) {
|
||||
setSearchIdx(0);
|
||||
@@ -372,7 +379,6 @@ export function ConversationPage() {
|
||||
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
|
||||
}, [searchMatches.length]);
|
||||
|
||||
// Auto-jump to current match.
|
||||
useEffect(() => {
|
||||
if (!searchOpen || searchMatches.length === 0) return;
|
||||
const target = searchMatches[searchIdx];
|
||||
@@ -407,7 +413,17 @@ export function ConversationPage() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setStickToBottom(distanceFromBottom < STICK_THRESHOLD);
|
||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
||||
setStickToBottom(nextStick);
|
||||
if (nextStick) setNewMessagesWhileAway(0);
|
||||
}, []);
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
setStickToBottom(true);
|
||||
setNewMessagesWhileAway(0);
|
||||
}, []);
|
||||
|
||||
async function handleSend(e?: React.FormEvent) {
|
||||
@@ -437,10 +453,27 @@ export function ConversationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePollSubmit = useCallback(
|
||||
async (question: string, options: string[]) => {
|
||||
setPollSending(true);
|
||||
setPollError(null);
|
||||
try {
|
||||
const payload = createPollPayload(question, options);
|
||||
await send(payload, [], replyTo?.id ?? null);
|
||||
setPollDialogOpen(false);
|
||||
setReplyTo(null);
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
} catch (err: unknown) {
|
||||
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
||||
} finally {
|
||||
setPollSending(false);
|
||||
}
|
||||
},
|
||||
[send, replyTo?.id, notifyStopTyping],
|
||||
);
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
|
||||
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
|
||||
// files are passed through unchanged.
|
||||
const compressed = await compressImages(files);
|
||||
const next: File[] = [];
|
||||
for (const f of compressed) {
|
||||
@@ -459,16 +492,12 @@ export function ConversationPage() {
|
||||
}
|
||||
|
||||
const { state: callState } = useCall();
|
||||
// Hide the chat header while this conversation hosts an active call — the
|
||||
// call topbar inside the dock already shows the channel name + duration,
|
||||
// and fullscreen cinema needs the whole slot.
|
||||
const callHereActive =
|
||||
(callState.kind === 'connected' ||
|
||||
callState.kind === 'connecting' ||
|
||||
callState.kind === 'outgoing') &&
|
||||
callState.conversationId === id;
|
||||
const incomingHere =
|
||||
callState.kind === 'incoming' && callState.conversationId === id;
|
||||
const incomingHere = callState.kind === 'incoming' && callState.conversationId === id;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -486,7 +515,6 @@ export function ConversationPage() {
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// leave fires on child enter too; only clear when leaving the page container.
|
||||
if (e.currentTarget === e.target) setIsDraggingFile(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
@@ -500,6 +528,19 @@ export function ConversationPage() {
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
onMediaClick={() => setMediaDrawerOpen((v) => !v)}
|
||||
{...(conversation?.type === 'dm' && conversation.peer
|
||||
? {
|
||||
onProfileClick: (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
setProfilePopover({
|
||||
userId: conversation.peer!.userId,
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
});
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
onSearchClick={() => setSearchOpen((v) => !v)}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
@@ -522,11 +563,15 @@ export function ConversationPage() {
|
||||
members={conversation?.members ?? []}
|
||||
onPrev={() =>
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
searchMatches.length === 0
|
||||
? 0
|
||||
: (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
)
|
||||
}
|
||||
onNext={() =>
|
||||
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length,
|
||||
)
|
||||
}
|
||||
onClose={() => {
|
||||
setSearchOpen(false);
|
||||
@@ -547,10 +592,29 @@ export function ConversationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaFilesDrawer
|
||||
open={mediaDrawerOpen}
|
||||
index={attachmentIndex}
|
||||
senderNameFor={senderNameFor}
|
||||
onJumpToMessage={(messageId) => {
|
||||
setMediaDrawerOpen(false);
|
||||
jumpToMessage(messageId);
|
||||
}}
|
||||
onClose={() => setMediaDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
||||
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
||||
someone is already waiting. Hides automatically once we're in. */}
|
||||
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
@@ -573,34 +637,21 @@ export function ConversationPage() {
|
||||
)}
|
||||
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
||||
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
||||
// A "run" is consecutive bubbles from the same sender with
|
||||
// nothing between them. Call-event separators break the run —
|
||||
// a bubble whose immediate next neighbour is a call_event must
|
||||
// anchor the avatar, even if another bubble from the same
|
||||
// sender appears after the separator.
|
||||
const prevRaw = messages[idx - 1];
|
||||
const nextRaw = messages[idx + 1];
|
||||
const prevIsCallEvent =
|
||||
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
|
||||
const nextIsCallEvent =
|
||||
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
|
||||
const grouped =
|
||||
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
||||
// Anchor avatar on the LAST message of a run so it aligns with
|
||||
// the bubble's tail (bottom corner). Tail is bottom-left for
|
||||
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
|
||||
const isLastOfRun =
|
||||
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
||||
// DM fallback: if member lookup fails (e.g. transient sync), fall
|
||||
// back to conversation.peer so the peer's avatar still resolves.
|
||||
const grouped = !!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
||||
const isLastOfRun = !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
||||
const memberProfile =
|
||||
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
|
||||
const senderProfile =
|
||||
memberProfile ??
|
||||
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
{firstUnreadRef.current === m.id && (
|
||||
{firstUnreadId === m.id && (
|
||||
<div
|
||||
aria-label="Neue Nachrichten"
|
||||
className="my-2 flex items-center gap-3 px-2"
|
||||
@@ -622,6 +673,7 @@ export function ConversationPage() {
|
||||
conversationId={id ?? ''}
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)}
|
||||
showSeen={m.id === lastSeenMessageId}
|
||||
{...(m.senderId === myId
|
||||
? {
|
||||
@@ -666,10 +718,36 @@ export function ConversationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TypingIndicator
|
||||
typingUserIds={typingUserIds}
|
||||
members={conversation?.members ?? []}
|
||||
/>
|
||||
{firstUnreadId && !firstUnreadJumpDismissed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
jumpToMessage(firstUnreadId);
|
||||
setFirstUnreadJumpDismissed(true);
|
||||
}}
|
||||
className="absolute left-1/2 top-[76px] z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-rose-500/30 bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg transition hover:bg-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
<span>Zu ungelesen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!stickToBottom || newMessagesWhileAway > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToBottom}
|
||||
className="absolute bottom-[92px] left-1/2 z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-line bg-surface-2 px-3 py-1.5 text-xs font-semibold text-fg shadow-lg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-[#2b2d31] dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 text-accent" />
|
||||
<span>
|
||||
{newMessagesWhileAway > 0
|
||||
? newMessagesWhileAway + ' neue Nachrichten'
|
||||
: 'Zum neuesten'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<TypingIndicator typingUserIds={typingUserIds} members={conversation?.members ?? []} />
|
||||
|
||||
{isDraggingFile && (
|
||||
<div
|
||||
@@ -682,7 +760,7 @@ export function ConversationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
||||
<form onSubmit={handleSend} className="discord-chat-surface bg-surface-3 px-5 pb-5 pt-2">
|
||||
{sendError && (
|
||||
<div className="mb-2">
|
||||
<Banner>{sendError}</Banner>
|
||||
@@ -690,7 +768,7 @@ export function ConversationPage() {
|
||||
)}
|
||||
|
||||
{replyTo && (
|
||||
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
|
||||
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm dark:bg-[#2b2d31]">
|
||||
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
|
||||
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -704,6 +782,7 @@ export function ConversationPage() {
|
||||
{(() => {
|
||||
if (!replyTo.plaintext) return '…';
|
||||
const p = parseMessagePayload(replyTo.plaintext);
|
||||
if (p.kind === 'poll') return 'Umfrage: ' + p.question;
|
||||
if (p.kind !== 'text') return '';
|
||||
if (!p.text && p.attachments.length > 0) return '📎';
|
||||
return p.text;
|
||||
@@ -714,7 +793,7 @@ export function ConversationPage() {
|
||||
type="button"
|
||||
onClick={() => setReplyTo(null)}
|
||||
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -727,22 +806,19 @@ export function ConversationPage() {
|
||||
<AttachmentPreview
|
||||
key={idx}
|
||||
file={file}
|
||||
onRemove={() =>
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-end gap-2">
|
||||
<div className="discord-composer relative flex items-end gap-1 rounded-xl border border-transparent bg-surface-2 p-1.5 shadow-sm focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
|
||||
{isGroup && mentionState && conversation && (
|
||||
<MentionAutocomplete
|
||||
members={conversation.members}
|
||||
query={mentionState.query}
|
||||
excludeUserId={myId}
|
||||
onSelect={(username) => {
|
||||
// Replace `@{query}` at `start..caret` with `@{username} `.
|
||||
const start = mentionState.start;
|
||||
const before = text.slice(0, start);
|
||||
const afterCaret = text.slice(start + 1 + mentionState.query.length);
|
||||
@@ -750,7 +826,6 @@ export function ConversationPage() {
|
||||
const next = before + inserted + afterCaret;
|
||||
setText(next);
|
||||
setMentionState(null);
|
||||
// Restore caret position after inserted mention.
|
||||
const caret = (before + inserted).length;
|
||||
requestAnimationFrame(() => {
|
||||
const el = composerRef.current;
|
||||
@@ -774,10 +849,22 @@ export function ConversationPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Datei anhängen"
|
||||
title="Datei anhängen"
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPollError(null);
|
||||
setPollDialogOpen(true);
|
||||
}}
|
||||
aria-label="Umfrage erstellen"
|
||||
title="Umfrage erstellen"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<PollIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -786,7 +873,7 @@ export function ConversationPage() {
|
||||
aria-label="Emoji einfügen"
|
||||
title="Emoji einfügen"
|
||||
aria-expanded={emojiOpen}
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<SmileIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -826,16 +913,10 @@ export function ConversationPage() {
|
||||
const next = e.target.value;
|
||||
setText(next);
|
||||
if (next.length > 0) notifyTyping();
|
||||
// Detect an in-progress @mention: find the last '@' before the
|
||||
// caret, with no whitespace between it and the caret. If
|
||||
// present, open the autocomplete with the partial query.
|
||||
const caret = e.target.selectionStart ?? next.length;
|
||||
const before = next.slice(0, caret);
|
||||
const atIdx = before.lastIndexOf('@');
|
||||
if (
|
||||
atIdx >= 0 &&
|
||||
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
|
||||
) {
|
||||
if (atIdx >= 0 && (atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))) {
|
||||
const q = before.slice(atIdx + 1);
|
||||
if (!/\s/.test(q)) {
|
||||
setMentionState({ query: q, start: atIdx });
|
||||
@@ -867,13 +948,13 @@ export function ConversationPage() {
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Nachricht schreiben…"
|
||||
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
className="max-h-40 min-h-[40px] flex-1 resize-none rounded-lg bg-transparent px-2 py-2.5 text-sm text-fg placeholder-fg-muted outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
||||
aria-busy={sending}
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -887,6 +968,16 @@ export function ConversationPage() {
|
||||
onClose={() => setForwardTarget(null)}
|
||||
/>
|
||||
|
||||
<PollComposerDialog
|
||||
open={pollDialogOpen}
|
||||
sending={pollSending}
|
||||
error={pollError}
|
||||
onClose={() => {
|
||||
if (!pollSending) setPollDialogOpen(false);
|
||||
}}
|
||||
onSubmit={handlePollSubmit}
|
||||
/>
|
||||
|
||||
{profilePopover && (
|
||||
<UserProfilePopover
|
||||
userId={profilePopover.userId}
|
||||
@@ -943,7 +1034,7 @@ function SearchBar({
|
||||
}: SearchBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<div className="discord-chat-panel flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
@@ -974,7 +1065,7 @@ function SearchBar({
|
||||
onClick={onPrev}
|
||||
disabled={matches === 0}
|
||||
aria-label="Previous"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -983,7 +1074,7 @@ function SearchBar({
|
||||
onClick={onNext}
|
||||
disabled={matches === 0}
|
||||
aria-label="Next"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -991,7 +1082,7 @@ function SearchBar({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1000,7 +1091,7 @@ function SearchBar({
|
||||
<select
|
||||
value={senderId}
|
||||
onChange={(e) => onSenderChange(e.target.value)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
>
|
||||
<option value="">Alle Sender</option>
|
||||
{members.map((m) => (
|
||||
@@ -1009,7 +1100,7 @@ function SearchBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
|
||||
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg dark:bg-[#383a40]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attachmentsOnly}
|
||||
@@ -1024,7 +1115,7 @@ function SearchBar({
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => onDateFromChange(e.target.value)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
@@ -1033,7 +1124,7 @@ function SearchBar({
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => onDateToChange(e.target.value)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1041,10 +1132,6 @@ function SearchBar({
|
||||
);
|
||||
}
|
||||
|
||||
// Walks `messages` from `idx + step` skipping call_event entries until a
|
||||
// regular bubble is found or the array boundary is reached. Used to decide
|
||||
// run-grouping for avatar placement so call separators don't bleed into
|
||||
// sender continuity.
|
||||
function computeDeliveryState(args: {
|
||||
messageId: string;
|
||||
isGroup: boolean;
|
||||
@@ -1054,15 +1141,11 @@ function computeDeliveryState(args: {
|
||||
groupRead: Map<string, Set<string>>;
|
||||
groupDelivered: Map<string, Set<string>>;
|
||||
}): 'sent' | 'delivered' | 'read' {
|
||||
// DM: single peer ack flips state.
|
||||
if (!args.isGroup) {
|
||||
if (args.peerReadSet.has(args.messageId)) return 'read';
|
||||
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
|
||||
return 'sent';
|
||||
}
|
||||
// Group: state advances only when ALL recipients have acknowledged. With
|
||||
// 0 recipients (admin-only group), we keep 'sent' so we don't show
|
||||
// misleading completed ticks.
|
||||
if (args.recipientCount === 0) return 'sent';
|
||||
const reads = args.groupRead.get(args.messageId);
|
||||
if (reads && reads.size >= args.recipientCount) return 'read';
|
||||
@@ -1114,9 +1197,7 @@ function PendingBubble({
|
||||
) : (
|
||||
<>
|
||||
<SpinnerIcon className="h-3 w-3 animate-spin" />
|
||||
<span>
|
||||
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
|
||||
</span>
|
||||
<span>{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1147,7 +1228,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
{isImage && url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
@@ -1155,12 +1236,8 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
<span className="truncate font-semibold text-fg" title={file.name}>
|
||||
{file.name || 'Datei'}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
{file.type || 'unbekannt'}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
{(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
|
||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -10,13 +10,29 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import { MicTestSection } from '../components/MicTestSection';
|
||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
|
||||
import {
|
||||
AVATAR_TARGET_DIM,
|
||||
deleteAvatarObject,
|
||||
uploadAvatarBlob,
|
||||
} from '../lib/avatarUpload';
|
||||
import {
|
||||
BANNER_MAX_INPUT_BYTES,
|
||||
BANNER_TARGET_HEIGHT,
|
||||
BANNER_TARGET_WIDTH,
|
||||
deleteBannerObject,
|
||||
uploadBannerBlob,
|
||||
} from '../lib/bannerUpload';
|
||||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
@@ -97,17 +113,20 @@ export function SettingsPage() {
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('app:settings.section_account')}>
|
||||
<AvatarControls
|
||||
patchProfile={patchProfile}
|
||||
busy={busy}
|
||||
/>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
|
||||
{/* Startup */}
|
||||
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
||||
<AutoStartControls />
|
||||
</Section>
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
@@ -151,6 +170,13 @@ export function SettingsPage() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Notification sound (new messages) */}
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</Section>
|
||||
|
||||
{/* Ringtone (incoming custom) */}
|
||||
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
@@ -176,6 +202,15 @@ export function SettingsPage() {
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
@@ -219,6 +254,55 @@ export function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AutoStartControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const on = await isAutoStartEnabled();
|
||||
if (!cancelled) setEnabled(on);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setAutoStart(next);
|
||||
setEnabled(next);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'autostart failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toggle
|
||||
label={t('app:settings.autostart', {
|
||||
defaultValue: 'Mit Windows starten',
|
||||
})}
|
||||
hint={t('app:settings.autostart_hint', {
|
||||
defaultValue:
|
||||
'ChatApp automatisch mitstarten wenn du dich am System anmeldest.',
|
||||
})}
|
||||
checked={enabled ?? false}
|
||||
disabled={busy || enabled === null}
|
||||
onChange={(v) => void handleToggle(v)}
|
||||
/>
|
||||
{error && <p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PttControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||
@@ -319,20 +403,43 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||
}, [capturing, kind]);
|
||||
|
||||
const binding = hotkeys[kind];
|
||||
const toggleLabel =
|
||||
kind === 'mute'
|
||||
? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' })
|
||||
: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' });
|
||||
const toggleHint =
|
||||
kind === 'mute'
|
||||
? t('app:settings.hotkey_mute_hint', {
|
||||
defaultValue:
|
||||
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||
})
|
||||
: t('app:settings.hotkey_deafen_hint', {
|
||||
defaultValue:
|
||||
'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||
});
|
||||
const labels: Record<VoiceHotkeyKind, { label: string; hint: string }> = {
|
||||
mute: {
|
||||
label: t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_mute_hint', {
|
||||
defaultValue:
|
||||
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||
}),
|
||||
},
|
||||
deafen: {
|
||||
label: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_deafen_hint', {
|
||||
defaultValue: 'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||
}),
|
||||
},
|
||||
hangup: {
|
||||
label: t('app:settings.hotkey_hangup_enabled', { defaultValue: 'Auflegen-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_hangup_hint', {
|
||||
defaultValue: 'Beendet den aktiven Anruf sofort.',
|
||||
}),
|
||||
},
|
||||
screenShare: {
|
||||
label: t('app:settings.hotkey_screenshare_enabled', {
|
||||
defaultValue: 'Bildschirmfreigabe-Hotkey',
|
||||
}),
|
||||
hint: t('app:settings.hotkey_screenshare_hint', {
|
||||
defaultValue: 'Startet oder stoppt die Bildschirmfreigabe.',
|
||||
}),
|
||||
},
|
||||
video: {
|
||||
label: t('app:settings.hotkey_video_enabled', { defaultValue: 'Kamera-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_video_hint', {
|
||||
defaultValue: 'Schaltet die Kamera während eines Anrufs an oder aus.',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const toggleLabel = labels[kind].label;
|
||||
const toggleHint = labels[kind].hint;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -549,106 +656,411 @@ interface AvatarControlsProps {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
function DisplayNameControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app', 'auth']);
|
||||
const { profile } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const url = profile?.avatarUrl ?? null;
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!userId) return;
|
||||
function startEdit() {
|
||||
setDraft(profile?.displayName ?? '');
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
// Focus on next tick so the input has mounted.
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError(
|
||||
t('app:settings.display_name_required', {
|
||||
defaultValue: 'Anzeigename darf nicht leer sein.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (trimmed === profile?.displayName) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const newUrl = await uploadAvatar(userId, file);
|
||||
const oldUrl = url;
|
||||
await patchProfile({ avatarUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
// Best-effort cleanup of the previous file (don't block on it).
|
||||
void deleteAvatarObject(oldUrl).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
await patchProfile({ displayName: trimmed });
|
||||
setEditing(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'upload failed');
|
||||
setError(err instanceof Error ? err.message : 'save failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!userId || !url) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
await patchProfile({ avatarUrl: null });
|
||||
void deleteAvatarObject(url).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</dt>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<dd className="max-w-[40ch] truncate text-right text-sm text-fg" title={profile?.displayName ?? ''}>
|
||||
{profile?.displayName ?? '—'}
|
||||
</dd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEdit}
|
||||
disabled={busy || !profile}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2.5 py-1 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:settings.edit', { defaultValue: 'Bearbeiten' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-16 w-16 text-2xl"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
maxLength={64}
|
||||
disabled={saving}
|
||||
className="flex-1 min-w-[12rem] rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-sm text-fg outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60 dark:bg-[#313338]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving || busy}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving
|
||||
? t('app:settings.display_name_saving', { defaultValue: 'Speichere…' })
|
||||
: t('app:settings.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={saving}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:settings.cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
// Default banner gradient when the user hasn't uploaded their own. Sits on
|
||||
// the same accent + surface tokens as the rest of the app so it never clashes
|
||||
// with theme changes. Used both here in settings and in UserProfilePopover.
|
||||
export const DEFAULT_BANNER_CLASS =
|
||||
'bg-gradient-to-br from-accent/40 via-accent/15 to-surface-3';
|
||||
|
||||
function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile } = useAuth();
|
||||
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [avatarBusy, setAvatarBusy] = useState(false);
|
||||
const [bannerBusy, setBannerBusy] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<string | null>(null);
|
||||
// Crop-dialog plumbing. The picked File lives here until the user
|
||||
// confirms a crop or cancels; on confirm we hand the resulting Blob to
|
||||
// the matching upload helper. Keeping `kind` separate from `file` lets
|
||||
// the same dialog component drive both flows with different aspect
|
||||
// ratios.
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
const bannerUrl = profile?.bannerUrl ?? null;
|
||||
|
||||
// Avatar pick → open crop dialog. Legacy `uploadAvatar` (center-crop) is
|
||||
// kept around for callers that bypass the picker, but the SettingsPage
|
||||
// path always goes through the crop flow now so the user controls the
|
||||
// framing.
|
||||
function openAvatarCrop(file: File) {
|
||||
if (!userId) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setAvatarError('only image files are accepted');
|
||||
return;
|
||||
}
|
||||
setAvatarError(null);
|
||||
setCropFile(file);
|
||||
setCropKind('avatar');
|
||||
}
|
||||
|
||||
async function handleAvatarCropConfirm(blob: Blob) {
|
||||
if (!userId) return;
|
||||
setAvatarBusy(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
const newUrl = await uploadAvatarBlob(userId, blob);
|
||||
const oldUrl = avatarUrl;
|
||||
await patchProfile({ avatarUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
void deleteAvatarObject(oldUrl).catch(() => undefined);
|
||||
}
|
||||
closeCropDialog();
|
||||
} catch (err: unknown) {
|
||||
setAvatarError(err instanceof Error ? err.message : 'upload failed');
|
||||
} finally {
|
||||
setAvatarBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCropDialog() {
|
||||
setCropFile(null);
|
||||
setCropKind(null);
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||
if (bannerInputRef.current) bannerInputRef.current.value = '';
|
||||
}
|
||||
|
||||
async function handleAvatarRemove() {
|
||||
if (!userId || !avatarUrl) return;
|
||||
setAvatarError(null);
|
||||
setAvatarBusy(true);
|
||||
try {
|
||||
await patchProfile({ avatarUrl: null });
|
||||
void deleteAvatarObject(avatarUrl).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setAvatarError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setAvatarBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBannerCrop(file: File) {
|
||||
if (!userId) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setBannerError('only image files are accepted');
|
||||
return;
|
||||
}
|
||||
if (file.size > BANNER_MAX_INPUT_BYTES) {
|
||||
setBannerError('image must be 8 MB or smaller');
|
||||
return;
|
||||
}
|
||||
setBannerError(null);
|
||||
setCropFile(file);
|
||||
setCropKind('banner');
|
||||
}
|
||||
|
||||
async function handleBannerCropConfirm(blob: Blob) {
|
||||
if (!userId) return;
|
||||
setBannerBusy(true);
|
||||
setBannerError(null);
|
||||
try {
|
||||
const newUrl = await uploadBannerBlob(userId, blob);
|
||||
const oldUrl = bannerUrl;
|
||||
await patchProfile({ bannerUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
void deleteBannerObject(oldUrl).catch(() => undefined);
|
||||
}
|
||||
closeCropDialog();
|
||||
} catch (err: unknown) {
|
||||
setBannerError(err instanceof Error ? err.message : 'upload failed');
|
||||
} finally {
|
||||
setBannerBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBannerRemove() {
|
||||
if (!userId || !bannerUrl) return;
|
||||
setBannerError(null);
|
||||
setBannerBusy(true);
|
||||
try {
|
||||
await patchProfile({ bannerUrl: null });
|
||||
void deleteBannerObject(bannerUrl).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setBannerError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setBannerBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = profile?.displayName ?? profile?.username;
|
||||
const lockedAll = busy || avatarBusy || bannerBusy;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Live preview — banner with avatar overlapping bottom-left, mirrors
|
||||
how the profile shows up in UserProfilePopover. The avatar row is
|
||||
explicitly stacked above the banner via `relative z-10`; without
|
||||
it, browsers can paint the negatively-margin'd avatar behind the
|
||||
banner's background image when the parent doesn't establish a
|
||||
stacking context. */}
|
||||
<div className="relative overflow-hidden rounded-xl border border-line bg-surface-3">
|
||||
<div
|
||||
className={
|
||||
'relative z-0 aspect-[3/1] w-full bg-cover bg-center ' +
|
||||
(bannerUrl ? '' : DEFAULT_BANNER_CLASS)
|
||||
}
|
||||
style={bannerUrl ? { backgroundImage: 'url("' + bannerUrl + '")' } : undefined}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
||||
style={{ marginTop: '-2rem' }}
|
||||
>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="truncate text-sm font-semibold text-fg">
|
||||
{displayName ?? '—'}
|
||||
</div>
|
||||
{profile?.username && (
|
||||
<div className="truncate text-xs text-fg-muted">@{profile.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.banner', { defaultValue: 'Banner' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.banner_hint', {
|
||||
defaultValue: '3:1 Format, max 8 MB. Standard ist ein Farbverlauf.',
|
||||
})}
|
||||
</div>
|
||||
{bannerError && (
|
||||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{bannerError}</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) openBannerCrop(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{bannerBusy
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: bannerUrl
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{bannerUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBannerRemove()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: url
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{url && (
|
||||
{/* Avatar controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
{avatarError && (
|
||||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{avatarError}</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) openAvatarCrop(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
{avatarBusy
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: avatarUrl
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
)}
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAvatarRemove()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageCropDialog
|
||||
open={cropFile !== null && cropKind !== null}
|
||||
file={cropFile}
|
||||
aspect={cropKind === 'banner' ? 3 : 1}
|
||||
outputWidth={cropKind === 'banner' ? BANNER_TARGET_WIDTH : AVATAR_TARGET_DIM}
|
||||
outputHeight={cropKind === 'banner' ? BANNER_TARGET_HEIGHT : AVATAR_TARGET_DIM}
|
||||
title={
|
||||
cropKind === 'banner'
|
||||
? t('app:settings.crop_banner_title', { defaultValue: 'Banner zuschneiden' })
|
||||
: t('app:settings.crop_avatar_title', { defaultValue: 'Profilbild zuschneiden' })
|
||||
}
|
||||
onConfirm={(blob) => {
|
||||
if (cropKind === 'banner') void handleBannerCropConfirm(blob);
|
||||
else if (cropKind === 'avatar') void handleAvatarCropConfirm(blob);
|
||||
}}
|
||||
onClose={closeCropDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -837,6 +1249,49 @@ function SettingRow({ label, children }: { label: string; children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// Theme picker row inside the Appearance section. Same pill-segmented style
|
||||
// as the language selector so the two siblings read as one control surface.
|
||||
// The toggle was previously a rail icon in the sidebar; moved here so it
|
||||
// sits with the other appearance preferences.
|
||||
function ThemeRow() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const options: Array<{ value: 'light' | 'dark'; label: string }> = [
|
||||
{
|
||||
value: 'light',
|
||||
label: t('app:theme.light', { defaultValue: 'Light' }),
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: t('app:theme.dark', { defaultValue: 'Dark' }),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<SettingRow label={t('app:settings.theme', { defaultValue: 'Design' })}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{options.map((o) => {
|
||||
const active = theme === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(o.value)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
@@ -880,15 +1335,19 @@ function Toggle({
|
||||
|
||||
function AudioDeviceControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
|
||||
const { setAudioInputDevice, setAudioOutputDevice, setVideoInputDevice } = useCall();
|
||||
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
||||
const [inputId, setInputId] = useState<string | null>(
|
||||
() => getAudioSettings().inputDeviceId,
|
||||
);
|
||||
const [outputId, setOutputId] = useState<string | null>(
|
||||
() => getAudioSettings().outputDeviceId,
|
||||
);
|
||||
const [cameraId, setCameraId] = useState<string | null>(
|
||||
() => getAudioSettings().videoInputDeviceId,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
|
||||
'unknown',
|
||||
@@ -899,6 +1358,7 @@ function AudioDeviceControls() {
|
||||
const list = await navigator.mediaDevices.enumerateDevices();
|
||||
setInputs(list.filter((d) => d.kind === 'audioinput'));
|
||||
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
|
||||
setCameras(list.filter((d) => d.kind === 'videoinput'));
|
||||
// If labels are empty, permission hasn't been granted yet — browsers
|
||||
// mask device names until a getUserMedia call succeeds at least once.
|
||||
const hasLabels = list.some(
|
||||
@@ -921,6 +1381,7 @@ function AudioDeviceControls() {
|
||||
const unsubSettings = subscribeAudioSettings((s) => {
|
||||
setInputId(s.inputDeviceId);
|
||||
setOutputId(s.outputDeviceId);
|
||||
setCameraId(s.videoInputDeviceId);
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -963,6 +1424,15 @@ function AudioDeviceControls() {
|
||||
[setAudioOutputDevice],
|
||||
);
|
||||
|
||||
const handleCamera = useCallback(
|
||||
async (id: string) => {
|
||||
const next = id === '' ? null : id;
|
||||
setCameraId(next);
|
||||
await setVideoInputDevice(next);
|
||||
},
|
||||
[setVideoInputDevice],
|
||||
);
|
||||
|
||||
const outputSupported =
|
||||
typeof HTMLAudioElement !== 'undefined' &&
|
||||
typeof HTMLAudioElement.prototype.setSinkId === 'function';
|
||||
@@ -1039,6 +1509,38 @@ function AudioDeviceControls() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">
|
||||
{t('app:settings.camera_title', { defaultValue: 'Kamera' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:settings.camera_hint', {
|
||||
defaultValue:
|
||||
'Bevorzugte Kamera. Bei aktivem Anruf wird live umgeschaltet.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
value={cameraId ?? ''}
|
||||
onChange={(e) => void handleCamera(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
<option value="">
|
||||
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||
</option>
|
||||
{cameras.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line pt-3">
|
||||
<MicTestSection />
|
||||
</div>
|
||||
|
||||
{permission !== 'granted' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||
<span>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Clean-Rail design tokens.
|
||||
* Desktop chat design tokens.
|
||||
*
|
||||
* `--bg-rgb / --fg-rgb / --accent-rgb` are *space-separated rgb triplets*
|
||||
* (no `rgb(...)` wrapper) so Tailwind's `rgb(var(--x) / <alpha-value>)`
|
||||
@@ -16,26 +16,26 @@
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--bg-rgb: 250 250 249;
|
||||
--bg-2-rgb: 244 244 242;
|
||||
--bg-rgb: 246 247 250;
|
||||
--bg-2-rgb: 235 237 242;
|
||||
--bg-3-rgb: 255 255 255;
|
||||
--fg-rgb: 10 10 15;
|
||||
--fg-muted-rgb: 115 115 128;
|
||||
--accent-rgb: 79 70 229;
|
||||
--fg-rgb: 24 25 28;
|
||||
--fg-muted-rgb: 91 95 104;
|
||||
--accent-rgb: 88 101 242;
|
||||
--accent-fg-rgb: 255 255 255;
|
||||
--line: rgba(10, 10, 15, 0.08);
|
||||
--line: rgba(24, 25, 28, 0.1);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-rgb: 5 5 7;
|
||||
--bg-2-rgb: 17 17 24;
|
||||
--bg-3-rgb: 10 10 15;
|
||||
--fg-rgb: 244 244 245;
|
||||
--fg-muted-rgb: 138 138 153;
|
||||
--accent-rgb: 109 115 255;
|
||||
--bg-rgb: 30 31 34;
|
||||
--bg-2-rgb: 43 45 49;
|
||||
--bg-3-rgb: 49 51 56;
|
||||
--fg-rgb: 242 243 245;
|
||||
--fg-muted-rgb: 148 155 164;
|
||||
--accent-rgb: 88 101 242;
|
||||
--accent-fg-rgb: 255 255 255;
|
||||
--line: rgba(255, 255, 255, 0.07);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,32 @@
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: rgb(var(--fg-muted-rgb) / 0.36) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--fg-muted-rgb) / 0.28);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--fg-muted-rgb) / 0.44);
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-accent/40 text-white;
|
||||
}
|
||||
@@ -72,11 +98,27 @@
|
||||
}
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
background-position: -1px -1px;
|
||||
}
|
||||
.discord-panel {
|
||||
background: rgb(var(--bg-2-rgb) / 0.94);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.dark .discord-rail {
|
||||
background: #1e1f22;
|
||||
}
|
||||
.dark .discord-chat-panel {
|
||||
background: #2b2d31;
|
||||
}
|
||||
.dark .discord-chat-surface {
|
||||
background: #313338;
|
||||
}
|
||||
.dark .discord-composer {
|
||||
background: #383a40;
|
||||
}
|
||||
/* Glass pill used for the fullscreen call controls bar. */
|
||||
.glass-pill {
|
||||
background: rgba(10, 10, 15, 0.7);
|
||||
|
||||
Reference in New Issue
Block a user