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
This commit is contained in:
@@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -22,8 +22,9 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
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 [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||
|
||||
const canGenerate = useMemo(() => {
|
||||
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||
@@ -32,9 +33,10 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
const reset = useCallback(() => {
|
||||
setPassphrase('');
|
||||
setConfirm('');
|
||||
setBackup(null);
|
||||
setBundle(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setCopiedRecovery(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -47,8 +49,13 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
|
||||
setBackup(str);
|
||||
const b = await exportDeviceBackupWithRecovery({
|
||||
userId,
|
||||
deviceId,
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
setBundle(b);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -56,27 +63,39 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
}
|
||||
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!backup) return;
|
||||
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backup);
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (marker === 'main') {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
setCopiedRecovery(true);
|
||||
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||
}
|
||||
} catch {
|
||||
/* fall back — user can select manually */
|
||||
}
|
||||
}, [backup]);
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!backup) return;
|
||||
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
|
||||
if (!bundle) return;
|
||||
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 a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [backup, deviceId]);
|
||||
}, [bundle, deviceId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -110,7 +129,7 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{!backup ? (
|
||||
{!bundle ? (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:backup.export_explainer', {
|
||||
@@ -183,40 +202,76 @@ export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={backup}
|
||||
rows={8}
|
||||
value={bundle.passphraseBackup}
|
||||
rows={6}
|
||||
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
|
||||
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"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>
|
||||
{copied
|
||||
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
|
||||
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
|
||||
</span>
|
||||
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
|
||||
Als Datei speichern (alles)
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
{!backup ? (
|
||||
{!bundle ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
|
||||
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||
|
||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||
|
||||
@@ -48,7 +48,6 @@ export interface ParticipantTileProps {
|
||||
* ever carries a truthy value. */
|
||||
deafened: boolean;
|
||||
speaking: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
e2ee: boolean;
|
||||
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||
@@ -57,7 +56,7 @@ export interface ParticipantTileProps {
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onOpenScreenShare?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
@@ -67,13 +66,12 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
muted,
|
||||
deafened,
|
||||
speaking,
|
||||
sharing,
|
||||
video,
|
||||
e2ee,
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
onOpenScreenShare,
|
||||
onContextMenu,
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
@@ -86,6 +84,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||
(onClick ? 'cursor-pointer ' : '') +
|
||||
@@ -93,13 +92,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
(small ? ' min-w-[140px]' : '')
|
||||
}
|
||||
>
|
||||
{sharing ? (
|
||||
<ScreenshareStub
|
||||
displayName={displayName}
|
||||
small={small}
|
||||
onOpen={onOpenScreenShare}
|
||||
/>
|
||||
) : video ? (
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
@@ -150,15 +143,6 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
<HeadphonesOffIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
{sharing && (
|
||||
<span
|
||||
aria-label="Teilt Bildschirm"
|
||||
title="Teilt Bildschirm"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white"
|
||||
>
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,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,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
decodePrivateKeyFromBackup,
|
||||
importDeviceBackup,
|
||||
normalizeRecoveryCode,
|
||||
} from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -28,6 +29,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const [backup, setBackup] = useState('');
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -39,7 +41,9 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
setError(null);
|
||||
let privateKey: Uint8Array | null = null;
|
||||
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);
|
||||
|
||||
const device = await restoreDeviceFromServerRecord({
|
||||
@@ -63,7 +67,7 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[backup, passphrase, userId, onRestored, busy],
|
||||
[backup, passphrase, mode, userId, onRestored, busy],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -102,16 +106,42 @@ export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||
</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
|
||||
type="password"
|
||||
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||
required
|
||||
value={passphrase}
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteParticipant, Room } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -15,6 +15,7 @@ import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
|
||||
@@ -76,6 +77,21 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [shareDialogOpen, setShareDialogOpen] = 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 =
|
||||
(state.kind === 'connected' ||
|
||||
@@ -142,15 +158,24 @@ export function InCallPanel({ conversation }: Props) {
|
||||
);
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||
// someone is sharing their screen. Without that we show an even grid of
|
||||
// all participants (Discord default). Clicking a tile switches to the
|
||||
// big-speaker + thumbnail-strip layout.
|
||||
const hasFocus = focusedId !== null || screenTile !== undefined;
|
||||
// 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 (
|
||||
<>
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={hasFocus ? speaker : undefined}
|
||||
speaker={effectiveSpeaker}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -160,8 +185,19 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// Toggle: click the already-focused tile to return to grid.
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
onTileContextMenu={openVolumeMenu}
|
||||
controls={controls}
|
||||
/>
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
userId={volumeMenu.userId}
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +255,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
onTileContextMenu={openVolumeMenu}
|
||||
compact
|
||||
/>
|
||||
|
||||
@@ -233,6 +270,16 @@ export function InCallPanel({ conversation }: Props) {
|
||||
await startScreenShare(opts);
|
||||
}}
|
||||
/>
|
||||
|
||||
{volumeMenu && (
|
||||
<ParticipantVolumeMenu
|
||||
userId={volumeMenu.userId}
|
||||
displayName={volumeMenu.displayName}
|
||||
x={volumeMenu.x}
|
||||
y={volumeMenu.y}
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -447,6 +494,7 @@ interface StageProps {
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
@@ -462,6 +510,7 @@ function TileRender({
|
||||
size,
|
||||
focused,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
activeSpeakers: Set<string>;
|
||||
@@ -471,6 +520,7 @@ function TileRender({
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
if (tile.self) {
|
||||
@@ -519,13 +569,13 @@ function TileRender({
|
||||
muted={tile.muted}
|
||||
deafened={tile.deafened}
|
||||
speaking={activeSpeakers.has(tile.userId)}
|
||||
sharing={false}
|
||||
video={tile.video}
|
||||
videoTrack={tile.videoTrack}
|
||||
e2ee={e2ee}
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -539,6 +589,7 @@ function CallStage({
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
@@ -569,6 +620,9 @@ function CallStage({
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -592,6 +646,9 @@ function CallStage({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -627,6 +684,8 @@ function FocusedTile({
|
||||
);
|
||||
}
|
||||
|
||||
const GRID_PAGE_SIZE = 12;
|
||||
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
@@ -636,7 +695,22 @@ function gridColsFor(n: number): string {
|
||||
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';
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -652,6 +726,7 @@ interface FullscreenProps {
|
||||
e2ee: boolean;
|
||||
onExit: () => void;
|
||||
onFocusTile: (id: string) => void;
|
||||
onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
|
||||
controls: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -664,9 +739,11 @@ function FullscreenCall({
|
||||
e2ee,
|
||||
onExit: _onExit,
|
||||
onFocusTile,
|
||||
onTileContextMenu,
|
||||
controls,
|
||||
}: FullscreenProps) {
|
||||
const [hintGone, setHintGone] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||
return () => window.clearTimeout(id);
|
||||
@@ -674,7 +751,23 @@ function FullscreenCall({
|
||||
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
|
||||
// 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 (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||
@@ -695,6 +788,9 @@ function FullscreenCall({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
@@ -712,6 +808,9 @@ function FullscreenCall({
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -721,7 +820,7 @@ function FullscreenCall({
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
{visibleTiles.map((p) => (
|
||||
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -730,10 +829,36 @@ function FullscreenCall({
|
||||
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>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { AttachmentAudio } from './AttachmentAudio';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
@@ -40,6 +41,8 @@ interface Props {
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||
/** Resolved quoted message info (parent does the lookup). */
|
||||
quoted?: QuotedRef | null;
|
||||
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
||||
@@ -63,6 +66,7 @@ export function MessageBubble({
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
showSeen = false,
|
||||
deliveryState,
|
||||
quoted = null,
|
||||
onJumpToMessage,
|
||||
onReply,
|
||||
@@ -296,9 +300,13 @@ export function MessageBubble({
|
||||
) : (
|
||||
<>
|
||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
||||
{attachments.map((a) => (
|
||||
{attachments.map((a) =>
|
||||
a.mimeType.startsWith('audio/') ? (
|
||||
<AttachmentAudio key={a.id} handle={a} />
|
||||
) : (
|
||||
<AttachmentImage key={a.id} handle={a} />
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
@@ -315,21 +323,24 @@ export function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSeen && mine && !editing && !message.deletedAt && (
|
||||
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
|
||||
{t('app:chats.seen')}
|
||||
</p>
|
||||
{mine && !editing && !message.deletedAt && deliveryState && (
|
||||
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
|
||||
<DeliveryTicks state={deliveryState} />
|
||||
{showSeen && <span>{t('app:chats.seen')}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reactions.length > 0 && !editing && (
|
||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
{reactions.map((r) => (
|
||||
<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"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
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
|
||||
? 'border-accent/40 bg-accent/20 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||
@@ -499,6 +510,45 @@ function formatDuration(totalSec: number): string {
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
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({
|
||||
label,
|
||||
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,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');
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { registerWebPush } from '../lib/webPush';
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
@@ -135,6 +136,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, [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]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabaseSignOut(supabase);
|
||||
}, []);
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
getCallE2EESettings,
|
||||
isE2EESupported,
|
||||
} from '../lib/callE2EE';
|
||||
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||
import {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
@@ -1310,7 +1311,7 @@ async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
|
||||
function attachTrack(
|
||||
track: RemoteTrack,
|
||||
_publication: RemoteTrackPublication,
|
||||
_participant: RemoteParticipant,
|
||||
participant: RemoteParticipant,
|
||||
): void {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const audio = track.attach();
|
||||
@@ -1318,6 +1319,10 @@ function attachTrack(
|
||||
audio.autoplay = true;
|
||||
audio.setAttribute('playsinline', 'true');
|
||||
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);
|
||||
// Apply persisted sinkId so the element routes to the user's chosen
|
||||
|
||||
@@ -99,7 +99,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
setConversations(convs);
|
||||
const entries = await Promise.all(
|
||||
@@ -218,8 +220,13 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
// 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
|
||||
@@ -231,12 +238,10 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
|
||||
@@ -140,3 +140,59 @@ export async function importDeviceBackup(
|
||||
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||
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('');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '\\"');
|
||||
}
|
||||
@@ -13,6 +13,16 @@ import {
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
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 { supabase } from './supabase';
|
||||
|
||||
@@ -53,10 +63,26 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||
send: (text: string, images?: File[], replyToId?: string | null) => 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 [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||
conversationId ? getOutbox(conversationId) : [],
|
||||
);
|
||||
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).
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
@@ -85,7 +111,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const refresh = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
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 decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
@@ -311,9 +342,16 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
|
||||
// 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.
|
||||
// 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();
|
||||
@@ -322,17 +360,40 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [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(
|
||||
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||
const trimmed = text.trim();
|
||||
@@ -340,6 +401,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const priv = privateKeyRef.current;
|
||||
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
|
||||
// (so the public attachment row can reference the blob-level nonce).
|
||||
const handles: AttachmentHandle[] = [];
|
||||
@@ -401,10 +481,104 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
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.
|
||||
|
||||
@@ -45,8 +45,15 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
|
||||
.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();
|
||||
@@ -55,12 +62,10 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
type AdminAuditEntry,
|
||||
type AdminConversationRow,
|
||||
type AdminProfileFlag,
|
||||
type AdminProfileRow,
|
||||
type AdminSetting,
|
||||
@@ -6,8 +8,11 @@ import {
|
||||
deleteInvite,
|
||||
type InviteRecord,
|
||||
listAdminSettings,
|
||||
listAllConversations,
|
||||
listAllProfiles,
|
||||
listAuditLog,
|
||||
listInvites,
|
||||
logAdminAction,
|
||||
setInviteDisabled,
|
||||
setUserFlag,
|
||||
updateAdminSetting,
|
||||
@@ -31,20 +36,26 @@ export function AdminPage() {
|
||||
const [settings, setSettings] = useState<AdminSetting[]>([]);
|
||||
const [invites, setInvites] = useState<InviteRecord[]>([]);
|
||||
const [users, setUsers] = useState<AdminProfileRow[]>([]);
|
||||
const [conversations, setConversations] = useState<AdminConversationRow[]>([]);
|
||||
const [audit, setAudit] = useState<AdminAuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [s, i, u] = await Promise.all([
|
||||
const [s, i, u, c, a] = await Promise.all([
|
||||
listAdminSettings(supabase),
|
||||
listInvites(supabase),
|
||||
listAllProfiles(supabase),
|
||||
listAllConversations(supabase),
|
||||
listAuditLog(supabase, 50),
|
||||
]);
|
||||
setSettings(s);
|
||||
setInvites(i);
|
||||
setUsers(u);
|
||||
setConversations(c);
|
||||
setAudit(a);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
@@ -92,6 +103,8 @@ export function AdminPage() {
|
||||
<SettingsSection settings={settings} onRefresh={refresh} />
|
||||
<InvitesSection invites={invites} onRefresh={refresh} />
|
||||
<UsersSection users={users} onRefresh={refresh} />
|
||||
<ConversationsSection conversations={conversations} />
|
||||
<AuditSection entries={audit} users={users} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -295,6 +308,13 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
||||
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
|
||||
try {
|
||||
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();
|
||||
} catch (err: unknown) {
|
||||
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 ------------------------------------------------------------------
|
||||
|
||||
function Toggle({
|
||||
|
||||
@@ -22,12 +22,16 @@ import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
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 { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
@@ -47,7 +51,8 @@ export function ConversationPage() {
|
||||
const peerId = conversation?.peer?.userId;
|
||||
const peerPresence = usePeerPresence(peerId);
|
||||
|
||||
const { messages, loading, error, send } = useConversationMessages({
|
||||
const { messages, loading, error, send, pending, retryPending, cancelPending } =
|
||||
useConversationMessages({
|
||||
conversationId: id,
|
||||
userId: session?.user.id,
|
||||
deviceId: device?.id,
|
||||
@@ -66,6 +71,36 @@ export function ConversationPage() {
|
||||
[messages, myId],
|
||||
);
|
||||
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(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -100,8 +135,14 @@ export function ConversationPage() {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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 [displayCount, setDisplayCount] = useState<number>(150);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -111,8 +152,28 @@ export function ConversationPage() {
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
}, [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 m = new Map<string, DecryptedMessage>();
|
||||
for (const msg of messages) m.set(msg.id, msg);
|
||||
@@ -176,17 +237,39 @@ export function ConversationPage() {
|
||||
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(() => {
|
||||
if (!searchActive) return [] as DecryptedMessage[];
|
||||
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) => {
|
||||
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;
|
||||
const parsed = parseMessagePayload(m.plaintext);
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -278,7 +361,6 @@ export function ConversationPage() {
|
||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||
}
|
||||
|
||||
const isGroup = conversation?.type === 'group';
|
||||
const { state: callState } = useCall();
|
||||
// Hide the chat header while this conversation hosts an active call — the
|
||||
// call topbar inside the dock already shows the channel name + duration,
|
||||
@@ -308,6 +390,15 @@ export function ConversationPage() {
|
||||
onQueryChange={setSearchQuery}
|
||||
matches={searchMatches.length}
|
||||
activeIdx={searchIdx}
|
||||
senderId={searchSenderId}
|
||||
onSenderChange={setSearchSenderId}
|
||||
attachmentsOnly={searchAttachmentsOnly}
|
||||
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
|
||||
dateFrom={searchDateFrom}
|
||||
onDateFromChange={setSearchDateFrom}
|
||||
dateTo={searchDateTo}
|
||||
onDateToChange={setSearchDateTo}
|
||||
members={conversation?.members ?? []}
|
||||
onPrev={() =>
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
@@ -319,6 +410,10 @@ export function ConversationPage() {
|
||||
onClose={() => {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery('');
|
||||
setSearchSenderId('');
|
||||
setSearchAttachmentsOnly(false);
|
||||
setSearchDateFrom('');
|
||||
setSearchDateTo('');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -345,7 +440,18 @@ export function ConversationPage() {
|
||||
<p className="text-center text-sm text-fg-muted">…</p>
|
||||
) : (
|
||||
<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
|
||||
// nothing between them. Call-event separators break the run —
|
||||
// a bubble whose immediate next neighbour is a call_event must
|
||||
@@ -384,6 +490,19 @@ export function ConversationPage() {
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
showSeen={m.id === lastSeenMessageId}
|
||||
{...(m.senderId === myId
|
||||
? {
|
||||
deliveryState: computeDeliveryState({
|
||||
messageId: m.id,
|
||||
isGroup: !!isGroup,
|
||||
recipientCount: groupRecipientCount,
|
||||
peerReadSet,
|
||||
peerDeliveredSet,
|
||||
groupRead,
|
||||
groupDelivered,
|
||||
}),
|
||||
}
|
||||
: {})}
|
||||
quoted={buildQuoted(m.replyToId)}
|
||||
onJumpToMessage={jumpToMessage}
|
||||
onReply={handleReply}
|
||||
@@ -393,6 +512,15 @@ export function ConversationPage() {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{pending.map((p) => (
|
||||
<li key={p.id}>
|
||||
<PendingBubble
|
||||
item={p}
|
||||
onRetry={() => retryPending(p.id)}
|
||||
onCancel={() => cancelPending(p.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
@@ -473,6 +601,18 @@ export function ConversationPage() {
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<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
|
||||
ref={composerRef}
|
||||
value={text}
|
||||
@@ -516,15 +656,42 @@ interface SearchBarProps {
|
||||
onQueryChange: (q: string) => void;
|
||||
matches: 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;
|
||||
onNext: () => 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']);
|
||||
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" />
|
||||
<input
|
||||
type="search"
|
||||
@@ -576,6 +743,48 @@ function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, o
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</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 +792,86 @@ function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, o
|
||||
// regular bubble is found or the array boundary is reached. Used to decide
|
||||
// run-grouping for avatar placement so call separators don't bleed into
|
||||
// sender continuity.
|
||||
function computeDeliveryState(args: {
|
||||
messageId: string;
|
||||
isGroup: boolean;
|
||||
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 }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -74,6 +74,7 @@ export default {
|
||||
'live-dot': 'liveDotPulse 1.5s ease-in-out infinite',
|
||||
'slide-in-call': 'slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)',
|
||||
'fs-hint': 'fsHintFade 3.5s ease-out forwards',
|
||||
'reaction-pop': 'reactionPop 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
},
|
||||
keyframes: {
|
||||
blob: {
|
||||
@@ -110,6 +111,11 @@ export default {
|
||||
'15%, 75%': { opacity: '1', transform: 'translate(-50%, 0)' },
|
||||
'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: {
|
||||
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/rail.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-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/data.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 type="text/babel">
|
||||
|
||||
+312
-214
@@ -1,30 +1,38 @@
|
||||
# Handoff: ChatApp — Clean Rail Redesign + Discord-Style Call UI
|
||||
# Handoff: ChatApp — Clean Rail Redesign (Chat + Friends + Admin + Settings + Calls)
|
||||
|
||||
## 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. The main chat interface (icon-rail sidebar + chat list + conversation area) in both **light** and **dark** mode.
|
||||
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.
|
||||
1. **Chat** — icon-rail + chat list + conversation area
|
||||
2. **Friends** — friends list, outgoing requests, incoming requests, user search
|
||||
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
|
||||
|
||||
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:
|
||||
- `ChatApp Redesign.html` — entry HTML that boots the prototype
|
||||
- `components/app.jsx` — main layout (rail + chat list + chat area, plus call state machine)
|
||||
- `components/call.jsx` — all call UI components (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
|
||||
- `components/icons.jsx` — SVG icon set used
|
||||
File layout:
|
||||
- `ChatApp Redesign.html` — entry HTML (open in a browser to see the prototype)
|
||||
- `components/app.jsx` — main layout (rail + chat list + chat area + call state machine + tab switching)
|
||||
- `components/pages.jsx` — **Friends, Admin, Settings page components** plus shared page primitives (PageHeader, Section, Toggle, SegmentedControl, Row, Empty, Banner, FlagChip, FriendRow)
|
||||
- `components/call.jsx` — call UI (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
|
||||
- `components/icons.jsx` — SVG icon set
|
||||
- `components/data.jsx` — dummy chat/message data
|
||||
- `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/call.css` — **all call UI styles** (the most important file for the call feature)
|
||||
- `screenshots/` — reference images of each state
|
||||
- `styles/call.css` — call UI styles
|
||||
- `styles/pages.css` — **Friends/Admin/Settings page styles**
|
||||
- `screenshots/` — reference images
|
||||
|
||||
## Fidelity
|
||||
|
||||
@@ -36,13 +44,13 @@ The design files are organized as:
|
||||
|
||||
### Colors — Light mode (`.variant-clean`)
|
||||
```
|
||||
--bg: #fafaf9 (app background)
|
||||
--bg-2: #f4f4f2 (secondary surfaces, hover, search input)
|
||||
--bg-3: #ffffff (elevated surfaces — chat list, chat area, cards)
|
||||
--fg: #0a0a0f (primary text)
|
||||
--fg-muted: #737380 (secondary text, icons)
|
||||
--line: rgba(10,10,15,0.08) (dividers, borders)
|
||||
--accent: #4F46E5 (indigo — primary actions, active states, my-bubble)
|
||||
--bg: #fafaf9
|
||||
--bg-2: #f4f4f2
|
||||
--bg-3: #ffffff
|
||||
--fg: #0a0a0f
|
||||
--fg-muted: #737380
|
||||
--line: rgba(10,10,15,0.08)
|
||||
--accent: #4F46E5
|
||||
--accent-fg: #ffffff
|
||||
```
|
||||
|
||||
@@ -54,20 +62,19 @@ The design files are organized as:
|
||||
--fg: #f4f4f5
|
||||
--fg-muted: #8a8a99
|
||||
--line: rgba(255,255,255,0.07)
|
||||
--accent: #6D73FF (slightly lighter indigo for contrast)
|
||||
--accent: #6D73FF
|
||||
--accent-fg: #ffffff
|
||||
```
|
||||
|
||||
### Status / Semantic Colors (both modes)
|
||||
### Semantic Colors
|
||||
```
|
||||
Speaking/Online/Success: #16a34a light · #22c55e / #4ade80 dark
|
||||
Danger/Decline/Hangup: #dc2626 (hover #b91c1c) · #fb7185 dark accent
|
||||
Warning/Idle: #f59e0b
|
||||
Live/Recording: #ef4444
|
||||
E2EE indicator (light): #16a34a (dark: #4ade80)
|
||||
Success / Online / E2EE: #16a34a (light) · #4ade80 / #22c55e (dark)
|
||||
Danger / Decline: #dc2626 / #b91c1c hover · #fb7185 (dark accent for borders/text)
|
||||
Warning / Expired: #f59e0b · #d97706 light · #fbbf24 dark
|
||||
Live / Recording: #ef4444
|
||||
```
|
||||
|
||||
### Avatar color swatches (assigned per chat)
|
||||
### Avatar swatches (assigned per user)
|
||||
```
|
||||
violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD)
|
||||
amber: bg #fde68a / fg #78350f
|
||||
@@ -76,239 +83,330 @@ teal: bg #99f6e4 / fg #134e4a (dark: #134e4a / #99f6e4)
|
||||
```
|
||||
|
||||
### 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:
|
||||
- Chat title / participant name large: 24px / 700
|
||||
- Primary labels, chat name: 14–15px / 600
|
||||
- Body (bubbles, messages): 14px / 400–500
|
||||
- Meta, timestamps, presence: 11–12px / 400–500
|
||||
- Rail tooltips, badges: 10–11px / 500–600
|
||||
- Uppercase eyebrow (e.g. "EINGEHENDER ANRUF"): 12px / 600 / letter-spacing 0.12em
|
||||
- Page title (`Freunde`, `Admin`, `Einstellungen`, call participant): 28px (page) / 24px (call) / 700 / Outfit / letter-spacing -0.02em
|
||||
- Section label (uppercase eyebrow): 11px / 600 / tracked 0.08em
|
||||
- Body labels, chat name, friend name: 14–15px / 600
|
||||
- Body text, bubbles, settings hint: 12–14px / 400–500
|
||||
- Meta, timestamps, handles, stat labels: 11–12px / 500, uppercase 10–11px tracked 0.06em
|
||||
- Mono values: 11–12px / JetBrains Mono / 500
|
||||
|
||||
### Spacing
|
||||
- Container padding: 16px (call-stage), 14–20px (topbars, composer)
|
||||
- Tile gap in grid: 8–10px
|
||||
- Control-button row gap: 8–10px
|
||||
- Border radius scale: 4 (small), 8 (tiles/badges), 10 (buttons), 14 (cards, large buttons), 18 (bubbles, bottom-fs controls), 24 (modal/card)
|
||||
### Spacing / Radius
|
||||
- Page content max-width: **820px**, centered, 28px top padding, 32px horizontal.
|
||||
- Section card radius 16, section header padded 16×20 with bottom border, section body padded 16×20 gap 14.
|
||||
- Button radius 8–10, chip/pill 999, stat card 12, device card 12.
|
||||
- Toggle switch: 40×22 with 16×16 thumb, 2px inset, transitions all 0.2s.
|
||||
|
||||
### Shadows
|
||||
- Elevated card light: `0 20px 60px rgba(0,0,0,0.1)`
|
||||
- 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
|
||||
- PiP glow: `0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)`
|
||||
- Accent button: `0 4px 14px rgba(79,70,229,0.25)`
|
||||
- Avatar-large glow: `0 2px 6px rgba(0,0,0,0.08)`
|
||||
|
||||
### Motion
|
||||
- Standard UI transition: `all 0.15s`
|
||||
- Pulse ring (incoming call): `2s ease-out infinite`, scale 0.85→1.25, opacity 0.6→0
|
||||
- Audio pulse (speaking ring): `1.3s ease-out infinite`, scale 1→1.3
|
||||
- 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 (0–15% fade in, 75–100% fade out)
|
||||
- Toggle switch thumb: `transform 0.2s`
|
||||
- PTT hotkey capture pulse: `keyPulse 1.2s ease-in-out infinite` (opacity 1↔0.6)
|
||||
|
||||
---
|
||||
|
||||
## 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):**
|
||||
- **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.
|
||||
- **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 Area** (flex, fills rest): chat header (counterpart avatar + name + presence/handle + search/voice/video buttons), messages scroll area, composer at bottom.
|
||||
|
||||
**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`).
|
||||
- **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): 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): chat header (counterpart avatar + name + presence/handle + search/voice/video), messages, composer.
|
||||
|
||||
**Message bubbles:**
|
||||
- Mine (outgoing): filled accent bg, white text, radius `18px 18px 4px 18px`.
|
||||
- Theirs (incoming): 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.
|
||||
- Reactions: pill row below bubble, `.mine` = filled accent.
|
||||
- System events ("Kaiwandi hat einen Anruf gestartet — Anruf beitreten"): centered `event-pill`, muted bg, muted fg, 11px.
|
||||
|
||||
**Composer:** plus button (attach), rounded input bar with inline emoji + mic icons, send button.
|
||||
- Mine: filled accent bg, white text, radius `18px 18px 4px 18px`.
|
||||
- Theirs: transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`.
|
||||
- Avatars only on the **last message of a run**.
|
||||
- Reactions: pill row below, `.mine` = filled accent.
|
||||
- System events: centered `event-pill`, muted bg/fg, 11px.
|
||||
|
||||
---
|
||||
|
||||
### 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):
|
||||
- Eyebrow "EINGEHENDER ANRUF" (uppercase, 12px, tracked)
|
||||
- Large avatar (96px) with **two pulsing rings** (staggered 1s)
|
||||
- Caller name (24px / 700 / Outfit)
|
||||
- Subtitle row: lock icon + "E2E verschlüsselt · Voice Call"
|
||||
- Two side-by-side action buttons:
|
||||
- **Ablehnen**: transparent, red border/text, hover = 8% red bg
|
||||
- **Annehmen**: green #16a34a, white, shadow
|
||||
### Layout
|
||||
- `PageHeader` with title "Freunde" and subtitle `"{N} Freunde · {M} offene Anfragen"`.
|
||||
- **Global search bar** (sticky at top of scroll): icon + input, placeholder "Benutzer suchen oder hinzufügen…". Focused state → border becomes accent.
|
||||
- **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`).
|
||||
- **List area**: either tab content OR search results, mutually exclusive.
|
||||
|
||||
### 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:**
|
||||
- **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.
|
||||
- **Call stage**: fills the remaining space, renders either:
|
||||
- **Grid**: CSS grid, 2×2 for 4 people, 3×2 for 5–6, 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.
|
||||
**Freunde** (`status === 'accepted'`):
|
||||
- FriendRow with: Nachricht (primary button opens conversation), Voice icon button (starts call), "Entfernen" (danger-ghost, confirms before `DELETE /api/friendships/{id}`).
|
||||
- Empty: "Noch keine Freunde. Suche oben nach Benutzern um anzufangen."
|
||||
|
||||
### 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`:
|
||||
- Entire call-dock container becomes absolute-positioned and takes over everything below the window bar (chat list still visible on the left).
|
||||
- The **call topbar is hidden** entirely.
|
||||
- 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).
|
||||
**Anfragen** (`status === 'pending' && direction === 'incoming'`):
|
||||
- `btn-primary` "Annehmen" (check icon) → `PATCH /api/friendships/{id}` with `{ accept: true }`; `btn-ghost` "Ablehnen" → same endpoint `{ accept: false }`.
|
||||
- Empty: "Keine eingehenden Anfragen."
|
||||
|
||||
### 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.
|
||||
- Tile border: 1px `var(--line)`, radius 14 (10 in dock). Hover → border becomes accent. `.speaking` → 2px green border + green glow shadow. `.focused` → accent border.
|
||||
- **Audio-only**: centered avatar (72px large, 44px small). When `speaking`, a green ring pulses around the avatar (`::before` pseudo).
|
||||
- **Video**: placeholder stub (gradient background + centered avatar, since real video not in scope here).
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
### 6. Picture-in-Picture (PiP)
|
||||
|
||||
A compact 260px pill docked bottom-right of the chat area:
|
||||
- 44px preview square (avatar of speaker, or mini shared-window placeholder if someone's sharing)
|
||||
- Title "Die Squad · {participantCount}"
|
||||
- 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
|
||||
### Data shapes
|
||||
```ts
|
||||
type Friendship = {
|
||||
id: string;
|
||||
userId: string; // the OTHER user's ID
|
||||
displayName: string;
|
||||
username: string; // @handle
|
||||
avatarColor: 'violet'|'amber'|'rose'|'teal';
|
||||
initial: string; // derived from displayName[0] uppercase
|
||||
status: 'accepted' | 'pending';
|
||||
direction: 'outgoing' | 'incoming' | null; // null when accepted
|
||||
presence?: 'online' | 'idle' | 'dnd' | 'offline';
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
// Primary state
|
||||
const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip'
|
||||
const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen'
|
||||
const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId);
|
||||
const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration });
|
||||
```
|
||||
|
||||
### Triggers & Transitions
|
||||
- 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.
|
||||
Triggers, keyboard and visibility rules identical to previous spec.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
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.
|
||||
3. **Message bubbles**: outlined `them` / filled `me`, asymmetric radius, avatars only on last-of-run.
|
||||
4. **Call state machine**: add the four `useState` hooks to whatever component owns the chat screen. Hook the Esc key effect.
|
||||
5. **IncomingCallScreen**: new component, full-bleed overlay of `.chat-area`.
|
||||
6. **Call dock**: new component, renders above `.messages`. Hide chat-header while docked.
|
||||
7. **ParticipantTile**: audio / video / sharing modes, speaking ring, meta overlay.
|
||||
8. **Fullscreen mode**: switch call container to position absolute, floating strip + floating controls + Esc hint.
|
||||
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
|
||||
```
|
||||
1. **Tokens**: add the Clean variant light + dark CSS variables to your theme layer.
|
||||
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. **Tab switching**: lift `activeTab` state. Conditionally render chat area vs page content.
|
||||
4. **Page primitives**: implement `PageHeader`, `Section`, `Toggle`, `SegmentedControl`, `Row`, `Stat`, `Banner`, `Empty`, `FlagChip`, `FriendRow` using your existing component library where possible.
|
||||
5. **FriendsPage**: wire to `FriendshipsContext` + user-search endpoint. Tabs + search + optimistic mutations.
|
||||
6. **AdminPage**: stats grid + system toggle + invites table (copy-to-clipboard, enable/disable, delete) + users list (flag chips with optimistic updates).
|
||||
7. **SettingsPage**: all 7 sections with two-way binding to user profile + settings. Debounced saves. Theme toggle wired to theme context.
|
||||
8. **Message bubbles + call UX** (already specified in previous scope — unchanged).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Tooltip`, use them.
|
||||
- Duration ("00:12:47") should tick live from the call start timestamp, not be static.
|
||||
- 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`).
|
||||
- The Clean Rail variant is **Variante 2** in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal).
|
||||
- `components/pages.jsx` uses **dummy data** for display. Replace with your live contexts (`FriendshipsContext`, admin API hooks, user/settings hooks).
|
||||
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Toggle`, use them.
|
||||
- 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.
|
||||
|
||||
@@ -188,7 +188,7 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
|
||||
<div className="app-body">
|
||||
{variant.layout === 'rail' ? (
|
||||
<div className="rail-layout">
|
||||
<div className={`rail-layout ${activeTab !== 'chats' ? 'page-mode' : ''}`}>
|
||||
{/* Icon rail left */}
|
||||
<div className="icon-rail">
|
||||
<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 />}
|
||||
</button>
|
||||
</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-header">
|
||||
<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>
|
||||
</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') && (
|
||||
<div className="chat-header">
|
||||
<Avatar chat={activeChat} />
|
||||
@@ -290,6 +296,7 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
</div>
|
||||
<button className="composer-btn"><Icon.Send /></button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -329,6 +336,10 @@ const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||
</div>
|
||||
|
||||
<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-header">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call screen switcher — only in Clean Rail */}
|
||||
{variant.layout === 'rail' && (
|
||||
{/* Call screen switcher — only in Clean Rail, only on chats tab */}
|
||||
{variant.layout === 'rail' && activeTab === 'chats' && (
|
||||
<div className="screen-switcher">
|
||||
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
|
||||
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
|
||||
|
||||
@@ -82,6 +82,18 @@ const Icon = {
|
||||
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>
|
||||
),
|
||||
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 });
|
||||
|
||||
@@ -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 |
@@ -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); }
|
||||
}
|
||||
@@ -187,3 +187,141 @@ export async function setUserFlag(
|
||||
.eq('user_id', userId);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,134 @@ export async function listPeerReadsForMessages(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user