Compare commits

...

7 Commits

Author SHA1 Message Date
byGalax 24fdfee738 chore: bump version to 0.9.0
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
File variety (video/pdf/generic), user status with custom message,
auto online/offline, DND gate for ring + notifications, drag-drop +
paste upload, @mentions in groups, link previews, emoji picker,
crash recovery.
2026-04-21 09:37:56 +02:00
byGalax 636565d552 feat: crash recovery — global error handlers + toast + burst-reload
- lib/crashRecovery.ts: window.onerror + unhandledrejection handlers,
  dedupe identical messages within 10s, burst-reload after 8 distinct
  errors in 30s
- components/CrashToast.tsx: portal stack in bottom-right, max 3 visible,
  auto-dismiss after 7s, per-entry close button
- Wired in main.tsx before bootstrap so crypto/i18n errors are captured,
  rendered from App.tsx alongside UpdateToast

Covers the async layer ErrorBoundary misses (realtime handlers, stray
Promises, setTimeout callbacks, LiveKit listeners).
2026-04-21 09:29:01 +02:00
byGalax 672c8738c7 feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
  non-images as file cards

User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
  via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
  online/idle/dnd (with message set); falls back to localized presence
  label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
  blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
  offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)

DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
  rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
  and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify

Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items

@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles

Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
  service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
  cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body

Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
  localStorage
- Trigger button next to + and voice buttons in composer

Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
  RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
2026-04-21 09:13:30 +02:00
byGalax b89ec90813 chore: bump version to 0.8.0
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Voice messages, offline queue, delivery receipts, group call scaling,
recovery code, push notifications scaffolding, admin panel, search v2,
focus-flicker fix.
2026-04-21 01:15:57 +02:00
byGalax a04ecf7a19 feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- Voice messages: MediaRecorder → encrypted attachment, custom waveform
  player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
  optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
  ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
  persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
  active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
  registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
  alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
  admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile

Fixes:
- Focus-triggered flicker: dropped window.focus listeners in three spots,
  throttled visibilitychange/online wake-refreshes to 30s, keep existing
  data visible during background re-syncs (no more spinner on every click)
- Voice attachment audio element collapsed to 0px on peer side — now
  forces 280px min-width on bubble

Migrations (push required):
  20260421000001_message_deliveries.sql
  20260421000002_admin_audit_log.sql

Server TODO:
  VAPID keys + notify-push edge function deploy
2026-04-21 01:14:16 +02:00
byGalax da85f0ba54 feat: call UX overhaul — deafen sync, share dialog, fullscreen redesign
Speaking ring:
- Switch useActiveSpeakers from LiveKit's smoothed isSpeaking / server-
  batched ActiveSpeakersChanged to Web Audio API AnalyserNode on each
  participant's raw audio MediaStreamTrack. Poll 50ms, RMS threshold 0.03,
  250ms hold. Feels real-time vs the old ~500ms lag
- Defensive syncProbe on every tick so probes catch up if TrackPublished
  missed (local mic publish race on join)
- Universal speaking overlay on tile (3px emerald border + inset glow,
  z-10) so video mode shows the ring too, not just audio mode

Screen sharing:
- Separate "screen" tile per sharer so the sharer's avatar tile stays
  intact with its speaking ring. Tile.id is kind-prefixed (user:xxx /
  screen:xxx) so focus tracking distinguishes them
- New ScreenShareDialog (quality preset + fps override + displaySurface
  hint) opens on the share button. startScreenShare / stopScreenShare
  actions in CallContext replace the one-shot toggle
- ScreenShareViewer: plain CSS-only fullscreen overlay (Tauri WKWebView
  doesn't implement requestFullscreen), always `h-full w-full
  object-contain`, Esc exits

Camera:
- toggleCamera action in CallContext tracks isCameraEnabled
- VideoStub renders real <video> srcObject for the participant's camera
  MediaStreamTrack; local preview is mirrored
- Tile video track resolves to Track.Source.Camera publications of the
  LocalParticipant / each RemoteParticipant
- Room listens for TrackMuted / TrackUnmuted and re-publishes remote
  state so peers switch to avatar placeholder when a camera is disabled

Deafen:
- New isDeafened state + toggleDeafen action. Sets `muted = true` on all
  attached `<audio[data-livekit-track]>` plus mutes fresh ones on attach
  via module-level flag
- Broadcast state over the LiveKit data channel
  ({type:'presence', deafened}) so peers can render the headphones-off
  badge. Attributes API not used because the self-hosted server may run
  older LiveKit versions
- remoteDeafen: Record<identity, bool> exposed via context, bumped on
  DataReceived and re-broadcast on ParticipantConnected

Incoming video call:
- acceptIncoming takes an optional CallKind override so the receiver can
  answer a video invite with audio only or promote an audio invite to
  video on accept
- IncomingCallPanel shows two accept buttons (audio + video) when the
  invite is a video call

Audio devices:
- audioSettings adds inputDeviceId + outputDeviceId, persisted
- CallContext uses them on setMicrophoneEnabled, plus new
  setAudioInputDevice / setAudioOutputDevice hot-swap actions.
  Output swap applies setSinkId to every attached remote-audio element
  since LiveKit's own switchActiveDevice only tracks elements it
  attached itself
- SettingsPage "Mikrofon" + "Ausgabegerät" selects with devicechange
  listener and a permission-probe button

Fullscreen mode:
- Replaced absolute-positioned speaker + floating thumbnails with a real
  flex layout. Default = even grid of all tiles. Clicking a tile flips
  to big-speaker + horizontal thumbnail strip. Click focused tile =
  back to grid
- Controls overlay pinned bottom; content wrapper has pb-24 so tiles
  never sit behind the toolbar
- Grid now uses explicit grid-rows-* so cells get a defined 1fr height
  (without it, video intrinsic dimensions blew tiles past the container
  bounds on Windows)

UI chips:
- Mic-off badge combines isMuted flag AND
  localParticipant.isMicrophoneEnabled, so a user with no mic / denied
  permission sees the badge + the toolbar button red even though they
  never pressed mute
- Deafen badge on tile chips for local + remote (remote driven by the
  data-channel broadcast)
2026-04-21 00:02:43 +02:00
byGalax a4c9b959a9 fix: editable decrypt + windows realtime wake (v0.7.1)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Edit message decrypt:
- handleUpdate in useConversationMessages now refetches the canonical row
  via REST after a realtime UPDATE instead of trusting the realtime
  payload's bytea encoding. Same pattern as handleInsert — base64 vs
  `\x…` hex serialisation varies across supabase/postgrest versions and
  was silently producing undecryptable ciphertext for edited messages
  on the receiver side

Windows WebView2 background throttling:
- ConversationsContext, useFriendships and useConversationMessages now
  listen for visibilitychange / focus / online events and trigger both a
  fresh REST refresh and a best-effort channel.subscribe() on wake.
  WebView2 aggressively throttles background WebSockets and was dropping
  realtime events entirely while the window was minimised, so new
  messages and friend acceptances only surfaced after a manual reload

Bump tauri version 0.7.0 -> 0.7.1
2026-04-20 22:15:49 +02:00
82 changed files with 10089 additions and 737 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.1.0", "version": "0.9.0",
"private": true, "private": true,
"description": "Tauri v2 desktop client (Windows / macOS / Linux)", "description": "Tauri v2 desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+67
View File
@@ -0,0 +1,67 @@
// Web Push service worker.
//
// Handles browser-delivered push events when the app tab is closed or in the
// background. Tauri desktop does not install service workers; native OS
// notifications are routed through the Tauri notification plugin instead
// (see src/lib/osNotify.ts).
//
// Payload contract — server sends JSON of shape:
// { title: string, body?: string, conversationId?: string, kind?: 'message' | 'call' }
// Body is intentionally generic; message ciphertext is never included.
self.addEventListener('install', (event) => {
// Activate immediately so updates apply on next page load.
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('push', (event) => {
let data = { title: 'Neue Nachricht', body: '' };
try {
if (event.data) {
data = { ...data, ...event.data.json() };
}
} catch (_err) {
/* malformed payload — fall back to defaults */
}
const opts = {
body: data.body || '',
icon: '/favicon.svg',
badge: '/favicon.svg',
tag: data.conversationId || 'default',
renotify: true,
data: {
conversationId: data.conversationId,
kind: data.kind,
},
};
event.waitUntil(self.registration.showNotification(data.title, opts));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const conversationId = event.notification.data && event.notification.data.conversationId;
const target = conversationId ? '/chats/' + conversationId : '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if ('focus' in client) {
client.postMessage({ type: 'navigate', to: target });
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(target);
}
return undefined;
}),
);
});
+1 -1
View File
@@ -678,7 +678,7 @@ dependencies = [
[[package]] [[package]]
name = "chat-app-desktop" name = "chat-app-desktop"
version = "0.1.0" version = "0.9.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "chat-app-desktop" name = "chat-app-desktop"
version = "0.1.0" version = "0.9.0"
description = "ChatApp desktop client" description = "ChatApp desktop client"
authors = ["Dennis"] authors = ["Dennis"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.7.0", "version": "0.9.0",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+2
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell'; import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateToast } from './components/UpdateToast'; import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards'; import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
@@ -91,6 +92,7 @@ export function App() {
<Route path="*" element={<Navigate to="/chats" replace />} /> <Route path="*" element={<Navigate to="/chats" replace />} />
</Routes> </Routes>
<UpdateToast /> <UpdateToast />
<CrashToast />
</BrowserRouter> </BrowserRouter>
</CallProvider> </CallProvider>
</ConversationsProvider> </ConversationsProvider>
@@ -0,0 +1,239 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useMemo, useRef, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
const BAR_COUNT = 48;
// Custom voice-message player with waveform visualisation. Decoded peaks are
// computed once per blob via OfflineAudioContext so playback only carries the
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
// blob mime is recognised by <audio> but not by AudioContext).
export function AttachmentAudio({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
const [error, setError] = useState<string | null>(null);
const [peaks, setPeaks] = useState<number[] | null>(null);
const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
setArrayBuf(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then(async (blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
const buf = await blob.arrayBuffer();
if (!cancelled) setArrayBuf(buf);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
// bucketed into BAR_COUNT bars. Done once per attachment.
useEffect(() => {
if (!arrayBuf) return;
let cancelled = false;
const Ctx =
window.AudioContext ||
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const ctx = new Ctx();
ctx
.decodeAudioData(arrayBuf.slice(0))
.then((decoded) => {
if (cancelled) return;
setDuration(decoded.duration);
const channel = decoded.getChannelData(0);
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
const out = new Array<number>(BAR_COUNT).fill(0);
for (let i = 0; i < BAR_COUNT; i++) {
let max = 0;
const start = i * bucket;
const end = Math.min(channel.length, start + bucket);
for (let j = start; j < end; j++) {
const v = Math.abs(channel[j]!);
if (v > max) max = v;
}
out[i] = max;
}
// Normalize so loudest peak is 1; keeps quiet recordings visible.
const peak = Math.max(...out, 0.001);
setPeaks(out.map((v) => v / peak));
})
.catch(() => {
// Fall through — UI shows a flat bar but playback still works.
})
.finally(() => {
void ctx.close().catch(() => {});
});
return () => {
cancelled = true;
};
}, [arrayBuf]);
const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks],
);
const visiblePeaks = peaks ?? fallbackPeaks!;
const progress = duration > 0 ? position / duration : 0;
const onTogglePlay = () => {
const el = audioRef.current;
if (!el || !blobUrl) return;
if (el.paused) void el.play();
else el.pause();
};
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const el = audioRef.current;
if (!el || duration === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
el.currentTime = ratio * duration;
};
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
return (
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
<button
type="button"
onClick={onTogglePlay}
disabled={!blobUrl}
aria-label={playing ? 'Pause' : 'Wiedergabe'}
title={playing ? 'Pause' : 'Wiedergabe'}
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent 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-60"
>
{!blobUrl ? (
<SpinnerIcon className="h-4 w-4" />
) : playing ? (
<PauseGlyph />
) : (
<PlayGlyph />
)}
</button>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
role="slider"
aria-label="Position"
aria-valuemin={0}
aria-valuemax={Math.max(1, Math.floor(duration))}
aria-valuenow={Math.floor(position)}
tabIndex={0}
onClick={onSeek}
className="flex h-7 cursor-pointer items-center gap-[2px]"
>
{visiblePeaks.map((v, i) => {
const playedRatio = (i + 0.5) / BAR_COUNT;
const played = playedRatio <= progress;
const h = Math.max(2, Math.round(v * 22));
return (
<span
key={i}
style={{ height: h + 'px' }}
className={
'w-[3px] rounded-full ' +
(played ? 'bg-accent' : 'bg-fg-muted/40')
}
/>
);
})}
</div>
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1">
<MicIcon className="h-3 w-3" />
<span>{formatSec(playing ? position : duration)}</span>
</span>
</div>
</div>
{blobUrl && (
<audio
ref={audioRef}
src={blobUrl}
preload="metadata"
onLoadedMetadata={(e) => {
// Some webm/opus blobs report Infinity until first seek (Chrome
// bug). Force a seek to flush real duration.
const el = e.currentTarget;
if (!Number.isFinite(el.duration)) {
el.currentTime = 1e9;
setTimeout(() => {
el.currentTime = 0;
}, 0);
} else if (duration === 0) {
setDuration(el.duration);
}
}}
onDurationChange={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) setDuration(d);
}}
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => {
setPlaying(false);
setPosition(0);
}}
className="hidden"
>
<track kind="captions" />
</audio>
)}
</div>
);
}
function PlayGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M5 3.5l8 4.5-8 4.5z" />
</svg>
);
}
function PauseGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<rect x="4" y="3" width="3" height="10" rx="1" />
<rect x="9" y="3" width="3" height="10" rx="1" />
</svg>
);
}
function formatSec(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ':' + s.toString().padStart(2, '0');
}
@@ -0,0 +1,127 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Catch-all card for attachments without a richer renderer (zip, docx,
// txt, etc). Decrypt is deferred to first download click — these can be
// large and there's no inline preview to justify auto-fetching them.
export function AttachmentGeneric({ handle }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const download = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(handle);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke so Safari has a chance to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'download failed');
} finally {
setBusy(false);
}
};
return (
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
<FileGlyph />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{prettyMime(handle.mimeType)}
</p>
<p className="truncate text-[10px] text-fg-muted">
{formatSize(handle.sizeBytes)}
</p>
{error && (
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
<AlertIcon className="h-3 w-3" />
<span>{error}</span>
</p>
)}
</div>
<button
type="button"
onClick={() => void download()}
disabled={busy}
aria-label="Download"
title="Download"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
</button>
</div>
);
}
function FileGlyph() {
return (
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
</svg>
);
}
function DownloadGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
</svg>
);
}
function filenameFor(handle: AttachmentHandle): string {
const ext = extFor(handle.mimeType);
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
}
function extFor(mime: string): string | null {
const map: Record<string, string> = {
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/x-7z-compressed': '7z',
'application/x-tar': 'tar',
'application/gzip': 'gz',
'application/json': 'json',
'application/xml': 'xml',
'text/plain': 'txt',
'text/markdown': 'md',
'text/csv': 'csv',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
};
return map[mime] ?? null;
}
function prettyMime(mime: string): string {
const ext = extFor(mime);
if (ext) return ext.toUpperCase() + '-Datei';
if (mime.startsWith('text/')) return 'Textdatei';
return mime || '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';
}
@@ -0,0 +1,109 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
// Safari both ship one). Embedding via <object> with a fallback link keeps
// the implementation tiny — no pdf.js dependency.
export function AttachmentPdf({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
// Force the application/pdf type so the browser plugin engages.
const typed = new Blob([blob], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
<span className="flex items-center gap-2 truncate text-fg">
<PdfGlyph />
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
>
{expanded ? 'Einklappen' : 'Vorschau'}
</button>
<a
href={blobUrl}
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
>
Download
</a>
</div>
</div>
{expanded && (
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
<p className="p-4 text-xs text-fg-muted">
Vorschau nicht verfügbar bitte herunterladen.
</p>
</object>
)}
</div>
);
}
function PdfGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
</svg>
);
}
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';
}
@@ -0,0 +1,68 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Decrypt the blob, render a native <video controls>. Loads on demand —
// metadata-only preload so we don't burn bandwidth until the user hits play.
export function AttachmentVideo({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<video
controls
preload="metadata"
src={blobUrl}
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
>
<track kind="captions" />
</video>
);
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { exportDeviceBackup } from '../lib/deviceBackup'; import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons'; import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
interface Props { interface Props {
@@ -22,8 +22,9 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
const [confirm, setConfirm] = useState(''); const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [backup, setBackup] = useState<string | null>(null); const [bundle, setBundle] = useState<BackupBundle | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [copiedRecovery, setCopiedRecovery] = useState(false);
const canGenerate = useMemo(() => { const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy; return passphrase.length >= 8 && passphrase === confirm && !busy;
@@ -32,9 +33,10 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
const reset = useCallback(() => { const reset = useCallback(() => {
setPassphrase(''); setPassphrase('');
setConfirm(''); setConfirm('');
setBackup(null); setBundle(null);
setError(null); setError(null);
setCopied(false); setCopied(false);
setCopiedRecovery(false);
}, []); }, []);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -47,8 +49,13 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase }); const b = await exportDeviceBackupWithRecovery({
setBackup(str); userId,
deviceId,
privateKey,
passphrase,
});
setBundle(b);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -56,27 +63,39 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
} }
}, [canGenerate, userId, deviceId, privateKey, passphrase]); }, [canGenerate, userId, deviceId, privateKey, passphrase]);
const handleCopy = useCallback(async () => { const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
if (!backup) return;
try { try {
await navigator.clipboard.writeText(backup); await navigator.clipboard.writeText(text);
if (marker === 'main') {
setCopied(true); setCopied(true);
window.setTimeout(() => setCopied(false), 1500); window.setTimeout(() => setCopied(false), 1500);
} else {
setCopiedRecovery(true);
window.setTimeout(() => setCopiedRecovery(false), 1500);
}
} catch { } catch {
/* fall back — user can select manually */ /* fall back — user can select manually */
} }
}, [backup]); };
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
if (!backup) return; if (!bundle) return;
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' }); const text =
'=== Passphrase backup ===\n' +
bundle.passphraseBackup +
'\n\n=== Recovery code ===\n' +
bundle.recoveryCode +
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
bundle.recoveryBackup +
'\n';
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`; a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [backup, deviceId]); }, [bundle, deviceId]);
if (!open) return null; if (!open) return null;
@@ -110,7 +129,7 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
</header> </header>
<div className="flex-1 overflow-y-auto p-5"> <div className="flex-1 overflow-y-auto p-5">
{!backup ? ( {!bundle ? (
<> <>
<p className="text-sm text-fg-muted"> <p className="text-sm text-fg-muted">
{t('app:backup.export_explainer', { {t('app:backup.export_explainer', {
@@ -183,40 +202,76 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
</span> </span>
</div> </div>
</div> </div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Backup-String
</label>
<textarea <textarea
readOnly readOnly
value={backup} value={bundle.passphraseBackup}
rows={8} rows={6}
onFocus={(e) => e.currentTarget.select()} onFocus={(e) => e.currentTarget.select()}
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg" className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/> />
<div className="mt-3 flex gap-2"> <div className="mt-2 flex gap-2">
<button <button
type="button" type="button"
onClick={() => void handleCopy()} onClick={() => void copyText(bundle.passphraseBackup, 'main')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3" className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
> >
<CopyIcon className="h-4 w-4" /> <CopyIcon className="h-4 w-4" />
<span> <span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
{copied
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
</span>
</button> </button>
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3" className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
> >
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })} Als Datei speichern (alles)
</button> </button>
</div> </div>
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
Recovery-Code (Passphrase vergessen?)
</p>
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
ohne Passphrase wiederherstellen.
</p>
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
{bundle.recoveryCode}
</p>
</div>
</div>
</div>
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
Recovery-Backup-String
</label>
<textarea
readOnly
value={bundle.recoveryBackup}
rows={6}
onFocus={(e) => e.currentTarget.select()}
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<button
type="button"
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
</button>
</> </>
)} )}
</div> </div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3"> <footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!backup ? ( {!bundle ? (
<> <>
<button <button
type="button" type="button"
@@ -1,10 +1,13 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
HeadphonesIcon,
HeadphonesOffIcon,
MicIcon, MicIcon,
MicOffIcon, MicOffIcon,
MonitorShareIcon, MonitorShareIcon,
MonitorStopIcon, MonitorStopIcon,
MusicIcon,
PhoneOffIcon, PhoneOffIcon,
UsersIcon, UsersIcon,
VideoIcon, VideoIcon,
@@ -14,11 +17,16 @@ interface Props {
muted: boolean; muted: boolean;
sharing: boolean; sharing: boolean;
video: boolean; video: boolean;
deafened: boolean;
onToggleMute: () => void; onToggleMute: () => void;
onToggleShare: () => void; onToggleShare: () => void;
onToggleVideo?: () => void; onToggleVideo?: () => void;
onToggleDeafen: () => void;
onHangup: () => void; onHangup: () => void;
onOpenParticipants?: () => void; onOpenParticipants?: () => void;
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */ /** Compact variant used inside the docked call (36px buttons). */
compact?: boolean; compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */ /** Glass variant used when controls float on fullscreen cinema mode. */
@@ -31,11 +39,15 @@ export function CallControls({
muted, muted,
sharing, sharing,
video, video,
deafened,
onToggleMute, onToggleMute,
onToggleShare, onToggleShare,
onToggleVideo, onToggleVideo,
onToggleDeafen,
onHangup, onHangup,
onOpenParticipants, onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
compact = false, compact = false,
glass = false, glass = false,
disabledMedia = false, disabledMedia = false,
@@ -59,6 +71,24 @@ export function CallControls({
> >
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />} {muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</CallButton> </CallButton>
<CallButton
label={
deafened
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
}
active={deafened}
activeTone="danger"
onClick={onToggleDeafen}
glass={glass}
className={btnSize}
>
{deafened ? (
<HeadphonesOffIcon className="h-5 w-5" />
) : (
<HeadphonesIcon className="h-5 w-5" />
)}
</CallButton>
{onToggleVideo && ( {onToggleVideo && (
<CallButton <CallButton
label={t('app:call.start_video', { defaultValue: 'Video' })} label={t('app:call.start_video', { defaultValue: 'Video' })}
@@ -91,6 +121,18 @@ export function CallControls({
<MonitorShareIcon className="h-5 w-5" /> <MonitorShareIcon className="h-5 w-5" />
)} )}
</CallButton> </CallButton>
{onToggleSoundboard && (
<CallButton
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
active={soundboardOpen}
activeTone="accent"
onClick={onToggleSoundboard}
glass={glass}
className={btnSize}
>
<MusicIcon className="h-5 w-5" />
</CallButton>
)}
{onOpenParticipants && ( {onOpenParticipants && (
<CallButton <CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })} label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
@@ -1,4 +1,6 @@
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons'; import { useEffect, useRef } from 'react';
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal'; export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
@@ -41,14 +43,20 @@ export interface ParticipantTileProps {
avatarUrl: string | null; avatarUrl: string | null;
me: boolean; me: boolean;
muted: 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. */
deafened: boolean;
speaking: boolean; speaking: boolean;
sharing: boolean;
video: boolean; video: boolean;
e2ee: 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;
size?: 'default' | 'small'; size?: 'default' | 'small';
focused?: boolean; focused?: boolean;
onClick?: () => void; onClick?: () => void;
onOpenScreenShare?: () => void; onContextMenu?: (e: React.MouseEvent) => void;
} }
export function CallParticipantTile(props: ParticipantTileProps) { export function CallParticipantTile(props: ParticipantTileProps) {
@@ -56,14 +64,14 @@ export function CallParticipantTile(props: ParticipantTileProps) {
displayName, displayName,
me, me,
muted, muted,
deafened,
speaking, speaking,
sharing,
video, video,
e2ee, e2ee,
size = 'default', size = 'default',
focused = false, focused = false,
onClick, onClick,
onOpenScreenShare, onContextMenu,
} = props; } = props;
const small = size === 'small'; const small = size === 'small';
@@ -76,6 +84,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
onContextMenu={onContextMenu}
className={ className={
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' + 'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
(onClick ? 'cursor-pointer ' : '') + (onClick ? 'cursor-pointer ' : '') +
@@ -83,18 +92,21 @@ export function CallParticipantTile(props: ParticipantTileProps) {
(small ? ' min-w-[140px]' : '') (small ? ' min-w-[140px]' : '')
} }
> >
{sharing ? ( {video ? (
<ScreenshareStub
displayName={displayName}
small={small}
onOpen={onOpenScreenShare}
/>
) : video ? (
<VideoStub {...props} small={small} /> <VideoStub {...props} small={small} />
) : ( ) : (
<AudioContent {...props} small={small} /> <AudioContent {...props} small={small} />
)} )}
{/* Speaking indicator visible regardless of content type (video or
audio). z-10 ensures it sits above the video element. */}
{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)]"
/>
)}
<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"> <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">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold"> <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" />} {me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
@@ -114,13 +126,21 @@ export function CallParticipantTile(props: ParticipantTileProps) {
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
{muted && ( {muted && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"> <span
aria-label="Mikro stumm"
title="Mikro stumm"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<MicOffIcon className="h-3 w-3" /> <MicOffIcon className="h-3 w-3" />
</span> </span>
)} )}
{sharing && ( {deafened && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white"> <span
<MonitorShareIcon className="h-3 w-3" /> aria-label="Ton aus"
title="Ton aus"
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
>
<HeadphonesOffIcon className="h-3 w-3" />
</span> </span>
)} )}
</div> </div>
@@ -179,11 +199,45 @@ function VideoStub({
userId, userId,
displayName, displayName,
avatarUrl, avatarUrl,
videoTrack,
me,
small, small,
}: ParticipantTileProps & { small: boolean }) { }: ParticipantTileProps & { small: boolean }) {
// Video capture playback is out of scope for this UI iteration — show the const videoRef = useRef<HTMLVideoElement | null>(null);
// audio-avatar gradient background as a placeholder so the layout is stable useEffect(() => {
// when a participant enables video. const el = videoRef.current;
if (!el || !videoTrack) return;
el.srcObject = new MediaStream([videoTrack]);
return () => {
if (el.srcObject) {
(el.srcObject as MediaStream).getTracks().forEach((t) => {
// Don't stop the live track — other consumers may still need it.
// Just detach from this element.
void t;
});
el.srcObject = null;
}
};
}, [videoTrack]);
if (videoTrack) {
return (
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className={
'h-full w-full object-cover ' +
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
/>
</div>
);
}
// Camera flag true but no track yet (publishing / subscribing race).
const key = colorKeyFor(userId); const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key]; const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?'; const letter = displayName.trim().charAt(0).toUpperCase() || '?';
@@ -206,49 +260,3 @@ function VideoStub({
); );
} }
interface ScreenshareStubProps {
displayName: string;
small: boolean;
onOpen?: (() => void) | undefined;
}
// Fake browser window placeholder — click to open the real <video> viewer.
// Matches the design spec's "screenshare-stub" look.
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
return (
<button
type="button"
onClick={
onOpen
? (e) => {
e.stopPropagation();
onOpen();
}
: undefined
}
aria-label={`${displayName} teilt Bildschirm`}
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
</div>
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
</div>
</div>
{!small && (
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
<MonitorShareIcon className="h-3 w-3" />
<span>{displayName} teilt Bildschirm</span>
</div>
)}
</button>
);
}
+8 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
// different route. // different route.
export function CallUI() { export function CallUI() {
const { state } = useCall(); const { state } = useCall();
const { profile } = useAuth();
const dnd = profile?.presenceState === 'dnd';
useEffect(() => { 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.
if (state.kind === 'outgoing') ringtone.start('outgoing'); if (state.kind === 'outgoing') ringtone.start('outgoing');
else if (state.kind === 'incoming') ringtone.start('incoming'); else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
else ringtone.stop(); else ringtone.stop();
}, [state.kind]); }, [state.kind, dnd]);
useEffect(() => { useEffect(() => {
return () => ringtone.stop(); return () => ringtone.stop();
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence'; import { useCallPresence } from '../lib/useCallPresence';
import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { import {
InfoIcon, InfoIcon,
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
interface Props { interface Props {
conversation: ConversationSummary | null; conversation: ConversationSummary | null;
peerPresence: PresenceState | null; peerPresence: PeerPresence | null;
onInfoClick?: () => void; onInfoClick?: () => void;
onSearchClick?: () => void; onSearchClick?: () => void;
} }
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
interface HeaderBarProps { interface HeaderBarProps {
conversation: ConversationSummary; conversation: ConversationSummary;
peerPresence: PresenceState | null; peerPresence: PeerPresence | null;
onInfoClick?: () => void; onInfoClick?: () => void;
onSearchClick?: () => void; onSearchClick?: () => void;
} }
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
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. // Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible'; const peerState = peerPresence?.state ?? null;
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : ''; const showPresence = isDm && peerState && peerState !== 'invisible';
// Subtitle priority: custom status_message when online (or idle/dnd), else
// the localized presence label. Offline always wins → just "Offline".
const presenceLabel = (() => {
if (!peerState) return '';
if (peerState === 'offline') return t('app:presence.offline');
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
return peerPresence.statusMessage.trim();
}
return t('app:presence.' + peerState);
})();
return ( return (
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3"> <header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
@@ -80,12 +91,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
<UsersIcon className="h-5 w-5" /> <UsersIcon className="h-5 w-5" />
</div> </div>
)} )}
{showPresence && peerPresence && ( {showPresence && peerState && (
<span <span
aria-hidden="true" aria-hidden="true"
className={ className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' + 'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence] PRESENCE_DOT[peerState]
} }
/> />
)} )}
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery';
import { AlertIcon, XIcon } from './icons';
const VISIBLE_MS = 7_000;
const MAX_STACK = 3;
// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each
// entry after VISIBLE_MS. The user can X-out earlier.
export function CrashToast() {
const [entries, setEntries] = useState<CrashEntry[]>([]);
useEffect(
() =>
subscribeCrashes((entry) => {
setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]);
}),
[],
);
useEffect(() => {
if (entries.length === 0) return;
const latest = entries[entries.length - 1]!;
const id = window.setTimeout(() => {
setEntries((prev) => prev.filter((e) => e.id !== latest.id));
}, VISIBLE_MS);
return () => window.clearTimeout(id);
}, [entries]);
if (entries.length === 0) return null;
return createPortal(
<div
role="region"
aria-label="Fehler"
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
>
{entries.map((entry) => (
<div
key={entry.id}
role="alert"
className="pointer-events-auto flex max-w-sm items-start gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-700 shadow-xl backdrop-blur-sm dark:text-rose-100"
>
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wider">
{entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'}
</p>
<p className="mt-0.5 break-words text-xs">{entry.message}</p>
</div>
<button
type="button"
aria-label="Schließen"
onClick={() =>
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
}
className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100"
>
<XIcon className="h-3 w-3" />
</button>
</div>
))}
</div>,
document.body,
);
}
+35 -5
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { import {
decodePrivateKeyFromBackup, decodePrivateKeyFromBackup,
importDeviceBackup, importDeviceBackup,
normalizeRecoveryCode,
} from '../lib/deviceBackup'; } from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
@@ -28,6 +29,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState(''); const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState(''); const [passphrase, setPassphrase] = useState('');
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -39,7 +41,9 @@ export function DeviceRestore({ userId, onRestored }: Props) {
setError(null); setError(null);
let privateKey: Uint8Array | null = null; let privateKey: Uint8Array | null = null;
try { try {
const payload = await importDeviceBackup(backup.trim(), passphrase); const secret =
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
const payload = await importDeviceBackup(backup.trim(), secret);
privateKey = decodePrivateKeyFromBackup(payload); privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({ const device = await restoreDeviceFromServerRecord({
@@ -63,7 +67,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
setBusy(false); setBusy(false);
} }
}, },
[backup, passphrase, userId, onRestored, busy], [backup, passphrase, mode, userId, onRestored, busy],
); );
return ( return (
@@ -102,16 +106,42 @@ export function DeviceRestore({ userId, onRestored }: Props) {
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400"> <label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })} {mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
</label> </label>
<button
type="button"
onClick={() => {
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
setPassphrase('');
setError(null);
}}
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
>
{mode === 'passphrase'
? 'Passphrase vergessen? Recovery-Code nutzen'
: 'Stattdessen Passphrase eingeben'}
</button>
</div>
<input <input
type="password" type={mode === 'passphrase' ? 'password' : 'text'}
required required
value={passphrase} value={passphrase}
onChange={(e) => setPassphrase(e.target.value)} onChange={(e) => setPassphrase(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
spellCheck={false}
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
className={
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
}
/> />
{mode === 'recovery' && (
<p className="text-[11px] text-neutral-500">
Stattdessen den Recovery-Backup-String oben einfügen.
</p>
)}
</div> </div>
</div> </div>
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react';
interface EmojiEntry {
e: string;
k: string[]; // search keywords (incl. name)
}
interface Category {
label: string;
entries: EmojiEntry[];
}
// Curated emoji set — small enough to stay fast without a dependency, wide
// enough to cover everyday messaging. Keywords are the primary search
// surface; the emoji character itself is also matched so a user typing ❤️
// literally finds it.
const CATEGORIES: Category[] = [
{
label: 'Smileys',
entries: [
{ e: '😀', k: ['grin', 'smile', 'happy'] },
{ e: '😃', k: ['smile', 'happy'] },
{ e: '😄', k: ['smile', 'laugh'] },
{ e: '😁', k: ['grin', 'smile'] },
{ e: '😆', k: ['laugh', 'lol'] },
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
{ e: '🙂', k: ['smile', 'slight'] },
{ e: '🙃', k: ['upside', 'irony'] },
{ e: '😉', k: ['wink'] },
{ e: '😊', k: ['blush', 'smile'] },
{ e: '😇', k: ['angel', 'innocent'] },
{ e: '🥰', k: ['love', 'hearts'] },
{ e: '😍', k: ['love', 'heart eyes'] },
{ e: '🤩', k: ['star', 'excited'] },
{ e: '😘', k: ['kiss'] },
{ e: '😗', k: ['kiss'] },
{ e: '😚', k: ['kiss'] },
{ e: '😙', k: ['kiss'] },
{ e: '🥲', k: ['tear', 'smile'] },
{ e: '😋', k: ['yum', 'tasty'] },
{ e: '😛', k: ['tongue'] },
{ e: '😜', k: ['tongue', 'wink'] },
{ e: '🤪', k: ['zany', 'silly'] },
{ e: '😝', k: ['tongue'] },
{ e: '🤑', k: ['money'] },
{ e: '🤗', k: ['hug'] },
{ e: '🤭', k: ['giggle', 'shy'] },
{ e: '🤫', k: ['shush', 'quiet'] },
{ e: '🤔', k: ['think'] },
{ e: '🤐', k: ['zip', 'quiet'] },
{ e: '🤨', k: ['raise brow'] },
{ e: '😐', k: ['neutral'] },
{ e: '😑', k: ['expressionless'] },
{ e: '😶', k: ['speechless'] },
{ e: '😏', k: ['smirk'] },
{ e: '😒', k: ['unamused'] },
{ e: '🙄', k: ['eye roll'] },
{ e: '😬', k: ['grimace', 'awkward'] },
{ e: '🤥', k: ['lying'] },
{ e: '😔', k: ['sad', 'pensive'] },
{ e: '😪', k: ['sleepy'] },
{ e: '😴', k: ['sleep'] },
{ e: '😷', k: ['mask', 'sick'] },
{ e: '🤒', k: ['sick', 'fever'] },
{ e: '🤕', k: ['injured'] },
{ e: '🤢', k: ['nauseated'] },
{ e: '🤮', k: ['vomit'] },
{ e: '🤧', k: ['sneeze'] },
{ e: '🥵', k: ['hot'] },
{ e: '🥶', k: ['cold'] },
{ e: '🥴', k: ['dizzy', 'woozy'] },
{ e: '😵', k: ['dizzy'] },
{ e: '🤯', k: ['mind blown'] },
{ e: '🤠', k: ['cowboy'] },
{ e: '🥳', k: ['party'] },
{ e: '😎', k: ['cool', 'sunglasses'] },
{ e: '🤓', k: ['nerd'] },
{ e: '🧐', k: ['monocle'] },
{ e: '😕', k: ['confused'] },
{ e: '😟', k: ['worried'] },
{ e: '🙁', k: ['frown'] },
{ e: '☹️', k: ['frown'] },
{ e: '😮', k: ['open mouth'] },
{ e: '😯', k: ['hushed'] },
{ e: '😲', k: ['astonished'] },
{ e: '😳', k: ['flushed'] },
{ e: '🥺', k: ['pleading'] },
{ e: '😦', k: ['frowning'] },
{ e: '😧', k: ['anguished'] },
{ e: '😨', k: ['fear'] },
{ e: '😰', k: ['anxious', 'sweat'] },
{ e: '😥', k: ['sad', 'relieved'] },
{ e: '😢', k: ['cry'] },
{ e: '😭', k: ['cry', 'loud'] },
{ e: '😱', k: ['scream', 'scared'] },
{ e: '😖', k: ['confounded'] },
{ e: '😣', k: ['persevere'] },
{ e: '😞', k: ['disappointed'] },
{ e: '😓', k: ['sweat'] },
{ e: '😩', k: ['weary'] },
{ e: '😫', k: ['tired'] },
{ e: '🥱', k: ['yawn'] },
{ e: '😤', k: ['triumph'] },
{ e: '😡', k: ['angry', 'rage'] },
{ e: '😠', k: ['angry'] },
{ e: '🤬', k: ['swear', 'curse'] },
{ e: '😈', k: ['devil'] },
{ e: '👿', k: ['imp'] },
{ e: '💀', k: ['skull', 'dead'] },
{ e: '🤡', k: ['clown'] },
{ e: '👻', k: ['ghost'] },
{ e: '👽', k: ['alien'] },
{ e: '🤖', k: ['robot'] },
{ e: '💩', k: ['poop', 'shit'] },
],
},
{
label: 'Gestures',
entries: [
{ e: '👋', k: ['wave', 'hi'] },
{ e: '🤚', k: ['hand'] },
{ e: '🖐️', k: ['hand'] },
{ e: '✋', k: ['stop', 'high five'] },
{ e: '🖖', k: ['spock'] },
{ e: '👌', k: ['ok'] },
{ e: '🤌', k: ['pinch'] },
{ e: '🤏', k: ['small'] },
{ e: '✌️', k: ['peace', 'victory'] },
{ e: '🤞', k: ['crossed fingers'] },
{ e: '🤟', k: ['love you'] },
{ e: '🤘', k: ['rock'] },
{ e: '🤙', k: ['call me'] },
{ e: '👈', k: ['point left'] },
{ e: '👉', k: ['point right'] },
{ e: '👆', k: ['point up'] },
{ e: '🖕', k: ['middle finger', 'fuck'] },
{ e: '👇', k: ['point down'] },
{ e: '☝️', k: ['point up'] },
{ e: '👍', k: ['thumbs up', 'like'] },
{ e: '👎', k: ['thumbs down', 'dislike'] },
{ e: '✊', k: ['fist'] },
{ e: '👊', k: ['punch'] },
{ e: '🤛', k: ['fist left'] },
{ e: '🤜', k: ['fist right'] },
{ e: '👏', k: ['clap'] },
{ e: '🙌', k: ['raised hands'] },
{ e: '👐', k: ['open hands'] },
{ e: '🤲', k: ['palms'] },
{ e: '🙏', k: ['pray', 'thanks'] },
{ e: '✍️', k: ['write'] },
{ e: '💪', k: ['flex', 'strong'] },
],
},
{
label: 'Hearts',
entries: [
{ e: '❤️', k: ['heart', 'love'] },
{ e: '🧡', k: ['orange heart'] },
{ e: '💛', k: ['yellow heart'] },
{ e: '💚', k: ['green heart'] },
{ e: '💙', k: ['blue heart'] },
{ e: '💜', k: ['purple heart'] },
{ e: '🖤', k: ['black heart'] },
{ e: '🤍', k: ['white heart'] },
{ e: '🤎', k: ['brown heart'] },
{ e: '💔', k: ['broken heart'] },
{ e: '❣️', k: ['heart exclamation'] },
{ e: '💕', k: ['hearts'] },
{ e: '💞', k: ['revolving hearts'] },
{ e: '💓', k: ['beating heart'] },
{ e: '💗', k: ['growing heart'] },
{ e: '💖', k: ['sparkle heart'] },
{ e: '💘', k: ['cupid'] },
{ e: '💝', k: ['heart gift'] },
],
},
{
label: 'Animals & Food',
entries: [
{ e: '🐶', k: ['dog'] },
{ e: '🐱', k: ['cat'] },
{ e: '🐭', k: ['mouse'] },
{ e: '🐹', k: ['hamster'] },
{ e: '🐰', k: ['rabbit'] },
{ e: '🦊', k: ['fox'] },
{ e: '🐻', k: ['bear'] },
{ e: '🐼', k: ['panda'] },
{ e: '🐨', k: ['koala'] },
{ e: '🐯', k: ['tiger'] },
{ e: '🦁', k: ['lion'] },
{ e: '🐸', k: ['frog'] },
{ e: '🐵', k: ['monkey'] },
{ e: '🐔', k: ['chicken'] },
{ e: '🐧', k: ['penguin'] },
{ e: '🐦', k: ['bird'] },
{ e: '🦆', k: ['duck'] },
{ e: '🍎', k: ['apple'] },
{ e: '🍌', k: ['banana'] },
{ e: '🍕', k: ['pizza'] },
{ e: '🍔', k: ['burger'] },
{ e: '🍟', k: ['fries'] },
{ e: '🌭', k: ['hotdog'] },
{ e: '🍿', k: ['popcorn'] },
{ e: '🍣', k: ['sushi'] },
{ e: '🍩', k: ['donut'] },
{ e: '🍪', k: ['cookie'] },
{ e: '🎂', k: ['cake', 'birthday'] },
{ e: '🍰', k: ['cake'] },
{ e: '🍫', k: ['chocolate'] },
{ e: '🍺', k: ['beer'] },
{ e: '🍷', k: ['wine'] },
{ e: '🥂', k: ['cheers'] },
{ e: '☕', k: ['coffee'] },
],
},
{
label: 'Objects & Symbols',
entries: [
{ e: '🔥', k: ['fire', 'lit'] },
{ e: '✨', k: ['sparkle'] },
{ e: '⭐', k: ['star'] },
{ e: '🌟', k: ['star glowing'] },
{ e: '💫', k: ['dizzy'] },
{ e: '💥', k: ['boom', 'explosion'] },
{ e: '⚡', k: ['lightning'] },
{ e: '☀️', k: ['sun'] },
{ e: '🌈', k: ['rainbow'] },
{ e: '☁️', k: ['cloud'] },
{ e: '🌧️', k: ['rain'] },
{ e: '❄️', k: ['snow'] },
{ e: '🎉', k: ['party', 'tada'] },
{ e: '🎊', k: ['confetti'] },
{ e: '🎁', k: ['gift'] },
{ e: '🎈', k: ['balloon'] },
{ e: '💯', k: ['100', 'perfect'] },
{ e: '✅', k: ['check'] },
{ e: '❌', k: ['x', 'no'] },
{ e: '⚠️', k: ['warning'] },
{ e: '❓', k: ['question'] },
{ e: '❗', k: ['exclamation'] },
{ e: '💬', k: ['speech'] },
{ e: '💭', k: ['thought'] },
{ e: '👀', k: ['eyes'] },
{ e: '🚀', k: ['rocket'] },
{ e: '🎵', k: ['music'] },
{ e: '🎶', k: ['music'] },
{ e: '🔔', k: ['bell'] },
{ e: '💡', k: ['idea', 'bulb'] },
],
},
];
const RECENT_KEY = 'chat.emoji.recents.v1';
const RECENT_MAX = 24;
function loadRecents(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
} catch {
return [];
}
}
function saveRecents(list: string[]): void {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
} catch {
/* ignore quota */
}
}
interface Props {
open: boolean;
onPick: (emoji: string) => void;
onClose: () => void;
}
export function EmojiPicker({ open, onPick, onClose }: Props) {
const [query, setQuery] = useState('');
const [recents, setRecents] = useState<string[]>(() => loadRecents());
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
onClose();
}
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
useEffect(() => {
if (open) setQuery('');
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return CATEGORIES;
return CATEGORIES.map((cat) => ({
label: cat.label,
entries: cat.entries.filter(
(entry) =>
entry.e.includes(q) ||
entry.k.some((k) => k.includes(q)) ||
cat.label.toLowerCase().includes(q),
),
})).filter((cat) => cat.entries.length > 0);
}, [query]);
if (!open) return null;
const handlePick = (emoji: string) => {
onPick(emoji);
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
setRecents(next);
saveRecents(next);
};
return (
<div
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"
>
<div className="border-b border-line p-2">
<input
type="search"
autoFocus
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"
/>
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{recents.length > 0 && !query && (
<CategoryBlock
label="Zuletzt"
entries={recents.map((e) => ({ e, k: [] }))}
onPick={handlePick}
/>
)}
{filtered.map((cat) => (
<CategoryBlock
key={cat.label}
label={cat.label}
entries={cat.entries}
onPick={handlePick}
/>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
)}
</div>
</div>
);
}
function CategoryBlock({
label,
entries,
onPick,
}: {
label: string;
entries: EmojiEntry[];
onPick: (emoji: string) => void;
}) {
return (
<div className="mb-2">
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="grid grid-cols-8 gap-0.5">
{entries.map((entry, idx) => (
<button
key={entry.e + ':' + idx}
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"
>
{entry.e}
</button>
))}
</div>
</div>
);
}
+478 -140
View File
@@ -1,5 +1,7 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react'; import type { RemoteParticipant, Room } from 'livekit-client';
import { Track } from 'livekit-client';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -13,7 +15,10 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile'; import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons'; import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer'; import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
// Discord-style in-call dock rendered above the message list. Renders three // Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen. // visual modes driven by CallContext.callMode: grid, focus, fullscreen.
@@ -24,13 +29,24 @@ interface Props {
} }
interface Tile { interface Tile {
// `user` = person with avatar/video/mute info. Speaking ring applies here.
// `screen` = a separate screen-share window from a user. No speaking
// indicator, no mute, no avatar — just the stream.
kind: 'user' | 'screen';
// Stable id used for focus tracking + React keys. `user:${userId}` or
// `screen:${userId}`.
id: string;
userId: string; userId: string;
displayName: string; displayName: string;
avatarUrl: string | null; avatarUrl: string | null;
self: boolean; self: boolean;
muted: boolean; muted: boolean;
deafened: boolean;
video: boolean; video: boolean;
videoTrack: MediaStreamTrack | null;
// True iff this is the current user's own screen-share tile.
sharing: boolean; sharing: boolean;
// True iff this is a remote user's screen-share tile.
remoteSharing: boolean; remoteSharing: boolean;
} }
@@ -43,11 +59,18 @@ export function InCallPanel({ conversation }: Props) {
isMuted, isMuted,
isE2EEActive, isE2EEActive,
isScreenSharing, isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares, remoteScreenShares,
callMode, callMode,
focusedId, focusedId,
toggleMute, toggleMute,
toggleScreenShare, startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
hangup, hangup,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
@@ -55,6 +78,23 @@ export function InCallPanel({ conversation }: Props) {
const { session } = useAuth(); const { session } = useAuth();
const myId = session?.user.id ?? null; const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room); const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null
>(null);
const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => {
if (tile.self) return;
if (tile.kind !== 'user') return;
e.preventDefault();
setVolumeMenu({
userId: tile.userId,
displayName: tile.displayName,
x: e.clientX,
y: e.clientY,
});
};
const active = const active =
(state.kind === 'connected' || (state.kind === 'connected' ||
@@ -66,9 +106,14 @@ export function InCallPanel({ conversation }: Props) {
const tiles = buildTiles({ const tiles = buildTiles({
conversation, conversation,
myId, myId,
remoteIdentities: remoteParticipants.map((p) => p.identity), room,
remoteParticipants,
isMuted, isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing, isScreenSharing,
isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)), remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
}); });
@@ -86,17 +131,31 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected'); : t('app:call.connected');
const sharingTile = tiles.find((p) => p.sharing); // A screen-share tile becomes the auto-focus target when no one explicitly
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null; // picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0]; // distinguish a user's own avatar tile from their screen tile.
const screenTile = tiles.find((p) => p.kind === 'screen');
const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null;
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
const controls = ( const controls = (
<CallControls <CallControls
muted={isMuted} muted={isMuted || !(room?.localParticipant?.isMicrophoneEnabled ?? false)}
sharing={isScreenSharing} sharing={isScreenSharing}
video={false} video={isCameraEnabled}
deafened={isDeafened}
onToggleMute={toggleMute} onToggleMute={toggleMute}
onToggleShare={() => void toggleScreenShare()} onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
setShareDialogOpen(true);
}
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()} onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'} compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'} glass={callMode === 'fullscreen'}
@@ -105,20 +164,50 @@ export function InCallPanel({ conversation }: Props) {
); );
if (callMode === 'fullscreen') { if (callMode === 'fullscreen') {
// In fullscreen, a "manual focus" = user explicitly picked someone, OR
// someone is sharing a screen, OR exactly one non-self speaker is talking
// (auto-promote). Without that we show an even grid of all participants
// (Discord default). Clicking a tile switches to the big-speaker layout.
const speakingNonSelf = tiles.filter(
(t: Tile) => activeSpeakers.has(t.userId) && !t.self && t.kind === 'user',
);
const autoSpeaker =
focusedId === null && screenTile === undefined && speakingNonSelf.length === 1
? speakingNonSelf[0]
: undefined;
const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined;
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
return ( return (
<>
<FullscreenCall <FullscreenCall
tiles={tiles} tiles={tiles}
speaker={speaker} speaker={effectiveSpeaker}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members} conversationMembers={conversation.members}
activeSpeakers={activeSpeakers} activeSpeakers={activeSpeakers}
e2ee={isE2EEActive} e2ee={isE2EEActive}
onExit={() => setCallMode('grid')} onExit={() => setCallMode('grid')}
onFocusTile={(id) => { onFocusTile={(id) => {
setFocusedId(id); // Toggle: click the already-focused tile to return to grid.
setFocusedId(focusedId === id ? null : id);
}} }}
onTileContextMenu={openVolumeMenu}
controls={controls} controls={controls}
/> />
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</>
); );
} }
@@ -127,11 +216,19 @@ export function InCallPanel({ conversation }: Props) {
? conversation.name ?? t('app:chats.new_group') ? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—'; : conversation.peer?.displayName ?? '—';
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
const sectionClass =
callMode === 'focus'
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
return ( return (
<section <section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })} aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2" className={sectionClass}
style={{ height: '50%' }} style={{ height: sectionHeight }}
> >
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2"> <div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
@@ -168,16 +265,53 @@ export function InCallPanel({ conversation }: Props) {
setFocusedId(id); setFocusedId(id);
if (callMode === 'grid') setCallMode('focus'); if (callMode === 'grid') setCallMode('focus');
}} }}
onTileContextMenu={openVolumeMenu}
compact compact
/> />
{controls} {controls}
<PttHint /> <PttHint />
<ScreenShareDialog
open={shareDialogOpen}
onClose={() => setShareDialogOpen(false)}
onStart={async (opts) => {
await startScreenShare(opts);
}}
/>
{volumeMenu && (
<ParticipantVolumeMenu
userId={volumeMenu.userId}
displayName={volumeMenu.displayName}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</section> </section>
); );
} }
// Fixed-position overlay so the popover sits above both docked + fullscreen
// call modes without needing a portal or parent-relative anchoring.
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
<div className="pointer-events-auto">
<SoundboardPanel onClose={onClose} />
</div>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -185,49 +319,123 @@ export function InCallPanel({ conversation }: Props) {
interface BuildArgs { interface BuildArgs {
conversation: ConversationSummary; conversation: ConversationSummary;
myId: string | null; myId: string | null;
remoteIdentities: string[]; room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean; isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean; isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>; remoteSharerIds: Set<string>;
} }
function cameraTrackFor(
participant: { videoTrackPublications: Map<string, { source: Track.Source; track?: { mediaStreamTrack: MediaStreamTrack } | undefined }> } | null,
): MediaStreamTrack | null {
if (!participant) return null;
for (const pub of participant.videoTrackPublications.values()) {
if (pub.source === Track.Source.Camera && pub.track) {
return pub.track.mediaStreamTrack ?? null;
}
}
return null;
}
function buildTiles({ function buildTiles({
conversation, conversation,
myId, myId,
remoteIdentities, room,
remoteParticipants,
isMuted, isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing, isScreenSharing,
isCameraEnabled,
remoteSharerIds, remoteSharerIds,
}: BuildArgs): Tile[] { }: BuildArgs): Tile[] {
const remoteSet = new Set(remoteIdentities); const remoteById = new Map<string, RemoteParticipant>();
for (const p of remoteParticipants) {
if (p.identity) remoteById.set(p.identity, p);
}
const out: Tile[] = []; const out: Tile[] = [];
if (myId) { if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null; const me = conversation.members.find((m) => m.userId === myId) ?? null;
// Source-of-truth for own mic: the LocalParticipant publication state.
// `isMuted` reflects the toggle button, but if mic never published (no
// 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;
out.push({ out.push({
kind: 'user',
id: 'user:' + myId,
userId: myId, userId: myId,
displayName: me?.profile?.displayName ?? '?', displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null, avatarUrl: me?.profile?.avatarUrl ?? null,
self: true, self: true,
muted: isMuted, muted: isMuted || !micLive,
deafened: isDeafened,
video: isCameraEnabled,
videoTrack: cameraTrackFor(room?.localParticipant ?? null),
sharing: false,
remoteSharing: false,
});
if (isScreenSharing) {
out.push({
kind: 'screen',
id: 'screen:' + myId,
userId: myId,
displayName: (me?.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: false,
deafened: false,
video: false, video: false,
sharing: isScreenSharing, videoTrack: null,
sharing: true,
remoteSharing: false, remoteSharing: false,
}); });
} }
}
for (const m of conversation.members) { for (const m of conversation.members) {
if (m.userId === myId) continue; if (m.userId === myId) continue;
if (!remoteSet.has(m.userId)) continue; const rp = remoteById.get(m.userId);
const sharing = remoteSharerIds.has(m.userId); if (!rp) continue;
out.push({ out.push({
kind: 'user',
id: 'user:' + m.userId,
userId: m.userId, userId: m.userId,
displayName: m.profile?.displayName ?? '?', displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null, avatarUrl: m.profile?.avatarUrl ?? null,
self: false, self: false,
muted: false, // Peer's self-reported mute state via data channel. LiveKit's own
video: false, // `isMicrophoneEnabled` no longer flips on mute since the pipeline
sharing, // output track stays published. See remoteMute broadcast in CallContext.
remoteSharing: sharing, muted: remoteMute[m.userId] ?? false,
// Deafen state arrives via LiveKit data channel; see CallContext.
deafened: remoteDeafen[m.userId] ?? false,
video: rp.isCameraEnabled,
videoTrack: cameraTrackFor(rp),
sharing: false,
remoteSharing: false,
}); });
if (remoteSharerIds.has(m.userId)) {
out.push({
kind: 'screen',
id: 'screen:' + m.userId,
userId: m.userId,
displayName: (m.profile?.displayName ?? '?') + ' · Bildschirm',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
deafened: false,
video: false,
videoTrack: null,
sharing: false,
remoteSharing: true,
});
}
} }
return out; return out;
} }
@@ -319,9 +527,92 @@ interface StageProps {
}[]; }[];
conversationMembers: ConversationSummary['members']; conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void; onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
compact?: boolean; compact?: boolean;
} }
// Dispatches a Tile to the right renderer. Screen-tiles show the screenshare
// stream directly (no avatar, no speaking ring, no mic indicator); user-tiles
// render via CallParticipantTile with all its chrome.
function TileRender({
tile,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
size,
focused,
onClick,
onContextMenu,
}: {
tile: Tile;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
}): 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>
);
}
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (!share) return <div className="h-full w-full" />;
return (
<div
onClick={onClick}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
return (
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
deafened={tile.deafened}
speaking={activeSpeakers.has(tile.userId)}
video={tile.video}
videoTrack={tile.videoTrack}
e2ee={e2ee}
{...(size ? { size } : {})}
{...(focused ? { focused } : {})}
{...(onClick ? { onClick } : {})}
{...(onContextMenu ? { onContextMenu } : {})}
/>
);
}
function CallStage({ function CallStage({
tiles, tiles,
speaker, speaker,
@@ -331,37 +622,40 @@ function CallStage({
remoteScreenShares, remoteScreenShares,
conversationMembers, conversationMembers,
onFocusTile, onFocusTile,
onTileContextMenu,
compact = false, compact = false,
}: StageProps) { }: StageProps) {
if (mode === 'focus' && speaker) { if (mode === 'focus' && speaker) {
const others = tiles.filter((p) => p.userId !== speaker.userId); const others = tiles.filter((p) => p.id !== speaker.id);
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3"> <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<FocusedTile <FocusedTile
tile={speaker} tile={speaker}
e2ee={e2ee} e2ee={e2ee}
speaking={activeSpeakers.has(speaker.userId)} activeSpeakers={activeSpeakers}
remoteScreenShares={remoteScreenShares} remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers} conversationMembers={conversationMembers}
/> />
</div> </div>
{others.length > 0 && ( {others.length > 0 && (
<div className="flex h-[110px] gap-2.5 overflow-x-auto"> <div className="flex h-[180px] gap-2.5 overflow-x-auto">
{others.map((p) => ( {others.map((p) => (
<div key={p.userId} className="h-full min-w-[160px]"> <div
<CallParticipantTile key={p.id}
userId={p.userId} className="h-full w-[240px] shrink-0 [&>div]:h-full"
displayName={p.displayName} >
avatarUrl={p.avatarUrl} <TileRender
me={p.self} tile={p}
muted={p.muted} activeSpeakers={activeSpeakers}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small" size="small"
onClick={() => onFocusTile(p.userId)} onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/> />
</div> </div>
))} ))}
@@ -377,19 +671,19 @@ function CallStage({
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}> <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}> <div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => ( {tiles.map((p) => (
<CallParticipantTile <div key={p.id} className="[&>div]:h-full [&>div]:w-full">
key={p.userId} <TileRender
userId={p.userId} tile={p}
displayName={p.displayName} activeSpeakers={activeSpeakers}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee} e2ee={e2ee}
onClick={() => onFocusTile(p.userId)} remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/> />
</div>
))} ))}
</div> </div>
</div> </div>
@@ -399,57 +693,57 @@ function CallStage({
function FocusedTile({ function FocusedTile({
tile, tile,
e2ee, e2ee,
speaking, activeSpeakers,
remoteScreenShares, remoteScreenShares,
conversationMembers, conversationMembers,
}: { }: {
tile: Tile; tile: Tile;
e2ee: boolean; e2ee: boolean;
speaking: boolean; activeSpeakers: Set<string>;
remoteScreenShares: StageProps['remoteScreenShares']; remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers']; conversationMembers: StageProps['conversationMembers'];
}) { }) {
// When the focused participant is remotely sharing their screen, embed the
// real video stream rather than the fake-window placeholder.
if (tile.remoteSharing) {
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (share) {
return ( return (
<div className="h-full"> <div className="h-full [&>div]:h-full">
<ScreenShareViewer <TileRender
share={share} tile={tile}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl} activeSpeakers={activeSpeakers}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
}
return (
<div className="h-full">
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
speaking={speaking}
sharing={tile.sharing}
video={tile.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused focused
/> />
</div> </div>
); );
} }
const GRID_PAGE_SIZE = 12;
function gridColsFor(n: number): string { function gridColsFor(n: number): string {
if (n <= 1) return 'grid-cols-1'; // Explicit `grid-rows-*` so cells get a defined height (1fr of available
if (n === 2) return 'grid-cols-2'; // space). Without this, implicit rows default to auto → they size to
if (n === 3) return 'grid-cols-3'; // content, and a video element's intrinsic size blows the tile past the
// container bounds (overlapping the toolbar below).
if (n <= 1) return 'grid-cols-1 grid-rows-1';
if (n === 2) return 'grid-cols-2 grid-rows-1';
if (n === 3) return 'grid-cols-3 grid-rows-1';
if (n === 4) return 'grid-cols-2 grid-rows-2'; if (n === 4) return 'grid-cols-2 grid-rows-2';
return 'grid-cols-3 grid-rows-2'; if (n <= 6) return 'grid-cols-3 grid-rows-2';
if (n <= 9) return 'grid-cols-3 grid-rows-3';
return 'grid-cols-4 grid-rows-3';
}
// Promote self + active speakers to the front of the tile list. Stable
// otherwise. Used by both pagination (so page 1 always carries the most
// "useful" tiles) and active-speaker promotion in fullscreen.
function prioritizeTiles(tiles: Tile[], activeSpeakers: Set<string>): Tile[] {
const score = (t: Tile): number => {
if (t.self) return 3;
if (activeSpeakers.has(t.userId)) return 2;
if (t.kind === 'screen') return 1;
return 0;
};
return [...tiles].sort((a, b) => score(b) - score(a));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -465,6 +759,7 @@ interface FullscreenProps {
e2ee: boolean; e2ee: boolean;
onExit: () => void; onExit: () => void;
onFocusTile: (id: string) => void; onFocusTile: (id: string) => void;
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
controls: React.ReactNode; controls: React.ReactNode;
} }
@@ -477,62 +772,129 @@ function FullscreenCall({
e2ee, e2ee,
onExit: _onExit, onExit: _onExit,
onFocusTile, onFocusTile,
onTileContextMenu,
controls, controls,
}: FullscreenProps) { }: FullscreenProps) {
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
const [hintGone, setHintGone] = useState(false); const [hintGone, setHintGone] = useState(false);
const [page, setPage] = useState(0);
useEffect(() => { useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500); const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id); return () => window.clearTimeout(id);
}, []); }, []);
const hasFocus = speaker !== undefined;
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
// exist, slice them into pages. Reset to page 0 if the page count drops
// below the current page (someone left).
const sortedGridTiles = useMemo(
() => prioritizeTiles(tiles, activeSpeakers),
[tiles, activeSpeakers],
);
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
useEffect(() => {
if (page >= pageCount) setPage(0);
}, [pageCount, page]);
const visibleTiles = sortedGridTiles.slice(
page * GRID_PAGE_SIZE,
(page + 1) * GRID_PAGE_SIZE,
);
const gridClass = gridColsFor(visibleTiles.length);
return ( return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface"> <div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
<div className="relative min-h-0 flex-1"> {/* Content area. pb-24 reserves ~96px space at the bottom for the
{speaker && ( floating controls bar so tiles never sit behind it. */}
<div className="absolute inset-0"> <div className="relative flex min-h-0 flex-1 flex-col pb-24">
{speaker.remoteSharing ? ( {hasFocus ? (
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} /> <>
) : ( <div
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0"> className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
<CallParticipantTile onClick={() => onFocusTile(speaker!.id)}
userId={speaker.userId} title="Zurück zur Übersicht"
displayName={speaker.displayName} >
avatarUrl={speaker.avatarUrl} <TileRender
me={speaker.self} tile={speaker!}
muted={speaker.muted} activeSpeakers={activeSpeakers}
speaking={activeSpeakers.has(speaker.userId)}
sharing={speaker.sharing}
video={speaker.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused focused
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
/> />
</div> </div>
)}
</div>
)}
{others.length > 0 && ( {others.length > 0 && (
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2"> <div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
{others.slice(0, 4).map((p) => ( {others.map((p) => (
<div key={p.userId} className="h-[100px] backdrop-blur-xl"> <div
<CallParticipantTile key={p.id}
userId={p.userId} className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
displayName={p.displayName} >
avatarUrl={p.avatarUrl} <TileRender
me={p.self} tile={p}
muted={p.muted} activeSpeakers={activeSpeakers}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee} e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
size="small" size="small"
onClick={() => onFocusTile(p.userId)} onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/> />
</div> </div>
))} ))}
</div> </div>
)} )}
</>
) : (
<div className="min-h-0 flex-1 p-4">
<div className={'grid h-full gap-2 ' + gridClass}>
{visibleTiles.map((p) => (
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
<TileRender
tile={p}
activeSpeakers={activeSpeakers}
e2ee={e2ee}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
onClick={() => onFocusTile(p.id)}
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
: {})}
/>
</div>
))}
</div>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
<button
type="button"
onClick={() => setPage((p) => (p === 0 ? pageCount - 1 : p - 1))}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Vorherige Seite"
>
</button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<button
type="button"
onClick={() => setPage((p) => (p + 1) % pageCount)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-1 hover:bg-surface-3"
aria-label="Nächste Seite"
>
</button>
</div>
)}
</div>
)}
</div>
{!hintGone && ( {!hintGone && (
<div <div
@@ -547,30 +909,6 @@ function FullscreenCall({
{controls} {controls}
</div> </div>
</div> </div>
</div>
);
}
function FullscreenShare({
speaker,
remoteScreenShares,
conversationMembers,
}: {
speaker: Tile;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
const member = conversationMembers.find((m) => m.userId === speaker.userId);
if (!share) return null;
return (
<div className="h-full w-full">
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
displayName={member?.profile?.displayName ?? speaker.displayName}
/>
</div>
); );
} }
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile'; import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons'; import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
interface Props { interface Props {
conversation: ConversationSummary; conversation: ConversationSummary;
@@ -99,23 +99,37 @@ export function IncomingCallPanel({ conversation }: Props) {
</> </>
)} )}
</p> </p>
<div className="mt-5 flex w-full max-w-[360px] gap-3"> <div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
<button <button
type="button" type="button"
onClick={rejectIncoming} onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 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" className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 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" /> <PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span> <span>{t('app:call.decline')}</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => void acceptIncoming()} onClick={() => void acceptIncoming('audio')}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50" className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
> >
<PhoneIcon className="h-4 w-4" /> <PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span> <span>
{state.mediaKind === 'video'
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
: t('app:call.accept')}
</span>
</button> </button>
{state.mediaKind === 'video' && (
<button
type="button"
onClick={() => void acceptIncoming('video')}
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" />
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
</button>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -0,0 +1,49 @@
import { useLinkPreview } from '../lib/useLinkPreview';
interface Props {
url: string;
}
// Renders a compact OpenGraph preview card under a message bubble. Fetches
// lazily through the edge function; silent when the URL returned no meta.
export function LinkPreviewCard({ url }: Props) {
const preview = useLinkPreview(url);
if (!preview || !preview.ok) return null;
if (!preview.title && !preview.description && !preview.imageUrl) return null;
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
loading="lazy"
className="h-20 w-20 shrink-0 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
{preview.siteName && (
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
{preview.siteName}
</p>
)}
{preview.title && (
<p className="line-clamp-2 text-sm font-semibold text-fg">
{preview.title}
</p>
)}
{preview.description && (
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
)}
</div>
</a>
);
}
@@ -0,0 +1,103 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { Avatar } from './Avatar';
interface Props {
members: ConversationSummary['members'];
query: string;
excludeUserId: string | undefined;
onSelect: (username: string) => void;
onClose: () => void;
}
// 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) {
const q = query.toLowerCase();
const matches = members
.filter((m) => m.userId !== excludeUserId)
.filter((m) => {
if (!q) return true;
const name = (m.profile?.displayName ?? '').toLowerCase();
const handle = (m.profile?.username ?? '').toLowerCase();
return name.includes(q) || handle.includes(q);
})
.slice(0, 8);
const [active, setActive] = useState(0);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (matches.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => (i + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => (i - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const pick = matches[active];
if (pick?.profile?.username) onSelect(pick.profile.username);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('keydown', onKey, true);
};
}, [matches, active, onSelect, onClose]);
if (matches.length === 0) return null;
return (
<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"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
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')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
</div>
);
}
+105 -10
View File
@@ -13,7 +13,13 @@ import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
import { extractFirstUrl } from '../lib/useLinkPreview';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentGeneric } from './AttachmentGeneric';
import { AttachmentImage } from './AttachmentImage'; import { AttachmentImage } from './AttachmentImage';
import { AttachmentPdf } from './AttachmentPdf';
import { AttachmentVideo } from './AttachmentVideo';
import { LinkPreviewCard } from './LinkPreviewCard';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
@@ -40,6 +46,8 @@ interface Props {
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean; showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read';
/** Resolved quoted message info (parent does the lookup). */ /** Resolved quoted message info (parent does the lookup). */
quoted?: QuotedRef | null; quoted?: QuotedRef | null;
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */ /** Tap-to-jump on quote bubble. Receives the quoted message's id. */
@@ -63,6 +71,7 @@ export function MessageBubble({
reactions, reactions,
onToggleReaction, onToggleReaction,
showSeen = false, showSeen = false,
deliveryState,
quoted = null, quoted = null,
onJumpToMessage, onJumpToMessage,
onReply, onReply,
@@ -295,10 +304,27 @@ export function MessageBubble({
<span className="italic opacity-70">cannot decrypt</span> <span className="italic opacity-70">cannot decrypt</span>
) : ( ) : (
<> <>
{bodyText.length > 0 && <div>{bodyText}</div>} {bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
{attachments.map((a) => ( {bodyText.length > 0 &&
<AttachmentImage key={a.id} handle={a} /> (() => {
))} const url = extractFirstUrl(bodyText);
return url ? <LinkPreviewCard url={url} /> : null;
})()}
{attachments.map((a) => {
if (a.mimeType.startsWith('audio/')) {
return <AttachmentAudio key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('image/')) {
return <AttachmentImage key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('video/')) {
return <AttachmentVideo key={a.id} handle={a} />;
}
if (a.mimeType === 'application/pdf') {
return <AttachmentPdf key={a.id} handle={a} />;
}
return <AttachmentGeneric key={a.id} handle={a} />;
})}
</> </>
)} )}
<div <div
@@ -315,21 +341,24 @@ export function MessageBubble({
</div> </div>
)} )}
{showSeen && mine && !editing && !message.deletedAt && ( {mine && !editing && !message.deletedAt && deliveryState && (
<p className="mt-0.5 text-right text-[10px] text-fg-muted"> <div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
{t('app:chats.seen')} <DeliveryTicks state={deliveryState} />
</p> {showSeen && <span>{t('app:chats.seen')}</span>}
</div>
)} )}
{reactions.length > 0 && !editing && ( {reactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}> <div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.map((r) => ( {reactions.map((r) => (
<button <button
key={r.emoji} // 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" type="button"
onClick={() => void onToggleReaction(r.emoji)} onClick={() => void onToggleReaction(r.emoji)}
className={ className={
'inline-flex 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 ' + '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 (r.mine
? 'border-accent/40 bg-accent/20 text-accent' ? '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')
@@ -499,6 +528,72 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0'); 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[] {
const out: React.ReactNode[] = [];
let lastIdx = 0;
let m: RegExpExecArray | null;
MENTION_RE.lastIndex = 0;
while ((m = MENTION_RE.exec(text)) !== null) {
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
out.push(
<span
key={m.index + ':' + m[1]}
className="rounded bg-accent/20 px-1 text-accent"
>
{m[0]}
</span>,
);
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
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';
return (
<span
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">
{state === 'sent' ? (
<polyline points="2 7 6 11 14 1" />
) : (
<>
<polyline points="1 7 5 11 11 2" />
<polyline points="6 11 10 11 14 1" />
</>
)}
</svg>
</span>
);
}
function ActionButton({ function ActionButton({
label, label,
onClick, onClick,
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
getParticipantVolume,
setParticipantVolume,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
interface Props {
userId: string;
displayName: string;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 240;
const MENU_H = 84;
export function ParticipantVolumeMenu({
userId,
displayName,
x,
y,
onClose,
}: Props) {
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
// Re-sync from store in case another menu instance changed the same user.
useEffect(() => subscribeParticipantVolumes(() => {
setVolume(getParticipantVolume(userId));
}), [userId]);
// Outside click + Esc to close.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-volume-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 - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
return createPortal(
<div
data-volume-menu
role="dialog"
aria-label={'Lautstärke ' + displayName}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
<span className="truncate font-semibold text-fg">{displayName}</span>
<span className="tabular-nums text-fg-muted">
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
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>,
document.body,
);
}
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearIncomingRingtone,
getIncomingRingtone,
MAX_RINGTONE_BYTES,
saveIncomingRingtone,
type StoredRingtone,
} from '../lib/ringtoneStorage';
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
interface Props {
/** Disable interactions while a parent action is in flight. */
disabled?: boolean;
}
const BYTES_PER_MB = 1024 * 1024;
// UI for the custom incoming-call ringtone. Single file slot. Upload
// validates size + mime and surfaces errors inline. Preview button plays
// the stored blob through a local <audio> element without touching the
// shared ringtone singleton so we don't interfere with a live call.
export function RingtoneSettings({ disabled = false }: Props) {
const { t } = useTranslation(['app']);
const inputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [current, setCurrent] = useState<StoredRingtone | null>(null);
const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const cur = await getIncomingRingtone();
setCurrent(cur);
} catch (err: unknown) {
console.error('getIncomingRingtone failed', err);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
// Revoke any preview blob URL when the component unmounts so long-lived
// pages don't leak memory.
return () => {
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPlaying(false);
}
async function handleFile(file: File): Promise<void> {
setError(null);
setBusy(true);
try {
await saveIncomingRingtone(file);
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'upload_failed';
if (code === 'ringtone_too_large') {
setError(
t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max 2 MB).',
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'ringtone_not_audio') {
setError(
t('app:settings.ringtone_error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:settings.ringtone_error_generic', {
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleReset(): Promise<void> {
setError(null);
setBusy(true);
stopPreview();
try {
await clearIncomingRingtone();
await refresh();
} catch (err: unknown) {
console.error('clearIncomingRingtone failed', err);
} finally {
setBusy(false);
}
}
function handlePreview(): void {
if (!current) return;
if (playing) {
stopPreview();
return;
}
const url = URL.createObjectURL(current.blob);
const el = new Audio(url);
el.loop = false;
el.volume = 0.85;
el.onended = () => stopPreview();
el.onerror = () => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
};
el.play().catch(() => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
});
previewRef.current = el;
previewUrlRef.current = url;
setPlaying(true);
}
const hasCustom = current !== null;
const sizeMb = current ? (current.blob.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="flex items-center gap-2 text-sm font-medium text-fg">
<PhoneIcon className="h-4 w-4 text-fg-muted" />
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
</div>
<div className="mt-1 text-xs text-fg-muted">
{hasCustom && current
? t('app:settings.ringtone_custom_active', {
defaultValue: '{{name}} · {{size}} MB',
name: current.filename,
size: sizeMb,
})
: t('app:settings.ringtone_default_active', {
defaultValue: 'Standard-Klingelton (Doppelton)',
})}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{hasCustom && (
<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"
>
{playing
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhö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.ringtone_replace', { defaultValue: 'Ersetzen' })
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
</span>
</button>
{hasCustom && (
<button
type="button"
onClick={() => void handleReset()}
disabled={interactionsDisabled}
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
title={t('app:settings.ringtone_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.ringtone_hint', {
defaultValue:
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})}
</p>
</div>
);
}
@@ -0,0 +1,233 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
SpinnerIcon,
XIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
onStart: (opts: {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}) => Promise<void>;
}
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
{ value: null, label: 'Preset-Standard' },
{ value: 15, label: '15 fps' },
{ value: 30, label: '30 fps' },
{ value: 60, label: '60 fps' },
];
// Discord-style pre-share dialog. The OS still owns the final source picker
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
// in quality + framerate up-front so the user doesn't have to re-open the
// system picker to adjust them mid-call.
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
const presetParams = getPresetParams(preset);
const effectiveFps = framerate ?? presetParams.framerate;
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/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-lg 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="Close"
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="space-y-5 p-5">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
</p>
<div className="grid grid-cols-3 gap-2">
<SurfaceOption
active={surface === null}
onClick={() => setSurface(null)}
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
/>
<SurfaceOption
active={surface === 'monitor'}
onClick={() => setSurface('monitor')}
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
/>
<SurfaceOption
active={surface === 'window'}
onClick={() => setSurface('window')}
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
/>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</p>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
</p>
<div className="flex flex-wrap gap-2">
{FRAMERATE_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setFramerate(opt.value)}
className={
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
(framerate === opt.value
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
{opt.label}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-fg-muted">
{t('app:call.share_fps_effective', {
defaultValue: 'Effektiv: {{fps}} fps',
fps: effectiveFps,
})}
</p>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:call.share_hint', {
defaultValue:
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
})}
</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 starten' })}
</span>
</button>
</footer>
</div>
</div>
);
}
function SurfaceOption({
active,
onClick,
label,
sub,
}: {
active: boolean;
onClick: () => void;
label: string;
sub: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
<span className="text-xs font-semibold">{label}</span>
<span className="text-[10px] text-fg-muted">{sub}</span>
</button>
);
}
@@ -1,6 +1,5 @@
import type { RemoteTrack } from 'livekit-client'; import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext'; import type { RemoteScreenShare } from '../context/CallContext';
@@ -51,13 +50,13 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
setIsFullscreen((v) => !v); setIsFullscreen((v) => !v);
}; };
const viewerNode = ( return (
<div <div
ref={containerRef} ref={containerRef}
className={ className={
isFullscreen isFullscreen
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black' ? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black' : 'flex h-full w-full flex-col overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
} }
> >
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200"> <div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
@@ -104,10 +103,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
playsInline playsInline
muted muted
onDoubleClick={toggleFullscreen} onDoubleClick={toggleFullscreen}
className={ className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
'block cursor-zoom-in bg-black ' +
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
}
/> />
) : ( ) : (
<button <button
@@ -132,14 +128,6 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)} )}
</div> </div>
); );
// When in fullscreen, portal out of the call-panel subtree into <body> so
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
// AppShell's root — portalled node sits above them via z-[60].
if (isFullscreen) {
return createPortal(viewerNode, document.body);
}
return viewerNode;
} }
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) { function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getPttSettings } from '../lib/pttSettings';
import { codeToShortcut } from '../lib/globalShortcut';
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
import {
addSound,
deleteSound,
getSoundBlob,
listSounds,
MAX_SOUND_BYTES,
reorderCategory,
type SoundboardEntry,
subscribeSoundboardChanges,
updateSound,
} from '../lib/soundboardStorage';
import { Modal } from './Modal';
import {
AlertIcon,
ChevronDownIcon,
PencilIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
}
const BYTES_PER_MB = 1024 * 1024;
const CATEGORY_LIST_ID = 'sb-category-list';
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
// In-call panel only reads the resulting manifest.
export function SoundboardManagerDialog({ open, onClose }: Props) {
const { t } = useTranslation(['app']);
const fileRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setEntries(await listSounds());
} catch (err: unknown) {
console.error('listSounds failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void refresh();
// External mutations (hotkey fires, multi-tab edits) should reflect
// immediately while the dialog is open.
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
unsub();
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refresh]);
useEffect(() => {
return () => stopPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const categories = useMemo(() => {
const set = new Set<string>();
for (const e of entries) if (e.category) set.add(e.category);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [entries]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of entries) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [entries]);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewingId(null);
}
async function handleAdd(file: File): Promise<void> {
setError(null);
setBusyId('__add');
try {
await addSound({ file });
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'add_failed';
if (code === 'sound_too_large') {
setError(
t('app:soundboard.error_too_large', {
defaultValue: 'Datei zu groß (max {{max}} MB).',
max: MAX_SOUND_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'sound_not_audio') {
setError(
t('app:soundboard.error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:soundboard.error_generic', {
defaultValue: 'Sound konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusyId(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function handlePatch(
id: string,
patch: Parameters<typeof updateSound>[1],
): Promise<void> {
setBusyId(id);
try {
await updateSound(id, patch);
} catch (err: unknown) {
console.error('updateSound failed', err);
} finally {
setBusyId(null);
}
}
async function handleDelete(id: string): Promise<void> {
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
return;
}
setBusyId(id);
try {
await deleteSound(id);
invalidateSoundCache(id);
if (previewingId === id) stopPreview();
} catch (err: unknown) {
console.error('deleteSound failed', err);
} finally {
setBusyId(null);
}
}
async function handlePreview(entry: SoundboardEntry): Promise<void> {
if (previewingId === entry.id) {
stopPreview();
return;
}
stopPreview();
const blob = await getSoundBlob(entry.id);
if (!blob) return;
const url = URL.createObjectURL(blob);
const el = new Audio(url);
el.volume = entry.gain;
el.onended = () => stopPreview();
el.onerror = () => stopPreview();
el.play().catch(() => stopPreview());
previewRef.current = el;
previewUrlRef.current = url;
setPreviewingId(entry.id);
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
// playback doesn't pause on decode.
void preload(entry.id);
}
async function handleReorder(
category: string | null,
idx: number,
dir: -1 | 1,
): Promise<void> {
const bucket = grouped.get(category ?? '') ?? [];
const next = idx + dir;
if (next < 0 || next >= bucket.length) return;
const reordered = bucket.slice();
const tmp = reordered[idx]!;
reordered[idx] = reordered[next]!;
reordered[next] = tmp;
setBusyId('__reorder:' + (category ?? ''));
try {
await reorderCategory(
category,
reordered.map((e) => e.id),
);
} catch (err: unknown) {
console.error('reorderCategory failed', err);
} finally {
setBusyId(null);
}
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onClose}
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
size="lg"
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{t('app:soundboard.manager_hint', {
defaultValue:
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
})}
</p>
<input
ref={fileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleAdd(f);
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busyId !== null}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busyId === '__add' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PlusIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<datalist id={CATEGORY_LIST_ID}>
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
{loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div>
) : entries.length === 0 ? (
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t('app:soundboard.empty', {
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
})}
</p>
) : (
<div className="flex flex-col gap-4">
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
const category = categoryKey === '' ? null : categoryKey;
return (
<SoundboardCategoryGroup
key={categoryKey || '__uncat'}
category={category}
entries={bucket}
entriesTotal={entries}
busyId={busyId}
previewingId={previewingId}
onPatch={handlePatch}
onDelete={handleDelete}
onPreview={handlePreview}
onReorder={handleReorder}
/>
);
})}
</div>
)}
</div>
</Modal>
);
}
// ---------------------------------------------------------------------------
interface GroupProps {
category: string | null;
entries: SoundboardEntry[];
entriesTotal: SoundboardEntry[];
busyId: string | null;
previewingId: string | null;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
}
function SoundboardCategoryGroup({
category,
entries,
entriesTotal,
busyId,
previewingId,
onPatch,
onDelete,
onPreview,
onReorder,
}: GroupProps) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(true);
const label =
category ??
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
return (
<section className="rounded-lg border border-line bg-surface-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
>
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
/>
</button>
{open && (
<ul className="flex flex-col gap-2 border-t border-line p-3">
{entries.map((entry, idx) => (
<SoundboardRow
key={entry.id}
entry={entry}
entriesTotal={entriesTotal}
isFirst={idx === 0}
isLast={idx === entries.length - 1}
busy={busyId === entry.id}
previewing={previewingId === entry.id}
onPatch={onPatch}
onDelete={onDelete}
onPreview={onPreview}
onReorderUp={() => onReorder(category, idx, -1)}
onReorderDown={() => onReorder(category, idx, 1)}
/>
))}
</ul>
)}
</section>
);
}
// ---------------------------------------------------------------------------
interface RowProps {
entry: SoundboardEntry;
entriesTotal: SoundboardEntry[];
isFirst: boolean;
isLast: boolean;
busy: boolean;
previewing: boolean;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorderUp: () => Promise<void>;
onReorderDown: () => Promise<void>;
}
function SoundboardRow({
entry,
entriesTotal,
isFirst,
isLast,
busy,
previewing,
onPatch,
onDelete,
onPreview,
onReorderUp,
onReorderDown,
}: RowProps) {
const { t } = useTranslation(['app']);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState(entry.name);
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
const [capturingHotkey, setCapturingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
useEffect(() => {
setNameDraft(entry.name);
setCategoryDraft(entry.category ?? '');
}, [entry.name, entry.category]);
useEffect(() => {
if (!capturingHotkey) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
setCapturingHotkey(false);
setHotkeyError(null);
return;
}
// Conflict checks: PTT + other soundboard entries with this code.
const ptt = getPttSettings();
if (ptt.enabled && ptt.key === e.code) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_ptt', {
defaultValue: 'Konflikt mit Push-to-Talk.',
}),
);
return;
}
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
if (taken) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_sound', {
defaultValue: 'Bereits von "{{name}}" belegt.',
name: taken.name,
}),
);
return;
}
setHotkeyError(null);
setCapturingHotkey(false);
void onPatch(entry.id, { hotkey: e.code });
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
return (
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{editingName ? (
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => {
setEditingName(false);
if (nameDraft.trim() && nameDraft !== entry.name) {
void onPatch(entry.id, { name: nameDraft });
} else {
setNameDraft(entry.name);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
setNameDraft(entry.name);
setEditingName(false);
}
}}
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
>
{entry.name}
<PencilIcon className="h-3 w-3 opacity-50" />
</button>
)}
<p className="text-[10px] text-fg-muted">
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
</p>
</div>
<input
type="text"
value={categoryDraft}
onChange={(e) => setCategoryDraft(e.target.value)}
onBlur={() => {
const next = categoryDraft.trim() || null;
if (next !== (entry.category ?? null)) {
void onPatch(entry.id, { category: next });
}
}}
list={CATEGORY_LIST_ID}
placeholder={t('app:soundboard.category_placeholder', {
defaultValue: 'Kategorie…',
})}
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setHotkeyError(null);
setCapturingHotkey((v) => !v);
}}
className={
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
(capturingHotkey
? 'animate-pulse border-accent bg-accent/20 text-fg'
: hotkeyLabel
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
}
title={t('app:soundboard.hotkey_capture', {
defaultValue: 'Hotkey binden (Esc = abbrechen)',
})}
>
{capturingHotkey
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
: hotkeyLabel ??
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
</button>
{entry.hotkey && !capturingHotkey && (
<button
type="button"
onClick={() => void onPatch(entry.id, { hotkey: null })}
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
>
×
</button>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.05}
value={entry.gain}
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
className="accent-accent w-20"
title={t('app:soundboard.gain_title', {
defaultValue: 'Lautstärke',
})}
/>
<button
type="button"
onClick={() => void onPreview(entry)}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
>
{previewing
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
</button>
<button
type="button"
onClick={onReorderUp}
disabled={busy || isFirst}
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={onReorderDown}
disabled={busy || isLast}
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => void onDelete(entry.id)}
disabled={busy}
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
{hotkeyError && capturingHotkey && (
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
{hotkeyError}
</p>
)}
</li>
);
}
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { codeToShortcut } from '../lib/globalShortcut';
import {
DEFAULT_PREFS,
getPrefs,
listSounds,
type SoundboardEntry,
type SoundboardPrefs,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import { ChevronDownIcon, XIcon } from './icons';
interface Props {
onClose: () => void;
}
// In-call popover that lists every stored sound grouped by category. Click a
// pad to play through the active pipeline. Hotkey-badge shows the bound
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
export function SoundboardPanel({ onClose }: Props) {
const { t } = useTranslation(['app']);
const {
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
} = useCall();
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [query, setQuery] = useState('');
const closeRef = useRef(onClose);
closeRef.current = onClose;
const refresh = useCallback(async () => {
try {
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
setEntries(all);
setPrefs(p);
} catch (err: unknown) {
console.warn('soundboard panel refresh failed', err);
}
}, []);
useEffect(() => {
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return unsub;
}, [refresh]);
// Close on Esc — tapping outside is handled by the trigger's parent.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeRef.current();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
(e.category ?? '').toLowerCase().includes(q),
);
}, [entries, query]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of filtered) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [filtered]);
function toggleCategory(key: string): void {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div
role="dialog"
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
</h3>
<button
type="button"
onClick={onClose}
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{entries.length > 0 && (
<div className="border-b border-line px-3 pb-2 pt-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{entries.length === 0 ? (
<p className="py-6 text-center text-xs text-fg-muted">
{t('app:soundboard.panel_empty', {
defaultValue:
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
})}
</p>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-xs text-fg-muted">
{t('app:soundboard.panel_no_matches', {
defaultValue: 'Keine Treffer.',
})}
</p>
) : (
<div className="flex flex-col gap-3">
{Array.from(grouped.entries()).map(([key, bucket]) => (
<CategorySection
key={key || '__uncat'}
categoryKey={key}
entries={bucket}
collapsed={collapsed[key] ?? false}
activeIds={activeSoundboardIds}
onToggle={() => toggleCategory(key)}
onActivate={(id) => {
if (activeSoundboardIds.has(id)) {
stopSoundboard(id);
} else {
void playSoundboard(id);
}
}}
/>
))}
</div>
)}
</div>
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
<VolumeSlider
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
value={prefs.masterGain}
onChange={(v) => {
setPrefs((p) => ({ ...p, masterGain: v }));
void setSoundboardMasterGain(v);
}}
/>
<VolumeSlider
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
value={prefs.monitorGain}
onChange={(v) => {
setPrefs((p) => ({ ...p, monitorGain: v }));
void setSoundboardMonitorGain(v);
}}
/>
<button
type="button"
onClick={() => stopSoundboard()}
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
>
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
</button>
</footer>
</div>
);
}
interface CategorySectionProps {
categoryKey: string;
entries: SoundboardEntry[];
collapsed: boolean;
activeIds: ReadonlySet<string>;
onToggle: () => void;
onActivate: (id: string) => void;
}
function CategorySection({
categoryKey,
entries,
collapsed,
activeIds,
onToggle,
onActivate,
}: CategorySectionProps) {
const { t } = useTranslation(['app']);
const label =
categoryKey === ''
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
: categoryKey;
return (
<section>
<button
type="button"
onClick={onToggle}
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
>
<span>
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
/>
</button>
{!collapsed && (
<div className="grid grid-cols-2 gap-1.5">
{entries.map((entry) => (
<SoundPad
key={entry.id}
entry={entry}
active={activeIds.has(entry.id)}
onActivate={() => onActivate(entry.id)}
/>
))}
</div>
)}
</section>
);
}
interface PadProps {
entry: SoundboardEntry;
active: boolean;
onActivate: () => void;
}
function SoundPad({ entry, active, onActivate }: PadProps) {
const { t } = useTranslation(['app']);
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
const base =
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
const toneClass = active
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
return (
<button
type="button"
onClick={onActivate}
className={`${base} ${toneClass}`}
title={
active
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
: entry.name
}
aria-pressed={active}
>
<span className="flex items-center gap-1.5 truncate">
{active && (
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
/>
)}
<span className="truncate">{entry.name}</span>
</span>
<div className="flex items-center justify-between gap-1.5">
{hotkey ? (
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
{hotkey}
</span>
) : (
<span aria-hidden="true" />
)}
{active && (
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
</span>
)}
</div>
</button>
);
}
function VolumeSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center gap-3 text-[11px] text-fg">
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
<input
type="range"
min={0}
max={1}
step={0.02}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="accent-accent flex-1"
/>
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
{Math.round(value * 100)}%
</span>
</label>
);
}
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
listSounds,
subscribeSoundboardChanges,
type SoundboardEntry,
} from '../lib/soundboardStorage';
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
import { ArrowRightIcon } from './icons';
// Entry point into the soundboard manager from the settings page. Shows a
// tiny summary (count, category count) and opens the big dialog on click.
export function SoundboardSettings() {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const all = await listSounds();
if (!cancelled) setEntries(all);
} catch (err: unknown) {
console.warn('listSounds failed', err);
}
};
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
cancelled = true;
unsub();
};
}, []);
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
return (
<>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
</p>
<p className="mt-1 text-xs text-fg-muted">
{entries.length === 0
? t('app:soundboard.summary_empty', {
defaultValue: 'Noch keine Sounds vorhanden.',
})
: t('app:soundboard.summary_counts', {
defaultValue:
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
sounds: entries.length,
categories: categoryCount,
hotkeys: withHotkey,
})}
</p>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex shrink-0 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"
>
<span>
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
</span>
<ArrowRightIcon className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-fg-muted">
{t('app:soundboard.settings_hint', {
defaultValue:
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
})}
</p>
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
</>
);
}
+59 -4
View File
@@ -1,6 +1,6 @@
import { updateOwnProfile } from '@chat-app/shared/auth'; import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase'; import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons'; import { ChevronDownIcon } from './icons';
const STATUS_MAX = 128;
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline']; const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = { const PRESENCE_DOT: Record<PresenceState, string> = {
@@ -25,6 +27,23 @@ export function UserBar() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline'; const presence = profile?.presenceState ?? 'offline';
const persistedStatus = profile?.statusMessage ?? '';
const [statusDraft, setStatusDraft] = useState(persistedStatus);
// Re-sync local draft with the server when the profile refreshes (e.g. after
// a successful save) — without this the input would forget any incoming
// updates from another device.
useEffect(() => {
setStatusDraft(persistedStatus);
}, [persistedStatus]);
// Subtitle priority: custom status when online and set, else label, else "Offline".
const subtitle =
presence === 'offline'
? t('app:presence.offline')
: persistedStatus.trim().length > 0
? persistedStatus.trim()
: t('app:presence.' + presence);
async function changePresence(next: PresenceState) { async function changePresence(next: PresenceState) {
if (busy || next === presence) { if (busy || next === presence) {
@@ -43,6 +62,20 @@ export function UserBar() {
} }
} }
async function saveStatus() {
const next = statusDraft.trim().slice(0, STATUS_MAX);
if (next === persistedStatus) return;
setBusy(true);
try {
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
await refreshProfile();
} catch (err: unknown) {
console.error('updateStatusMessage failed', err);
} finally {
setBusy(false);
}
}
return ( return (
<div className="relative"> <div className="relative">
<button <button
@@ -69,9 +102,7 @@ export function UserBar() {
<p className="truncate text-sm font-medium text-fg"> <p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'} {profile?.displayName ?? '—'}
</p> </p>
<p className="truncate text-xs text-fg-muted"> <p className="truncate text-xs text-fg-muted">{subtitle}</p>
{t('app:presence.' + presence)}
</p>
</div> </div>
<ChevronDownIcon <ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')} className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
@@ -83,6 +114,30 @@ export function UserBar() {
role="menu" 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"
> >
<div className="border-b border-line p-2">
<input
type="text"
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
onBlur={() => void saveStatus()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void saveStatus();
setOpen(false);
}
if (e.key === 'Escape') {
setStatusDraft(persistedStatus);
setOpen(false);
}
}}
placeholder={t('app:presence.status_placeholder', {
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"
/>
</div>
{PRESENCE_OPTIONS.map((opt) => ( {PRESENCE_OPTIONS.map((opt) => (
<button <button
key={opt} key={opt}
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from 'react';
import { MicIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
/** Called with the recorded audio file when the user confirms. The button
* ships as a single-attachment message, so the parent can feed it into
* the normal send flow. */
onComplete: (file: File) => Promise<void> | void;
disabled?: boolean;
}
// Single-button voice recorder. Click to start; shows an inline pill with
// elapsed time + stop + cancel while recording. On stop, hands a File to the
// parent. No in-place preview yet — we lean on the optimistic message bubble
// to appear once the parent sends.
const MAX_RECORD_SEC = 60;
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
const [elapsedSec, setElapsedSec] = useState(0);
const [level, setLevel] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const startedAtRef = useRef<number>(0);
const cancelledRef = useRef(false);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number>(0);
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
useEffect(() => {
if (state !== 'recording') return;
const id = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
setElapsedSec(sec);
if (sec >= MAX_RECORD_SEC) {
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
}
}, 200);
return () => {
window.clearInterval(id);
};
}, [state]);
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
useEffect(() => {
if (state !== 'recording') return;
const analyser = analyserRef.current;
if (!analyser) return;
const buf = new Uint8Array(analyser.frequencyBinCount);
let cancelled = false;
const tick = () => {
if (cancelled) return;
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
setLevel(sum / (buf.length * 255));
rafRef.current = window.requestAnimationFrame(tick);
};
rafRef.current = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
window.cancelAnimationFrame(rafRef.current);
};
}, [state]);
const stopStream = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (audioCtxRef.current) {
void audioCtxRef.current.close().catch(() => {});
audioCtxRef.current = null;
}
analyserRef.current = null;
setLevel(0);
};
const start = async () => {
if (disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
try {
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;
analyser.smoothingTimeConstant = 0.4;
source.connect(analyser);
audioCtxRef.current = ctx;
analyserRef.current = analyser;
} catch {
/* Analyser is best-effort; recording still works without level meter. */
}
const mime = pickMime();
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorderRef.current = rec;
chunksRef.current = [];
cancelledRef.current = false;
rec.ondataavailable = (ev: BlobEvent) => {
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
};
rec.onstop = () => {
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
chunksRef.current = [];
stopStream();
if (cancelledRef.current || blob.size === 0) {
setState('idle');
setElapsedSec(0);
return;
}
setState('finalizing');
const ext = extFor(blob.type);
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
const file = new File([blob], filename, { type: blob.type });
void Promise.resolve(onComplete(file)).finally(() => {
setState('idle');
setElapsedSec(0);
});
};
rec.start();
startedAtRef.current = Date.now();
setElapsedSec(0);
setState('recording');
} catch (err: unknown) {
stopStream();
// Surface device-permission denials or capture failures via console;
// the composer doesn't have space for inline errors here and the
// browser already shows a system-level permission prompt.
console.error('VoiceRecorder.start failed', err);
}
};
const confirm = () => {
const rec = recorderRef.current;
if (!rec) return;
if (rec.state !== 'inactive') rec.stop();
};
const cancel = () => {
cancelledRef.current = true;
const rec = recorderRef.current;
if (rec && rec.state !== 'inactive') rec.stop();
else {
stopStream();
setState('idle');
setElapsedSec(0);
}
};
if (state === 'idle') {
return (
<button
type="button"
onClick={() => void start()}
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"
>
<MicIcon className="h-4 w-4" />
</button>
);
}
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">
<SpinnerIcon className="h-4 w-4" />
<span>Wird gesendet</span>
</div>
);
}
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
// stays visible when silent.
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
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">
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
<div
aria-hidden="true"
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
>
<span
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
style={{ width: levelPct + '%' }}
/>
</div>
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
{remaining}s
</span>
<button
type="button"
onClick={cancel}
aria-label="Aufnahme abbrechen"
title="Abbrechen"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={confirm}
aria-label="Aufnahme senden"
title="Senden"
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
>
<MicIcon className="h-3.5 w-3.5" />
</button>
</div>
);
}
function pickMime(): string | null {
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;
}
return null;
}
function extFor(mime: string): string {
if (mime.includes('webm')) return 'webm';
if (mime.includes('ogg')) return 'ogg';
if (mime.includes('mp4')) return 'm4a';
return 'bin';
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return m + ':' + s.toString().padStart(2, '0');
}
+32
View File
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
); );
} }
export function MusicIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</Base>
);
}
export function SendIcon(props: IconProps) { export function SendIcon(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
@@ -451,6 +461,28 @@ export function MoreVerticalIcon(props: IconProps) {
); );
} }
export function HeadphonesIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
</Base>
);
}
export function HeadphonesOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
<path d="M21 14v-2a9 9 0 0 0-1.5-5" />
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
<path d="M3 3 21 21" />
</Base>
);
}
export function ReplyIcon(props: IconProps) { export function ReplyIcon(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
+46
View File
@@ -3,6 +3,7 @@ import {
getOwnProfile, getOwnProfile,
signOut as supabaseSignOut, signOut as supabaseSignOut,
type Profile, type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth'; } from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n'; import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { Session } from '@supabase/supabase-js'; import type { Session } from '@supabase/supabase-js';
@@ -20,6 +21,7 @@ import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device'; import { findExistingDevice } from '../lib/device';
import { setSecretStoreUser } from '../lib/secretStore'; import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { registerWebPush } from '../lib/webPush';
interface AuthContextValue { interface AuthContextValue {
session: Session | null; session: Session | null;
@@ -135,6 +137,50 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}); });
}, [session, refreshProfile, refreshDevice]); }, [session, refreshProfile, refreshDevice]);
// Best-effort web-push registration once we know the device id. No-op on
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
useEffect(() => {
if (!device?.id) return;
void registerWebPush(device.id);
}, [device?.id]);
// Auto online/offline transition.
//
// - On mount with a session whose last persisted state is `offline`, flip
// to `online`. We never override an explicit `idle`, `dnd`, or
// `invisible` choice — those are user intent.
// - On `pagehide` / `beforeunload`, fire a best-effort update to
// `offline`. Browsers don't guarantee delivery during unload, but the
// request usually slips through; the next page load corrects state if it
// didn't.
useEffect(() => {
if (!session || !profile) return;
if (profile.presenceState === 'offline') {
void updateOwnProfile(supabase, { presenceState: 'online' })
.then(() => refreshProfile())
.catch((err: unknown) => {
console.warn('auto online flip failed', err);
});
}
const onLeave = () => {
// Skip if user explicitly chose a non-online state — they probably
// want to look unavailable on next reconnect too.
if (
profile.presenceState !== 'online' &&
profile.presenceState !== 'offline'
) {
return;
}
void updateOwnProfile(supabase, { presenceState: 'offline' }).catch(() => {});
};
window.addEventListener('beforeunload', onLeave);
window.addEventListener('pagehide', onLeave);
return () => {
window.removeEventListener('beforeunload', onLeave);
window.removeEventListener('pagehide', onLeave);
};
}, [session, profile, refreshProfile]);
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
await supabaseSignOut(supabase); await supabaseSignOut(supabase);
}, []); }, []);
+445 -34
View File
@@ -47,9 +47,21 @@ import {
getCallE2EESettings, getCallE2EESettings,
isE2EESupported, isE2EESupported,
} from '../lib/callE2EE'; } from '../lib/callE2EE';
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
import { getParticipantVolume } from '../lib/participantVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playEntry } from '../lib/soundboardPlayback';
import { import {
getPrefs as getSoundboardPrefs,
listSounds as listSoundboard,
updatePrefs as updateSoundboardPrefs,
} from '../lib/soundboardStorage';
import {
type DisplaySurfaceHint,
getPresetParams, getPresetParams,
getScreenShareSettings, getScreenShareSettings,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings'; } from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
@@ -102,6 +114,15 @@ interface CallContextValue {
isMuted: boolean; isMuted: boolean;
isE2EEActive: boolean; isE2EEActive: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
isCameraEnabled: boolean;
isDeafened: boolean;
/** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>;
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
* the mic pipeline keeps the track published with sound flowing even
* while the mic path is gain-silenced, so LK never sees "muted". */
remoteMute: Record<string, boolean>;
// Zero or more remote screen shares (LiveKit supports multiple simultaneous). // Zero or more remote screen shares (LiveKit supports multiple simultaneous).
remoteScreenShares: RemoteScreenShare[]; remoteScreenShares: RemoteScreenShare[];
// Remembers the conversation of the last call we left so a sidebar widget // Remembers the conversation of the last call we left so a sidebar widget
@@ -113,14 +134,37 @@ interface CallContextValue {
// Actions: // Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
acceptIncoming: () => Promise<void>; acceptIncoming: (override?: CallKind) => Promise<void>;
rejectIncoming: () => void; rejectIncoming: () => void;
hangup: () => Promise<void>; hangup: () => Promise<void>;
toggleMute: () => void; toggleMute: () => void;
toggleScreenShare: () => Promise<void>; toggleScreenShare: () => Promise<void>;
startScreenShare: (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => Promise<void>;
stopScreenShare: () => Promise<void>;
toggleCamera: () => Promise<void>;
toggleDeafen: () => void;
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void; setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void; setFocusedId: (id: string | null) => void;
/** Play a soundboard entry through the active call's mic pipeline.
* No-op when not connected. Default single-fire per id (spamming the
* hotkey cuts the previous instance); set overlap=true to layer. */
playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise<void>;
/** Stop every active sb source, or just the one matching `id` if given. */
stopSoundboard: (id?: string) => void;
/** Ids of soundboard entries currently emitting audio. Updated live so
* the in-call panel can show a stop icon on active pads. */
activeSoundboardIds: ReadonlySet<string>;
/** Apply new sb master/monitor gains to the live pipeline. Persists via
* updatePrefs in the storage module. */
setSoundboardMasterGain: (value: number) => Promise<void>;
setSoundboardMonitorGain: (value: number) => Promise<void>;
// Runtime mic switch. Persists choice so subsequent calls reuse it, and // Runtime mic switch. Persists choice so subsequent calls reuse it, and
// hot-swaps the input on an active call without a reconnect. // hot-swaps the input on an active call without a reconnect.
setAudioInputDevice: (deviceId: string | null) => Promise<void>; setAudioInputDevice: (deviceId: string | null) => Promise<void>;
@@ -139,7 +183,7 @@ function newCallId(): string {
} }
export function CallProvider({ children }: { children: ReactNode }) { export function CallProvider({ children }: { children: ReactNode }) {
const { session, device } = useAuth(); const { session, device, profile } = useAuth();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const myId = session?.user.id; const myId = session?.user.id;
@@ -149,16 +193,24 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isE2EEActive, setIsE2EEActive] = useState(false); const [isE2EEActive, setIsE2EEActive] = useState(false);
const [isScreenSharing, setIsScreenSharing] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false);
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]); const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid'); const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null); const [focusedId, setFocusedIdState] = useState<string | null>(null);
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const signalChannelRef = useRef<RealtimeChannel | null>(null); const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null); const presenceChannelRef = useRef<RealtimeChannel | null>(null);
const ringTimerRef = useRef<number | null>(null); const ringTimerRef = useRef<number | null>(null);
const soloTimerRef = useRef<number | null>(null); const soloTimerRef = useRef<number | null>(null);
const roomRef = useRef<Room | null>(null); const roomRef = useRef<Room | null>(null);
// Web Audio graph that mixes live mic + soundboard sources into a single
// published track. Created per call in joinRoom, destroyed in disconnectRoom.
const pipelineRef = useRef<MicPipeline | null>(null);
// Tracks whether the current call was ever in the connected state — needed // Tracks whether the current call was ever in the connected state — needed
// so hangup/solo-timeout can emit a real duration message vs. "missed". // so hangup/solo-timeout can emit a real duration message vs. "missed".
const everConnectedRef = useRef<boolean>(false); const everConnectedRef = useRef<boolean>(false);
@@ -167,12 +219,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
// For 1:1 calls the set has one entry so behaviour is unchanged; for // For 1:1 calls the set has one entry so behaviour is unchanged; for
// groups, a single reject doesn't terminate the call while others ring. // groups, a single reject doesn't terminate the call while others ring.
const pendingPeersRef = useRef<Set<string>>(new Set()); const pendingPeersRef = useRef<Set<string>>(new Set());
// identity -> their current deafen state. Populated via LiveKit data
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
// state so consumer components re-render on change.
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
// Mirror of isMuted for use inside LiveKit-event callbacks that run outside
// the React component (ParticipantConnected rebroadcast etc).
const mutedRef = useRef<boolean>(false);
const stateRef = useRef<CallState>(state); const stateRef = useRef<CallState>(state);
stateRef.current = state; stateRef.current = state;
// Keep latest conversations accessible from signal-channel closures without // Keep latest conversations accessible from signal-channel closures without
// re-subscribing the channel on every conversations update. // re-subscribing the channel on every conversations update.
const conversationsRef = useRef(conversations); const conversationsRef = useRef(conversations);
conversationsRef.current = conversations; conversationsRef.current = conversations;
// Mirror own presence state for use inside signal-channel callbacks. DND
// suppresses incoming-call OS notifications (ringtone is handled in CallUI
// which has direct access to the auth profile).
const presenceRef = useRef(profile?.presenceState ?? 'offline');
presenceRef.current = profile?.presenceState ?? 'offline';
// --- Helpers ----------------------------------------------------------- // --- Helpers -----------------------------------------------------------
@@ -243,8 +308,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteParticipants([]); setRemoteParticipants([]);
setRemoteScreenShares([]); setRemoteScreenShares([]);
setIsScreenSharing(false); setIsScreenSharing(false);
setIsCameraEnabled(false);
setIsDeafened(false);
deafenedActive = false;
setRemoteDeafen({});
setRemoteMute({});
setIsMuted(false);
mutedRef.current = false;
setIsE2EEActive(false); setIsE2EEActive(false);
// Tear down the mic pipeline AFTER LiveKit disconnects so the published
// track is unpublished cleanly first; then close AudioContext + stop
// raw mic + output tracks we own.
const pipeline = pipelineRef.current;
if (pipeline) {
try {
pipeline.destroy();
} catch {
/* ignore */
}
pipelineRef.current = null;
}
setActiveSoundboardIds(new Set<string>());
const pres = presenceChannelRef.current; const pres = presenceChannelRef.current;
if (pres) { if (pres) {
try { try {
@@ -463,6 +549,58 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
}); });
// Mute/unmute a camera doesn't publish or unpublish — the publication
// stays, just its `muted` flag flips. Without this listener, remote
// participants who toggle video mid-call appear as a frozen last frame
// or (worse) a black tile on every other client. Bumping the
// remoteParticipants state reference forces buildTiles to re-read
// `isCameraEnabled` and swap to the avatar placeholder.
const bumpParticipants = () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
};
r.on(RoomEvent.TrackMuted, bumpParticipants);
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
// Remote deafen state is broadcast via the LiveKit data channel. We
// store incoming states in `remoteDeafenMapRef` and bump participants
// so buildTiles re-reads it.
r.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
if (!participant?.identity) return;
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as {
type?: string;
deafened?: boolean;
muted?: boolean;
};
if (msg.type !== 'presence') return;
const id: string = participant.identity;
if (typeof msg.deafened === 'boolean') {
const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened };
});
}
if (typeof msg.muted === 'boolean') {
const muted: boolean = msg.muted;
setRemoteMute((prev) => {
if (prev[id] === muted) return prev;
return { ...prev, [id]: muted };
});
}
} catch {
/* ignore malformed */
}
},
);
// When someone joins, re-send our current presence (deafen + mute) so
// they know immediately instead of waiting for the next toggle.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive, mutedRef.current);
});
// Track my own screen-share state via LocalTrack events so the toggle // Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI. // stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => { r.on(RoomEvent.LocalTrackPublished, (publication) => {
@@ -490,22 +628,48 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
try { try {
const inputId = getAudioSettings().inputDeviceId; const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, { // Grab the raw mic ourselves instead of going through LiveKit's
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
// through createMicPipeline, which mixes in soundboard buffers and
// exposes a single output track we hand to publishTrack. Mute / PTT
// are gain-based from here on, never track.enabled or device stop.
const rawStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation, echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression, noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl, autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1, channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz, sampleRate: aParams.sampleRateHz,
// Plain string maps to `ideal` — if the device is gone we fall back ...(inputId ? { deviceId: { ideal: inputId } } : {}),
// to OS default instead of throwing NotFoundError. },
...(inputId ? { deviceId: inputId } : {}), video: false,
});
const rawTrack = rawStream.getAudioTracks()[0];
if (!rawTrack) throw new Error('no audio track from getUserMedia');
const pipeline = createMicPipeline(rawTrack);
pipelineRef.current = pipeline;
// Pull the user's last-saved soundboard gains onto the live pipeline
// before the first sound ever plays so nothing blasts at 100%.
try {
const prefs = await getSoundboardPrefs();
pipeline.setSoundboardGain(prefs.masterGain);
pipeline.setMonitorGain(prefs.monitorGain);
} catch (err: unknown) {
console.warn('getSoundboardPrefs failed', err);
}
await r.localParticipant.publishTrack(pipeline.outputTrack, {
source: Track.Source.Microphone,
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
}); });
} catch (micErr: unknown) { } catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr); console.error('mic pipeline setup failed', micErr);
} }
if (mediaKind === 'video') { if (mediaKind === 'video') {
try { try {
await r.localParticipant.setCameraEnabled(true); await r.localParticipant.setCameraEnabled(true);
setIsCameraEnabled(true);
} catch (camErr: unknown) { } catch (camErr: unknown) {
console.error('setCameraEnabled failed', camErr); console.error('setCameraEnabled failed', camErr);
} }
@@ -650,10 +814,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
[myId, joinRoom, disconnectRoom], [myId, joinRoom, disconnectRoom],
); );
const acceptIncoming = useCallback(async () => { const acceptIncoming = useCallback(
async (override?: CallKind) => {
const s = stateRef.current; const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return; if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId, mediaKind } = s; const { callId, conversationId } = s;
// Caller's `mediaKind` is the INVITE kind (what they started with). The
// receiver can accept with audio even if the caller rang as video, or
// upgrade an audio invite to video on accept. `override` picks the
// receiver's choice.
const mediaKind: CallKind = override ?? s.mediaKind;
everConnectedRef.current = false; everConnectedRef.current = false;
setLastCallConversationId(null); setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind }); setState({ kind: 'connecting', callId, conversationId, mediaKind });
@@ -666,7 +836,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
}); });
await disconnectRoom(); await disconnectRoom();
} }
}, [myId, joinRoom, disconnectRoom]); },
[myId, joinRoom, disconnectRoom],
);
const rejectIncoming = useCallback(() => { const rejectIncoming = useCallback(() => {
const s = stateRef.current; const s = stateRef.current;
@@ -731,42 +903,141 @@ export function CallProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const toggleMute = useCallback(() => { const toggleMute = useCallback(() => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
setIsMuted((prev) => {
const nextMuted = !prev;
pipeline.setMicGain(nextMuted ? 0 : 1);
mutedRef.current = nextMuted;
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (r) void broadcastPresence(r, deafenedActive, nextMuted);
const lp = r.localParticipant; return nextMuted;
const shouldEnable = !lp.isMicrophoneEnabled;
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
setIsMuted(!shouldEnable);
}); });
}, []); }, []);
const toggleScreenShare = useCallback(async () => { const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => {
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (!r) return;
const lp = r.localParticipant; const lp = r.localParticipant;
const nextOn = !lp.isScreenShareEnabled; if (lp.isScreenShareEnabled) return;
// Persist the user's choice so subsequent shares use the same config
// without re-opening the picker unless they want to change something.
const settings = getScreenShareSettings();
const preset = overrides?.preset ?? settings.preset;
const displaySurface =
overrides?.displaySurface !== undefined
? overrides.displaySurface
: settings.displaySurface;
const framerateOverride =
overrides?.framerate !== undefined
? overrides.framerate
: settings.framerateOverride;
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
try { try {
const ssParams = getPresetParams(getScreenShareSettings().preset); await lp.setScreenShareEnabled(true, {
await lp.setScreenShareEnabled(nextOn, {
audio: false, audio: false,
// Omitting `resolution` lets the browser return native source size —
// best possible input quality. Fixed presets pass explicit dims so
// the encoder has a predictable target.
...(ssParams.dims ...(ssParams.dims
? { ? {
resolution: { resolution: {
width: ssParams.dims.width, width: ssParams.dims.width,
height: ssParams.dims.height, height: ssParams.dims.height,
frameRate: ssParams.framerate, frameRate: fps,
}, },
} }
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
// Hints the OS picker to pre-filter by source kind. `null` = no
// filter (show both). Cast because TS lib.dom doesn't know the
// field yet on all branches.
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}), : {}),
contentHint: 'detail', contentHint: 'detail',
}); });
setIsScreenSharing(nextOn); setIsScreenSharing(true);
} catch (err: unknown) { } catch (err: unknown) {
console.error('setScreenShareEnabled failed', err); console.error('setScreenShareEnabled failed', err);
// User cancelled or permission denied — leave state as-is. }
},
[],
);
const stopScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isScreenShareEnabled) return;
try {
await lp.setScreenShareEnabled(false);
setIsScreenSharing(false);
} catch (err: unknown) {
console.error('stopScreenShare failed', err);
}
}, []);
// Legacy toggle kept for convenience elsewhere — opens/closes with the
// last-persisted settings and no picker UI.
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
if (r.localParticipant.isScreenShareEnabled) {
await stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
const toggleDeafen = useCallback(() => {
setIsDeafened((prev) => {
const next = !prev;
deafenedActive = next;
// Apply to every currently-attached remote-audio element. Fresh tracks
// that attach during a deafened session are muted in attachTrack above.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
els.forEach((el) => {
el.muted = next;
});
// Broadcast via LiveKit data channel so peers' UIs can show the
// headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current;
if (r) void broadcastPresence(r, next, mutedRef.current);
return next;
});
}, []);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
// publication state so the button doesn't lie.
setIsCameraEnabled(lp.isCameraEnabled);
} }
}, []); }, []);
@@ -785,9 +1056,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
let globalRegisteredFor: string | null = null; let globalRegisteredFor: string | null = null;
const setMic = (on: boolean) => { const setMic = (on: boolean) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.setMicGain(on ? 1 : 0);
const nextMuted = !on;
mutedRef.current = nextMuted;
setIsMuted(nextMuted);
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (r) void broadcastPresence(r, deafenedActive, nextMuted);
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
}; };
const pressPtt = () => { const pressPtt = () => {
@@ -916,6 +1192,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.peer?.displayName ?? conv?.peer?.displayName ??
'…'; '…';
const isGroup = conv?.type === 'group'; const isGroup = conv?.type === 'group';
if (presenceRef.current !== 'dnd') {
void notify({ void notify({
title: isGroup title: isGroup
? (conv?.name ?? 'Gruppenanruf') ? (conv?.name ?? 'Gruppenanruf')
@@ -924,6 +1201,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
? callerName + ' ruft die Gruppe' ? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an', : callerName + ' ruft dich an',
}); });
}
break; break;
} }
case 'accept': case 'accept':
@@ -964,10 +1242,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ?? conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ?? conv?.peer?.displayName ??
'…'; '…';
if (presenceRef.current !== 'dnd') {
void notify({ void notify({
title: 'Verpasster Anruf', title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt', body: callerName + ' hat aufgelegt',
}); });
}
setState({ kind: 'idle' }); setState({ kind: 'idle' });
} }
break; break;
@@ -984,16 +1264,96 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedIdState(id); setFocusedIdState(id);
}, []); }, []);
const markActive = useCallback((id: string, on: boolean) => {
setActiveSoundboardIds((prev) => {
const has = prev.has(id);
if (on && has) return prev;
if (!on && !has) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const playSoundboard = useCallback(
async (id: string, opts?: { overlap?: boolean }) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const handle = await playEntry(pipeline, entry, {
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
onEnded: () => markActive(id, false),
});
if (handle) markActive(id, true);
},
[markActive],
);
const stopSoundboard = useCallback(
(id?: string) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.stopAll(id);
if (id) {
markActive(id, false);
} else {
setActiveSoundboardIds(new Set<string>());
}
},
[markActive],
);
const setSoundboardMasterGain = useCallback(async (value: number) => {
const prefs = await updateSoundboardPrefs({ masterGain: value });
pipelineRef.current?.setSoundboardGain(prefs.masterGain);
}, []);
const setSoundboardMonitorGain = useCallback(async (value: number) => {
const prefs = await updateSoundboardPrefs({ monitorGain: value });
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []);
// Global soundboard hotkey registration — runs only while connected so the
// OS-level shortcuts don't fire when the user is outside of a call.
useEffect(() => {
if (state.kind !== 'connected') return;
const teardown = startSoundboardHotkeys((id) => {
void playSoundboard(id);
});
return teardown;
}, [state.kind, playSoundboard]);
const setAudioInputDevice = useCallback(async (deviceId: string | null) => { const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId }); updateAudioSettings({ inputDeviceId: deviceId });
const r = roomRef.current; const pipeline = pipelineRef.current;
if (!r) return; if (!pipeline) return;
try { try {
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a // We own the mic track (see joinRoom pipeline setup), so LiveKit's
// reconnect. Pass empty string or `default` to revert to OS default. // switchActiveDevice no longer applies. Fetch a new raw track with the
await r.switchActiveDevice('audioinput', deviceId ?? 'default'); // updated deviceId + the same quality constraints, then hand ownership
// to the pipeline. It disconnects the old source, stops the old track,
// and rewires micGain onto the new source — the published track stays
// stable so peers don't see a republish.
const aParams = getAudioQualityParams(getAudioSettings().quality);
const newStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(deviceId ? { deviceId: { ideal: deviceId } } : {}),
},
video: false,
});
const newTrack = newStream.getAudioTracks()[0];
if (!newTrack) throw new Error('no audio track for device');
pipeline.replaceMicTrack(newTrack);
} catch (err: unknown) { } catch (err: unknown) {
console.error('switchActiveDevice(audioinput) failed', err); console.error('setAudioInputDevice failed', err);
} }
}, []); }, []);
@@ -1054,6 +1414,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted, isMuted,
isE2EEActive, isE2EEActive,
isScreenSharing, isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -1065,11 +1429,20 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup, hangup,
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
setAudioInputDevice, setAudioInputDevice,
setAudioOutputDevice, setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
}), }),
[ [
state, state,
@@ -1078,6 +1451,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
isMuted, isMuted,
isE2EEActive, isE2EEActive,
isScreenSharing, isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -1089,11 +1466,20 @@ export function CallProvider({ children }: { children: ReactNode }) {
hangup, hangup,
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
setAudioInputDevice, setAudioInputDevice,
setAudioOutputDevice, setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
], ],
); );
@@ -1106,10 +1492,30 @@ export function useCall(): CallContextValue {
return ctx; return ctx;
} }
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
// React component) can apply the current deafen state to freshly-attached
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;
async function broadcastPresence(
room: Room,
deafened: boolean,
muted: boolean,
): Promise<void> {
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened, muted }),
);
await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) {
console.warn('broadcastPresence failed', err);
}
}
function attachTrack( function attachTrack(
track: RemoteTrack, track: RemoteTrack,
_publication: RemoteTrackPublication, _publication: RemoteTrackPublication,
_participant: RemoteParticipant, participant: RemoteParticipant,
): void { ): void {
if (track.kind === Track.Kind.Audio) { if (track.kind === Track.Kind.Audio) {
const audio = track.attach(); const audio = track.attach();
@@ -1117,6 +1523,11 @@ function attachTrack(
audio.autoplay = true; audio.autoplay = true;
audio.setAttribute('playsinline', 'true'); audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? ''); audio.setAttribute('data-livekit-track', track.sid ?? '');
if (participant.identity) {
audio.setAttribute('data-participant', participant.identity);
audio.volume = getParticipantVolume(participant.identity);
}
if (deafenedActive) audio.muted = true;
document.body.appendChild(audio); document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen // Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice` // speaker/headphone from the start (LiveKit's own `switchActiveDevice`
@@ -99,7 +99,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
return; return;
} }
try { try {
setLoading(true); // Only flag loading on initial fetch so background re-syncs (visibility
// change, online event) don't blank the list each time.
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
const convs = await listConversations(supabase); const convs = await listConversations(supabase);
setConversations(convs); setConversations(convs);
const entries = await Promise.all( const entries = await Promise.all(
@@ -213,7 +215,34 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
) )
.subscribe(); .subscribe();
// Windows WebView2 aggressively throttles background WebSockets and
// sometimes drops events entirely while the window is minimised. Force a
// refresh + realtime reconnect on visibility/focus regain so we never
// leave stale conversation lists on a Windows client after the user
// returns to the app.
let lastAwakeRefresh = 0;
const AWAKE_THROTTLE_MS = 30_000;
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
lastAwakeRefresh = now;
void refresh();
try {
// If the socket got wedged during background throttle, a no-op
// unsubscribe+resubscribe brings it back. `subscribe()` on an already
// joined channel is a no-op so this is safe.
channel.subscribe();
} catch {
/* ignore — already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('online', onAwake);
return () => { return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel); void supabase.removeChannel(channel);
}; };
}, [myId, refresh, markRead]); }, [myId, refresh, markRead]);
+128
View File
@@ -0,0 +1,128 @@
// Crash / uncaught-error recovery.
//
// The React <ErrorBoundary> already catches render errors. This module covers
// the OTHER half: async code outside React (realtime handlers, Promises
// that never hit a .catch, setTimeout callbacks, LiveKit event listeners,
// etc.). Those bubble to window.onerror / onunhandledrejection and would
// otherwise disappear silently in production.
//
// Behaviour:
// - First error: emit a toast + log.
// - Repeated identical errors within DEDUPE_MS: swallow (no spam).
// - More than BURST_LIMIT distinct errors within BURST_WINDOW_MS: force a
// full page reload. Something is systematically broken and the UI
// probably can't recover in-place.
export interface CrashEntry {
id: string;
message: string;
stack: string | null;
source: 'window' | 'promise';
at: number;
}
type Listener = (entry: CrashEntry) => void;
const DEDUPE_MS = 10_000;
const BURST_WINDOW_MS = 30_000;
const BURST_LIMIT = 8;
const listeners = new Set<Listener>();
const recent = new Map<string, number>(); // message hash → last seen
let burst: number[] = [];
let installed = false;
function hash(message: string): string {
let h = 0;
for (let i = 0; i < message.length; i++) {
h = ((h << 5) - h + message.charCodeAt(i)) | 0;
}
return h.toString(36);
}
function emit(entry: CrashEntry): void {
// Dedupe on the message hash so a handler that fires every animation
// frame doesn't drown the UI in toasts.
const key = hash(entry.message);
const now = entry.at;
const last = recent.get(key);
if (last && now - last < DEDUPE_MS) return;
recent.set(key, now);
// Purge stale dedupe entries to keep the map bounded.
for (const [k, ts] of recent) {
if (now - ts > DEDUPE_MS * 3) recent.delete(k);
}
// Burst detection: drop samples older than window, count what's left.
burst = burst.filter((t) => now - t < BURST_WINDOW_MS);
burst.push(now);
if (burst.length >= BURST_LIMIT) {
console.error('[crash-recovery] burst threshold reached — reloading');
// Let existing toast handlers see the final entry first, then reload.
// Delay lets the log flush + any pending realtime ack go through.
window.setTimeout(() => window.location.reload(), 500);
}
for (const fn of listeners) {
try {
fn(entry);
} catch (err) {
// Listener itself threw — log but don't recurse.
console.error('[crash-recovery] listener threw', err);
}
}
}
function toEntry(
err: unknown,
source: CrashEntry['source'],
): CrashEntry {
const message =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: (() => {
try {
return JSON.stringify(err);
} catch {
return String(err);
}
})();
const stack = err instanceof Error ? err.stack ?? null : null;
return {
id: crypto.randomUUID(),
message: message || 'unknown error',
stack,
source,
at: Date.now(),
};
}
export function installCrashHandlers(): void {
if (installed) return;
installed = true;
window.addEventListener('error', (ev: ErrorEvent) => {
// Some events (ResizeObserver loop, benign extension injections) arrive
// as errors without a real message. Skip those to avoid spam.
if (!ev.message && !ev.error) return;
emit(toEntry(ev.error ?? ev.message, 'window'));
});
window.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) => {
emit(toEntry(ev.reason, 'promise'));
});
}
export function subscribeCrashes(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
// Test / escape hatch — lets the reload threshold get checked in dev.
export function reportManualCrash(err: unknown): void {
emit(toEntry(err, 'window'));
}
+56
View File
@@ -140,3 +140,59 @@ export async function importDeviceBackup(
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array { export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
return unb64url(payload.privateKeyB64); return unb64url(payload.privateKeyB64);
} }
// ---------------------------------------------------------------------------
// Recovery code
// ---------------------------------------------------------------------------
//
// Generates a high-entropy code shown to the user once at backup time. The
// same payload is encrypted twice — once with the user's passphrase, once
// with the recovery code — so either string can decrypt the device key.
//
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
// characters), grouped as 4×6. ~120 bits of entropy.
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export interface BackupBundle {
passphraseBackup: string;
recoveryBackup: string;
recoveryCode: string;
}
export async function exportDeviceBackupWithRecovery(params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
}): Promise<BackupBundle> {
const recoveryCode = await generateRecoveryCode();
const [passphraseBackup, recoveryBackup] = await Promise.all([
exportDeviceBackup(params),
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
]);
return { passphraseBackup, recoveryBackup, recoveryCode };
}
async function generateRecoveryCode(): Promise<string> {
const s = await ensureSodium();
const raw = s.randombytes_buf(24);
let out = '';
for (let i = 0; i < raw.length; i++) {
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
}
return out;
}
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
// maps look-alike characters. Lets users enter the code with imperfect
// spacing without rejecting valid input.
export function normalizeRecoveryCode(input: string): string {
return input
.toUpperCase()
.replace(/[\s-]/g, '')
.split('')
.filter((c) => RECOVERY_ALPHABET.includes(c))
.join('');
}
+69
View File
@@ -55,3 +55,72 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
export function isTauriRuntime(): boolean { export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
} }
// --- Soundboard shortcuts --------------------------------------------------
//
// Separate from the single PTT shortcut: the soundboard needs to register
// many fire-and-forget press bindings at once, keep track of which ids own
// which accelerators so we can unregister just one, and expose conflict
// detection for the settings UI.
interface SoundShortcutRegistration {
shortcut: string;
onPress: () => void;
}
// Map of logical id (sound uuid) -> registration.
const soundRegistry = new Map<string, SoundShortcutRegistration>();
export async function registerSoundShortcut(
id: string,
code: string,
onPress: () => void,
): Promise<boolean> {
if (!isTauriRuntime()) return false;
const shortcut = codeToShortcut(code);
// Unregister any previous binding for this id first — caller may be
// re-registering after the user changed the hotkey for the same sound.
await unregisterSoundShortcut(id);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
});
soundRegistry.set(id, { shortcut, onPress });
return true;
} catch (err: unknown) {
console.warn('registerSoundShortcut failed', { id, code, err });
return false;
}
}
export async function unregisterSoundShortcut(id: string): Promise<void> {
const reg = soundRegistry.get(id);
if (!reg) return;
soundRegistry.delete(id);
if (!isTauriRuntime()) return;
try {
if (await isRegistered(reg.shortcut)) {
await unregister(reg.shortcut);
}
} catch (err: unknown) {
console.warn('unregisterSoundShortcut failed', { id, err });
}
}
export async function unregisterAllSoundShortcuts(): Promise<void> {
const ids = Array.from(soundRegistry.keys());
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
}
// Resolve a DOM code to the registry's current owner (if any). Used by the
// settings UI to surface conflicts before saving a new hotkey.
export function soundShortcutOwnerFor(code: string): string | null {
const shortcut = codeToShortcut(code);
for (const [id, reg] of soundRegistry) {
if (reg.shortcut === shortcut) return id;
}
return null;
}
+164
View File
@@ -0,0 +1,164 @@
// Offline text-message outbox.
//
// When `sendEncryptedMessage` fails (network loss, server 5xx, transient
// realtime hiccups) we stash the plaintext in localStorage keyed by
// conversation. The UI shows it inline with a "sending…" indicator; the
// drain loop retries with exponential backoff until it lands.
//
// Attachments are intentionally NOT queued — they're too large to persist
// and require server-side upload that can't be deferred reliably. The
// composer surfaces an immediate error for those.
const STORAGE_KEY = 'chat.outbox.v1';
const MAX_ATTEMPTS = 8;
export interface OutboxItem {
/** Local-only id (never collides with server UUIDs). */
id: string;
conversationId: string;
text: string;
replyToId: string | null;
createdAt: string;
attempts: number;
/** Timestamp of the next permitted send attempt. */
nextAttemptAt: string;
/** Last error message for surface in the UI. */
lastError: string | null;
}
type Store = Record<string, OutboxItem[]>; // keyed by conversationId
type Listener = (byConv: Store) => void;
function load(): Store {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
const out: Store = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (Array.isArray(v)) {
out[k] = v.filter((x): x is OutboxItem => isItem(x));
}
}
return out;
} catch {
return {};
}
}
function isItem(x: unknown): x is OutboxItem {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return (
typeof o.id === 'string' &&
typeof o.conversationId === 'string' &&
typeof o.text === 'string' &&
typeof o.createdAt === 'string'
);
}
let store: Store = load();
const listeners = new Set<Listener>();
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
/* quota exceeded — ignore; in-memory queue still retries */
}
}
function notify(): void {
for (const fn of listeners) fn(store);
}
export function subscribeOutbox(fn: Listener): () => void {
listeners.add(fn);
fn(store);
return () => {
listeners.delete(fn);
};
}
export function getOutbox(conversationId: string): OutboxItem[] {
return store[conversationId] ?? [];
}
export function enqueueOutbox(args: {
conversationId: string;
text: string;
replyToId: string | null;
error: string | null;
}): OutboxItem {
const item: OutboxItem = {
id: 'local-' + crypto.randomUUID(),
conversationId: args.conversationId,
text: args.text,
replyToId: args.replyToId,
createdAt: new Date().toISOString(),
attempts: 0,
nextAttemptAt: new Date().toISOString(),
lastError: args.error,
};
const bucket = store[args.conversationId] ?? [];
store = { ...store, [args.conversationId]: [...bucket, item] };
persist();
notify();
return item;
}
export function removeOutbox(conversationId: string, id: string): void {
const bucket = store[conversationId];
if (!bucket) return;
const next = bucket.filter((x) => x.id !== id);
if (next.length === bucket.length) return;
if (next.length === 0) {
const copy = { ...store };
delete copy[conversationId];
store = copy;
} else {
store = { ...store, [conversationId]: next };
}
persist();
notify();
}
export function markAttempt(
conversationId: string,
id: string,
error: string | null,
): OutboxItem | null {
const bucket = store[conversationId];
if (!bucket) return null;
const idx = bucket.findIndex((x) => x.id === id);
if (idx < 0) return null;
const prev = bucket[idx]!;
const attempts = prev.attempts + 1;
// Exponential backoff: 2^n seconds, capped at 60s.
const delaySec = Math.min(60, Math.pow(2, attempts));
const next: OutboxItem = {
...prev,
attempts,
lastError: error,
nextAttemptAt: new Date(Date.now() + delaySec * 1000).toISOString(),
};
const nextBucket = [...bucket];
nextBucket[idx] = next;
store = { ...store, [conversationId]: nextBucket };
persist();
notify();
return next;
}
export function shouldGiveUp(item: OutboxItem): boolean {
return item.attempts >= MAX_ATTEMPTS;
}
export function isDue(item: OutboxItem): boolean {
return new Date(item.nextAttemptAt).getTime() <= Date.now();
}
export function allConversationsWithItems(): string[] {
return Object.keys(store);
}
+275
View File
@@ -0,0 +1,275 @@
// Shared Web Audio graph that sits between the raw microphone MediaStream
// and LiveKit's published track. Mixes live mic with on-demand soundboard
// buffers so both paths reach the peer through a single published track,
// and lets us locally monitor soundboard output without feedback from the
// mic path.
//
// Graph:
// rawMicSource ──► micGain ──┐
// ├─► destinationNode ─► publishedTrack
// sbBufferSources ─► sbGain ─┤
// └─► monitorGain ─► ctx.destination (local hear,
// soundboard only)
//
// Lifetime:
// createMicPipeline(rawTrack) — builds graph + AudioContext
// pipeline.outputTrack — pass to `localParticipant.publishTrack`
// pipeline.setMicGain(0..1) — mute / PTT
// pipeline.setSoundboardGain(..) / setMonitorGain(..) — sb master + local hear
// pipeline.playBuffer(buffer, opts) — returns a handle so callers can stop
// pipeline.stopAll(buffers?) — kill every active sb source (or only one id)
// pipeline.replaceMicTrack(newTrack) — hot-swap on device change
// pipeline.destroy() — close ctx, stop owned tracks
export interface PlayBufferOpts {
/** Per-source gain 0..1, multiplied by sb master. */
gain?: number;
/** Stable id — calling playBuffer with the same id stops the previous one
* first (single-fire mode). Omit for overlap mode. */
id?: string;
/** Fired when the buffer ends naturally (not when stopped manually). */
onEnded?: () => void;
}
export interface PlayHandle {
id: string | null;
stop(): void;
}
export interface MicPipeline {
readonly outputTrack: MediaStreamTrack;
setMicGain(value: number): void;
setSoundboardGain(value: number): void;
setMonitorGain(value: number): void;
replaceMicTrack(newTrack: MediaStreamTrack): void;
playBuffer(buffer: AudioBuffer, opts?: PlayBufferOpts): PlayHandle;
stopAll(id?: string): void;
destroy(): void;
}
interface ActiveSource {
id: string | null;
node: AudioBufferSourceNode;
gain: GainNode;
}
// Clamp helper — avoid letting callers pass NaN or out-of-range values.
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
export function createMicPipeline(rawTrack: MediaStreamTrack): MicPipeline {
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) {
throw new Error('AudioContext unavailable');
}
const ctx = new AudioCtx();
const micGain = ctx.createGain();
micGain.gain.value = 1;
const sbGain = ctx.createGain();
sbGain.gain.value = 1;
const monitorGain = ctx.createGain();
monitorGain.gain.value = 1;
const dest = ctx.createMediaStreamDestination();
// Mic path → published only.
micGain.connect(dest);
// Soundboard path → published + local monitor.
sbGain.connect(dest);
sbGain.connect(monitorGain);
monitorGain.connect(ctx.destination);
let currentRawTrack: MediaStreamTrack = rawTrack;
let micSource: MediaStreamAudioSourceNode = buildMicSource(ctx, rawTrack, micGain);
const active = new Set<ActiveSource>();
let destroyed = false;
function buildMicSource(
c: AudioContext,
t: MediaStreamTrack,
target: AudioNode,
): MediaStreamAudioSourceNode {
const stream = new MediaStream([t]);
const node = c.createMediaStreamSource(stream);
node.connect(target);
return node;
}
const outputTrack = dest.stream.getAudioTracks()[0];
if (!outputTrack) {
throw new Error('MediaStreamAudioDestinationNode produced no audio track');
}
return {
outputTrack,
setMicGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
micGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
setSoundboardGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
sbGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
setMonitorGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
monitorGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
replaceMicTrack(newTrack: MediaStreamTrack) {
if (destroyed) return;
// Tear down the old MediaStreamSourceNode and stop the raw track we
// owned. Caller passes ownership of `newTrack` to the pipeline.
try {
micSource.disconnect();
} catch {
/* ignore */
}
if (currentRawTrack !== newTrack) {
try {
currentRawTrack.stop();
} catch {
/* ignore */
}
}
currentRawTrack = newTrack;
micSource = buildMicSource(ctx, newTrack, micGain);
},
playBuffer(buffer: AudioBuffer, opts: PlayBufferOpts = {}): PlayHandle {
if (destroyed) {
return { id: opts.id ?? null, stop: () => undefined };
}
// Single-fire: stop previous instance of the same id so holding a
// hotkey doesn't stack a dozen overlapping plays.
if (opts.id) {
for (const entry of active) {
if (entry.id === opts.id) stopActive(entry);
}
}
const node = ctx.createBufferSource();
node.buffer = buffer;
const g = ctx.createGain();
g.gain.value = clamp01(opts.gain ?? 1);
node.connect(g);
g.connect(sbGain);
const entry: ActiveSource = { id: opts.id ?? null, node, gain: g };
active.add(entry);
node.onended = () => {
if (!active.has(entry)) return;
try {
node.disconnect();
} catch {
/* ignore */
}
try {
g.disconnect();
} catch {
/* ignore */
}
active.delete(entry);
opts.onEnded?.();
};
try {
node.start();
} catch {
active.delete(entry);
}
return {
id: entry.id,
stop: () => stopActive(entry),
};
},
stopAll(id?: string) {
for (const entry of Array.from(active)) {
if (id !== undefined && entry.id !== id) continue;
stopActive(entry);
}
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const entry of Array.from(active)) stopActive(entry);
try {
micSource.disconnect();
} catch {
/* ignore */
}
try {
micGain.disconnect();
} catch {
/* ignore */
}
try {
sbGain.disconnect();
} catch {
/* ignore */
}
try {
monitorGain.disconnect();
} catch {
/* ignore */
}
try {
currentRawTrack.stop();
} catch {
/* ignore */
}
try {
outputTrack.stop();
} catch {
/* ignore */
}
void ctx.close().catch(() => {
/* ignore */
});
},
};
function stopActive(entry: ActiveSource): void {
try {
entry.node.onended = null;
entry.node.stop();
} catch {
/* already ended */
}
try {
entry.node.disconnect();
} catch {
/* ignore */
}
try {
entry.gain.disconnect();
} catch {
/* ignore */
}
active.delete(entry);
}
}
@@ -0,0 +1,91 @@
// Per-participant output volume overrides. Values are 0..1 (HTMLMediaElement
// scale). Persisted so the user's mixing choices survive reconnects.
//
// The store is intentionally tiny — a flat Record keyed by LiveKit identity
// (our `userId`). `attachTrack` reads from here when a remote audio track
// first lands; live changes are pushed to any already-attached audio
// elements via the `data-participant` attribute selector.
const STORAGE_KEY = 'call.participantVolumes.v1';
const DEFAULT_VOLUME = 1;
type VolumeMap = Record<string, number>;
type Listener = (map: VolumeMap) => void;
function load(): VolumeMap {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return {};
const out: VolumeMap = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'number' && Number.isFinite(v)) {
out[k] = clamp(v);
}
}
return out;
} catch {
return {};
}
}
function clamp(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
let current: VolumeMap = load();
const listeners = new Set<Listener>();
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
} catch {
/* ignore quota */
}
}
function notify(): void {
for (const fn of listeners) fn(current);
}
export function getParticipantVolume(userId: string): number {
return current[userId] ?? DEFAULT_VOLUME;
}
export function setParticipantVolume(userId: string, volume: number): void {
const next = clamp(volume);
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
current = { ...current, [userId]: next };
persist();
applyToAttachedElements(userId, next);
notify();
}
export function subscribeParticipantVolumes(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
// Apply a volume to any audio elements already attached for this user.
// Attached elements are tagged with `data-participant` in attachTrack.
function applyToAttachedElements(userId: string, volume: number): void {
const nodes = document.querySelectorAll<HTMLAudioElement>(
'audio[data-participant="' + cssEscape(userId) + '"]',
);
nodes.forEach((el) => {
el.volume = volume;
});
}
function cssEscape(v: string): string {
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
?.escape === 'function') {
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
}
return v.replace(/"/g, '\\"');
}
+87 -6
View File
@@ -1,6 +1,13 @@
// Looping WebAudio ringtones. Two patterns: // Looping ringtones. Two patterns:
// - outgoing: long calling tone, 3s cycle // - outgoing: long calling tone, 3s cycle (oscillator only)
// - incoming: classic "ring ring" double beep, 2s cycle // - incoming: classic "ring ring" double beep (oscillator), optionally
// upgraded to a user-supplied audio file stored in ringtoneStorage
//
// The custom file plays immediately if we can load it; otherwise we fall
// back to the generated oscillator pattern so ringing never misses an
// incoming call due to an IO failure.
import { getIncomingRingtone } from './ringtoneStorage';
type Pattern = 'outgoing' | 'incoming'; type Pattern = 'outgoing' | 'incoming';
@@ -9,15 +16,90 @@ class Ringtone {
private interval: number | null = null; private interval: number | null = null;
private pattern: Pattern | null = null; private pattern: Pattern | null = null;
// Custom-file playback path (incoming only).
private audioEl: HTMLAudioElement | null = null;
private customUrl: string | null = null;
// Sequence token to ignore slow IO completing after user changed state.
private startSeq = 0;
start(pattern: Pattern): void { start(pattern: Pattern): void {
if (this.pattern === pattern) return; // already playing this pattern if (this.pattern === pattern) return; // already playing this pattern
this.stop(); this.stop();
this.pattern = pattern;
const seq = ++this.startSeq;
if (pattern === 'incoming') {
// Kick off oscillator immediately so we never miss ringing feedback
// while the custom file (if any) loads asynchronously. Once the blob
// is ready we hand playback over to the <audio> element.
this.startOscillator(pattern);
void this.tryUpgradeToCustom(seq);
} else {
this.startOscillator(pattern);
}
}
stop(): void {
this.startSeq++;
this.stopOscillator();
this.stopCustom();
this.pattern = null;
}
// --- Custom file path (incoming only) ----------------------------------
private async tryUpgradeToCustom(seq: number): Promise<void> {
let stored;
try {
stored = await getIncomingRingtone();
} catch {
return; // keep oscillator
}
// User stopped or switched patterns while we were loading.
if (seq !== this.startSeq || this.pattern !== 'incoming' || !stored) return;
const url = URL.createObjectURL(stored.blob);
const el = new Audio(url);
el.loop = true;
el.volume = 0.85;
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
// but ringtones must be audible, so play() may reject the first time
// before the user interacted. If it rejects, we keep the oscillator.
el.play().catch(() => {
URL.revokeObjectURL(url);
});
this.audioEl = el;
this.customUrl = url;
// Only swap off the oscillator once the custom element is actually
// wired — avoids a silent gap on transition.
this.stopOscillator();
}
private stopCustom(): void {
if (this.audioEl) {
try {
this.audioEl.pause();
} catch {
/* ignore */
}
this.audioEl.src = '';
this.audioEl = null;
}
if (this.customUrl) {
URL.revokeObjectURL(this.customUrl);
this.customUrl = null;
}
}
// --- Oscillator fallback ----------------------------------------------
private startOscillator(pattern: Pattern): void {
const AudioCtx = const AudioCtx =
window.AudioContext ?? window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return; if (!AudioCtx) return;
this.ctx = new AudioCtx(); this.ctx = new AudioCtx();
this.pattern = pattern;
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming; const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
play.call(this); play.call(this);
@@ -27,7 +109,7 @@ class Ringtone {
); );
} }
stop(): void { private stopOscillator(): void {
if (this.interval !== null) { if (this.interval !== null) {
window.clearInterval(this.interval); window.clearInterval(this.interval);
this.interval = null; this.interval = null;
@@ -38,7 +120,6 @@ class Ringtone {
}); });
this.ctx = null; this.ctx = null;
} }
this.pattern = null;
} }
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void { private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
+108
View File
@@ -0,0 +1,108 @@
// IndexedDB-backed storage for the user's custom incoming ringtone.
//
// Only one slot is exposed (`incoming`) — outgoing ringtone stays tied to the
// bundled oscillator pattern. The blob is stored alongside its mime type +
// original filename so playback + UI can show what's currently in use.
const DB_NAME = 'netralax-ringtones';
const DB_VERSION = 1;
const STORE_NAME = 'ringtones';
const SLOT_INCOMING = 'incoming';
export interface StoredRingtone {
blob: Blob;
mime: string;
filename: string;
updatedAt: number;
}
export const MAX_RINGTONE_BYTES = 2 * 1024 * 1024; // 2 MB cap
export const SUPPORTED_RINGTONE_MIMES = [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-wav',
'audio/ogg',
'audio/webm',
'audio/mp4',
'audio/aac',
'audio/x-m4a',
'audio/m4a',
];
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
});
return dbPromise;
}
function runTx<T>(
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T> | void,
): Promise<T | undefined> {
return openDb().then(
(db) =>
new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
let result: T | undefined = undefined;
const maybeReq = fn(store);
if (maybeReq) {
maybeReq.onsuccess = () => {
result = maybeReq.result;
};
maybeReq.onerror = () => reject(maybeReq.error);
}
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
}),
);
}
export async function saveIncomingRingtone(file: File): Promise<void> {
if (file.size === 0) throw new Error('empty file');
if (file.size > MAX_RINGTONE_BYTES) {
throw new Error('ringtone_too_large');
}
const mime = file.type || 'application/octet-stream';
if (!mime.startsWith('audio/')) {
throw new Error('ringtone_not_audio');
}
const stored: StoredRingtone = {
blob: file,
mime,
filename: file.name,
updatedAt: Date.now(),
};
await runTx('readwrite', (store) => store.put(stored, SLOT_INCOMING));
}
export async function getIncomingRingtone(): Promise<StoredRingtone | null> {
const result = await runTx<StoredRingtone | undefined>('readonly', (store) =>
store.get(SLOT_INCOMING) as IDBRequest<StoredRingtone | undefined>,
);
return result ?? null;
}
export async function clearIncomingRingtone(): Promise<void> {
await runTx('readwrite', (store) => store.delete(SLOT_INCOMING));
}
export async function hasIncomingRingtone(): Promise<boolean> {
const cur = await getIncomingRingtone();
return cur !== null;
}
@@ -14,12 +14,22 @@ export type ScreenSharePreset =
| '1440p60' | '1440p60'
| '4k60'; | '4k60';
// Browser getDisplayMedia `displaySurface` hint. The OS picker honours this
// to pre-filter the source list (monitor = whole display, window = single
// window). `null` leaves everything selectable.
export type DisplaySurfaceHint = 'monitor' | 'window' | null;
export interface ScreenShareSettings { export interface ScreenShareSettings {
preset: ScreenSharePreset; preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
// User-chosen framerate. `null` falls back to preset's default.
framerateOverride: number | null;
} }
const DEFAULTS: ScreenShareSettings = { const DEFAULTS: ScreenShareSettings = {
preset: 'auto', preset: 'auto',
displaySurface: null,
framerateOverride: null,
}; };
export interface PresetParams { export interface PresetParams {
@@ -103,6 +113,14 @@ function read(): ScreenShareSettings {
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>; const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
cached = { cached = {
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset, preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset,
displaySurface:
parsed.displaySurface === 'monitor' || parsed.displaySurface === 'window'
? parsed.displaySurface
: DEFAULTS.displaySurface,
framerateOverride:
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
? parsed.framerateOverride
: DEFAULTS.framerateOverride,
}; };
return cached; return cached;
} catch { } catch {
+88
View File
@@ -0,0 +1,88 @@
// Binds soundboard entries to global shortcuts and keeps the registry in
// sync with storage mutations. Starting returns a teardown function that
// undoes every registration the binding made.
//
// Usage (in CallContext effect when call becomes connected):
// const teardown = startSoundboardHotkeys((id) => void playSoundboard(id));
// return teardown; // useEffect cleanup
import {
isTauriRuntime,
registerSoundShortcut,
unregisterAllSoundShortcuts,
unregisterSoundShortcut,
} from './globalShortcut';
import { getPttSettings } from './pttSettings';
import { listSounds, subscribeSoundboardChanges } from './soundboardStorage';
export type FirePress = (id: string) => void;
export function startSoundboardHotkeys(onPress: FirePress): () => void {
if (!isTauriRuntime()) {
// No-op on pure web preview — global shortcuts unsupported. Storage
// change subscription would still fire, but there's nothing to sync.
return () => undefined;
}
let active = true;
// identity-keyed: sound id -> currently bound DOM code
const bound = new Map<string, string>();
const sync = async (): Promise<void> => {
if (!active) return;
let entries;
try {
entries = await listSounds();
} catch (err: unknown) {
console.warn('soundboardHotkeys list failed', err);
return;
}
const pttKey = getPttSettings().key;
const wantByCode = new Map<string, string>(); // code -> id (winner on conflict)
for (const e of entries) {
if (!e.hotkey) continue;
// PTT wins over soundboard: don't hijack the talk key, skip silently.
if (pttKey && e.hotkey === pttKey) continue;
// First-writer-wins for dupes (stable because listSounds is sorted
// deterministically). Settings UI should prevent this upstream.
if (!wantByCode.has(e.hotkey)) wantByCode.set(e.hotkey, e.id);
}
const want = new Map<string, string>();
for (const [code, id] of wantByCode) want.set(id, code);
// Unregister bindings that disappeared or changed key.
for (const [id, code] of bound) {
const nextCode = want.get(id);
if (nextCode !== code) {
await unregisterSoundShortcut(id);
bound.delete(id);
}
}
// Register new / updated bindings.
for (const [id, code] of want) {
if (bound.get(id) === code) continue;
const ok = await registerSoundShortcut(id, code, () => {
if (!active) return;
onPress(id);
});
if (ok) bound.set(id, code);
}
};
const unsubscribe = subscribeSoundboardChanges(() => {
void sync();
});
// Initial binding pass.
void sync();
return () => {
active = false;
unsubscribe();
void unregisterAllSoundShortcuts();
bound.clear();
};
}
+103
View File
@@ -0,0 +1,103 @@
// Decode + cache AudioBuffers for soundboard entries and play them through a
// MicPipeline. Cache is LRU-bounded per sound id. `invalidate(id)` drops the
// decoded buffer when the underlying blob changes (rename keeps blob intact,
// but re-upload of the same id rebuilds the buffer on next play).
import type { MicPipeline, PlayHandle } from './micPipeline';
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
const MAX_CACHE_ENTRIES = 64;
const cache = new Map<string, AudioBuffer>();
// Decoding requires an AudioContext. We keep one short-lived context just for
// decoding — the pipeline's own ctx is used for playback, so we don't share.
// `decodeAudioData` is legacy-sync in Safari/WKWebView: it mutates the input
// ArrayBuffer, so we always pass a fresh slice() copy.
let decodeCtx: AudioContext | null = null;
function getDecodeCtx(): AudioContext {
if (decodeCtx) return decodeCtx;
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) throw new Error('AudioContext unavailable');
decodeCtx = new AudioCtx();
return decodeCtx;
}
function touch(id: string, buffer: AudioBuffer): void {
// Re-insert to push to the back of insertion order (Map is ordered by
// insertion, oldest first).
cache.delete(id);
cache.set(id, buffer);
if (cache.size > MAX_CACHE_ENTRIES) {
const first = cache.keys().next().value;
if (first !== undefined) cache.delete(first);
}
}
export async function preload(id: string): Promise<AudioBuffer | null> {
const existing = cache.get(id);
if (existing) {
touch(id, existing);
return existing;
}
const blob = await getSoundBlob(id);
if (!blob) return null;
const arrayBuffer = await blob.arrayBuffer();
// Clone into a fresh buffer because Safari's decodeAudioData transfers
// ownership on its legacy promise path.
const copy = arrayBuffer.slice(0);
const ctx = getDecodeCtx();
const buffer = await new Promise<AudioBuffer>((resolve, reject) => {
// The `(data, success, error)` callback form is the only one guaranteed
// on older WebKit. Modern browsers accept promise chains too.
const maybe = ctx.decodeAudioData(
copy,
(b) => resolve(b),
(e) => reject(e ?? new Error('decode failed')),
);
if (maybe && typeof (maybe as Promise<AudioBuffer>).then === 'function') {
(maybe as Promise<AudioBuffer>).then(resolve, reject);
}
});
touch(id, buffer);
return buffer;
}
export function invalidate(id: string): void {
cache.delete(id);
}
export function clearCache(): void {
cache.clear();
}
export interface PlayOptions {
/** Overrides entry.gain. Falls back to entry.gain when omitted. */
gain?: number;
/** Overlap mode: omit this flag to stop previous instance of the same id.
* Defaults to single-fire (id-keyed) so hotkey spamming doesn't stack. */
overlap?: boolean;
onEnded?: () => void;
}
/**
* Decode (if needed) and play the entry through the supplied pipeline.
* Returns the pipeline handle, or null if the pipeline is unavailable or
* the sound no longer exists.
*/
export async function playEntry(
pipeline: MicPipeline,
entry: SoundboardEntry,
opts: PlayOptions = {},
): Promise<PlayHandle | null> {
const buffer = await preload(entry.id);
if (!buffer) return null;
return pipeline.playBuffer(buffer, {
gain: opts.gain ?? entry.gain,
...(opts.overlap ? {} : { id: entry.id }),
...(opts.onEnded ? { onEnded: opts.onEnded } : {}),
});
}
+371
View File
@@ -0,0 +1,371 @@
// IndexedDB-backed soundboard.
//
// Two object stores:
// `sounds` — one record per user-added sound, keyed by uuid. Contains both
// metadata (name, category, hotkey, gain, order, timestamps) and
// the raw blob so decoding can pull everything in one get().
// `prefs` — single "prefs" record with global soundboard state (master
// volume, local monitor volume).
//
// CRUD helpers surface a SoundboardEntry shape without the blob so call sites
// that only need metadata (list views, hotkey registration) don't pull the
// audio payload into memory. `getSoundBlob(id)` fetches the blob on demand.
export interface SoundboardEntry {
id: string;
name: string;
mime: string;
size: number;
category: string | null;
hotkey: string | null;
gain: number; // 0..1
order: number; // ascending within (category, uncategorized) bucket
createdAt: number;
updatedAt: number;
}
export interface SoundboardPrefs {
masterGain: number; // 0..1
monitorGain: number; // 0..1
}
interface StoredSound extends SoundboardEntry {
blob: Blob;
}
export const DEFAULT_PREFS: SoundboardPrefs = {
masterGain: 0.8,
monitorGain: 0.5,
};
export const MAX_SOUND_BYTES = 5 * 1024 * 1024; // 5 MB per clip
// --- Change observer ------------------------------------------------------
// Synchronous tiny pub-sub so interested modules (hotkey registry, in-call
// panel, settings dialog) refresh when the manifest mutates in any tab.
type Listener = () => void;
const listeners = new Set<Listener>();
export function subscribeSoundboardChanges(l: Listener): () => void {
listeners.add(l);
return () => {
listeners.delete(l);
};
}
function notifyChange(): void {
for (const l of listeners) {
try {
l();
} catch (err: unknown) {
console.warn('soundboard change listener threw', err);
}
}
}
const DB_NAME = 'netralax-soundboard';
const DB_VERSION = 1;
const SOUNDS_STORE = 'sounds';
const PREFS_STORE = 'prefs';
const PREFS_KEY = 'prefs';
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SOUNDS_STORE)) {
db.createObjectStore(SOUNDS_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(PREFS_STORE)) {
db.createObjectStore(PREFS_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
});
return dbPromise;
}
function tx<T>(
store: string,
mode: IDBTransactionMode,
fn: (s: IDBObjectStore) => IDBRequest<T> | void,
): Promise<T | undefined> {
return openDb().then(
(db) =>
new Promise<T | undefined>((resolve, reject) => {
const t = db.transaction(store, mode);
const s = t.objectStore(store);
let result: T | undefined = undefined;
const req = fn(s);
if (req) {
req.onsuccess = () => {
result = req.result;
};
req.onerror = () => reject(req.error);
}
t.oncomplete = () => resolve(result);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error);
}),
);
}
function stripBlob(stored: StoredSound): SoundboardEntry {
const { blob: _blob, ...rest } = stored;
return rest;
}
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
function genId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'sb-' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
export async function listSounds(): Promise<SoundboardEntry[]> {
const db = await openDb();
return new Promise<SoundboardEntry[]>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readonly');
const s = t.objectStore(SOUNDS_STORE);
const req = s.getAll();
req.onsuccess = () => {
const all = (req.result as StoredSound[]).map(stripBlob);
// Stable sort: category name asc (null last), then order asc, then
// createdAt as a tie-breaker so freshly added sounds don't leapfrog.
all.sort((a, b) => {
const catCmp = compareCategory(a.category, b.category);
if (catCmp !== 0) return catCmp;
if (a.order !== b.order) return a.order - b.order;
return a.createdAt - b.createdAt;
});
resolve(all);
};
req.onerror = () => reject(req.error);
});
}
function compareCategory(a: string | null, b: string | null): number {
if (a === b) return 0;
if (a === null) return 1; // uncategorized last
if (b === null) return -1;
return a.localeCompare(b);
}
export async function listCategories(): Promise<string[]> {
const all = await listSounds();
const set = new Set<string>();
for (const s of all) {
if (s.category) set.add(s.category);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
async function nextOrderFor(category: string | null): Promise<number> {
const all = await listSounds();
let max = -1;
for (const s of all) {
if (s.category === category && s.order > max) max = s.order;
}
return max + 1;
}
export interface AddSoundInput {
file: File;
name?: string;
category?: string | null;
}
export async function addSound(input: AddSoundInput): Promise<SoundboardEntry> {
const { file, name, category = null } = input;
if (file.size === 0) throw new Error('empty file');
if (file.size > MAX_SOUND_BYTES) throw new Error('sound_too_large');
const mime = file.type || 'application/octet-stream';
if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');
const now = Date.now();
const trimmedName = (name ?? file.name.replace(/\.[^.]+$/, '')).trim() || 'Untitled';
const entry: StoredSound = {
id: genId(),
name: trimmedName,
mime,
size: file.size,
category: category ?? null,
hotkey: null,
gain: 1,
order: await nextOrderFor(category ?? null),
createdAt: now,
updatedAt: now,
blob: file,
};
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(entry));
notifyChange();
return stripBlob(entry);
}
export interface UpdateSoundPatch {
name?: string;
category?: string | null;
hotkey?: string | null;
gain?: number;
}
export async function updateSound(
id: string,
patch: UpdateSoundPatch,
): Promise<SoundboardEntry> {
const db = await openDb();
return new Promise<SoundboardEntry>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readwrite');
const s = t.objectStore(SOUNDS_STORE);
const getReq = s.get(id);
getReq.onsuccess = () => {
const current = getReq.result as StoredSound | undefined;
if (!current) {
reject(new Error('sound_not_found'));
return;
}
const nextCategory = patch.category !== undefined ? patch.category : current.category;
const categoryChanged = nextCategory !== current.category;
const next: StoredSound = {
...current,
...(patch.name !== undefined ? { name: patch.name.trim() || current.name } : {}),
...(patch.category !== undefined ? { category: nextCategory } : {}),
...(patch.hotkey !== undefined ? { hotkey: patch.hotkey } : {}),
...(patch.gain !== undefined ? { gain: clamp01(patch.gain) } : {}),
updatedAt: Date.now(),
};
// When moving categories, append to the destination's end so the move
// doesn't collide with existing order values.
if (categoryChanged) {
const getAll = s.getAll();
getAll.onsuccess = () => {
const all = getAll.result as StoredSound[];
let max = -1;
for (const e of all) {
if (e.category === nextCategory && e.order > max) max = e.order;
}
next.order = max + 1;
const putReq = s.put(next);
putReq.onsuccess = () => {
notifyChange();
resolve(stripBlob(next));
};
putReq.onerror = () => reject(putReq.error);
};
getAll.onerror = () => reject(getAll.error);
return;
}
const putReq = s.put(next);
putReq.onsuccess = () => {
notifyChange();
resolve(stripBlob(next));
};
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
t.onerror = () => reject(t.error);
});
}
export async function deleteSound(id: string): Promise<void> {
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
notifyChange();
}
export async function reorderCategory(
category: string | null,
orderedIds: string[],
): Promise<void> {
const db = await openDb();
return new Promise<void>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readwrite');
const s = t.objectStore(SOUNDS_STORE);
let remaining = orderedIds.length;
if (remaining === 0) {
resolve();
return;
}
orderedIds.forEach((id, idx) => {
const getReq = s.get(id);
getReq.onsuccess = () => {
const current = getReq.result as StoredSound | undefined;
if (!current || current.category !== category) {
remaining--;
if (remaining === 0) resolve();
return;
}
const next: StoredSound = { ...current, order: idx, updatedAt: Date.now() };
const putReq = s.put(next);
putReq.onsuccess = () => {
remaining--;
if (remaining === 0) resolve();
};
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
});
t.oncomplete = () => notifyChange();
t.onerror = () => reject(t.error);
});
}
export async function getSoundBlob(id: string): Promise<Blob | null> {
const db = await openDb();
return new Promise<Blob | null>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readonly');
const s = t.objectStore(SOUNDS_STORE);
const req = s.get(id);
req.onsuccess = () => {
const cur = req.result as StoredSound | undefined;
resolve(cur ? cur.blob : null);
};
req.onerror = () => reject(req.error);
});
}
export async function getPrefs(): Promise<SoundboardPrefs> {
const raw = await tx<SoundboardPrefs | undefined>('prefs', 'readonly', (s) =>
s.get(PREFS_KEY) as IDBRequest<SoundboardPrefs | undefined>,
);
if (!raw) return { ...DEFAULT_PREFS };
return {
masterGain: clamp01(raw.masterGain ?? DEFAULT_PREFS.masterGain),
monitorGain: clamp01(raw.monitorGain ?? DEFAULT_PREFS.monitorGain),
};
}
export async function updatePrefs(patch: Partial<SoundboardPrefs>): Promise<SoundboardPrefs> {
const cur = await getPrefs();
const next: SoundboardPrefs = {
masterGain: patch.masterGain !== undefined ? clamp01(patch.masterGain) : cur.masterGain,
monitorGain: patch.monitorGain !== undefined ? clamp01(patch.monitorGain) : cur.monitorGain,
};
await tx('prefs', 'readwrite', (s) => s.put(next, PREFS_KEY));
return next;
}
// Convenience: checks whether `accelerator` is already bound to a sound other
// than `excludeId`. Pair with PTT check at the CallContext layer.
export async function isHotkeyTaken(
accelerator: string,
excludeId: string | null = null,
): Promise<boolean> {
const all = await listSounds();
for (const s of all) {
if (s.hotkey === accelerator && s.id !== excludeId) return true;
}
return false;
}
+151 -14
View File
@@ -1,10 +1,83 @@
import type { Participant, Room } from 'livekit-client'; import type { AudioTrack, Participant, Room } from 'livekit-client';
import { RoomEvent } from 'livekit-client'; import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
// Subscribe to LiveKit ActiveSpeakersChanged to drive the speaking-ring pulse // Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
// on participant tiles. Returns the set of currently speaking identities // each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
// (includes local participant when they're active). // `isSpeaking` are updated by a background monitor (default ~1s) — too
// laggy. AnalyserNodes give us raw PCM at browser frame rate, so the ring
// lights up within one animation frame of actual speech.
//
// Hold time of 250ms prevents flicker between words / short pauses.
const POLL_MS = 50;
const HOLD_MS = 250;
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
const FFT_SIZE = 256;
interface Probe {
ctx: AudioContext;
analyser: AnalyserNode;
source: MediaStreamAudioSourceNode;
buf: Uint8Array;
// Cached MediaStreamTrack reference — if a publication swaps tracks
// (mute/unmute, device switch) we rebuild the node chain.
trackId: string;
}
function makeProbe(track: MediaStreamTrack): Probe | null {
try {
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
const stream = new MediaStream([track]);
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = FFT_SIZE;
analyser.smoothingTimeConstant = 0.2;
source.connect(analyser);
return {
ctx,
analyser,
source,
buf: new Uint8Array(analyser.frequencyBinCount),
trackId: track.id,
};
} catch {
return null;
}
}
function destroyProbe(probe: Probe): void {
try {
probe.source.disconnect();
} catch {
/* ignore */
}
try {
void probe.ctx.close();
} catch {
/* ignore */
}
}
function sampleRms(probe: Probe): number {
// `getByteFrequencyData` types expect `Uint8Array<ArrayBuffer>` (no
// SharedArrayBuffer). Our `probe.buf` satisfies that at runtime; the cast
// sidesteps the TS lib signature quirk.
probe.analyser.getByteFrequencyData(probe.buf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < probe.buf.length; i++) sum += probe.buf[i]!;
return sum / (probe.buf.length * 255);
}
function firstAudioTrack(p: Participant): AudioTrack | null {
const pubs = p.audioTrackPublications;
for (const pub of pubs.values()) {
if (pub.kind === Track.Kind.Audio && pub.track) {
return pub.track as AudioTrack;
}
}
return null;
}
export function useActiveSpeakers(room: Room | null): Set<string> { export function useActiveSpeakers(room: Room | null): Set<string> {
const [ids, setIds] = useState<Set<string>>(() => new Set()); const [ids, setIds] = useState<Set<string>>(() => new Set());
@@ -14,20 +87,84 @@ export function useActiveSpeakers(room: Room | null): Set<string> {
return; return;
} }
const update = (speakers: Participant[]) => { const probes = new Map<string, Probe>();
const next = new Set<string>(); const lastActive = new Map<string, number>();
for (const s of speakers) {
if (s.identity) next.add(s.identity); const syncProbe = (p: Participant): void => {
if (!p.identity) return;
const track = firstAudioTrack(p);
const mst = track?.mediaStreamTrack ?? null;
const existing = probes.get(p.identity);
if (!mst) {
if (existing) {
destroyProbe(existing);
probes.delete(p.identity);
} }
setIds(next); return;
}
if (existing && existing.trackId === mst.id) return;
if (existing) destroyProbe(existing);
const probe = makeProbe(mst);
if (probe) probes.set(p.identity, probe);
}; };
// Seed from current state. const bind = (p: Participant): void => {
update(room.activeSpeakers ?? []); p.on(ParticipantEvent.TrackPublished, () => syncProbe(p));
p.on(ParticipantEvent.TrackUnpublished, () => syncProbe(p));
p.on(ParticipantEvent.TrackSubscribed, () => syncProbe(p));
p.on(ParticipantEvent.TrackUnsubscribed, () => syncProbe(p));
p.on(ParticipantEvent.TrackMuted, () => syncProbe(p));
p.on(ParticipantEvent.TrackUnmuted, () => syncProbe(p));
syncProbe(p);
};
bind(room.localParticipant);
room.remoteParticipants.forEach(bind);
const onConnected = (p: Participant) => bind(p);
const onDisconnected = (p: Participant) => {
if (!p.identity) return;
const probe = probes.get(p.identity);
if (probe) {
destroyProbe(probe);
probes.delete(p.identity);
}
lastActive.delete(p.identity);
};
room.on(RoomEvent.ParticipantConnected, onConnected);
room.on(RoomEvent.ParticipantDisconnected, onDisconnected);
const tick = () => {
// Defensively resync every tick — covers the gap where local mic
// finishes publishing between effect mount and the first
// TrackPublished event, and handles event misses on Tauri WebView.
syncProbe(room.localParticipant);
room.remoteParticipants.forEach(syncProbe);
const now = Date.now();
for (const [id, probe] of probes) {
if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now);
}
const next = new Set<string>();
for (const [id, t] of lastActive) {
if (now - t <= HOLD_MS) next.add(id);
}
setIds((prev) => {
if (prev.size === next.size && Array.from(prev).every((v) => next.has(v))) {
return prev;
}
return next;
});
};
const interval = window.setInterval(tick, POLL_MS);
room.on(RoomEvent.ActiveSpeakersChanged, update);
return () => { return () => {
room.off(RoomEvent.ActiveSpeakersChanged, update); window.clearInterval(interval);
room.off(RoomEvent.ParticipantConnected, onConnected);
room.off(RoomEvent.ParticipantDisconnected, onDisconnected);
for (const probe of probes.values()) destroyProbe(probe);
probes.clear();
}; };
}, [room]); }, [room]);
+253 -7
View File
@@ -13,6 +13,16 @@ import {
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase'; import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
enqueueOutbox,
getOutbox,
isDue,
markAttempt,
type OutboxItem,
removeOutbox,
shouldGiveUp,
subscribeOutbox,
} from './messageOutbox';
import { devLocalSecretStore } from './secretStore'; import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase'; import { supabase } from './supabase';
@@ -53,10 +63,26 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & { export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>; send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
pending: OutboxItem[];
retryPending: (id: string) => void;
cancelPending: (id: string) => void;
} { } {
const [state, setState] = useState<State>({ messages: [], loading: true, error: null }); const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
const [pending, setPending] = useState<OutboxItem[]>(() =>
conversationId ? getOutbox(conversationId) : [],
);
const privateKeyRef = useRef<Uint8Array | null>(null); const privateKeyRef = useRef<Uint8Array | null>(null);
useEffect(() => {
if (!conversationId) {
setPending([]);
return;
}
return subscribeOutbox((byConv) => {
setPending(byConv[conversationId] ?? []);
});
}, [conversationId]);
// Load own private key once per (user, device). // Load own private key once per (user, device).
useEffect(() => { useEffect(() => {
privateKeyRef.current = null; privateKeyRef.current = null;
@@ -85,7 +111,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!conversationId) return; if (!conversationId) return;
try { try {
setState((prev) => ({ ...prev, loading: true })); // Only show the loading spinner on the *initial* fetch — subsequent
// refreshes (focus/visibility) replace messages in-place to avoid
// flickering an empty state on every wake.
setState((prev) =>
prev.messages.length === 0 ? { ...prev, loading: true } : prev,
);
const rows = await fetchConversationMessages(supabase, conversationId); const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows); const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null }); setState({ messages: decrypted, loading: false, error: null });
@@ -192,18 +223,71 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
if (partial.editedAt && !partial.deletedAt) { if (partial.editedAt && !partial.deletedAt) {
const [decrypted] = await decryptBatch([partial]); // Realtime bytea encoding varies (base64 vs hex, even null for
// unchanged columns on some configs). Refetch via REST to get the
// canonical `\x…` hex then decrypt — same pattern as handleInsert.
if (!conversationId || !deviceId) return;
let decrypted: DecryptedMessage | null = null;
for (let attempt = 0; attempt < 6; attempt++) {
const { data, error } = await supabase
.from('messages')
.select(
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
)
.eq('id', partial.id)
.maybeSingle();
if (error) {
console.warn('handleUpdate refetch failed', error);
return;
}
if (!data) {
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
continue;
}
const r = data as unknown as {
id: string;
conversation_id: string;
sender_id: string;
sender_device_id: string | null;
reply_to_id: string | null;
edited_at: string | null;
deleted_at: string | null;
created_at: string;
ciphertext: string;
nonce: string;
key_version: number;
};
const msg: MessageWithCipher = {
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
ciphertext: pgBytesToBytes(String(r.ciphertext)),
nonce: pgBytesToBytes(String(r.nonce)),
keyVersion: r.key_version,
};
const [d] = await decryptBatch([msg]);
if (d) {
decrypted = d;
if (d.plaintext !== null) break;
}
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
}
if (!decrypted) return; if (!decrypted) return;
setState((prev) => { setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted.id); const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
if (idx === -1) return prev; if (idx === -1) return prev;
const next = [...prev.messages]; const next = [...prev.messages];
next[idx] = decrypted; next[idx] = decrypted!;
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
} }
}, },
[decryptBatch], [conversationId, deviceId, decryptBatch],
); );
const handleDelete = useCallback((row: Record<string, unknown>) => { const handleDelete = useCallback((row: Record<string, unknown>) => {
@@ -256,11 +340,60 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
) )
.subscribe(); .subscribe();
// Refresh + reconnect on wake from background throttle (mostly Windows
// WebView2). Without this, messages inserted while the window is
// minimised never arrive until the user explicitly reloads. Throttled to
// at most once per AWAKE_THROTTLE_MS so the inevitable cluster of
// visibility/online events on focus does not cause a flicker storm.
let lastAwakeRefresh = 0;
const AWAKE_THROTTLE_MS = 30_000;
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
lastAwakeRefresh = now;
void refresh();
try {
channel.subscribe();
} catch {
/* already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('online', onAwake);
return () => { return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel); void supabase.removeChannel(channel);
}; };
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]); }, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
const sendText = useCallback(
async (convId: string, uid: string, did: string, priv: Uint8Array, text: string, replyToId: string | null): Promise<void> => {
const msg = await sendEncryptedMessage({
client: supabase,
conversationId: convId,
plaintext: text,
senderUserId: uid,
senderDeviceId: did,
senderPrivateKey: priv,
...(replyToId ? { replyToId } : {}),
});
setState((prev) => {
if (prev.messages.some((m) => m.id === msg.id)) return prev;
return {
...prev,
messages: [
...prev.messages,
{ ...msg, plaintext: text } as DecryptedMessage,
],
};
});
},
[],
);
const send = useCallback( const send = useCallback(
async (text: string, images: File[] = [], replyToId: string | null = null) => { async (text: string, images: File[] = [], replyToId: string | null = null) => {
const trimmed = text.trim(); const trimmed = text.trim();
@@ -268,6 +401,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const priv = privateKeyRef.current; const priv = privateKeyRef.current;
if (!priv) throw new Error('private key not loaded'); if (!priv) throw new Error('private key not loaded');
// Text-only path is retryable — if the network is down or the server
// rejects transiently, stash in the outbox and keep the UI optimistic.
// Attachments can't be deferred (large payloads, uploaded separately),
// so those still surface the error immediately.
if (images.length === 0) {
try {
await sendText(conversationId, userId, deviceId, priv, trimmed, replyToId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'send failed';
enqueueOutbox({
conversationId,
text: trimmed,
replyToId,
error: msg,
});
}
return;
}
// 1. Upload + encrypt each image. Collect handles + raw blob nonces // 1. Upload + encrypt each image. Collect handles + raw blob nonces
// (so the public attachment row can reference the blob-level nonce). // (so the public attachment row can reference the blob-level nonce).
const handles: AttachmentHandle[] = []; const handles: AttachmentHandle[] = [];
@@ -329,10 +481,104 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
await insertAttachmentRow(supabase, msg.id, h, blobNonce); await insertAttachmentRow(supabase, msg.id, h, blobNonce);
} }
}, },
[conversationId, userId, deviceId], [conversationId, userId, deviceId, sendText],
); );
return useMemo(() => ({ ...state, send, refresh }), [state, send, refresh]); // Drain outbox: retry due items, remove on success, record attempt on fail.
// Runs on online event, window focus, and a 10s interval.
useEffect(() => {
if (!conversationId || !userId || !deviceId) return;
let draining = false;
const drain = async (): Promise<void> => {
if (draining) return;
const priv = privateKeyRef.current;
if (!priv) return;
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
draining = true;
try {
const items = getOutbox(conversationId).filter(isDue);
for (const item of items) {
if (shouldGiveUp(item)) continue;
try {
await sendText(
conversationId,
userId,
deviceId,
priv,
item.text,
item.replyToId,
);
removeOutbox(conversationId, item.id);
} catch (err: unknown) {
markAttempt(
conversationId,
item.id,
err instanceof Error ? err.message : 'send failed',
);
}
}
} finally {
draining = false;
}
};
const interval = window.setInterval(() => {
void drain();
}, 10_000);
const onOnline = () => void drain();
window.addEventListener('online', onOnline);
window.addEventListener('focus', onOnline);
// Kick once immediately on mount for stale queued items.
void drain();
return () => {
window.clearInterval(interval);
window.removeEventListener('online', onOnline);
window.removeEventListener('focus', onOnline);
};
}, [conversationId, userId, deviceId, sendText]);
const retryPending = useCallback(
(id: string) => {
if (!conversationId) return;
// Force due now; the drain loop will pick it up on next tick.
markAttempt(conversationId, id, null);
// Manual kick: mutate nextAttemptAt by re-enqueuing? Simpler — just
// trigger a drain-ish by queueing a microtask. The interval picks up
// due items within 10s, but for UX we also eagerly try here.
const priv = privateKeyRef.current;
if (!priv || !userId || !deviceId) return;
const item = getOutbox(conversationId).find((x) => x.id === id);
if (!item) return;
void (async () => {
try {
await sendText(conversationId, userId, deviceId, priv, item.text, item.replyToId);
removeOutbox(conversationId, id);
} catch (err: unknown) {
markAttempt(
conversationId,
id,
err instanceof Error ? err.message : 'send failed',
);
}
})();
},
[conversationId, userId, deviceId, sendText],
);
const cancelPending = useCallback(
(id: string) => {
if (!conversationId) return;
removeOutbox(conversationId, id);
},
[conversationId],
);
return useMemo(
() => ({ ...state, send, refresh, pending, retryPending, cancelPending }),
[state, send, refresh, pending, retryPending, cancelPending],
);
} }
// Best-effort image dimension probe. Falls back silently on non-images. // Best-effort image dimension probe. Falls back silently on non-images.
+22
View File
@@ -44,7 +44,29 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
}) })
.subscribe(); .subscribe();
// Windows WebView2 throttles background sockets — refresh on wake.
// Throttled + visibility-only so a normal click into the window does not
// re-fetch on every focus.
let lastAwakeRefresh = 0;
const AWAKE_THROTTLE_MS = 30_000;
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
lastAwakeRefresh = now;
void refresh();
try {
channel.subscribe();
} catch {
/* already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('online', onAwake);
return () => { return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel); void supabase.removeChannel(channel);
}; };
}, [userId, refresh]); }, [userId, refresh]);
+87
View File
@@ -0,0 +1,87 @@
import {
listGroupDeliveriesForMessages,
listGroupReadsForMessages,
} from '@chat-app/shared/chat';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from './supabase';
export interface GroupReceiptState {
// message_id → set of user ids who have delivered/read.
deliveredByMessage: Map<string, Set<string>>;
readByMessage: Map<string, Set<string>>;
refresh: () => Promise<void>;
}
// Aggregated delivery + read receipts for group conversations. Returns the
// set of user ids per message; consumers join with conversation members to
// figure out who has not yet acknowledged.
export function useGroupReceipts(
messageIds: string[],
selfUserId: string | undefined,
active: boolean,
): GroupReceiptState {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [deliveredByMessage, setDelivered] = useState<Map<string, Set<string>>>(new Map());
const [readByMessage, setRead] = useState<Map<string, Set<string>>>(new Map());
const refresh = useCallback(async () => {
if (!active || !selfUserId || messageIds.length === 0) {
setDelivered(new Map());
setRead(new Map());
return;
}
try {
const [delivered, read] = await Promise.all([
listGroupDeliveriesForMessages(supabase, messageIds, selfUserId),
listGroupReadsForMessages(supabase, messageIds, selfUserId),
]);
setDelivered(toIdSets(delivered));
setRead(toIdSets(read));
} catch (err: unknown) {
console.warn('useGroupReceipts refresh failed', err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, selfUserId, idsKey]);
useEffect(() => {
void refresh();
if (!active || !selfUserId) return;
// Listen on both tables. RLS already enforces visibility.
const reads = supabase
.channel('group-reads:' + selfUserId)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'message_reads' },
() => {
void refresh();
},
)
.subscribe();
const deliveries = supabase
.channel('group-deliv:' + selfUserId)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'message_deliveries' },
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(reads);
void supabase.removeChannel(deliveries);
};
}, [active, selfUserId, refresh]);
return { deliveredByMessage, readByMessage, refresh };
}
function toIdSets(input: Map<string, Map<string, string>>): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
for (const [mid, inner] of input) {
out.set(mid, new Set(inner.keys()));
}
return out;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
export interface LinkPreview {
url: string;
title: string | null;
description: string | null;
imageUrl: string | null;
siteName: string | null;
ok: boolean;
}
// In-memory cache keyed by URL — avoids re-invoking the edge function for
// the same URL within a session even if many bubbles reference it.
const cache = new Map<string, LinkPreview | null>();
const inflight = new Map<string, Promise<LinkPreview | null>>();
async function loadPreview(url: string): Promise<LinkPreview | null> {
if (cache.has(url)) return cache.get(url)!;
const existing = inflight.get(url);
if (existing) return existing;
const p = (async () => {
// First try the cache table directly (RLS allows authenticated reads).
// Works offline-first if the server has already fetched this URL.
// `link_previews` is a later migration — cast around stale generated types.
const { data: cached } = await (supabase as unknown as {
from: (t: string) => {
select: (cols: string) => {
eq: (col: string, val: string) => {
maybeSingle: () => Promise<{
data: {
url: string;
title: string | null;
description: string | null;
image_url: string | null;
site_name: string | null;
ok: boolean;
fetched_at: string;
} | null;
error: Error | null;
}>;
};
};
};
})
.from('link_previews')
.select('url, title, description, image_url, site_name, ok, fetched_at')
.eq('url', url)
.maybeSingle();
if (cached && cached.ok) {
const preview: LinkPreview = {
url: cached.url,
title: cached.title,
description: cached.description,
imageUrl: cached.image_url,
siteName: cached.site_name,
ok: cached.ok,
};
cache.set(url, preview);
return preview;
}
try {
const { data, error } = await supabase.functions.invoke('og-preview', {
body: { url },
});
if (error) throw error;
const preview = data as LinkPreview | null;
cache.set(url, preview && preview.ok ? preview : null);
return cache.get(url) ?? null;
} catch {
cache.set(url, null);
return null;
}
})();
inflight.set(url, p);
try {
return await p;
} finally {
inflight.delete(url);
}
}
export function useLinkPreview(url: string | null): LinkPreview | null {
const [preview, setPreview] = useState<LinkPreview | null>(() =>
url ? cache.get(url) ?? null : null,
);
useEffect(() => {
if (!url) {
setPreview(null);
return;
}
let cancelled = false;
void loadPreview(url).then((p) => {
if (!cancelled) setPreview(p);
});
return () => {
cancelled = true;
};
}, [url]);
return preview;
}
// Regex tuned for plain URLs inside message text. No markdown link syntax yet.
const URL_RE = /https?:\/\/[^\s<>"']+/i;
export function extractFirstUrl(text: string): string | null {
const m = URL_RE.exec(text);
return m?.[0] ?? null;
}
@@ -0,0 +1,61 @@
import {
listPeerDeliveriesForMessages,
markMessagesDelivered,
} from '@chat-app/shared/chat';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from './supabase';
// Tracks peer delivery acknowledgements for our own messages. Single peer
// (DM). For groups this would need a per-user map; keep parity with
// `useMessageReads` for now.
export function useMessageDeliveries(
messageIds: string[],
peerUserId: string | undefined,
): { peerDeliveredSet: Set<string>; refresh: () => Promise<void> } {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [peerDeliveredSet, setPeerDeliveredSet] = useState<Set<string>>(new Set());
const refresh = useCallback(async () => {
if (!peerUserId || messageIds.length === 0) {
setPeerDeliveredSet(new Set());
return;
}
try {
const s = await listPeerDeliveriesForMessages(supabase, messageIds, peerUserId);
setPeerDeliveredSet(s);
} catch (err: unknown) {
console.error('listPeerDeliveriesForMessages failed', err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peerUserId, idsKey]);
useEffect(() => {
void refresh();
if (!peerUserId) return;
const channel = supabase
.channel('deliveries:' + peerUserId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_deliveries',
filter: 'user_id=eq.' + peerUserId,
},
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [peerUserId, refresh]);
return { peerDeliveredSet, refresh };
}
export async function markDelivered(messageIds: string[]): Promise<void> {
await markMessagesDelivered(supabase, messageIds);
}
+31 -8
View File
@@ -3,10 +3,16 @@ import { useEffect, useState } from 'react';
import { supabase } from './supabase'; import { supabase } from './supabase';
// Subscribe to a single peer's presence_state via Supabase realtime. export interface PeerPresence {
// Returns null until the first row arrives, or when userId is undefined. state: PresenceState;
export function usePeerPresence(userId: string | undefined): PresenceState | null { statusMessage: string | null;
const [presence, setPresence] = useState<PresenceState | null>(null); }
// Subscribe to a single peer's presence_state + status_message via Supabase
// realtime. Returns null until the first row arrives, or when userId is
// undefined.
export function usePeerPresence(userId: string | undefined): PeerPresence | null {
const [presence, setPresence] = useState<PeerPresence | null>(null);
useEffect(() => { useEffect(() => {
if (!userId) { if (!userId) {
@@ -17,11 +23,15 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
void supabase void supabase
.from('profiles') .from('profiles')
.select('presence_state') .select('presence_state, status_message')
.eq('user_id', userId) .eq('user_id', userId)
.maybeSingle() .maybeSingle()
.then(({ data }) => { .then(({ data }) => {
if (!cancelled) setPresence(data?.presence_state ?? null); if (cancelled || !data) return;
setPresence({
state: (data.presence_state as PresenceState | null) ?? 'offline',
statusMessage: data.status_message ?? null,
});
}); });
const channel = supabase const channel = supabase
@@ -35,8 +45,21 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
filter: 'user_id=eq.' + userId, filter: 'user_id=eq.' + userId,
}, },
(payload: { new: Record<string, unknown> }) => { (payload: { new: Record<string, unknown> }) => {
const next = payload.new['presence_state']; const nextState = payload.new['presence_state'];
if (typeof next === 'string') setPresence(next as PresenceState); const nextMsg = payload.new['status_message'];
setPresence((prev) => {
const state =
typeof nextState === 'string'
? (nextState as PresenceState)
: prev?.state ?? 'offline';
const statusMessage =
nextMsg === null
? null
: typeof nextMsg === 'string'
? nextMsg
: prev?.statusMessage ?? null;
return { state, statusMessage };
});
}, },
) )
.subscribe(); .subscribe();
+87
View File
@@ -0,0 +1,87 @@
// Web Push subscription registration. Browser-only; Tauri WebView2 / WebKit
// do not install service workers. The Tauri build relies on native OS
// notifications via osNotify.ts instead.
//
// Flow:
// 1. Register /sw.js if not already.
// 2. Subscribe with the VAPID public key (Vite injects it via env).
// 3. Persist {endpoint, keys} JSON-encoded into push_tokens.token for the
// current device. Server-side fan-out reads this row to send pushes.
import { isTauriRuntime } from './globalShortcut';
import { supabase } from './supabase';
const VAPID_PUBLIC_KEY: string | undefined =
(import.meta as unknown as { env?: { VITE_VAPID_PUBLIC_KEY?: string } }).env
?.VITE_VAPID_PUBLIC_KEY;
export async function registerWebPush(deviceId: string): Promise<void> {
// Tauri uses native notifications — no service worker.
if (isTauriRuntime()) return;
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
if (!('PushManager' in window)) return;
if (!VAPID_PUBLIC_KEY) {
console.warn('VITE_VAPID_PUBLIC_KEY not set — push disabled');
return;
}
try {
const reg = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) as BufferSource,
});
}
const token = JSON.stringify({
endpoint: sub.endpoint,
keys: sub.toJSON().keys ?? {},
});
const { error } = await supabase
.from('push_tokens')
.upsert(
{ device_id: deviceId, platform: 'web' as never, token },
{ onConflict: 'device_id' },
);
if (error) {
console.warn('push_tokens upsert failed', error);
}
} catch (err: unknown) {
console.error('registerWebPush failed', err);
}
}
export async function unregisterWebPush(deviceId: string): Promise<void> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
try {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
const sub = await reg.pushManager.getSubscription();
if (sub) await sub.unsubscribe();
}
} catch {
/* ignore */
}
try {
await supabase.from('push_tokens').delete().eq('device_id', deviceId);
} catch (err: unknown) {
console.warn('push_tokens delete failed', err);
}
}
// VAPID public key arrives as URL-safe base64; PushManager expects a raw byte
// array. Standard conversion.
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const padded = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(padded);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
+3
View File
@@ -4,10 +4,13 @@ import ReactDOM from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import { createLibsodiumBackend } from './lib/cryptoBackend'; import { createLibsodiumBackend } from './lib/cryptoBackend';
import { installCrashHandlers } from './lib/crashRecovery';
import { bootstrapI18n } from './lib/i18n'; import { bootstrapI18n } from './lib/i18n';
import './styles/globals.css'; import './styles/globals.css';
async function boot(): Promise<void> { async function boot(): Promise<void> {
// Install first so bootstrap errors (crypto backend, i18n) are captured.
installCrashHandlers();
bootstrapI18n(); bootstrapI18n();
setCryptoBackend(await createLibsodiumBackend()); setCryptoBackend(await createLibsodiumBackend());
+121 -1
View File
@@ -1,4 +1,6 @@
import { import {
type AdminAuditEntry,
type AdminConversationRow,
type AdminProfileFlag, type AdminProfileFlag,
type AdminProfileRow, type AdminProfileRow,
type AdminSetting, type AdminSetting,
@@ -6,8 +8,11 @@ import {
deleteInvite, deleteInvite,
type InviteRecord, type InviteRecord,
listAdminSettings, listAdminSettings,
listAllConversations,
listAllProfiles, listAllProfiles,
listAuditLog,
listInvites, listInvites,
logAdminAction,
setInviteDisabled, setInviteDisabled,
setUserFlag, setUserFlag,
updateAdminSetting, updateAdminSetting,
@@ -31,20 +36,26 @@ export function AdminPage() {
const [settings, setSettings] = useState<AdminSetting[]>([]); const [settings, setSettings] = useState<AdminSetting[]>([]);
const [invites, setInvites] = useState<InviteRecord[]>([]); const [invites, setInvites] = useState<InviteRecord[]>([]);
const [users, setUsers] = useState<AdminProfileRow[]>([]); const [users, setUsers] = useState<AdminProfileRow[]>([]);
const [conversations, setConversations] = useState<AdminConversationRow[]>([]);
const [audit, setAudit] = useState<AdminAuditEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const [s, i, u] = await Promise.all([ const [s, i, u, c, a] = await Promise.all([
listAdminSettings(supabase), listAdminSettings(supabase),
listInvites(supabase), listInvites(supabase),
listAllProfiles(supabase), listAllProfiles(supabase),
listAllConversations(supabase),
listAuditLog(supabase, 50),
]); ]);
setSettings(s); setSettings(s);
setInvites(i); setInvites(i);
setUsers(u); setUsers(u);
setConversations(c);
setAudit(a);
setError(null); setError(null);
} catch (err: unknown) { } catch (err: unknown) {
const code = extractErrorCode(err); const code = extractErrorCode(err);
@@ -92,6 +103,8 @@ export function AdminPage() {
<SettingsSection settings={settings} onRefresh={refresh} /> <SettingsSection settings={settings} onRefresh={refresh} />
<InvitesSection invites={invites} onRefresh={refresh} /> <InvitesSection invites={invites} onRefresh={refresh} />
<UsersSection users={users} onRefresh={refresh} /> <UsersSection users={users} onRefresh={refresh} />
<ConversationsSection conversations={conversations} />
<AuditSection entries={audit} users={users} />
</> </>
)} )}
</div> </div>
@@ -295,6 +308,13 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) { async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
try { try {
await setUserFlag(supabase, userId, flag, value); await setUserFlag(supabase, userId, flag, value);
// Best-effort audit; don't fail the UI if the log write fails.
void logAdminAction(
supabase,
'user.set_flag',
{ type: 'user', id: userId },
{ flag, value },
).catch(() => undefined);
await onRefresh(); await onRefresh();
} catch (err: unknown) { } catch (err: unknown) {
console.error(err); console.error(err);
@@ -345,6 +365,106 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
); );
} }
// --- Conversations ---------------------------------------------------------
function ConversationsSection({ conversations }: { conversations: AdminConversationRow[] }) {
if (conversations.length === 0) {
return (
<Section title="Unterhaltungen">
<p className="text-sm text-fg-muted">Keine Unterhaltungen.</p>
</Section>
);
}
return (
<Section title={'Unterhaltungen (' + conversations.length + ')'}>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
<tr>
<th className="py-2 pr-3">Name/ID</th>
<th className="py-2 pr-3">Typ</th>
<th className="py-2 pr-3">Members</th>
<th className="py-2 pr-3">Letzte Nachricht</th>
<th className="py-2 pr-3">Erstellt</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{conversations.map((c) => (
<tr key={c.id}>
<td className="py-2 pr-3 font-mono text-[11px] text-fg">
{c.name ?? c.id.slice(0, 12)}
</td>
<td className="py-2 pr-3 text-xs text-fg-muted">{c.type}</td>
<td className="py-2 pr-3 text-xs text-fg">{c.memberCount}</td>
<td className="py-2 pr-3 text-xs text-fg-muted">
{c.lastMessageAt
? new Date(c.lastMessageAt).toLocaleString()
: '—'}
</td>
<td className="py-2 pr-3 text-xs text-fg-muted">
{new Date(c.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
);
}
// --- Audit log -------------------------------------------------------------
function AuditSection({
entries,
users,
}: {
entries: AdminAuditEntry[];
users: AdminProfileRow[];
}) {
const nameByUserId = new Map(users.map((u) => [u.userId, u.displayName]));
if (entries.length === 0) {
return (
<Section title="Audit-Log">
<p className="text-sm text-fg-muted">Keine Einträge.</p>
</Section>
);
}
return (
<Section title={'Audit-Log (letzte ' + entries.length + ')'}>
<ul className="divide-y divide-line text-sm">
{entries.map((e) => (
<li key={e.id} className="flex items-start gap-3 py-2">
<span className="w-32 shrink-0 font-mono text-[10px] text-fg-muted">
{new Date(e.createdAt).toLocaleString()}
</span>
<div className="min-w-0 flex-1">
<p className="text-xs">
<span className="font-semibold text-fg">
{e.actorId ? nameByUserId.get(e.actorId) ?? e.actorId.slice(0, 8) : '—'}
</span>{' '}
<span className="text-accent">{e.action}</span>
{e.targetId && (
<span className="text-fg-muted">
{' '}
on {e.targetType}:{e.targetId.slice(0, 8)}
</span>
)}
</p>
{Object.keys(e.metadata).length > 0 && (
<p className="mt-0.5 font-mono text-[10px] text-fg-muted">
{JSON.stringify(e.metadata)}
</p>
)}
</div>
</li>
))}
</ul>
</Section>
);
}
// --- Bits ------------------------------------------------------------------ // --- Bits ------------------------------------------------------------------
function Toggle({ function Toggle({
+457 -25
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { ForwardDialog } from '../components/ForwardDialog'; import { ForwardDialog } from '../components/ForwardDialog';
import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { import {
@@ -15,19 +16,25 @@ import {
PlusIcon, PlusIcon,
ReplyIcon, ReplyIcon,
SearchIcon, SearchIcon,
SmileIcon,
SpinnerIcon, SpinnerIcon,
XIcon, XIcon,
} from '../components/icons'; } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator'; import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
import type { DecryptedMessage } from '@chat-app/shared/chat'; import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import type { OutboxItem } from '../lib/messageOutbox';
import { useConversationMessages } from '../lib/useConversationMessages'; import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions'; import { useMessageReactions } from '../lib/useMessageReactions';
import { useGroupReceipts } from '../lib/useGroupReceipts';
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads'; import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence'; import { usePeerPresence } from '../lib/usePeerPresence';
import { useTypingChannel } from '../lib/useTypingChannel'; import { useTypingChannel } from '../lib/useTypingChannel';
@@ -47,7 +54,8 @@ export function ConversationPage() {
const peerId = conversation?.peer?.userId; const peerId = conversation?.peer?.userId;
const peerPresence = usePeerPresence(peerId); const peerPresence = usePeerPresence(peerId);
const { messages, loading, error, send } = useConversationMessages({ const { messages, loading, error, send, pending, retryPending, cancelPending } =
useConversationMessages({
conversationId: id, conversationId: id,
userId: session?.user.id, userId: session?.user.id,
deviceId: device?.id, deviceId: device?.id,
@@ -66,6 +74,36 @@ export function ConversationPage() {
[messages, myId], [messages, myId],
); );
const { peerReadSet } = useMessageReads(ownMessageIds, peerId); const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
// Group receipts: only meaningful when conversation is a group. We feed it
// ownMessageIds since we only render delivery state on the sender side.
const isGroup = conversation?.type === 'group';
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
useGroupReceipts(ownMessageIds, myId, !!isGroup);
const groupRecipientCount = useMemo(() => {
if (!isGroup || !conversation) return 0;
return conversation.members.filter((m) => m.userId !== myId).length;
}, [isGroup, conversation, myId]);
// Mark every peer-authored message as delivered on our side. Idempotent,
// so rerunning for already-acknowledged ids is a no-op server-side.
const deliveredTrackedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!myId || messages.length === 0) return;
const toMark: string[] = [];
for (const m of messages) {
if (m.senderId === myId) continue;
if (deliveredTrackedRef.current.has(m.id)) continue;
deliveredTrackedRef.current.add(m.id);
toMark.push(m.id);
}
if (toMark.length > 0) {
void markDelivered(toMark).catch((err: unknown) => {
console.warn('markDelivered failed', err);
});
}
}, [messages, myId]);
const lastSeenMessageId = useMemo(() => { const lastSeenMessageId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) { for (let i = messages.length - 1; i >= 0; i--) {
@@ -100,8 +138,19 @@ export function ConversationPage() {
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchIdx, setSearchIdx] = useState(0); const [searchIdx, setSearchIdx] = useState(0);
const [searchSenderId, setSearchSenderId] = useState<string>('');
const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
const [searchDateFrom, setSearchDateFrom] = useState<string>('');
const [searchDateTo, setSearchDateTo] = useState<string>('');
const [highlightedId, setHighlightedId] = useState<string | null>(null); const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [mentionState, setMentionState] = useState<
{ query: string; start: number } | null
>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null); const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -111,8 +160,28 @@ export function ConversationPage() {
setForwardTarget(null); setForwardTarget(null);
setSearchOpen(false); setSearchOpen(false);
setSearchQuery(''); setSearchQuery('');
setDisplayCount(150);
}, [id]); }, [id]);
// Expand window when the "load older" sentinel scrolls into view. Doubles
// effective window on each trigger so scrolling up quickly converges to
// rendering everything.
useEffect(() => {
const el = loadMoreSentinelRef.current;
if (!el) return;
if (displayCount >= messages.length) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setDisplayCount((n) => Math.min(messages.length, n * 2));
}
},
{ root: scrollRef.current, rootMargin: '200px 0px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [displayCount, messages.length]);
const messageById = useMemo(() => { const messageById = useMemo(() => {
const m = new Map<string, DecryptedMessage>(); const m = new Map<string, DecryptedMessage>();
for (const msg of messages) m.set(msg.id, msg); for (const msg of messages) m.set(msg.id, msg);
@@ -176,17 +245,39 @@ export function ConversationPage() {
setForwardTarget(m); setForwardTarget(m);
}, []); }, []);
// Search matches: messages whose decrypted text includes the query. // Search matches: messages matching query + filters. Empty query is allowed
// when filters are active, so users can e.g. show "all attachments from
// alice in the last week" without a text query.
const searchActive = useMemo(
() =>
searchQuery.trim().length > 0 ||
searchSenderId !== '' ||
searchAttachmentsOnly ||
searchDateFrom !== '' ||
searchDateTo !== '',
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
);
const searchMatches = useMemo(() => { const searchMatches = useMemo(() => {
if (!searchActive) return [] as DecryptedMessage[];
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
if (!q) return [] as DecryptedMessage[]; const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
// Date inputs cover whole days — bump 'to' to end-of-day.
const toTs = searchDateTo
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
: null;
return messages.filter((m) => { return messages.filter((m) => {
if (searchSenderId && m.senderId !== searchSenderId) return false;
const created = new Date(m.createdAt).getTime();
if (fromTs !== null && created < fromTs) return false;
if (toTs !== null && created > toTs) return false;
if (!m.plaintext) return false; if (!m.plaintext) return false;
const parsed = parseMessagePayload(m.plaintext); const parsed = parseMessagePayload(m.plaintext);
if (parsed.kind !== 'text') return false; if (parsed.kind !== 'text') return false;
return parsed.text.toLowerCase().includes(q); if (searchAttachmentsOnly && parsed.attachments.length === 0) return false;
if (q && !parsed.text.toLowerCase().includes(q)) return false;
return true;
}); });
}, [messages, searchQuery]); }, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
// Reset/clamp the active match index when the match set changes. // Reset/clamp the active match index when the match set changes.
useEffect(() => { useEffect(() => {
@@ -262,13 +353,9 @@ export function ConversationPage() {
} }
} }
function handleFilesChosen(list: FileList | null) { function ingestFiles(files: File[]) {
if (!list) return;
const next: File[] = []; const next: File[] = [];
for (let i = 0; i < list.length; i++) { for (const f of files) {
const f = list[i];
if (!f) continue;
if (!f.type.startsWith('image/')) continue;
if (f.size > 10 * 1024 * 1024) { if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)'); setSendError('Datei zu groß (max 10 MB)');
continue; continue;
@@ -278,7 +365,11 @@ export function ConversationPage() {
setAttachments((prev) => [...prev, ...next].slice(0, 4)); setAttachments((prev) => [...prev, ...next].slice(0, 4));
} }
const isGroup = conversation?.type === 'group'; function handleFilesChosen(list: FileList | null) {
if (!list) return;
ingestFiles(Array.from(list));
}
const { state: callState } = useCall(); const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the // Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration, // call topbar inside the dock already shows the channel name + duration,
@@ -292,7 +383,31 @@ export function ConversationPage() {
callState.kind === 'incoming' && callState.conversationId === id; callState.kind === 'incoming' && callState.conversationId === id;
return ( return (
<div className="relative flex h-full flex-col"> <div
className="relative flex h-full flex-col"
onDragEnter={(e) => {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault();
setIsDraggingFile(true);
}
}}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDragLeave={(e) => {
// leave fires on child enter too; only clear when leaving the page container.
if (e.currentTarget === e.target) setIsDraggingFile(false);
}}
onDrop={(e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
setIsDraggingFile(false);
ingestFiles(Array.from(e.dataTransfer.files));
}}
>
{!callHereActive && ( {!callHereActive && (
<ConversationHeader <ConversationHeader
conversation={conversation} conversation={conversation}
@@ -308,6 +423,15 @@ export function ConversationPage() {
onQueryChange={setSearchQuery} onQueryChange={setSearchQuery}
matches={searchMatches.length} matches={searchMatches.length}
activeIdx={searchIdx} activeIdx={searchIdx}
senderId={searchSenderId}
onSenderChange={setSearchSenderId}
attachmentsOnly={searchAttachmentsOnly}
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
dateFrom={searchDateFrom}
onDateFromChange={setSearchDateFrom}
dateTo={searchDateTo}
onDateToChange={setSearchDateTo}
members={conversation?.members ?? []}
onPrev={() => onPrev={() =>
setSearchIdx((cur) => setSearchIdx((cur) =>
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length, searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
@@ -319,6 +443,10 @@ export function ConversationPage() {
onClose={() => { onClose={() => {
setSearchOpen(false); setSearchOpen(false);
setSearchQuery(''); setSearchQuery('');
setSearchSenderId('');
setSearchAttachmentsOnly(false);
setSearchDateFrom('');
setSearchDateTo('');
}} }}
/> />
)} )}
@@ -345,7 +473,18 @@ export function ConversationPage() {
<p className="text-center text-sm text-fg-muted"></p> <p className="text-center text-sm text-fg-muted"></p>
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{messages.map((m, idx) => { {displayCount < messages.length && (
<li>
<div
ref={loadMoreSentinelRef}
className="flex items-center justify-center py-2 text-xs text-fg-muted"
>
Lade ältere Nachrichten
</div>
</li>
)}
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
// A "run" is consecutive bubbles from the same sender with // A "run" is consecutive bubbles from the same sender with
// nothing between them. Call-event separators break the run — // nothing between them. Call-event separators break the run —
// a bubble whose immediate next neighbour is a call_event must // a bubble whose immediate next neighbour is a call_event must
@@ -384,6 +523,19 @@ export function ConversationPage() {
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
showSeen={m.id === lastSeenMessageId} showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId
? {
deliveryState: computeDeliveryState({
messageId: m.id,
isGroup: !!isGroup,
recipientCount: groupRecipientCount,
peerReadSet,
peerDeliveredSet,
groupRead,
groupDelivered,
}),
}
: {})}
quoted={buildQuoted(m.replyToId)} quoted={buildQuoted(m.replyToId)}
onJumpToMessage={jumpToMessage} onJumpToMessage={jumpToMessage}
onReply={handleReply} onReply={handleReply}
@@ -393,6 +545,15 @@ export function ConversationPage() {
</li> </li>
); );
})} })}
{pending.map((p) => (
<li key={p.id}>
<PendingBubble
item={p}
onRetry={() => retryPending(p.id)}
onCancel={() => cancelPending(p.id)}
/>
</li>
))}
</ul> </ul>
)} )}
</div> </div>
@@ -402,6 +563,17 @@ export function ConversationPage() {
members={conversation?.members ?? []} members={conversation?.members ?? []}
/> />
{isDraggingFile && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
>
<div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
Datei hier ablegen zum Anhängen
</div>
</div>
)}
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4"> <form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
{sendError && ( {sendError && (
<div className="mb-2"> <div className="mb-2">
@@ -455,11 +627,36 @@ export function ConversationPage() {
</div> </div>
)} )}
<div className="flex items-end gap-2"> <div className="relative flex items-end gap-2">
{isGroup && mentionState && conversation && (
<MentionAutocomplete
members={conversation.members}
query={mentionState.query}
excludeUserId={myId}
onSelect={(username) => {
// Replace `@{query}` at `start..caret` with `@{username} `.
const start = mentionState.start;
const before = text.slice(0, start);
const afterCaret = text.slice(start + 1 + mentionState.query.length);
const inserted = '@' + username + ' ';
const next = before + inserted + afterCaret;
setText(next);
setMentionState(null);
// Restore caret position after inserted mention.
const caret = (before + inserted).length;
requestAnimationFrame(() => {
const el = composerRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
}}
onClose={() => setMentionState(null)}
/>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*"
multiple multiple
className="hidden" className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)} onChange={(e) => handleFilesChosen(e.target.files)}
@@ -467,18 +664,77 @@ export function ConversationPage() {
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen" aria-label="Datei anhängen"
title="Bild anhängen" title="Datei anhängen"
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<div className="relative">
<button
type="button"
data-emoji-trigger
onClick={() => setEmojiOpen((v) => !v)}
aria-label="Emoji einfügen"
title="Emoji einfügen"
aria-expanded={emojiOpen}
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<SmileIcon className="h-4 w-4" />
</button>
<EmojiPicker
open={emojiOpen}
onPick={(emoji) => {
const el = composerRef.current;
const caret = el?.selectionStart ?? text.length;
const next = text.slice(0, caret) + emoji + text.slice(caret);
setText(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const pos = caret + emoji.length;
el.setSelectionRange(pos, pos);
});
}}
onClose={() => setEmojiOpen(false)}
/>
</div>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
try {
await send('', [file], replyTo?.id ?? null);
setReplyTo(null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'send failed';
setSendError(msg);
}
}}
/>
<textarea <textarea
ref={composerRef} ref={composerRef}
value={text} value={text}
onChange={(e) => { onChange={(e) => {
setText(e.target.value); const next = e.target.value;
if (e.target.value.length > 0) notifyTyping(); setText(next);
if (next.length > 0) notifyTyping();
// Detect an in-progress @mention: find the last '@' before the
// caret, with no whitespace between it and the caret. If
// present, open the autocomplete with the partial query.
const caret = e.target.selectionStart ?? next.length;
const before = next.slice(0, caret);
const atIdx = before.lastIndexOf('@');
if (
atIdx >= 0 &&
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
) {
const q = before.slice(atIdx + 1);
if (!/\s/.test(q)) {
setMentionState({ query: q, start: atIdx });
return;
}
}
setMentionState(null);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -486,6 +742,21 @@ export function ConversationPage() {
void handleSend(); void handleSend();
} }
}} }}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const pics: File[] = [];
for (const it of Array.from(items)) {
if (it.kind === 'file') {
const f = it.getAsFile();
if (f && f.type.startsWith('image/')) pics.push(f);
}
}
if (pics.length > 0) {
e.preventDefault();
ingestFiles(pics);
}
}}
rows={1} rows={1}
placeholder="Nachricht schreiben…" placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
@@ -516,15 +787,42 @@ interface SearchBarProps {
onQueryChange: (q: string) => void; onQueryChange: (q: string) => void;
matches: number; matches: number;
activeIdx: number; activeIdx: number;
senderId: string;
onSenderChange: (id: string) => void;
attachmentsOnly: boolean;
onAttachmentsOnlyChange: (v: boolean) => void;
dateFrom: string;
onDateFromChange: (v: string) => void;
dateTo: string;
onDateToChange: (v: string) => void;
members: { userId: string; profile: { displayName?: string | null } | null }[];
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
onClose: () => void; onClose: () => void;
} }
function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, onClose }: SearchBarProps) { function SearchBar({
query,
onQueryChange,
matches,
activeIdx,
senderId,
onSenderChange,
attachmentsOnly,
onAttachmentsOnlyChange,
dateFrom,
onDateFromChange,
dateTo,
onDateToChange,
members,
onPrev,
onNext,
onClose,
}: SearchBarProps) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
return ( return (
<div className="flex items-center gap-2 border-b border-line bg-surface-2 px-4 py-2"> <div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
<div className="flex items-center gap-2">
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" /> <SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<input <input
type="search" type="search"
@@ -576,6 +874,48 @@ function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, o
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
<select
value={senderId}
onChange={(e) => onSenderChange(e.target.value)}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
>
<option value="">Alle Sender</option>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{m.profile?.displayName ?? m.userId.slice(0, 6)}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
<input
type="checkbox"
checked={attachmentsOnly}
onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
className="accent-accent"
/>
<span>Nur Anhänge</span>
</label>
<label className="flex items-center gap-1">
<span>Von</span>
<input
type="date"
value={dateFrom}
onChange={(e) => onDateFromChange(e.target.value)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
/>
</label>
<label className="flex items-center gap-1">
<span>Bis</span>
<input
type="date"
value={dateTo}
onChange={(e) => onDateToChange(e.target.value)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
/>
</label>
</div>
</div>
); );
} }
@@ -583,6 +923,86 @@ function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, o
// regular bubble is found or the array boundary is reached. Used to decide // regular bubble is found or the array boundary is reached. Used to decide
// run-grouping for avatar placement so call separators don't bleed into // run-grouping for avatar placement so call separators don't bleed into
// sender continuity. // sender continuity.
function computeDeliveryState(args: {
messageId: string;
isGroup: boolean;
recipientCount: number;
peerReadSet: Set<string>;
peerDeliveredSet: Set<string>;
groupRead: Map<string, Set<string>>;
groupDelivered: Map<string, Set<string>>;
}): 'sent' | 'delivered' | 'read' {
// DM: single peer ack flips state.
if (!args.isGroup) {
if (args.peerReadSet.has(args.messageId)) return 'read';
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
return 'sent';
}
// Group: state advances only when ALL recipients have acknowledged. With
// 0 recipients (admin-only group), we keep 'sent' so we don't show
// misleading completed ticks.
if (args.recipientCount === 0) return 'sent';
const reads = args.groupRead.get(args.messageId);
if (reads && reads.size >= args.recipientCount) return 'read';
const delivered = args.groupDelivered.get(args.messageId);
if (delivered && delivered.size >= args.recipientCount) return 'delivered';
return 'sent';
}
function PendingBubble({
item,
onRetry,
onCancel,
}: {
item: OutboxItem;
onRetry: () => void;
onCancel: () => void;
}) {
const failed = item.attempts >= 8;
return (
<div className="flex justify-end py-0.5">
<div
className={
'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
(failed
? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
: 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
}
>
<p className="whitespace-pre-wrap break-words">{item.text}</p>
<div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
{failed ? (
<>
<span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
<button
type="button"
onClick={onRetry}
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
>
Erneut
</button>
<button
type="button"
onClick={onCancel}
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
>
Verwerfen
</button>
</>
) : (
<>
<SpinnerIcon className="h-3 w-3 animate-spin" />
<span>
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
</span>
</>
)}
</div>
</div>
</div>
);
}
function Banner({ children }: { children: React.ReactNode }) { function Banner({ children }: { children: React.ReactNode }) {
return ( return (
<div <div
@@ -596,18 +1016,30 @@ function Banner({ children }: { children: React.ReactNode }) {
} }
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) { function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!isImage) return;
const u = URL.createObjectURL(file); const u = URL.createObjectURL(file);
setUrl(u); setUrl(u);
return () => URL.revokeObjectURL(u); return () => URL.revokeObjectURL(u);
}, [file]); }, [file, isImage]);
return ( return (
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2"> <div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
{url ? ( {isImage && url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" /> <img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : ( ) : (
<div className="h-20 w-20" /> <div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
<span className="truncate font-semibold text-fg" title={file.name}>
{file.name || 'Datei'}
</span>
<span className="text-fg-muted">
{file.type || 'unbekannt'}
</span>
<span className="text-fg-muted">
{(file.size / 1024).toFixed(0)} KB
</span>
</div>
)} )}
<button <button
type="button" type="button"
+12
View File
@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog'; import { BackupExportDialog } from '../components/BackupExportDialog';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { LockIcon } from '../components/icons'; import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
@@ -141,6 +143,16 @@ export function SettingsPage() {
/> />
</Section> </Section>
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} />
</Section>
{/* Soundboard */}
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
<SoundboardSettings />
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */} {/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}> <Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioDeviceControls /> <AudioDeviceControls />
+6
View File
@@ -74,6 +74,7 @@ export default {
'live-dot': 'liveDotPulse 1.5s ease-in-out infinite', 'live-dot': 'liveDotPulse 1.5s ease-in-out infinite',
'slide-in-call': 'slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)', 'slide-in-call': 'slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)',
'fs-hint': 'fsHintFade 3.5s ease-out forwards', 'fs-hint': 'fsHintFade 3.5s ease-out forwards',
'reaction-pop': 'reactionPop 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',
}, },
keyframes: { keyframes: {
blob: { blob: {
@@ -110,6 +111,11 @@ export default {
'15%, 75%': { opacity: '1', transform: 'translate(-50%, 0)' }, '15%, 75%': { opacity: '1', transform: 'translate(-50%, 0)' },
'100%': { opacity: '0', transform: 'translate(-50%, -6px)' }, '100%': { opacity: '0', transform: 'translate(-50%, -6px)' },
}, },
reactionPop: {
'0%': { opacity: '0', transform: 'scale(0.4)' },
'70%': { opacity: '1', transform: 'scale(1.15)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
}, },
boxShadow: { boxShadow: {
glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)', glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)',
@@ -10,6 +10,11 @@
<link rel="stylesheet" href="styles/variants.css"> <link rel="stylesheet" href="styles/variants.css">
<link rel="stylesheet" href="styles/rail.css"> <link rel="stylesheet" href="styles/rail.css">
<link rel="stylesheet" href="styles/call.css"> <link rel="stylesheet" href="styles/call.css">
<link rel="stylesheet" href="styles/pages.css">
<style>
/* Cache-busting inline override for rail page-mode grid */
.rail-layout.page-mode { grid-template-columns: 64px 1fr !important; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
@@ -57,6 +62,7 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
<script src="components/icons.jsx" type="text/babel"></script> <script src="components/icons.jsx" type="text/babel"></script>
<script src="components/data.jsx" type="text/babel"></script> <script src="components/data.jsx" type="text/babel"></script>
<script src="components/call.jsx" type="text/babel"></script> <script src="components/call.jsx" type="text/babel"></script>
<script src="components/pages.jsx" type="text/babel"></script>
<script src="components/app.jsx" type="text/babel"></script> <script src="components/app.jsx" type="text/babel"></script>
<script type="text/babel"> <script type="text/babel">
+312 -214
View File
@@ -1,30 +1,38 @@
# Handoff: ChatApp — Clean Rail Redesign + Discord-Style Call UI # Handoff: ChatApp — Clean Rail Redesign (Chat + Friends + Admin + Settings + Calls)
## Overview ## Overview
This package contains the full design spec for redesigning the existing ChatApp (Netralax) interface around a **Clean Rail** layout aesthetic, plus a complete **Discord-style call system** (incoming call, in-call dock, picture-in-picture, fullscreen cinema mode). Full design spec for redesigning the existing ChatApp (Netralax) interface around a **Clean Rail** layout aesthetic. Covers **four main screens** plus the complete call UX:
The redesign covers: 1. **Chat** — icon-rail + chat list + conversation area
1. The main chat interface (icon-rail sidebar + chat list + conversation area) in both **light** and **dark** mode. 2. **Friends** — friends list, outgoing requests, incoming requests, user search
2. The call UX: incoming call overlay → active call docked above the chat (so users can keep typing while on a call) → fullscreen cinema mode → minimizable PiP bubble. 3. **Admin** — stats, invite-code management, user administration
4. **Settings** — account, appearance, privacy, voice/audio, screenshare, devices, danger zone
5. **Calls** — incoming overlay → in-call dock → fullscreen → picture-in-picture
All screens work in **light** and **dark** mode.
---
## About the Design Files ## About the Design Files
The files in this bundle are **design references created in HTML/React/CSS** — prototypes showing the intended look, layout, and behavior. They are **not production code** to copy verbatim. The files in this bundle are **design references created in HTML/React/CSS** — prototypes showing the intended look, layout, and behavior. They are **not production code** to copy verbatim.
Your task is to **recreate these designs inside the existing ChatApp codebase**, using its established patterns (existing React components, styling conventions, state management, icon library, etc.). If some tokens or helper components don't exist yet in the codebase, add them but keep them aligned with what's already there. Your task is to **recreate these designs inside the existing ChatApp codebase** using its established patterns (existing React components, styling conventions, state management, icon library, etc.). If some tokens or helper components don't exist yet in the codebase, add them but keep them aligned with what's already there.
The design files are organized as: File layout:
- `ChatApp Redesign.html` — entry HTML that boots the prototype - `ChatApp Redesign.html` — entry HTML (open in a browser to see the prototype)
- `components/app.jsx` — main layout (rail + chat list + chat area, plus call state machine) - `components/app.jsx` — main layout (rail + chat list + chat area + call state machine + tab switching)
- `components/call.jsx`all call UI components (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall) - `components/pages.jsx`**Friends, Admin, Settings page components** plus shared page primitives (PageHeader, Section, Toggle, SegmentedControl, Row, Empty, Banner, FlagChip, FriendRow)
- `components/icons.jsx`SVG icon set used - `components/call.jsx`call UI (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
- `components/icons.jsx` — SVG icon set
- `components/data.jsx` — dummy chat/message data - `components/data.jsx` — dummy chat/message data
- `styles/base.css` — shared base tokens + layout primitives - `styles/base.css` — shared base tokens + layout primitives
- `styles/variants.css``variant-clean` light + dark token definitions (this is the look you're targeting) - `styles/variants.css``variant-clean` light + dark token definitions
- `styles/rail.css` — rail-layout specifics - `styles/rail.css` — rail-layout specifics
- `styles/call.css` **all call UI styles** (the most important file for the call feature) - `styles/call.css` — call UI styles
- `screenshots/` — reference images of each state - `styles/pages.css`**Friends/Admin/Settings page styles**
- `screenshots/` — reference images
## Fidelity ## Fidelity
@@ -36,13 +44,13 @@ The design files are organized as:
### Colors — Light mode (`.variant-clean`) ### Colors — Light mode (`.variant-clean`)
``` ```
--bg: #fafaf9 (app background) --bg: #fafaf9
--bg-2: #f4f4f2 (secondary surfaces, hover, search input) --bg-2: #f4f4f2
--bg-3: #ffffff (elevated surfaces — chat list, chat area, cards) --bg-3: #ffffff
--fg: #0a0a0f (primary text) --fg: #0a0a0f
--fg-muted: #737380 (secondary text, icons) --fg-muted: #737380
--line: rgba(10,10,15,0.08) (dividers, borders) --line: rgba(10,10,15,0.08)
--accent: #4F46E5 (indigo — primary actions, active states, my-bubble) --accent: #4F46E5
--accent-fg: #ffffff --accent-fg: #ffffff
``` ```
@@ -54,20 +62,19 @@ The design files are organized as:
--fg: #f4f4f5 --fg: #f4f4f5
--fg-muted: #8a8a99 --fg-muted: #8a8a99
--line: rgba(255,255,255,0.07) --line: rgba(255,255,255,0.07)
--accent: #6D73FF (slightly lighter indigo for contrast) --accent: #6D73FF
--accent-fg: #ffffff --accent-fg: #ffffff
``` ```
### Status / Semantic Colors (both modes) ### Semantic Colors
``` ```
Speaking/Online/Success: #16a34a light · #22c55e / #4ade80 dark Success / Online / E2EE: #16a34a (light) · #4ade80 / #22c55e (dark)
Danger/Decline/Hangup: #dc2626 (hover #b91c1c) · #fb7185 dark accent Danger / Decline: #dc2626 / #b91c1c hover · #fb7185 (dark accent for borders/text)
Warning/Idle: #f59e0b Warning / Expired: #f59e0b · #d97706 light · #fbbf24 dark
Live/Recording: #ef4444 Live / Recording: #ef4444
E2EE indicator (light): #16a34a (dark: #4ade80)
``` ```
### Avatar color swatches (assigned per chat) ### Avatar swatches (assigned per user)
``` ```
violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD) violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD)
amber: bg #fde68a / fg #78350f amber: bg #fde68a / fg #78350f
@@ -76,239 +83,330 @@ teal: bg #99f6e4 / fg #134e4a (dark: #134e4a / #99f6e4)
``` ```
### Typography ### Typography
- Family: `'Inter', system-ui, sans-serif` — Inter is the base. Display/headline use `'Outfit'` (participant name in IncomingCall). - Family: `'Inter', system-ui, sans-serif` base; display `'Outfit'` (page titles, call participant name, stat-card values); mono `'JetBrains Mono'` (invite codes, device IDs, keyboard hotkeys, inline stats).
- Scale: - Scale:
- Chat title / participant name large: 24px / 700 - Page title (`Freunde`, `Admin`, `Einstellungen`, call participant): 28px (page) / 24px (call) / 700 / Outfit / letter-spacing -0.02em
- Primary labels, chat name: 1415px / 600 - Section label (uppercase eyebrow): 11px / 600 / tracked 0.08em
- Body (bubbles, messages): 14px / 400500 - Body labels, chat name, friend name: 1415px / 600
- Meta, timestamps, presence: 1112px / 400500 - Body text, bubbles, settings hint: 1214px / 400500
- Rail tooltips, badges: 1011px / 500600 - Meta, timestamps, handles, stat labels: 1112px / 500, uppercase 1011px tracked 0.06em
- Uppercase eyebrow (e.g. "EINGEHENDER ANRUF"): 12px / 600 / letter-spacing 0.12em - Mono values: 1112px / JetBrains Mono / 500
### Spacing ### Spacing / Radius
- Container padding: 16px (call-stage), 1420px (topbars, composer) - Page content max-width: **820px**, centered, 28px top padding, 32px horizontal.
- Tile gap in grid: 810px - Section card radius 16, section header padded 16×20 with bottom border, section body padded 16×20 gap 14.
- Control-button row gap: 810px - Button radius 810, chip/pill 999, stat card 12, device card 12.
- Border radius scale: 4 (small), 8 (tiles/badges), 10 (buttons), 14 (cards, large buttons), 18 (bubbles, bottom-fs controls), 24 (modal/card) - Toggle switch: 40×22 with 16×16 thumb, 2px inset, transitions all 0.2s.
### Shadows ### Shadows
- Elevated card light: `0 20px 60px rgba(0,0,0,0.1)` - Elevated card light: `0 20px 60px rgba(0,0,0,0.1)`
- Elevated card dark: `0 20px 60px rgba(0,0,0,0.5)` - Elevated card dark: `0 20px 60px rgba(0,0,0,0.5)`
- Accent button: `0 4px 14px rgba(22,163,74,0.3)` (green accept) / same pattern for accent - Accent button: `0 4px 14px rgba(79,70,229,0.25)`
- PiP glow: `0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)` - Avatar-large glow: `0 2px 6px rgba(0,0,0,0.08)`
### Motion ### Motion
- Standard UI transition: `all 0.15s` - Standard UI transition: `all 0.15s`
- Pulse ring (incoming call): `2s ease-out infinite`, scale 0.85→1.25, opacity 0.6→0 - Toggle switch thumb: `transform 0.2s`
- Audio pulse (speaking ring): `1.3s ease-out infinite`, scale 1→1.3 - PTT hotkey capture pulse: `keyPulse 1.2s ease-in-out infinite` (opacity 1↔0.6)
- Live dot: `1.5s ease-in-out infinite`, opacity 1↔0.4
- PiP entrance: `slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)`
- Fullscreen hint fade: 3.5s total (015% fade in, 75100% fade out)
--- ---
## Screens / Views ## Tab Switching Behavior
### 1. Main Chat — Clean Rail Layout The icon rail (`.icon-rail`) holds 4 primary tabs: **Chats**, **Friends**, **Admin**, **Settings**. Clicking an icon sets `activeTab`. Layout adapts:
- **`activeTab === 'chats'`** → renders icon-rail + chat-list + chat-area (3 columns: `64px 280px 1fr`).
- **`activeTab !== 'chats'`** → renders icon-rail + page content (2 columns: `64px 1fr`). Chat list is removed. The page component (`FriendsPageContent`, `AdminPageContent`, `SettingsPageContent`) renders inside the chat-area slot with its own scroll container and 820px centered inner.
The `.rail-layout.page-mode` class toggles the grid-template-columns. **IMPORTANT:** this override is inlined in the entry HTML as a `<style>` block to avoid CSS-cache issues during dev. In production, put it in your main stylesheet.
The **call dock** and **call screen switcher** are only shown on the chats tab.
---
## Screen 1: Chat
**Structure (left → right):** **Structure (left → right):**
- **Icon Rail** (72px wide): brand logo at top, tab buttons (Chats / Friends / Admin), spacer, Settings + theme-toggle at bottom. Active tab shows a vertical pill indicator on the left edge. - **Icon Rail** (64px): brand logo, tab buttons (Chats/Friends/Admin), spacer, Settings + theme-toggle at bottom. Active tab shows a vertical pill indicator on the left edge.
- **Chat List** (280px wide): header with title + "new chat" icon, search input, list of `ChatItem`s (avatar + name + time + preview, optional unread dot), `rail-user` footer showing own profile + quick mute/settings. - **Chat List** (280px): header with title + "new chat" icon, search input, list of `ChatItem`s (avatar + name + time + preview, optional unread dot), `rail-user` footer showing own profile + quick mute/settings.
- **Chat Area** (flex, fills rest): chat header (counterpart avatar + name + presence/handle + search/voice/video buttons), messages scroll area, composer at bottom. - **Chat Area** (flex): chat header (counterpart avatar + name + presence/handle + search/voice/video), messages, composer.
**Chat header actions (right side):** search icon, phone icon, video icon. Phone triggers voice call (`callScreen → 'active'`, `callState.video = false`), Video triggers video call (`callState.video = true`).
**Message bubbles:** **Message bubbles:**
- Mine (outgoing): filled accent bg, white text, radius `18px 18px 4px 18px`. - Mine: filled accent bg, white text, radius `18px 18px 4px 18px`.
- Theirs (incoming): transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`. - Theirs: transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`.
- Avatars appear only on the **last message of a run** from that sender. - Avatars only on the **last message of a run**.
- Reactions: pill row below bubble, `.mine` = filled accent. - Reactions: pill row below, `.mine` = filled accent.
- System events ("Kaiwandi hat einen Anruf gestartet — Anruf beitreten"): centered `event-pill`, muted bg, muted fg, 11px. - System events: centered `event-pill`, muted bg/fg, 11px.
**Composer:** plus button (attach), rounded input bar with inline emoji + mic icons, send button.
--- ---
### 2. Incoming Call Overlay ## Screen 2: Friends
Full-bleed overlay replacing the chat area (takes over `.chat-area` space), radial-gradient background tinted with accent. Component: **`FriendsPageContent`** (`components/pages.jsx`)
Centered card (420px wide): ### Layout
- Eyebrow "EINGEHENDER ANRUF" (uppercase, 12px, tracked) - `PageHeader` with title "Freunde" and subtitle `"{N} Freunde · {M} offene Anfragen"`.
- Large avatar (96px) with **two pulsing rings** (staggered 1s) - **Global search bar** (sticky at top of scroll): icon + input, placeholder "Benutzer suchen oder hinzufügen…". Focused state → border becomes accent.
- Caller name (24px / 700 / Outfit) - **Tabs** (only when not searching): 3 pill tabs with inline counts — `Freunde`, `Ausgehend`, `Anfragen`. When there are pending incoming requests, the tab count badge is red (`.tab-count.highlight`).
- Subtitle row: lock icon + "E2E verschlüsselt · Voice Call" - **List area**: either tab content OR search results, mutually exclusive.
- Two side-by-side action buttons:
- **Ablehnen**: transparent, red border/text, hover = 8% red bg
- **Annehmen**: green #16a34a, white, shadow
### 3. Active Call — Docked (Default) ### Search behavior
- Trigger: input.length ≥ 2. Below that, tabs show.
- Filters a global user directory (API: `GET /api/users/search?q={query}` — returns up to 20 matches).
- Each result renders a `FriendRow` with a single primary button: "Anfrage senden" (icon + label). On click → `POST /api/friendships` with `{ targetUserId }`, optimistically updates the row to "Angefragt".
- Empty state: "Keine Ergebnisse für „{query}"" with Search icon.
**CRITICAL layout decision:** The active call does NOT take over the chat area. Instead it renders as a **dock above the messages**, taking roughly 50% of the chat-area height (min 280px, max 420px). The chat header is **hidden** while the dock is active (redundant — call topbar shows the channel name). Messages + composer remain fully visible and usable below, so users can keep chatting during the call. This is the Discord desktop pattern. ### Tabs
**Dock content:** **Freunde** (`status === 'accepted'`):
- **Call topbar**: left side shows group icon + "Die Squad" + duration (tabular-nums), with E2E verschlüsselt below in success-green. Right side has 3 mode toggles: Grid / Focus / Fullscreen. - FriendRow with: Nachricht (primary button opens conversation), Voice icon button (starts call), "Entfernen" (danger-ghost, confirms before `DELETE /api/friendships/{id}`).
- **Call stage**: fills the remaining space, renders either: - Empty: "Noch keine Freunde. Suche oben nach Benutzern um anzufangen."
- **Grid**: CSS grid, 2×2 for 4 people, 3×2 for 56, 1fr 1fr for 2.
- **Focus**: main speaker tile takes most height, bottom strip with 110px-tall smaller tiles for others.
- **Fullscreen**: switches to `mode === 'fullscreen'` — see screen 4.
- **Call controls bar** at the bottom of the dock: Mic, Video, Screen-share, Participants, **Hangup** (wider, red). Compact 36px buttons when docked.
### 4. Active Call — Fullscreen Cinema Mode **Ausgehend** (`status === 'pending' && direction === 'outgoing'`):
- "Wartet auf Antwort…" italicized + `btn-ghost` "Abbrechen" → `DELETE /api/friendships/{id}`.
- Empty: "Keine ausgehenden Anfragen."
When mode = `fullscreen`: **Anfragen** (`status === 'pending' && direction === 'incoming'`):
- Entire call-dock container becomes absolute-positioned and takes over everything below the window bar (chat list still visible on the left). - `btn-primary` "Annehmen" (check icon) → `PATCH /api/friendships/{id}` with `{ accept: true }`; `btn-ghost` "Ablehnen" → same endpoint `{ accept: false }`.
- The **call topbar is hidden** entirely. - Empty: "Keine eingehenden Anfragen."
- Main speaker fills the whole frame, no border/radius.
- Participants strip moves to a **floating column in the bottom-right** (`.fs-strip`): 180px wide, 100px tall tiles, glass-blurred bg.
- **Call controls bar floats** at bottom center — glass pill: `rgba(10,10,15,0.7)` + `backdrop-filter: blur(20px)` + subtle border + 18px radius.
- **"Esc zum Verlassen" hint** fades in top-center on entry and auto-fades out over 3.5s. Pressing Esc or clicking Grid/Focus buttons exits.
- **No visible X button** (intentional — keyboard-only exit, confirmed design decision).
### 5. Participant Tile — Shared Component ### FriendRow (shared)
12px gap row, hover → `bg-2`. Avatar (with presence indicator on accepted friends), name (14px/600), handle `@{username}` (12px muted), actions on the right.
A participant is either in **audio-only**, **video**, or **sharing screen** mode. ### Data shapes
- Tile border: 1px `var(--line)`, radius 14 (10 in dock). Hover → border becomes accent. `.speaking` → 2px green border + green glow shadow. `.focused` → accent border. ```ts
- **Audio-only**: centered avatar (72px large, 44px small). When `speaking`, a green ring pulses around the avatar (`::before` pseudo). type Friendship = {
- **Video**: placeholder stub (gradient background + centered avatar, since real video not in scope here). id: string;
- **Screenshare**: renders a "fake window" — chrome row with 3 dots, content row with grey skeleton lines + one accent-tinted block. A glass badge top-left: monitor icon + "Max teilt Bildschirm" (badge hidden on small tiles). userId: string; // the OTHER user's ID
- **Meta row** (absolute, bottom 8px, glass): left shows crown (if me) + name + lock (if E2EE), right shows muted mic icon (red chip) and/or sharing icon (green chip). displayName: string;
username: string; // @handle
### 6. Picture-in-Picture (PiP) avatarColor: 'violet'|'amber'|'rose'|'teal';
initial: string; // derived from displayName[0] uppercase
A compact 260px pill docked bottom-right of the chat area: status: 'accepted' | 'pending';
- 44px preview square (avatar of speaker, or mini shared-window placeholder if someone's sharing) direction: 'outgoing' | 'incoming' | null; // null when accepted
- Title "Die Squad · {participantCount}" presence?: 'online' | 'idle' | 'dnd' | 'offline';
- Subtitle row: red pulsing dot + "Live · tippe zum Öffnen" };
- Circular red hangup button on the right ```
- Whole thing is clickable → expands back to active call
- Entrance animation: slide from bottom + fade in (`slideInCall`)
- Shadow: standard + 3px accent glow ring
--- ---
## Interactions & Behavior ## Screen 3: Admin
### Call State Machine (lives in `ChatApp`) Component: **`AdminPageContent`** (`components/pages.jsx`)
Gated route — only accessible if `currentUser.isAdmin === true`. If not, render 403 "Kein Zugriff" page.
### Layout
1. **`PageHeader`** "Admin" + subtitle `"{userCount} User · {activeInviteCount} aktive Invites"`.
2. **Stats grid** (4 cards, 1fr × 4 @ ≥1200px, 2×2 below):
- Gesamt User
- Admins
- Gebannt (value in `#dc2626` / dark `#fb7185`)
- Aktive Invites
- Each card: 11px uppercase tracked label, 28px Outfit bold value, tabular-nums.
3. **System section**: single `Toggle` for `invites_enabled`. Hint: "Wenn aus, können keine neuen User per Invite-Code beitreten."
- Maps to `PATCH /api/admin/settings { invitesEnabled: boolean }`.
4. **Invite-Codes section**:
- Header action: **+ Neuer Code** primary button → opens modal (out of scope here) with fields for `code` (optional; auto-generated if blank), `usesLimit` (int or ∞), `expiresAt` (date or never).
- Table columns: Code · Nutzungen · Ablauf · Status · actions.
- Code column: mono 12px.
- Status pill: `Aktiv` (green), `Abgelaufen` (amber), `Deaktiviert` (red).
- Actions (per row, right-aligned): Copy icon (shows check on copy, 1.4s timeout), "Deaktivieren/Aktivieren" ghost xs button, red Trash icon.
- API:
- List: `GET /api/admin/invites`
- Create: `POST /api/admin/invites`
- Toggle: `PATCH /api/admin/invites/{code} { disabled: boolean }`
- Delete: `DELETE /api/admin/invites/{code}`
5. **Benutzer section**:
- Row per user: avatar + name (with **Admin badge** if admin — pill with crown icon, accent bg + `accent-fg`) + `@handle · beigetreten {date}` meta.
- Flag chips on the right:
- **Admin** (toggle accent when active)
- **Invite-Block** (toggle accent when active) — prevents this user from creating invites
- **Bann** (`tone="danger"` — red when active)
- Click a chip → `PATCH /api/admin/users/{userId} { [flag]: boolean }`. Optimistic update.
- No delete — banning is the terminal state.
### Data shapes
```ts
type Invite = {
code: string;
usesCount: number;
usesLimit: number | null; // null = unlimited
expiresAt: string | null; // ISO date
disabled: boolean;
createdBy: string; // userId
createdAt: string;
};
type AdminUser = {
userId: string;
displayName: string;
username: string;
initial: string;
avatarColor: string;
isAdmin: boolean;
blockedFromInviting: boolean;
banned: boolean;
joinedAt: string; // display-formatted in list
};
```
---
## Screen 4: Settings
Component: **`SettingsPageContent`** (`components/pages.jsx`)
### Sections (in this exact order)
**1. Account**
- `avatar-control` row: 64px circular avatar with initial, label + hint, "Ändern" primary, "Entfernen" danger-ghost.
- Upload: 512×512 max, WebP only, client-side crop, `POST /api/me/avatar`.
- `row-stack` with 3 `Row` entries:
- Benutzername (editable — click to inline-edit, `PATCH /api/me { username }`, uniqueness check)
- Anzeigename (editable, `PATCH /api/me { displayName }`)
- User-ID (mono, read-only)
**2. Aussehen**
- Sprache: `SegmentedControl` Deutsch/English → `PATCH /api/me { locale }` and updates i18n context.
- Theme: `SegmentedControl` ☀ Light / ☾ Dark → toggles dark mode globally. Wire to your theme provider; persist in localStorage + `PATCH /api/me { theme }`.
**3. Datenschutz**
- `Toggle` Lesebestätigungen (bidirectional — hint explains).
- `Toggle` DMs von Fremden erlauben.
**4. Sprache & Audio**
- `SegmentedControl` Audio-Qualität: `Sprache` (default) vs `HiFi`.
- **Sprache**: 48 kbps Mono Opus, 48 kHz, DSP on (noise suppression + echo cancellation + auto-gain).
- **HiFi**: 510 kbps Stereo Opus, 48 kHz, DSP off.
- Hint text updates live with the mode.
- Inline `stats-inline` box showing the 4 resolved parameters (Bitrate / Channels / Sample / DSP).
- Divider.
- `Toggle` Push-to-Talk. When enabled, an extra row appears: label "Hotkey" + `.key-capture` button showing the current key. Click → enters capture mode (pulsing animation), next keydown sets the key. Esc cancels.
- Divider.
- `Toggle` E2EE (default on). Hint: all participants must have it on.
**5. Bildschirmfreigabe**
- `SegmentedControl` Flüssig / Balanced / Scharf:
- Flüssig: 3 Mbps, 1280×720, 60 fps
- Balanced: 6 Mbps, 1920×1080, 30 fps (default)
- Scharf: 12 Mbps, 2560×1440, 30 fps
- Inline `stats-inline` shows resolved params.
**6. Geräte**
- `device-card` (green-tinted): lock icon + "Dieses Gerät", followed by Row stack: Name, Plattform, Device-ID (mono).
- `device-backup` card: password input + "Export erstellen" button → generates an encrypted JSON file containing the device's private key + room keys, downloaded client-side (never sent to server).
**7. Danger Zone**
- Single `btn-danger` "Abmelden" (full red button). Triggers sign-out + clears all local state.
### SegmentedControl
Inline group with 3px padding, transparent buttons, active button elevated with white bg (light) or elevated surface (dark) + shadow. 12px/500.
### Toggle
40×22 track, 16×16 thumb, 999 radius. Off: `bg-2` bg + `line` border. On: `accent` bg+border. Thumb translates 18px on. Full click target = entire row (via `<label>`).
### State persistence
Every setting writes to `PATCH /api/me/settings { [key]: value }` on change, with a debounced save (300ms for toggles is fine, immediate for segmented controls). Show a subtle top-right toast "Gespeichert" on success (not shown in this prototype).
---
## Screen 5: Calls (unchanged from previous scope)
See previous sections — Incoming → Active Dock → Fullscreen Cinema → PiP flow with `callScreen` + `callMode` state machine, Esc key handling, Discord-style dock above messages.
---
## Shared Page Primitives
All in `components/pages.jsx`. Re-use these across Friends/Admin/Settings; they define the aesthetic rhythm of the whole app.
```jsx
<PageHeader title="…" subtitle="…" actions={<button />} />
<Section title="…" action={<button />}></Section>
<Toggle label="…" hint="…" checked={} onChange={} />
<SegmentedControl value={} onChange={} options={[{value, label}]} />
<Row label="…" value="…" mono /> // key/value line in a stack
<Stat label="…" value="…" /> // used inside stats-inline
<Banner kind="error|success"></Banner>
<Empty icon={Icon.X}></Empty> // for empty states
<FlagChip active={} tone="danger" onToggle={}>Admin</FlagChip>
<FriendRow profile={}>{actions}</FriendRow>
```
---
## API Endpoints (used by pages)
Friends:
```
GET /api/friendships → { accepted, outgoing, incoming }
POST /api/friendships { targetUserId } → creates outgoing
PATCH /api/friendships/{id} { accept: boolean }
DELETE /api/friendships/{id}
GET /api/users/search?q=… → up to 20 users (friendships excluded)
```
Admin:
```
GET /api/admin/stats → { userCount, adminCount, bannedCount, activeInviteCount }
GET /api/admin/settings → { invitesEnabled }
PATCH /api/admin/settings { invitesEnabled }
GET /api/admin/invites → Invite[]
POST /api/admin/invites { code?, usesLimit?, expiresAt? } → Invite
PATCH /api/admin/invites/{code} { disabled }
DELETE /api/admin/invites/{code}
GET /api/admin/users → AdminUser[]
PATCH /api/admin/users/{userId} { isAdmin? | blockedFromInviting? | banned? }
```
Settings / Me:
```
GET /api/me → profile + settings
PATCH /api/me { username? | displayName? | locale? | theme? }
PATCH /api/me/settings { readReceipts? | allowDmsFromStrangers? | audioQuality? | ptt? | pttKey? | e2ee? | screenShareQuality? }
POST /api/me/avatar multipart → new avatar URL
DELETE /api/me/avatar
GET /api/me/devices → Device[]
POST /api/me/devices/backup { passphrase } → encrypted blob download
POST /api/auth/signout
```
Scope assumption: your codebase already has most of these (see `FriendshipsContext.tsx`, `AdminPage.tsx`, `SettingsPage.tsx`). Wire the new UI to the existing hooks — don't rebuild the data layer.
---
## Call State Machine (unchanged)
```js ```js
// Primary state
const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip' const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip'
const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen' const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen'
const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId); const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId);
const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration }); const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration });
``` ```
### Triggers & Transitions Triggers, keyboard and visibility rules identical to previous spec.
- Chat-header **phone icon** click → `setCallScreen('active')`, `callState.video = false`
- Chat-header **video icon** click → `setCallScreen('active')`, `callState.video = true`
- Grid tile click → `setFocusedId(id); setCallMode('focus')`
- Focus/Grid/Fullscreen buttons in call topbar → set mode directly
- Incoming "Annehmen" → `setCallScreen('active')`
- Incoming "Ablehnen" → `setCallScreen('none')`
- Hangup → `setCallScreen('none')`
- PiP clicked → `setCallScreen('active')`
- PiP hangup (circle red btn) → `stopPropagation()` + `setCallScreen('none')`
- **Esc key** (global listener while `callScreen === 'active' && callMode === 'fullscreen'`) → `setCallMode('grid')`
### Chat Header Visibility Rule
```
if (callScreen === 'active' && callMode !== 'fullscreen') → hide chat header
else → show chat header
```
### Keyboard
- `Esc` exits fullscreen (only). No other global keybindings.
---
## State Management
Data shapes to wire up (see `components/call.jsx`):
```ts
type Participant = {
id: string;
name: string;
initial: string;
color: 'violet' | 'amber' | 'rose' | 'teal';
speaking: boolean;
muted: boolean;
video: boolean;
sharing?: boolean;
me?: boolean;
e2ee: boolean;
};
type CallState = {
muted: boolean;
sharing: boolean;
video: boolean;
duration: string; // "HH:MM:SS"
};
```
In production these all come from your real-time signaling layer (WebRTC, LiveKit, etc.). The prototype uses static data for layout purposes only — wire them to your call store.
---
## Assets
No custom imagery. Everything is SVG icons (inlined in `components/icons.jsx`) and CSS. Fonts from Google Fonts: `Inter`, `Outfit`.
Icons used in this scope: Chat, Friends, Shield, Settings, Logo, AddUser, Search, Phone, Video, Plus, Smile, Mic, MicOff, Send, Sun, Moon, Monitor, Lock, Crown, Grid, Focus, Maximize, PhoneOff.
Replace with your codebase's icon library if one exists (e.g. lucide-react, heroicons) — names match lucide conventions.
--- ---
## Implementation Checklist ## Implementation Checklist
1. **Tokens**: add the Clean-variant light + dark CSS variables to your theme layer. If you already have a token system, map them to your existing names; otherwise create `--bg`, `--bg-2`, `--bg-3`, `--fg`, `--fg-muted`, `--line`, `--accent`, `--accent-fg`. 1. **Tokens**: add the Clean variant light + dark CSS variables to your theme layer.
2. **Icon rail + chat list + chat area**: refactor the current top-nav layout into the 3-column rail layout. Use your existing `Avatar`, `ChatItem`, `Message` components — only restyle. 2. **Rail layout**: refactor top-nav to icon-rail + chat list + chat area (`.rail-layout`). Add `.rail-layout.page-mode` (2-column) for non-chat tabs.
3. **Message bubbles**: outlined `them` / filled `me`, asymmetric radius, avatars only on last-of-run. 3. **Tab switching**: lift `activeTab` state. Conditionally render chat area vs page content.
4. **Call state machine**: add the four `useState` hooks to whatever component owns the chat screen. Hook the Esc key effect. 4. **Page primitives**: implement `PageHeader`, `Section`, `Toggle`, `SegmentedControl`, `Row`, `Stat`, `Banner`, `Empty`, `FlagChip`, `FriendRow` using your existing component library where possible.
5. **IncomingCallScreen**: new component, full-bleed overlay of `.chat-area`. 5. **FriendsPage**: wire to `FriendshipsContext` + user-search endpoint. Tabs + search + optimistic mutations.
6. **Call dock**: new component, renders above `.messages`. Hide chat-header while docked. 6. **AdminPage**: stats grid + system toggle + invites table (copy-to-clipboard, enable/disable, delete) + users list (flag chips with optimistic updates).
7. **ParticipantTile**: audio / video / sharing modes, speaking ring, meta overlay. 7. **SettingsPage**: all 7 sections with two-way binding to user profile + settings. Debounced saves. Theme toggle wired to theme context.
8. **Fullscreen mode**: switch call container to position absolute, floating strip + floating controls + Esc hint. 8. **Message bubbles + call UX** (already specified in previous scope — unchanged).
9. **PiP**: minimizable widget bottom-right of chat area.
10. **Wire real data**: replace `CALL_PARTICIPANTS` dummy with your live call-session participants.
---
## Files in This Handoff
```
design_handoff_chatapp/
├── README.md (this file)
├── ChatApp Redesign.html (entry — open in browser to see the prototype)
├── components/
│ ├── app.jsx (chat app + rail layout + call state machine)
│ ├── call.jsx (Incoming / Active / Pip / ParticipantTile / CallControls)
│ ├── icons.jsx (all SVG icons)
│ └── data.jsx (dummy chats, messages)
├── styles/
│ ├── base.css
│ ├── variants.css (Clean light + dark tokens — this is your theme)
│ ├── rail.css (rail layout)
│ └── call.css (all call UI — highest priority file)
└── screenshots/
├── 01-chat-light.png
├── 03-chat-dark.png
├── 04-incoming-dark.png
├── 05-incall-grid-dark.png
├── 06-incall-focus-dark.png
├── 07-incall-fullscreen-dark.png
├── 09-pip-dark.png
└── 11-incall-light.png
```
--- ---
## Notes for Claude Code ## Notes for Claude Code
- The Clean Rail variant is **Variante 2** in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal), they were design exploration and not the chosen direction. - The Clean Rail variant is **Variante 2** in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal).
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Tooltip`, use them. - `components/pages.jsx` uses **dummy data** for display. Replace with your live contexts (`FriendshipsContext`, admin API hooks, user/settings hooks).
- Duration ("00:12:47") should tick live from the call start timestamp, not be static. - Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Toggle`, use them.
- For WebRTC/media handling: this package specifies **UI only**. The media/signaling layer is out of scope and already exists in the codebase (see `CallUI.tsx`, `InCallPanel.tsx`). - Keep German copy as-is — it matches the existing product. English translations via i18n are a separate task.
- The `.rail-layout.page-mode` grid override is inlined in the HTML in this prototype. Move it to your main stylesheet.
- All settings that affect media (audio quality, PTT, E2EE, screen-share quality) must be read at **call-start time** by the existing call code — they do not hot-apply to an active call.
+18 -6
View File
@@ -188,7 +188,7 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
<div className="app-body"> <div className="app-body">
{variant.layout === 'rail' ? ( {variant.layout === 'rail' ? (
<div className="rail-layout"> <div className={`rail-layout ${activeTab !== 'chats' ? 'page-mode' : ''}`}>
{/* Icon rail left */} {/* Icon rail left */}
<div className="icon-rail"> <div className="icon-rail">
<div className="rail-brand" title="Netralax"><Icon.Logo /></div> <div className="rail-brand" title="Netralax"><Icon.Logo /></div>
@@ -213,7 +213,8 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
{isDark ? <Icon.Sun /> : <Icon.Moon />} {isDark ? <Icon.Sun /> : <Icon.Moon />}
</button> </button>
</div> </div>
{/* Chatlist + user profile at bottom */} {/* Chatlist + user profile at bottom — only on chats tab */}
{activeTab === 'chats' && (
<div className="chat-list rail-chatlist"> <div className="chat-list rail-chatlist">
<div className="chat-list-header"> <div className="chat-list-header">
<div className="chat-list-title">Chats</div> <div className="chat-list-title">Chats</div>
@@ -235,8 +236,13 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button> <button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
</div> </div>
</div> </div>
{/* Chat area (unchanged below) */} )}
<div className="chat-area"> {/* Chat area OR page content */}
<div className={`chat-area ${activeTab !== 'chats' ? 'has-page' : ''}`}>
{activeTab === 'friends' && <FriendsPageContent />}
{activeTab === 'admin' && <AdminPageContent />}
{activeTab === 'settings' && <SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} />}
{activeTab === 'chats' && <>
{!(callScreen === 'active' && callMode !== 'fullscreen') && ( {!(callScreen === 'active' && callMode !== 'fullscreen') && (
<div className="chat-header"> <div className="chat-header">
<Avatar chat={activeChat} /> <Avatar chat={activeChat} />
@@ -290,6 +296,7 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
</div> </div>
<button className="composer-btn"><Icon.Send /></button> <button className="composer-btn"><Icon.Send /></button>
</div> </div>
</>}
</div> </div>
</div> </div>
) : ( ) : (
@@ -329,6 +336,10 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
</div> </div>
<div className="main-area"> <div className="main-area">
{activeTab === 'friends' && <div className="chat-area has-page" style={{width: '100%'}}><FriendsPageContent /></div>}
{activeTab === 'admin' && <div className="chat-area has-page" style={{width: '100%'}}><AdminPageContent /></div>}
{activeTab === 'settings' && <div className="chat-area has-page" style={{width: '100%'}}><SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} /></div>}
{activeTab === 'chats' && <>
<div className="chat-list"> <div className="chat-list">
<div className="chat-list-header"> <div className="chat-list-header">
<div className="chat-list-title">Chats</div> <div className="chat-list-title">Chats</div>
@@ -382,13 +393,14 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
<button className="composer-btn"><Icon.Send /></button> <button className="composer-btn"><Icon.Send /></button>
</div> </div>
</div> </div>
</>}
</div> </div>
</> </>
)} )}
</div> </div>
{/* Call screen switcher — only in Clean Rail */} {/* Call screen switcher — only in Clean Rail, only on chats tab */}
{variant.layout === 'rail' && ( {variant.layout === 'rail' && activeTab === 'chats' && (
<div className="screen-switcher"> <div className="screen-switcher">
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button> <button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button> <button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
@@ -82,6 +82,18 @@ const Icon = {
Minimize: () => ( Minimize: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
), ),
Copy: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
),
Trash: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
),
Alert: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
),
Check: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
),
}; };
Object.assign(window, { Icon }); Object.assign(window, { Icon });
+591
View File
@@ -0,0 +1,591 @@
// Freunde / Admin / Settings pages — Clean Rail aesthetic
// ===================================================================
// SHARED PRIMITIVES
// ===================================================================
const PageHeader = ({ title, subtitle, actions }) => (
<div className="page-header">
<div>
<h1 className="page-title">{title}</h1>
{subtitle && <p className="page-subtitle">{subtitle}</p>}
</div>
{actions && <div className="page-header-actions">{actions}</div>}
</div>
);
const Section = ({ title, children, action }) => (
<section className="page-section">
<div className="page-section-header">
<h2 className="page-section-title">{title}</h2>
{action}
</div>
<div className="page-section-body">{children}</div>
</section>
);
const Toggle = ({ checked, onChange, label, hint, disabled }) => (
<label className={`toggle-row ${disabled ? 'disabled' : ''}`}>
<div className="toggle-copy">
<div className="toggle-label">{label}</div>
{hint && <div className="toggle-hint">{hint}</div>}
</div>
<span className={`toggle-switch ${checked ? 'on' : ''}`} onClick={() => !disabled && onChange(!checked)}>
<span className="toggle-thumb"></span>
</span>
</label>
);
const SegmentedControl = ({ options, value, onChange }) => (
<div className="segmented">
{options.map(opt => (
<button
key={opt.value}
className={`seg-btn ${value === opt.value ? 'active' : ''}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
const Row = ({ label, value, mono }) => (
<div className="row-kv">
<span className="row-label">{label}</span>
<span className={`row-value ${mono ? 'mono' : ''}`}>{value}</span>
</div>
);
const Stat = ({ label, value }) => (
<div className="stat">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
</div>
);
const Banner = ({ kind = 'info', children }) => (
<div className={`banner ${kind}`}>
{kind === 'error' && <Icon.Alert />}
{kind === 'success' && <Icon.Check />}
<span>{children}</span>
</div>
);
const Empty = ({ icon: IconComp = Icon.Friends, children }) => (
<div className="empty-state">
<div className="empty-icon"><IconComp /></div>
<p>{children}</p>
</div>
);
// ===================================================================
// FRIENDS PAGE
// ===================================================================
const FRIENDS_DATA = [
{ id: 'max', userId: 'max', displayName: 'Max', username: 'maxx', initial: 'M', color: 'teal', status: 'accepted', direction: null, presence: 'online' },
{ id: 'lena', userId: 'lena', displayName: 'Lena', username: 'lena_k', initial: 'L', color: 'amber', status: 'accepted', direction: null, presence: 'online' },
{ id: 'tom', userId: 'tom', displayName: 'Tom', username: 'tomtom', initial: 'T', color: 'violet', status: 'accepted', direction: null, presence: 'idle' },
{ id: 'nico', userId: 'nico', displayName: 'nico', username: 'niklas', initial: 'N', color: 'teal', status: 'accepted', direction: null, presence: 'offline' },
{ id: 'julia', userId: 'julia', displayName: 'Julia', username: 'jul', initial: 'J', color: 'rose', status: 'accepted', direction: null, presence: 'dnd' },
{ id: 'ben', userId: 'ben', displayName: 'Ben', username: 'benny', initial: 'B', color: 'amber', status: 'pending', direction: 'outgoing' },
{ id: 'anna', userId: 'anna', displayName: 'Anna', username: 'anna_m', initial: 'A', color: 'violet', status: 'pending', direction: 'outgoing' },
{ id: 'kai', userId: 'kai', displayName: 'Kai', username: 'kaiser', initial: 'K', color: 'teal', status: 'pending', direction: 'incoming' },
{ id: 'sara', userId: 'sara', displayName: 'Sara', username: 'sari', initial: 'S', color: 'rose', status: 'pending', direction: 'incoming' },
];
const SEARCH_RESULTS = [
{ id: 'felix', userId: 'felix', displayName: 'Felix', username: 'felix_x', initial: 'F', color: 'violet' },
{ id: 'mia', userId: 'mia', displayName: 'Mia', username: 'mia_w', initial: 'M', color: 'amber' },
{ id: 'paul', userId: 'paul', displayName: 'Paul', username: 'paulk', initial: 'P', color: 'teal' },
];
const FriendRow = ({ profile, children }) => (
<div className="friend-row">
<Avatar chat={{initial: profile.initial, color: profile.color, presence: profile.presence}} />
<div className="friend-body">
<div className="friend-name">{profile.displayName}</div>
<div className="friend-handle">@{profile.username}</div>
</div>
<div className="friend-actions">{children}</div>
</div>
);
const FriendsPageContent = () => {
const [tab, setTab] = React.useState('friends');
const [query, setQuery] = React.useState('');
const accepted = FRIENDS_DATA.filter(f => f.status === 'accepted');
const outgoing = FRIENDS_DATA.filter(f => f.status === 'pending' && f.direction === 'outgoing');
const incoming = FRIENDS_DATA.filter(f => f.status === 'pending' && f.direction === 'incoming');
const showSearch = query.trim().length >= 2;
const tabs = [
{ key: 'friends', label: 'Freunde', count: accepted.length },
{ key: 'pending', label: 'Ausgehend', count: outgoing.length },
{ key: 'requests', label: 'Anfragen', count: incoming.length, highlight: incoming.length > 0 },
];
const currentList = tab === 'friends' ? accepted : tab === 'pending' ? outgoing : incoming;
return (
<div className="page-scroll">
<div className="page-inner friends-page">
<PageHeader title="Freunde" subtitle={`${accepted.length} Freunde · ${incoming.length} offene Anfragen`} />
<div className="search-wrap">
<Icon.Search />
<input
type="search"
placeholder="Benutzer suchen oder hinzufügen…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
{showSearch && (
<div className="page-section">
<div className="page-section-header">
<h2 className="page-section-title">Suchergebnisse</h2>
</div>
<div className="friend-list">
{SEARCH_RESULTS.filter(r =>
r.displayName.toLowerCase().includes(query.toLowerCase()) ||
r.username.toLowerCase().includes(query.toLowerCase())
).map(p => (
<FriendRow key={p.id} profile={p}>
<button className="btn btn-primary">
<Icon.AddUser /> Anfrage senden
</button>
</FriendRow>
))}
{SEARCH_RESULTS.filter(r =>
r.displayName.toLowerCase().includes(query.toLowerCase()) ||
r.username.toLowerCase().includes(query.toLowerCase())
).length === 0 && (
<Empty icon={Icon.Search}>Keine Ergebnisse für {query}"</Empty>
)}
</div>
</div>
)}
{!showSearch && (
<>
<div className="tab-row">
{tabs.map(t => (
<button
key={t.key}
className={`tab-btn ${tab === t.key ? 'active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
<span className={`tab-count ${t.highlight ? 'highlight' : ''}`}>{t.count}</span>
</button>
))}
</div>
<div className="friend-list">
{currentList.length === 0 && (
<Empty>
{tab === 'friends' && 'Noch keine Freunde. Suche oben nach Benutzern um anzufangen.'}
{tab === 'pending' && 'Keine ausgehenden Anfragen.'}
{tab === 'requests' && 'Keine eingehenden Anfragen.'}
</Empty>
)}
{currentList.map(f => (
<FriendRow key={f.id} profile={f}>
{tab === 'friends' && (
<>
<button className="btn btn-primary">
<Icon.Chat /> Nachricht
</button>
<button className="btn btn-ghost" title="Voice"><Icon.Phone /></button>
<button className="btn btn-danger-ghost">Entfernen</button>
</>
)}
{tab === 'pending' && (
<>
<span className="pending-label">Wartet auf Antwort…</span>
<button className="btn btn-ghost">Abbrechen</button>
</>
)}
{tab === 'requests' && (
<>
<button className="btn btn-primary">
<Icon.Check /> Annehmen
</button>
<button className="btn btn-ghost">Ablehnen</button>
</>
)}
</FriendRow>
))}
</div>
</>
)}
</div>
</div>
);
};
// ===================================================================
// ADMIN PAGE
// ===================================================================
const INVITES_DATA = [
{ code: 'SQUAD-2k26', usesCount: 3, usesLimit: 10, expiresAt: '2026-05-01', disabled: false },
{ code: 'FRIENDS-OF-DENNIS', usesCount: 1, usesLimit: null, expiresAt: null, disabled: false },
{ code: 'BETA-ROUND-1', usesCount: 12, usesLimit: 12, expiresAt: null, disabled: false },
{ code: 'OLD-UNUSED', usesCount: 0, usesLimit: 5, expiresAt: '2026-03-01', disabled: true },
];
const USERS_DATA = [
{ userId: 'dennis', displayName: 'dennis', username: 'dennis', initial: 'D', color: 'teal', isAdmin: true, blockedFromInviting: false, banned: false, joinedAt: '14. Mär 2026' },
{ userId: 'max', displayName: 'Max', username: 'maxx', initial: 'M', color: 'teal', isAdmin: false, blockedFromInviting: false, banned: false, joinedAt: '16. Mär 2026' },
{ userId: 'lena', displayName: 'Lena', username: 'lena_k', initial: 'L', color: 'amber', isAdmin: false, blockedFromInviting: false, banned: false, joinedAt: '02. Apr 2026' },
{ userId: 'tom', displayName: 'Tom', username: 'tomtom', initial: 'T', color: 'violet', isAdmin: false, blockedFromInviting: true, banned: false, joinedAt: '08. Apr 2026' },
{ userId: 'spam', displayName: 'spam_user_42', username: 'spam42', initial: 'S', color: 'rose', isAdmin: false, blockedFromInviting: false, banned: true, joinedAt: '11. Apr 2026' },
];
const FlagChip = ({ active, tone, onToggle, children }) => (
<button
className={`flag-chip ${active ? 'active' : ''} ${tone === 'danger' ? 'danger' : ''}`}
onClick={() => onToggle(!active)}
>
{children}
</button>
);
const AdminPageContent = () => {
const [invitesEnabled, setInvitesEnabled] = React.useState(true);
const [invites, setInvites] = React.useState(INVITES_DATA);
const [users, setUsers] = React.useState(USERS_DATA);
const [copiedCode, setCopiedCode] = React.useState(null);
const handleCopy = (code) => {
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 1400);
};
const toggleUserFlag = (userId, flag) => {
setUsers(us => us.map(u => u.userId === userId ? {...u, [flag]: !u[flag]} : u));
};
const toggleInvite = (code) => {
setInvites(is => is.map(i => i.code === code ? {...i, disabled: !i.disabled} : i));
};
const deleteInvite = (code) => {
setInvites(is => is.filter(i => i.code !== code));
};
return (
<div className="page-scroll">
<div className="page-inner admin-page">
<PageHeader
title="Admin"
subtitle={`${users.length} User · ${invites.filter(i => !i.disabled).length} aktive Invites`}
/>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-card-label">Gesamt User</div>
<div className="stat-card-value">{users.length}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">Admins</div>
<div className="stat-card-value">{users.filter(u => u.isAdmin).length}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">Gebannt</div>
<div className="stat-card-value danger">{users.filter(u => u.banned).length}</div>
</div>
<div className="stat-card">
<div className="stat-card-label">Aktive Invites</div>
<div className="stat-card-value">{invites.filter(i => !i.disabled).length}</div>
</div>
</div>
<Section title="System">
<Toggle
label="Invites aktiviert"
hint="Wenn aus, können keine neuen User per Invite-Code beitreten."
checked={invitesEnabled}
onChange={setInvitesEnabled}
/>
</Section>
<Section
title="Invite-Codes"
action={
<button className="btn btn-primary">
<Icon.Plus /> Neuer Code
</button>
}
>
<div className="table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>Code</th>
<th>Nutzungen</th>
<th>Ablauf</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{invites.map(inv => {
const expired = inv.expiresAt && new Date(inv.expiresAt) < new Date('2026-04-19');
const status = inv.disabled ? 'disabled' : expired ? 'expired' : 'active';
const usesText = inv.usesLimit ? `${inv.usesCount}/${inv.usesLimit}` : `${inv.usesCount}/∞`;
return (
<tr key={inv.code}>
<td className="mono">{inv.code}</td>
<td className="muted">{usesText}</td>
<td className="muted">{inv.expiresAt || 'nie'}</td>
<td><span className={`status-pill ${status}`}>{status === 'active' ? 'Aktiv' : status === 'expired' ? 'Abgelaufen' : 'Deaktiviert'}</span></td>
<td>
<div className="row-actions">
<button className="icon-btn sm" title={copiedCode === inv.code ? 'Kopiert!' : 'Kopieren'} onClick={() => handleCopy(inv.code)}>
{copiedCode === inv.code ? <Icon.Check /> : <Icon.Copy />}
</button>
<button className="btn btn-xs btn-ghost" onClick={() => toggleInvite(inv.code)}>
{inv.disabled ? 'Aktivieren' : 'Deaktivieren'}
</button>
<button className="icon-btn sm danger" title="Löschen" onClick={() => deleteInvite(inv.code)}>
<Icon.Trash />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Section>
<Section title="Benutzer">
<div className="user-admin-list">
{users.map(u => (
<div key={u.userId} className="user-admin-row">
<Avatar chat={{initial: u.initial, color: u.color}} />
<div className="user-admin-body">
<div className="user-admin-name">
{u.displayName}
{u.isAdmin && <span className="admin-badge"><Icon.Crown /> Admin</span>}
</div>
<div className="user-admin-meta">@{u.username} · beigetreten {u.joinedAt}</div>
</div>
<div className="user-admin-flags">
<FlagChip active={u.isAdmin} onToggle={() => toggleUserFlag(u.userId, 'isAdmin')}>Admin</FlagChip>
<FlagChip active={u.blockedFromInviting} onToggle={() => toggleUserFlag(u.userId, 'blockedFromInviting')}>Invite-Block</FlagChip>
<FlagChip active={u.banned} tone="danger" onToggle={() => toggleUserFlag(u.userId, 'banned')}>Bann</FlagChip>
</div>
</div>
))}
</div>
</Section>
</div>
</div>
);
};
// ===================================================================
// SETTINGS PAGE
// ===================================================================
const SettingsPageContent = ({ isDark, onToggleDark }) => {
const [language, setLanguage] = React.useState('de');
const [readReceipts, setReadReceipts] = React.useState(true);
const [allowDms, setAllowDms] = React.useState(true);
const [audioQuality, setAudioQuality] = React.useState('voice');
const [ptt, setPtt] = React.useState(false);
const [pttKey, setPttKey] = React.useState('Space');
const [capturingKey, setCapturingKey] = React.useState(false);
const [e2ee, setE2ee] = React.useState(true);
const [screenShareQuality, setScreenShareQuality] = React.useState('balanced');
const audioParams = audioQuality === 'hifi'
? { bitrate: '510 kbps', channels: 'Stereo', sample: '48 kHz', dsp: 'Off' }
: { bitrate: '48 kbps', channels: 'Mono', sample: '48 kHz', dsp: 'On' };
const screenParams = {
smooth: { bitrate: '3 Mbps', resolution: '1280×720', framerate: '60 fps' },
balanced: { bitrate: '6 Mbps', resolution: '1920×1080', framerate: '30 fps' },
crisp: { bitrate: '12 Mbps', resolution: '2560×1440', framerate: '30 fps' },
}[screenShareQuality];
return (
<div className="page-scroll">
<div className="page-inner settings-page">
<PageHeader title="Einstellungen" />
<Section title="Account">
<div className="avatar-control">
<div className="avatar-large" data-color="teal">D</div>
<div className="avatar-copy">
<div className="avatar-label">Avatar</div>
<div className="avatar-hint">Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.</div>
</div>
<button className="btn btn-primary">Ändern</button>
<button className="btn btn-danger-ghost">Entfernen</button>
</div>
<div className="row-stack">
<Row label="Benutzername" value="dennis" />
<Row label="Anzeigename" value="dennis" />
<Row label="User-ID" value="b4f2-a912-c7e8-d091" mono />
</div>
</Section>
<Section title="Aussehen">
<div className="toggle-row">
<div className="toggle-copy">
<div className="toggle-label">Sprache</div>
<div className="toggle-hint">Sprache der App-Oberfläche</div>
</div>
<SegmentedControl
value={language}
onChange={setLanguage}
options={[{value: 'de', label: 'Deutsch'}, {value: 'en', label: 'English'}]}
/>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<div className="toggle-label">Theme</div>
<div className="toggle-hint">Dark Mode ist dezenter für späte Gaming-Sessions.</div>
</div>
<SegmentedControl
value={isDark ? 'dark' : 'light'}
onChange={v => { if ((v === 'dark') !== isDark) onToggleDark(); }}
options={[{value: 'light', label: '☀ Light'}, {value: 'dark', label: '☾ Dark'}]}
/>
</div>
</Section>
<Section title="Datenschutz">
<Toggle
label="Lesebestätigungen anzeigen"
hint="Freunde sehen wann du ihre Nachrichten gelesen hast. Beidseitig du siehst dann auch ihre."
checked={readReceipts}
onChange={setReadReceipts}
/>
<Toggle
label="DMs von Fremden erlauben"
hint="Erlaubt Direktnachrichten von Usern mit denen du (noch) nicht befreundet bist."
checked={allowDms}
onChange={setAllowDms}
/>
</Section>
<Section title="Sprache & Audio">
<div className="toggle-row">
<div className="toggle-copy">
<div className="toggle-label">Audio-Qualität</div>
<div className="toggle-hint">
{audioQuality === 'hifi'
? 'Stereo 510 kbps Opus ohne Noise-Suppression — bester Musik/Broadcast-Sound.'
: 'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain — optimiert für Sprache.'}
</div>
</div>
<SegmentedControl
value={audioQuality}
onChange={setAudioQuality}
options={[{value: 'voice', label: 'Sprache'}, {value: 'hifi', label: 'HiFi'}]}
/>
</div>
<div className="stats-inline">
<Stat label="Bitrate" value={audioParams.bitrate} />
<Stat label="Channels" value={audioParams.channels} />
<Stat label="Sample" value={audioParams.sample} />
<Stat label="DSP" value={audioParams.dsp} />
</div>
<div className="divider"></div>
<Toggle
label="Push-to-Talk"
hint="Mic bleibt stumm bis die Taste gedrückt wird. Überschreibt den Mute-Button."
checked={ptt}
onChange={setPtt}
/>
{ptt && (
<div className="toggle-row">
<div className="toggle-copy">
<div className="toggle-label">Hotkey</div>
<div className="toggle-hint">Klick zum Ändern. Esc zum Abbrechen.</div>
</div>
<button
className={`key-capture ${capturingKey ? 'active' : ''}`}
onClick={() => setCapturingKey(!capturingKey)}
>
{capturingKey ? 'Taste drücken…' : pttKey}
</button>
</div>
)}
<div className="divider"></div>
<Toggle
label="Ende-zu-Ende-Verschlüsselung für Calls"
hint="Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben."
checked={e2ee}
onChange={setE2ee}
/>
</Section>
<Section title="Bildschirmfreigabe">
<div className="toggle-row">
<div className="toggle-copy">
<div className="toggle-label">Qualität</div>
<div className="toggle-hint">
WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an. Die Werte sind Obergrenzen.
</div>
</div>
<SegmentedControl
value={screenShareQuality}
onChange={setScreenShareQuality}
options={[
{value: 'smooth', label: 'Flüssig'},
{value: 'balanced', label: 'Balanced'},
{value: 'crisp', label: 'Scharf'},
]}
/>
</div>
<div className="stats-inline">
<Stat label="Bitrate (max)" value={screenParams.bitrate} />
<Stat label="Resolution" value={screenParams.resolution} />
<Stat label="Framerate" value={screenParams.framerate} />
</div>
</Section>
<Section title="Geräte">
<div className="device-card">
<div className="device-card-title">
<Icon.Lock /> Dieses Gerät
</div>
<div className="row-stack" style={{marginTop: 10}}>
<Row label="Name" value="Dennis' MacBook Pro" />
<Row label="Plattform" value="macOS 14 · Desktop" />
<Row label="Device-ID" value="dev_01JC4NKT7XYZ" mono />
</div>
</div>
<div className="device-backup">
<div className="toggle-label">Geräteschlüssel-Backup</div>
<div className="toggle-hint" style={{marginTop: 4}}>
Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.
</div>
<div className="backup-row">
<input type="password" placeholder="Passphrase (min 8 Zeichen)" />
<button className="btn btn-primary">Export erstellen</button>
</div>
</div>
</Section>
<Section title="Danger Zone">
<button className="btn btn-danger">Abmelden</button>
</Section>
</div>
</div>
);
};
Object.assign(window, { FriendsPageContent, AdminPageContent, SettingsPageContent });
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+651
View File
@@ -0,0 +1,651 @@
/* ============================================================
PAGES — Friends / Admin / Settings (Clean Rail aesthetic)
============================================================ */
.page-scroll {
flex: 1;
overflow-y: auto;
background: var(--bg);
}
.page-inner {
max-width: 820px;
margin: 0 auto;
padding: 28px 32px 64px;
display: flex;
flex-direction: column;
gap: 24px;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding-bottom: 4px;
}
.page-title {
font-family: 'Outfit', 'Inter', sans-serif;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--fg);
margin: 0;
}
.page-subtitle {
font-size: 13px;
color: var(--fg-muted);
margin: 6px 0 0;
}
.page-header-actions { display: flex; gap: 8px; }
/* ------------- Section ------------- */
.page-section {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 16px;
overflow: hidden;
}
.page-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.page-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-muted);
margin: 0;
}
.page-section-body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ------------- Toggle Row ------------- */
.toggle-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
cursor: pointer;
}
.toggle-row.disabled { opacity: 0.5; cursor: not-allowed; }
.toggle-copy { flex: 1; min-width: 0; }
.toggle-label {
font-size: 14px;
font-weight: 500;
color: var(--fg);
line-height: 1.3;
}
.toggle-hint {
font-size: 12px;
color: var(--fg-muted);
margin-top: 4px;
line-height: 1.5;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 999px;
flex-shrink: 0;
transition: all 0.2s;
cursor: pointer;
}
.toggle-switch.on {
background: var(--accent);
border-color: var(--accent);
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.toggle-switch.on .toggle-thumb { transform: translateX(18px); }
/* ------------- Segmented ------------- */
.segmented {
display: inline-flex;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 10px;
padding: 3px;
flex-shrink: 0;
}
.seg-btn {
background: transparent;
border: none;
padding: 6px 12px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
color: var(--fg-muted);
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.seg-btn:hover { color: var(--fg); }
.seg-btn.active {
background: var(--bg-3);
color: var(--fg);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.variant-clean.dark .seg-btn.active { box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
/* ------------- Row KV ------------- */
.row-stack { display: flex; flex-direction: column; gap: 10px; }
.row-kv {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.row-kv:last-child { border-bottom: none; }
.row-label { font-size: 13px; color: var(--fg-muted); }
.row-value { font-size: 13px; color: var(--fg); font-weight: 500; text-align: right; }
.row-value.mono {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--fg-muted);
}
/* ------------- Buttons ------------- */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.btn svg { width: 14px; height: 14px; }
.btn-primary {
background: var(--accent);
color: var(--accent-fg);
}
.btn-primary:hover { filter: brightness(1.08); }
.btn-ghost {
background: transparent;
color: var(--fg);
border-color: var(--line);
}
.btn-ghost:hover { background: var(--bg-2); }
.btn-danger {
background: #dc2626;
color: #fff;
}
.btn-danger:hover { background: #b91c1c; }
.variant-clean.dark .btn-danger { background: #dc2626; }
.variant-clean.dark .btn-danger:hover { background: #ef4444; }
.btn-danger-ghost {
background: transparent;
color: #dc2626;
border-color: rgba(220,38,38,0.3);
}
.btn-danger-ghost:hover { background: rgba(220,38,38,0.08); }
.variant-clean.dark .btn-danger-ghost {
color: #fb7185;
border-color: rgba(251,113,133,0.3);
}
.variant-clean.dark .btn-danger-ghost:hover { background: rgba(251,113,133,0.1); }
.btn-xs { padding: 4px 8px; font-size: 11px; }
/* ------------- Search ------------- */
.search-wrap {
position: relative;
display: flex;
align-items: center;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 14px;
gap: 10px;
transition: border-color 0.15s;
}
.search-wrap:focus-within { border-color: var(--accent); }
.search-wrap svg { width: 16px; height: 16px; color: var(--fg-muted); flex-shrink: 0; }
.search-wrap input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--fg);
font-family: inherit;
}
.search-wrap input::placeholder { color: var(--fg-muted); }
/* ------------- Tabs ------------- */
.tab-row {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--line);
}
.tab-btn {
background: transparent;
border: none;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
display: inline-flex;
align-items: center;
gap: 8px;
font-family: inherit;
transition: all 0.15s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; }
.tab-count {
background: var(--bg-2);
color: var(--fg-muted);
padding: 2px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.tab-btn.active .tab-count { background: var(--accent); color: var(--accent-fg); }
.tab-count.highlight:not(.tab-btn.active .tab-count) {
background: #dc2626;
color: #fff;
}
/* ------------- Friend Row ------------- */
.friend-list { display: flex; flex-direction: column; gap: 2px; }
.friend-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
transition: background 0.15s;
}
.friend-row:hover { background: var(--bg-2); }
.friend-body { flex: 1; min-width: 0; }
.friend-name {
font-size: 14px;
font-weight: 600;
color: var(--fg);
line-height: 1.3;
}
.friend-handle {
font-size: 12px;
color: var(--fg-muted);
margin-top: 2px;
}
.friend-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.pending-label {
font-size: 12px;
color: var(--fg-muted);
font-style: italic;
align-self: center;
padding-right: 4px;
}
/* ------------- Empty State ------------- */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
text-align: center;
color: var(--fg-muted);
}
.empty-icon {
width: 48px;
height: 48px;
border-radius: 14px;
background: var(--bg-2);
display: grid;
place-items: center;
margin-bottom: 12px;
color: var(--fg-muted);
}
.empty-icon svg { width: 20px; height: 20px; }
.empty-state p { font-size: 13px; margin: 0; max-width: 320px; }
/* ------------- Banner ------------- */
.banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
border: 1px solid;
}
.banner.error { background: rgba(220,38,38,0.08); border-color: rgba(220,38,38,0.2); color: #dc2626; }
.variant-clean.dark .banner.error { color: #fb7185; }
.banner.success { background: rgba(22,163,74,0.08); border-color: rgba(22,163,74,0.2); color: #16a34a; }
.banner svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* ============================================================
ADMIN PAGE
============================================================ */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-card {
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px 16px;
}
.stat-card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-muted);
font-weight: 500;
}
.stat-card-value {
font-family: 'Outfit', 'Inter', sans-serif;
font-size: 28px;
font-weight: 700;
color: var(--fg);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.stat-card-value.danger { color: #dc2626; }
.variant-clean.dark .stat-card-value.danger { color: #fb7185; }
/* Inline stats (inside sections) */
.stats-inline {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 12px 14px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 10px;
}
.stat-label, .stat .stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-muted);
font-weight: 500;
}
.stat-value, .stat .stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--fg);
margin-top: 2px;
font-weight: 500;
}
/* ------------- Admin Table ------------- */
.table-wrap { overflow-x: auto; margin: -4px; padding: 4px; }
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.admin-table thead th {
text-align: left;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-muted);
padding: 4px 12px 10px;
border-bottom: 1px solid var(--line);
}
.admin-table tbody td {
padding: 12px 12px;
border-bottom: 1px solid var(--line);
color: var(--fg);
vertical-align: middle;
}
.admin-table tbody tr:last-child td { border-bottom: none; }
.admin-table td.mono {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 500;
}
.admin-table td.muted { color: var(--fg-muted); font-size: 12px; }
.status-pill {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
border: 1px solid;
}
.status-pill.active { background: rgba(22,163,74,0.08); border-color: rgba(22,163,74,0.25); color: #16a34a; }
.variant-clean.dark .status-pill.active { color: #4ade80; border-color: rgba(74,222,128,0.3); }
.status-pill.expired { background: rgba(245,158,11,0.08); border-color: rgba(245,158,11,0.25); color: #d97706; }
.variant-clean.dark .status-pill.expired { color: #fbbf24; border-color: rgba(251,191,36,0.3); }
.status-pill.disabled { background: rgba(220,38,38,0.08); border-color: rgba(220,38,38,0.25); color: #dc2626; }
.variant-clean.dark .status-pill.disabled { color: #fb7185; border-color: rgba(251,113,133,0.3); }
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
.icon-btn.sm { width: 28px; height: 28px; }
.icon-btn.sm svg { width: 13px; height: 13px; }
.icon-btn.sm.danger { color: #dc2626; border-color: rgba(220,38,38,0.2); }
.icon-btn.sm.danger:hover { background: rgba(220,38,38,0.08); }
.variant-clean.dark .icon-btn.sm.danger { color: #fb7185; }
/* ------------- User Admin Row ------------- */
.user-admin-list { display: flex; flex-direction: column; }
.user-admin-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 4px;
border-bottom: 1px solid var(--line);
}
.user-admin-row:last-child { border-bottom: none; }
.user-admin-body { flex: 1; min-width: 0; }
.user-admin-name {
font-size: 14px;
font-weight: 600;
color: var(--fg);
display: flex;
align-items: center;
gap: 8px;
}
.user-admin-meta {
font-size: 12px;
color: var(--fg-muted);
margin-top: 2px;
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 999px;
background: var(--accent);
color: var(--accent-fg);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-badge svg { width: 9px; height: 9px; }
.user-admin-flags { display: flex; gap: 6px; flex-shrink: 0; }
.flag-chip {
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--fg-muted);
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.flag-chip:hover { color: var(--fg); background: var(--bg-3); }
.flag-chip.active {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
.flag-chip.active.danger {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.variant-clean.dark .flag-chip.active.danger { background: #ef4444; border-color: #ef4444; }
/* ============================================================
SETTINGS PAGE
============================================================ */
.avatar-control {
display: flex;
align-items: center;
gap: 14px;
}
.avatar-large {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--accent);
color: var(--accent-fg);
display: grid;
place-items: center;
font-family: 'Outfit', sans-serif;
font-size: 26px;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.avatar-copy { flex: 1; min-width: 0; }
.avatar-label { font-size: 14px; font-weight: 500; color: var(--fg); }
.avatar-hint { font-size: 12px; color: var(--fg-muted); margin-top: 4px; }
.divider {
height: 1px;
background: var(--line);
margin: 6px -20px;
}
.key-capture {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
border-radius: 8px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--fg);
cursor: pointer;
min-width: 90px;
transition: all 0.15s;
}
.key-capture:hover { background: var(--bg-3); }
.key-capture.active {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
animation: keyPulse 1.2s ease-in-out infinite;
}
@keyframes keyPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.device-card {
background: rgba(22,163,74,0.06);
border: 1px solid rgba(22,163,74,0.2);
border-radius: 12px;
padding: 14px 16px;
}
.variant-clean.dark .device-card {
background: rgba(74,222,128,0.08);
border-color: rgba(74,222,128,0.2);
}
.device-card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #16a34a;
}
.variant-clean.dark .device-card-title { color: #4ade80; }
.device-card-title svg { width: 14px; height: 14px; }
.device-backup {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px 16px;
}
.backup-row {
display: flex;
gap: 8px;
margin-top: 12px;
}
.backup-row input {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--bg-3);
color: var(--fg);
font-size: 13px;
font-family: inherit;
outline: none;
}
.backup-row input:focus { border-color: var(--accent); }
/* ============================================================
Layout glue — make pages fill the rail's chat-area slot
============================================================ */
.rail-layout.page-mode {
grid-template-columns: 64px 1fr !important;
}
.rail-layout .chat-area.has-page {
padding: 0;
}
.rail-layout .chat-area.has-page > .page-scroll {
height: 100%;
}
/* Responsive tweaks */
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
+138
View File
@@ -187,3 +187,141 @@ export async function setUserFlag(
.eq('user_id', userId); .eq('user_id', userId);
if (error) throw error; if (error) throw error;
} }
// ---------------------------------------------------------------------------
// Conversations (admin view — reads all regardless of membership)
// ---------------------------------------------------------------------------
export interface AdminConversationRow {
id: string;
type: 'dm' | 'group';
name: string | null;
memberCount: number;
lastMessageAt: string | null;
createdAt: string;
}
export async function listAllConversations(
client: AppSupabaseClient,
): Promise<AdminConversationRow[]> {
const { data, error } = await client
.from('conversations')
.select('id, type, name, created_at')
.order('created_at', { ascending: false });
if (error) throw error;
const conversations = data ?? [];
if (conversations.length === 0) return [];
const ids = conversations.map((c) => c.id);
const { data: memberRows, error: mErr } = await client
.from('conversation_members')
.select('conversation_id')
.in('conversation_id', ids);
if (mErr) throw mErr;
const counts = new Map<string, number>();
for (const row of memberRows ?? []) {
counts.set(row.conversation_id, (counts.get(row.conversation_id) ?? 0) + 1);
}
// Approximate "last message at" by peeking at latest message.created_at per
// conversation. Cheap since conversation count stays small (<20 users,
// <few hundred conversations).
const { data: lastMsg, error: lErr } = await client
.from('messages')
.select('conversation_id, created_at')
.in('conversation_id', ids)
.order('created_at', { ascending: false })
.limit(5000);
if (lErr) throw lErr;
const lastAt = new Map<string, string>();
for (const row of lastMsg ?? []) {
if (!lastAt.has(row.conversation_id)) {
lastAt.set(row.conversation_id, row.created_at);
}
}
return conversations.map((c) => ({
id: c.id,
type: c.type as 'dm' | 'group',
name: c.name,
memberCount: counts.get(c.id) ?? 0,
lastMessageAt: lastAt.get(c.id) ?? null,
createdAt: c.created_at,
}));
}
// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
export interface AdminAuditEntry {
id: string;
actorId: string | null;
action: string;
targetType: string | null;
targetId: string | null;
metadata: Record<string, unknown>;
createdAt: string;
}
export async function listAuditLog(
client: AppSupabaseClient,
limit = 100,
): Promise<AdminAuditEntry[]> {
const { data, error } = await (client as unknown as {
from: (t: string) => {
select: (cols: string) => {
order: (
col: string,
opts: { ascending: boolean },
) => {
limit: (n: number) => Promise<{
data:
| {
id: string;
actor_id: string | null;
action: string;
target_type: string | null;
target_id: string | null;
metadata: Record<string, unknown>;
created_at: string;
}[]
| null;
error: Error | null;
}>;
};
};
};
})
.from('admin_audit_log')
.select('id, actor_id, action, target_type, target_id, metadata, created_at')
.order('created_at', { ascending: false })
.limit(limit);
if (error) throw error;
return (data ?? []).map((r) => ({
id: r.id,
actorId: r.actor_id,
action: r.action,
targetType: r.target_type,
targetId: r.target_id,
metadata: r.metadata ?? {},
createdAt: r.created_at,
}));
}
export async function logAdminAction(
client: AppSupabaseClient,
action: string,
target?: { type?: string; id?: string },
metadata?: Record<string, unknown>,
): Promise<void> {
const { error } = await (client as unknown as {
rpc: (
name: string,
args: Record<string, unknown>,
) => Promise<{ error: Error | null }>;
}).rpc('admin_log_action', {
p_action: action,
p_target_type: target?.type ?? null,
p_target_id: target?.id ?? null,
p_metadata: metadata ?? {},
});
if (error) throw error;
}
+128
View File
@@ -245,6 +245,134 @@ export async function listPeerReadsForMessages(
return new Set((data ?? []).map((r) => r.message_id)); return new Set((data ?? []).map((r) => r.message_id));
} }
// Group variant: returns reads from ALL users (other than `excludeUserId`,
// typically caller themselves) keyed by message id → user id → timestamp.
// RLS already filters out users whose receipts are off.
export async function listGroupReadsForMessages(
client: AppSupabaseClient,
messageIds: string[],
excludeUserId: string,
): Promise<Map<string, Map<string, string>>> {
if (messageIds.length === 0) return new Map();
const { data, error } = await client
.from('message_reads')
.select('message_id, user_id, read_at')
.neq('user_id', excludeUserId)
.in('message_id', messageIds);
if (error) throw error;
const out = new Map<string, Map<string, string>>();
for (const row of data ?? []) {
const mid = row.message_id;
const uid = row.user_id;
const at = row.read_at;
let inner = out.get(mid);
if (!inner) {
inner = new Map();
out.set(mid, inner);
}
inner.set(uid, at);
}
return out;
}
// Group variant of delivery receipts. Same shape as listGroupReadsForMessages.
export async function listGroupDeliveriesForMessages(
client: AppSupabaseClient,
messageIds: string[],
excludeUserId: string,
): Promise<Map<string, Map<string, string>>> {
if (messageIds.length === 0) return new Map();
const { data, error } = await (client as unknown as {
from: (t: string) => {
select: (cols: string) => {
neq: (col: string, val: string) => {
in: (
col: string,
vals: string[],
) => Promise<{
data:
| { message_id: string; user_id: string; delivered_at: string }[]
| null;
error: Error | null;
}>;
};
};
};
})
.from('message_deliveries')
.select('message_id, user_id, delivered_at')
.neq('user_id', excludeUserId)
.in('message_id', messageIds);
if (error) throw error;
const out = new Map<string, Map<string, string>>();
for (const row of data ?? []) {
let inner = out.get(row.message_id);
if (!inner) {
inner = new Map();
out.set(row.message_id, inner);
}
inner.set(row.user_id, row.delivered_at);
}
return out;
}
// ---------------------------------------------------------------------------
// Delivery receipts
// ---------------------------------------------------------------------------
// Records that the caller has received (fetched + decrypted) these messages.
// Idempotent; composite PK absorbs duplicates.
export async function markMessagesDelivered(
client: AppSupabaseClient,
messageIds: string[],
): Promise<void> {
if (messageIds.length === 0) return;
const { data: session, error: aErr } = await client.auth.getUser();
if (aErr) throw aErr;
if (!session.user) return;
const myId = session.user.id;
const rows = messageIds.map((id) => ({ message_id: id, user_id: myId }));
// `message_deliveries` is a later migration and not yet in the generated
// Supabase types; skip the strict table-name check for this call.
const { error } = await (client as unknown as {
from: (t: string) => {
upsert: (
rows: unknown,
opts: { onConflict: string; ignoreDuplicates: boolean },
) => Promise<{ error: Error | null }>;
};
})
.from('message_deliveries')
.upsert(rows, { onConflict: 'message_id,user_id', ignoreDuplicates: true });
if (error) throw error;
}
export async function listPeerDeliveriesForMessages(
client: AppSupabaseClient,
messageIds: string[],
peerUserId: string,
): Promise<Set<string>> {
if (messageIds.length === 0) return new Set();
const { data, error } = await (client as unknown as {
from: (t: string) => {
select: (cols: string) => {
eq: (col: string, val: string) => {
in: (
col: string,
vals: string[],
) => Promise<{ data: { message_id: string }[] | null; error: Error | null }>;
};
};
};
})
.from('message_deliveries')
.select('message_id')
.eq('user_id', peerUserId)
.in('message_id', messageIds);
if (error) throw error;
return new Set((data ?? []).map((r) => r.message_id));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reactions // Reactions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+57 -2
View File
@@ -15,7 +15,8 @@
"idle": "Abwesend", "idle": "Abwesend",
"dnd": "Nicht stören", "dnd": "Nicht stören",
"invisible": "Unsichtbar", "invisible": "Unsichtbar",
"offline": "Offline" "offline": "Offline",
"status_placeholder": "Status setzen…"
}, },
"chats": { "chats": {
"empty_title": "Noch keine Unterhaltungen", "empty_title": "Noch keine Unterhaltungen",
@@ -194,7 +195,61 @@
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.", "allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
"this_device": "Dieses Gerät", "this_device": "Dieses Gerät",
"danger_zone": "Gefahrenzone", "danger_zone": "Gefahrenzone",
"sign_out": "Abmelden" "sign_out": "Abmelden",
"section_ringtone": "Klingelton",
"ringtone_incoming": "Eingehender Anruf",
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
"ringtone_custom_active": "{{name}} · {{size}} MB",
"ringtone_upload": "Hochladen",
"ringtone_replace": "Ersetzen",
"ringtone_preview": "Vorhören",
"ringtone_stop": "Stop",
"ringtone_reset": "Zurücksetzen",
"ringtone_hint": "MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.",
"ringtone_error_too_large": "Datei zu groß (max {{max}} MB).",
"ringtone_error_not_audio": "Nur Audio-Dateien werden unterstützt.",
"ringtone_error_generic": "Ringtone konnte nicht gespeichert werden.",
"ringtone_error_play": "Ringtone konnte nicht abgespielt werden.",
"section_soundboard": "Soundboard"
},
"soundboard": {
"summary_title": "Deine Sounds",
"summary_empty": "Noch keine Sounds vorhanden.",
"summary_counts": "{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey",
"settings_hint": "Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.",
"manage": "Verwalten",
"manager_title": "Soundboard verwalten",
"manager_hint": "Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.",
"add": "Sound hinzufügen",
"empty": "Noch keine Sounds. Lade oben welche hoch.",
"category_placeholder": "Kategorie…",
"category_uncategorized": "(Ohne Kategorie)",
"hotkey_capture": "Hotkey binden (Esc = abbrechen)",
"hotkey_press": "Drücke…",
"hotkey_none": "Kein Hotkey",
"hotkey_clear": "Hotkey entfernen",
"hotkey_conflict_ptt": "Konflikt mit Push-to-Talk.",
"hotkey_conflict_sound": "Bereits von \"{{name}}\" belegt.",
"gain_title": "Lautstärke",
"preview": "Vorhören",
"preview_stop": "Stop",
"move_up": "Nach oben",
"move_down": "Nach unten",
"delete": "Löschen",
"delete_confirm": "Sound löschen?",
"error_too_large": "Datei zu groß (max {{max}} MB).",
"error_not_audio": "Nur Audio-Dateien werden unterstützt.",
"error_generic": "Sound konnte nicht gespeichert werden.",
"toggle": "Soundboard",
"panel_title": "Soundboard",
"panel_close": "Schließen",
"panel_search": "Suche…",
"panel_empty": "Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.",
"panel_no_matches": "Keine Treffer.",
"panel_master": "Master",
"panel_monitor": "Mithören",
"panel_stop_all": "Alle stoppen",
"pad_stop": "Stoppen"
}, },
"update": { "update": {
"available": "Update verfügbar", "available": "Update verfügbar",
+57 -2
View File
@@ -15,7 +15,8 @@
"idle": "Idle", "idle": "Idle",
"dnd": "Do not disturb", "dnd": "Do not disturb",
"invisible": "Invisible", "invisible": "Invisible",
"offline": "Offline" "offline": "Offline",
"status_placeholder": "Set status…"
}, },
"chats": { "chats": {
"empty_title": "No conversations yet", "empty_title": "No conversations yet",
@@ -194,7 +195,61 @@
"allow_dms_strangers_hint": "When off, only friends can DM you.", "allow_dms_strangers_hint": "When off, only friends can DM you.",
"this_device": "This device", "this_device": "This device",
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"sign_out": "Sign out" "sign_out": "Sign out",
"section_ringtone": "Ringtone",
"ringtone_incoming": "Incoming call",
"ringtone_default_active": "Default ringtone (double beep)",
"ringtone_custom_active": "{{name}} · {{size}} MB",
"ringtone_upload": "Upload",
"ringtone_replace": "Replace",
"ringtone_preview": "Preview",
"ringtone_stop": "Stop",
"ringtone_reset": "Reset",
"ringtone_hint": "MP3, WAV, OGG or M4A up to 2 MB. Incoming calls only — outgoing keeps the default.",
"ringtone_error_too_large": "File too large (max {{max}} MB).",
"ringtone_error_not_audio": "Only audio files are supported.",
"ringtone_error_generic": "Could not save the ringtone.",
"ringtone_error_play": "Could not play the ringtone.",
"section_soundboard": "Soundboard"
},
"soundboard": {
"summary_title": "Your sounds",
"summary_empty": "No sounds stored yet.",
"summary_counts": "{{sounds}} sounds · {{categories}} categories · {{hotkeys}} with hotkey",
"settings_hint": "Hotkeys are optional. Sounds can also be played from the in-call panel.",
"manage": "Manage",
"manager_title": "Manage soundboard",
"manager_hint": "Any number of sounds, hotkeys optional. Hotkeys fire only while in a call.",
"add": "Add sound",
"empty": "No sounds yet. Upload some above.",
"category_placeholder": "Category…",
"category_uncategorized": "(Uncategorized)",
"hotkey_capture": "Bind hotkey (Esc = cancel)",
"hotkey_press": "Press…",
"hotkey_none": "No hotkey",
"hotkey_clear": "Clear hotkey",
"hotkey_conflict_ptt": "Conflicts with Push-to-Talk.",
"hotkey_conflict_sound": "Already bound to \"{{name}}\".",
"gain_title": "Volume",
"preview": "Preview",
"preview_stop": "Stop",
"move_up": "Move up",
"move_down": "Move down",
"delete": "Delete",
"delete_confirm": "Delete this sound?",
"error_too_large": "File too large (max {{max}} MB).",
"error_not_audio": "Only audio files are supported.",
"error_generic": "Could not save the sound.",
"toggle": "Soundboard",
"panel_title": "Soundboard",
"panel_close": "Close",
"panel_search": "Search…",
"panel_empty": "No sounds stored. Add some from settings.",
"panel_no_matches": "No matches.",
"panel_master": "Master",
"panel_monitor": "Monitor",
"panel_stop_all": "Stop all",
"pad_stop": "Stop"
}, },
"update": { "update": {
"available": "Update available", "available": "Update available",
+102
View File
@@ -0,0 +1,102 @@
// Supabase Edge Function — fan-out push notifications via Web Push (VAPID).
//
// Trigger: server-side (e.g. database trigger on messages INSERT calling
// pg_net.http_post → this function), or app-level after sendEncryptedMessage
// resolves successfully.
//
// Required environment variables (set via supabase secrets):
// VAPID_PUBLIC_KEY
// VAPID_PRIVATE_KEY
// VAPID_SUBJECT e.g. "mailto:admin@example.com"
// PUSH_FANOUT_SHARED_SECRET shared header secret to authenticate caller
//
// Request body:
// { conversationId: string, senderUserId: string, senderName: string, kind?: "message" | "call" }
//
// The function:
// 1. Looks up conversation_members minus sender.
// 2. Joins to push_tokens via devices.
// 3. Sends a Web Push to each token using web-push npm via npm: specifier.
//
// Note: this is a skeleton — wire web-push library and verify against the
// chosen Deno deploy runtime.
// deno-lint-ignore-file no-explicit-any
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import webPush from 'npm:web-push@3.6.7';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const VAPID_PUBLIC_KEY = Deno.env.get('VAPID_PUBLIC_KEY') ?? '';
const VAPID_PRIVATE_KEY = Deno.env.get('VAPID_PRIVATE_KEY') ?? '';
const VAPID_SUBJECT = Deno.env.get('VAPID_SUBJECT') ?? 'mailto:admin@example.com';
const SHARED_SECRET = Deno.env.get('PUSH_FANOUT_SHARED_SECRET') ?? '';
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) {
webPush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
}
interface Payload {
conversationId: string;
senderUserId: string;
senderName: string;
kind?: 'message' | 'call';
}
Deno.serve(async (req) => {
if (req.method !== 'POST') return new Response('method', { status: 405 });
// Shared-secret gate. Cheap, replaces user JWT here because the trigger has
// no authenticated session.
const auth = req.headers.get('x-shared-secret');
if (!SHARED_SECRET || auth !== SHARED_SECRET) {
return new Response('unauthorized', { status: 401 });
}
let body: Payload;
try {
body = await req.json();
} catch {
return new Response('bad json', { status: 400 });
}
const client = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
const { data: members, error: mErr } = await client
.from('conversation_members')
.select('user_id')
.eq('conversation_id', body.conversationId);
if (mErr) return new Response('members lookup failed', { status: 500 });
const recipientIds = (members ?? [])
.map((m: any) => m.user_id as string)
.filter((id: string) => id !== body.senderUserId);
if (recipientIds.length === 0) return new Response('no recipients', { status: 200 });
const { data: tokens, error: tErr } = await client
.from('push_tokens')
.select('token, devices!inner(user_id)')
.in('devices.user_id', recipientIds);
if (tErr) return new Response('tokens lookup failed', { status: 500 });
const payload = JSON.stringify({
title: body.senderName,
body: body.kind === 'call' ? 'Eingehender Anruf' : 'Neue Nachricht',
conversationId: body.conversationId,
kind: body.kind ?? 'message',
});
const results = await Promise.allSettled(
(tokens ?? []).map(async (row: any) => {
try {
const sub = JSON.parse(row.token);
await webPush.sendNotification(sub, payload);
} catch (err) {
console.warn('push send failed', err);
}
}),
);
return new Response(JSON.stringify({ sent: results.length }), {
headers: { 'content-type': 'application/json' },
});
});
+213
View File
@@ -0,0 +1,213 @@
// Edge function — fetch OpenGraph/meta tags for a URL and cache the result.
//
// POST /og-preview { "url": "..." }
//
// Returns: { url, title, description, imageUrl, siteName, ok }
//
// Caching: rows live in public.link_previews keyed by URL. Repeat calls
// return the cached row without re-fetching (unless older than MAX_CACHE_AGE_MS).
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const MAX_CACHE_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
const FETCH_TIMEOUT_MS = 6_000;
const MAX_HTML_BYTES = 1_000_000;
const corsHeaders = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...corsHeaders },
});
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders });
if (req.method !== 'POST') return json({ error: 'method' }, 405);
let body: { url?: string };
try {
body = await req.json();
} catch {
return json({ error: 'bad json' }, 400);
}
const url = body.url;
if (!url || typeof url !== 'string') return json({ error: 'missing url' }, 400);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return json({ error: 'invalid url' }, 400);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return json({ error: 'unsupported scheme' }, 400);
}
const client = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
// Cache lookup.
const { data: cached } = await client
.from('link_previews')
.select('*')
.eq('url', url)
.maybeSingle();
if (
cached &&
Date.now() - new Date(cached.fetched_at).getTime() < MAX_CACHE_AGE_MS
) {
return json(toClientShape(cached));
}
// Fetch HTML with timeout.
let html = '';
let ok = true;
let error: string | null = null;
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(url, {
signal: ctrl.signal,
headers: {
'user-agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)',
accept: 'text/html,application/xhtml+xml',
},
redirect: 'follow',
});
clearTimeout(timer);
if (!res.ok) throw new Error('status ' + res.status);
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('html')) throw new Error('not html');
const reader = res.body?.getReader();
if (!reader) throw new Error('no body');
const decoder = new TextDecoder();
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
html += decoder.decode(value, { stream: true });
if (total >= MAX_HTML_BYTES) {
await reader.cancel();
break;
}
}
} catch (err) {
ok = false;
error = err instanceof Error ? err.message : 'fetch failed';
}
const meta = ok ? parseMeta(html, parsed) : null;
// Upsert cache row so even failures cache briefly.
const row = {
url,
title: meta?.title ?? null,
description: meta?.description ?? null,
image_url: meta?.imageUrl ?? null,
site_name: meta?.siteName ?? null,
ok,
error,
fetched_at: new Date().toISOString(),
};
await client.from('link_previews').upsert(row);
return json(toClientShape(row));
});
interface MetaRow {
url: string;
title: string | null;
description: string | null;
image_url: string | null;
site_name: string | null;
ok: boolean;
}
function toClientShape(row: MetaRow) {
return {
url: row.url,
title: row.title,
description: row.description,
imageUrl: row.image_url,
siteName: row.site_name,
ok: row.ok,
};
}
function parseMeta(html: string, baseUrl: URL): {
title: string | null;
description: string | null;
imageUrl: string | null;
siteName: string | null;
} {
// Cheap regex-based parser — no DOM in Deno runtime without extra deps.
// Extracts <meta property|name="..."> values + <title>.
const getMeta = (...keys: string[]): string | null => {
for (const key of keys) {
const re = new RegExp(
'<meta[^>]+(?:property|name)=[\'"]' +
escapeRegex(key) +
'[\'"][^>]+content=[\'"]([^\'"]+)[\'"]',
'i',
);
const m = re.exec(html);
if (m?.[1]) return decodeHtmlEntities(m[1]);
const re2 = new RegExp(
'<meta[^>]+content=[\'"]([^\'"]+)[\'"][^>]+(?:property|name)=[\'"]' +
escapeRegex(key) +
'[\'"]',
'i',
);
const m2 = re2.exec(html);
if (m2?.[1]) return decodeHtmlEntities(m2[1]);
}
return null;
};
const title =
getMeta('og:title', 'twitter:title') ??
/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ??
null;
const description = getMeta('og:description', 'twitter:description', 'description');
const imageRaw = getMeta('og:image', 'twitter:image', 'twitter:image:src');
const siteName = getMeta('og:site_name') ?? baseUrl.hostname;
let imageUrl: string | null = null;
if (imageRaw) {
try {
imageUrl = new URL(imageRaw, baseUrl).toString();
} catch {
imageUrl = null;
}
}
return {
title: title ? decodeHtmlEntities(title) : null,
description,
imageUrl,
siteName,
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'");
}
@@ -0,0 +1,54 @@
-- ============================================================================
-- Delivery receipts — "✓✓" tick in WhatsApp/Telegram parlance: the recipient's
-- device has fetched the message from the server, regardless of whether they
-- have actually opened the conversation yet. `message_reads` already covers
-- the "read" step; this table fills the gap between "sent" and "read".
--
-- A row exists iff the recipient user has acknowledged receiving the message
-- on at least one device. Writes are triggered client-side after successful
-- message fetch/decrypt. Select policy mirrors read receipts (opt-out via
-- profiles.show_read_receipts on BOTH ends).
-- ============================================================================
create table public.message_deliveries (
message_id uuid not null references public.messages(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
delivered_at timestamptz not null default now(),
primary key (message_id, user_id)
);
alter table public.message_deliveries enable row level security;
create policy deliveries_select_reciprocal on public.message_deliveries
for select to authenticated
using (
user_id = auth.uid()
or (
coalesce(
(select show_read_receipts from public.profiles where profiles.user_id = auth.uid()),
true
)
and coalesce(
(select show_read_receipts from public.profiles where profiles.user_id = message_deliveries.user_id),
true
)
and exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
)
)
);
create policy deliveries_insert_own on public.message_deliveries
for insert to authenticated
with check (
user_id = auth.uid()
and exists (
select 1 from public.messages m
where m.id = message_id and public.is_conversation_member(m.conversation_id)
)
);
create index message_deliveries_message_id_idx on public.message_deliveries (message_id);
create index message_deliveries_user_id_idx on public.message_deliveries (user_id);
alter publication supabase_realtime add table public.message_deliveries;
@@ -0,0 +1,63 @@
-- ============================================================================
-- Admin audit log — append-only journal of administrative actions. Writes
-- go through an RPC so the insert path is a single choke-point with input
-- validation; reads are admin-only via RLS.
-- ============================================================================
create table public.admin_audit_log (
id uuid primary key default gen_random_uuid(),
actor_id uuid not null references auth.users(id) on delete set null,
action text not null,
target_type text,
target_id text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index admin_audit_log_created_at_idx on public.admin_audit_log (created_at desc);
create index admin_audit_log_actor_idx on public.admin_audit_log (actor_id);
alter table public.admin_audit_log enable row level security;
create policy audit_log_select_admin on public.admin_audit_log
for select to authenticated
using (public.current_user_is_admin());
-- Insert via RPC only — no direct insert policy, so clients can't forge rows
-- with a different actor_id.
create or replace function public.admin_log_action(
p_action text,
p_target_type text default null,
p_target_id text default null,
p_metadata jsonb default '{}'::jsonb
) returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
new_id uuid;
begin
if not public.current_user_is_admin() then
raise exception 'not an admin';
end if;
insert into public.admin_audit_log (actor_id, action, target_type, target_id, metadata)
values (auth.uid(), p_action, p_target_type, p_target_id, coalesce(p_metadata, '{}'::jsonb))
returning id into new_id;
return new_id;
end;
$$;
revoke all on function public.admin_log_action(text, text, text, jsonb) from public;
grant execute on function public.admin_log_action(text, text, text, jsonb) to authenticated;
-- ============================================================================
-- Admin conversations view — lets admins list every conversation (bypassing
-- is_conversation_member). Read-only: no writes flow through the view.
-- ============================================================================
create policy conversations_select_admin on public.conversations
for select to authenticated
using (public.current_user_is_admin());
create policy conversation_members_select_admin on public.conversation_members
for select to authenticated
using (public.current_user_is_admin());
@@ -0,0 +1,29 @@
-- ============================================================================
-- Cached OpenGraph previews. The edge function `og-preview` writes rows
-- server-side (service-role). Clients read via RLS for any authenticated
-- user. Rows are keyed by URL so hits across conversations deduplicate.
-- ============================================================================
create table public.link_previews (
url text primary key,
title text,
description text,
image_url text,
site_name text,
fetched_at timestamptz not null default now(),
-- Absence of metadata (404, unreachable, parse failure) still produces a
-- cache row so we don't spam the fetcher. `ok = false` signals the UI to
-- hide the preview.
ok boolean not null default true,
error text
);
create index link_previews_fetched_at_idx on public.link_previews (fetched_at);
alter table public.link_previews enable row level security;
create policy link_previews_select_authenticated on public.link_previews
for select to authenticated
using (true);
-- No INSERT/UPDATE policy: writes flow exclusively through the edge function
-- which authenticates with the service-role key.