Files
byGalax a04ecf7a19 feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- Voice messages: MediaRecorder → encrypted attachment, custom waveform
  player via OfflineAudioContext, 60s limit + live mic-level meter
- Offline message queue: localStorage outbox, exponential backoff retries,
  optimistic pending bubble with retry/discard
- Delivery indicator: message_deliveries table + RLS (reciprocal receipts),
  ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack)
- Per-participant volume slider in calls via right-click tile menu,
  persisted to localStorage, applied to attached audio elements
- Group call scaling: grid up to 12 tiles with pagination,
  active-speaker auto-promotion in fullscreen
- Push notifications scaffolding: service worker, VAPID subscription
  registration, notify-push edge function skeleton
- Backup recovery code: 24-char base32 code (~120 bits entropy) as
  alternative decrypt path, restore UI with mode toggle
- Admin panel: conversations list, audit log (admin_audit_log table +
  admin_log_action RPC), audit entry on user flag toggle
- Search v2: sender filter, attachment-only toggle, date range
- Reactions pop animation (scale 0.4→1.15→1 on count change)
- Message list windowing (150 default, expand via IntersectionObserver)
- Stub cleanup: removed dead ScreenshareStub from CallParticipantTile

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

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

Server TODO:
  VAPID keys + notify-push edge function deploy
2026-04-21 01:14:16 +02:00

592 lines
24 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 });