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:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
+4 -6
View File
@@ -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';
}
+185 -37
View File
@@ -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>
+9 -4
View File
@@ -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;
}
+352 -37
View File
@@ -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
+381 -84
View File
@@ -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>
);
}
+22 -30
View File
@@ -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>
+3 -1
View File
@@ -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>
+7 -11
View File
@@ -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>
);
}
+8 -11
View File
@@ -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;
}
+95
View File
@@ -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}>