feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -181,7 +181,10 @@ function BrandSection() {
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-between gap-3 text-xs text-fg-muted">
|
||||
<span className="font-mono">v0.1.0 · {t('common:dev_build')}</span>
|
||||
<span className="font-mono">
|
||||
v{window.electronAPI?.appVersion ?? '0.0.0'}
|
||||
{import.meta.env.DEV ? ' · ' + t('common:dev_build') : ''}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function ChangelogPage() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await fetchChangelog();
|
||||
if (!cancelled) setEntries(list);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Konnte Changelog nicht laden.');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<header className="mb-2 flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
<p className="mt-0.5 text-sm text-fg-muted">
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-line bg-surface-2 p-6 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
<span>Lade Changelog…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-4 text-sm text-rose-600 dark:text-rose-200">
|
||||
<p className="font-semibold">Fehler beim Laden</p>
|
||||
<p className="mt-1 text-xs opacity-90">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries !== null && entries.length === 0 && !error && (
|
||||
<div className="rounded-xl border border-line bg-surface-2 p-8 text-center text-sm text-fg-muted">
|
||||
Noch keine Einträge vorhanden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries && entries.length > 0 && (
|
||||
<ol className="flex flex-col gap-4">
|
||||
{entries.slice(0, visible).map((entry) => (
|
||||
<li
|
||||
key={entry.version}
|
||||
className="rounded-2xl border border-line bg-surface-2 p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 className="font-display text-lg font-semibold tracking-tight text-fg">
|
||||
v{entry.version}
|
||||
</h2>
|
||||
<time
|
||||
dateTime={entry.pub_date}
|
||||
className="text-xs tabular-nums text-fg-muted"
|
||||
>
|
||||
{formatDate(entry.pub_date)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-line break-words text-sm leading-relaxed text-fg">
|
||||
{entry.notes}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
{visible < entries.length && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((n) => n + PAGE_SIZE)}
|
||||
className="inline-flex w-full cursor-pointer items-center justify-center rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm font-semibold text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Mehr laden ({entries.length - visible} verbleibend)
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
acceptDm,
|
||||
type ConversationSummary,
|
||||
isConversationMuted,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { acceptDm, type ConversationSummary, isConversationMuted } from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -15,12 +11,14 @@ import {
|
||||
ArchiveIcon,
|
||||
BellOffIcon,
|
||||
ChatBubbleIcon,
|
||||
PhoneIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
} from '../components/icons';
|
||||
import { UserBar } from '../components/UserBar';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function ChatsPage() {
|
||||
@@ -43,22 +41,13 @@ export function ChatsPage() {
|
||||
if (!q) return sorted;
|
||||
return sorted.filter((c) => {
|
||||
const title = (c.type === 'dm' ? c.peer?.displayName : c.name) ?? '';
|
||||
const handle = c.type === 'dm' ? c.peer?.username ?? '' : '';
|
||||
const handle = c.type === 'dm' ? (c.peer?.username ?? '') : '';
|
||||
return title.toLowerCase().includes(q) || handle.toLowerCase().includes(q);
|
||||
});
|
||||
}, [sorted, query]);
|
||||
|
||||
// Split into active vs archived — the user toggles which bucket shows in the
|
||||
// main list. Archived conversations with unread messages still surface so
|
||||
// the user can't accidentally silence an ongoing conversation permanently.
|
||||
const activeItems = useMemo(
|
||||
() => queryFiltered.filter((c) => !c.archived),
|
||||
[queryFiltered],
|
||||
);
|
||||
const archivedItems = useMemo(
|
||||
() => queryFiltered.filter((c) => c.archived),
|
||||
[queryFiltered],
|
||||
);
|
||||
const activeItems = useMemo(() => queryFiltered.filter((c) => !c.archived), [queryFiltered]);
|
||||
const archivedItems = useMemo(() => queryFiltered.filter((c) => c.archived), [queryFiltered]);
|
||||
|
||||
const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0);
|
||||
|
||||
@@ -85,8 +74,10 @@ export function ChatsPage() {
|
||||
showArchived={showArchived}
|
||||
onToggleArchived={() => setShowArchived((v) => !v)}
|
||||
archivedUnread={archivedUnread}
|
||||
activeCount={activeItems.length}
|
||||
archivedCount={archivedItems.length}
|
||||
/>
|
||||
<div className="flex-1 border-l border-line bg-surface-3">
|
||||
<div className="discord-chat-surface flex-1 border-l border-line bg-surface-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
@@ -107,6 +98,8 @@ interface ConversationListProps {
|
||||
showArchived: boolean;
|
||||
onToggleArchived: () => void;
|
||||
archivedUnread: number;
|
||||
activeCount: number;
|
||||
archivedCount: number;
|
||||
}
|
||||
|
||||
function ConversationList({
|
||||
@@ -122,62 +115,49 @@ function ConversationList({
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
archivedUnread,
|
||||
activeCount,
|
||||
archivedCount,
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Conversations"
|
||||
className="flex h-full w-[320px] shrink-0 flex-col bg-surface-2"
|
||||
className="discord-chat-panel flex h-full w-[312px] shrink-0 flex-col bg-surface-2"
|
||||
>
|
||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||
<h2 className="font-display text-base font-semibold tracking-tight text-fg">
|
||||
{showArchived
|
||||
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
|
||||
: t('app:nav.chats')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleArchived}
|
||||
aria-label={
|
||||
showArchived
|
||||
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||
}
|
||||
title={
|
||||
showArchived
|
||||
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||
}
|
||||
className={
|
||||
'relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(showArchived
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
{!showArchived && archivedUnread > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-accent px-1 text-[9px] font-bold leading-tight text-accent-fg"
|
||||
<header className="px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-base font-semibold text-fg">
|
||||
{showArchived
|
||||
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
|
||||
: t('app:nav.chats')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{showArchived
|
||||
? t('app:chats.archived_count', {
|
||||
count: archivedCount,
|
||||
defaultValue: archivedCount + ' archiviert',
|
||||
})
|
||||
: t('app:chats.active_count', {
|
||||
count: activeCount,
|
||||
defaultValue: activeCount + ' aktiv',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!showArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{archivedUnread > 9 ? '9+' : archivedUnread}
|
||||
</span>
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
{!showArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewGroup}
|
||||
aria-label={t('app:chats.new_group')}
|
||||
title={t('app:chats.new_group')}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -193,11 +173,49 @@ function ConversationList({
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder={t('app:chats.search_placeholder', { defaultValue: 'Suche…' })}
|
||||
className="w-full rounded-lg border border-line bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
className="discord-composer w-full rounded-lg border border-transparent bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-2">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-surface/70 p-1 dark:bg-[#232428]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showArchived) onToggleArchived();
|
||||
}}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-2 py-1.5 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(!showArchived
|
||||
? 'bg-surface-3 text-fg shadow-sm dark:bg-[#383a40]'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t('app:chats.active_filter', { defaultValue: 'Aktiv' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showArchived) onToggleArchived();
|
||||
}}
|
||||
className={
|
||||
'relative cursor-pointer rounded-md px-2 py-1.5 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(showArchived
|
||||
? 'bg-surface-3 text-fg shadow-sm dark:bg-[#383a40]'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t('app:chats.archive_filter', { defaultValue: 'Archiv' })}
|
||||
{archivedUnread > 0 && (
|
||||
<span className="ml-1 rounded-full bg-rose-500 px-1.5 py-0.5 text-[9px] font-bold text-white">
|
||||
{archivedUnread > 9 ? '9+' : archivedUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
@@ -242,10 +260,6 @@ function ConversationList({
|
||||
);
|
||||
}
|
||||
|
||||
// Windowed list: renders the first N rows and expands by N whenever a
|
||||
// bottom sentinel scrolls into view. Under the threshold we skip the
|
||||
// machinery entirely because rendering 50 rows costs less than the
|
||||
// overhead of observers + state updates.
|
||||
const VLIST_INITIAL = 40;
|
||||
const VLIST_STEP = 40;
|
||||
|
||||
@@ -320,20 +334,20 @@ function ConversationRow({
|
||||
onAccept: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const title =
|
||||
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||
const title = item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
|
||||
const avatarUrl =
|
||||
item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
|
||||
const avatarUrl = item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
|
||||
const preview =
|
||||
item.type === 'dm' ? handle : t('app:chats.group_preview', {
|
||||
count: item.members.length,
|
||||
defaultValue: `${item.members.length} Mitglieder`,
|
||||
});
|
||||
item.type === 'dm'
|
||||
? handle
|
||||
: t('app:chats.group_preview', {
|
||||
count: item.members.length,
|
||||
defaultValue: `${item.members.length} Mitglieder`,
|
||||
});
|
||||
|
||||
if (!item.acceptedByMe) {
|
||||
return (
|
||||
<div className="my-1 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div className="my-1 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -359,29 +373,30 @@ function ConversationRow({
|
||||
<NavLink
|
||||
to={'/chats/' + item.id}
|
||||
className={
|
||||
'group relative my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
'group relative my-0.5 flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent/15 text-fg'
|
||||
: 'text-fg hover:bg-surface-3/70')
|
||||
? 'bg-surface-3 text-fg shadow-sm dark:bg-[#404249]'
|
||||
: 'text-fg hover:bg-surface-3/70 dark:hover:bg-[#35373c]')
|
||||
}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-0 top-1/2 h-7 w-1 -translate-y-1/2 rounded-r-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p
|
||||
className={
|
||||
'truncate text-sm ' +
|
||||
(unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
|
||||
'truncate text-sm ' + (unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{muted && (
|
||||
<BellOffIcon
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
{muted && <BellOffIcon aria-hidden="true" className="h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
<ConversationVoiceDot conversationId={item.id} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">{preview}</p>
|
||||
</div>
|
||||
@@ -390,9 +405,7 @@ function ConversationRow({
|
||||
aria-label={'Unread: ' + unreadCount}
|
||||
className={
|
||||
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold leading-tight ' +
|
||||
(muted
|
||||
? 'bg-fg-muted/30 text-fg-muted'
|
||||
: 'bg-accent text-accent-fg')
|
||||
(muted ? 'bg-fg-muted/30 text-fg-muted' : 'bg-rose-500 text-white')
|
||||
}
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
@@ -407,6 +420,23 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Tiny green phone-icon next to the conversation title when somebody is
|
||||
* currently in voice for this conversation. Discord-parity: surfaces voice
|
||||
* activity in the chat list so users can hop in without opening the chat. */
|
||||
function ConversationVoiceDot({ conversationId }: { conversationId: string }) {
|
||||
const present = useCallPresence(conversationId);
|
||||
if (present.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
aria-label={'Sprach-Channel aktiv: ' + present.length}
|
||||
title={'Sprach-Channel aktiv: ' + present.length}
|
||||
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500"
|
||||
>
|
||||
<PhoneIcon className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConvAvatar({
|
||||
url,
|
||||
title,
|
||||
@@ -421,21 +451,21 @@ function ConvAvatar({
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isGroup) {
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent dark:bg-accent/25 dark:text-white">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent dark:bg-accent/25 dark:text-white">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
@@ -449,7 +479,7 @@ export function ChatsEmptyState() {
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
|
||||
<ChatBubbleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
<h2 className="font-display text-2xl font-semibold text-fg">
|
||||
{t('app:chats.select_prompt')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('app:chats.select_subtitle')}</p>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
PollIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SmileIcon,
|
||||
@@ -22,8 +23,11 @@ import {
|
||||
} from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { VoiceChannelRail } from '../components/VoiceChannelRail';
|
||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { PollComposerDialog } from '../components/PollComposerDialog';
|
||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||
@@ -31,6 +35,7 @@ import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
@@ -64,14 +69,14 @@ export function ConversationPage() {
|
||||
deviceId: device?.id,
|
||||
});
|
||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
|
||||
messageIds,
|
||||
session?.user.id,
|
||||
);
|
||||
const {
|
||||
byMessage: reactionsByMessage,
|
||||
toggle: toggleReaction,
|
||||
voteExclusive: votePoll,
|
||||
} = useMessageReactions(messageIds, session?.user.id);
|
||||
|
||||
const myId = session?.user.id;
|
||||
|
||||
// Peer read tracking — only for 1:1 DMs.
|
||||
const ownMessageIds = useMemo(
|
||||
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
|
||||
[messages, myId],
|
||||
@@ -79,18 +84,17 @@ export function ConversationPage() {
|
||||
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
|
||||
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
|
||||
|
||||
// Group receipts: only meaningful when conversation is a group. We feed it
|
||||
// ownMessageIds since we only render delivery state on the sender side.
|
||||
const isGroup = conversation?.type === 'group';
|
||||
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
|
||||
useGroupReceipts(ownMessageIds, myId, !!isGroup);
|
||||
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } = useGroupReceipts(
|
||||
ownMessageIds,
|
||||
myId,
|
||||
!!isGroup,
|
||||
);
|
||||
const groupRecipientCount = useMemo(() => {
|
||||
if (!isGroup || !conversation) return 0;
|
||||
return conversation.members.filter((m) => m.userId !== myId).length;
|
||||
}, [isGroup, conversation, myId]);
|
||||
|
||||
// Mark every peer-authored message as delivered on our side. Idempotent,
|
||||
// so rerunning for already-acknowledged ids is a no-op server-side.
|
||||
const deliveredTrackedRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!myId || messages.length === 0) return;
|
||||
@@ -116,11 +120,8 @@ export function ConversationPage() {
|
||||
return null;
|
||||
}, [messages, peerReadSet, myId]);
|
||||
|
||||
// Typing channel.
|
||||
const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);
|
||||
|
||||
// Mark incoming messages as read (server-side, visible to peer if both sides
|
||||
// have receipts on). Runs whenever new messages arrive or id changes.
|
||||
useEffect(() => {
|
||||
if (!id || messages.length === 0 || !myId) return;
|
||||
const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
|
||||
@@ -136,6 +137,10 @@ export function ConversationPage() {
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
||||
const [pollSending, setPollSending] = useState(false);
|
||||
const [pollError, setPollError] = useState<string | null>(null);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
@@ -148,54 +153,64 @@ export function ConversationPage() {
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
// Snapshot of the "first-unread-message" id captured once the very first
|
||||
// render of this conversation lands. Stays fixed until the user switches
|
||||
// away so the divider doesn't jump around while new messages arrive.
|
||||
const firstUnreadRef = useRef<string | null>(null);
|
||||
const [firstUnreadId, setFirstUnreadId] = useState<string | null>(null);
|
||||
const [firstUnreadJumpDismissed, setFirstUnreadJumpDismissed] = useState(false);
|
||||
const [newMessagesWhileAway, setNewMessagesWhileAway] = useState(0);
|
||||
const firstUnreadComputedRef = useRef<boolean>(false);
|
||||
const [profilePopover, setProfilePopover] = useState<
|
||||
{ userId: string; x: number; y: number } | null
|
||||
>(null);
|
||||
const [mentionState, setMentionState] = useState<
|
||||
{ query: string; start: number } | null
|
||||
>(null);
|
||||
const previousMessageIdsRef = useRef<Set<string>>(new Set());
|
||||
const [profilePopover, setProfilePopover] = useState<{
|
||||
userId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Drop reply-to / clear search state when switching conversation.
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setMediaDrawerOpen(false);
|
||||
setPollDialogOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
firstUnreadRef.current = null;
|
||||
setFirstUnreadId(null);
|
||||
setFirstUnreadJumpDismissed(false);
|
||||
setNewMessagesWhileAway(0);
|
||||
previousMessageIdsRef.current = new Set();
|
||||
firstUnreadComputedRef.current = false;
|
||||
}, [id]);
|
||||
|
||||
// On first message-list populate for this conversation, pin the divider
|
||||
// above the oldest-unread message. We only compute once — subsequent
|
||||
// inserts push the divider "further back" visually, which matches
|
||||
// Discord's behaviour.
|
||||
useEffect(() => {
|
||||
if (firstUnreadComputedRef.current) return;
|
||||
if (!id || messages.length === 0) return;
|
||||
const count = unread[id] ?? 0;
|
||||
firstUnreadComputedRef.current = true;
|
||||
if (count === 0 || count > messages.length) {
|
||||
firstUnreadRef.current = null;
|
||||
setFirstUnreadId(null);
|
||||
return;
|
||||
}
|
||||
const boundary = messages[messages.length - count];
|
||||
firstUnreadRef.current = boundary ? boundary.id : null;
|
||||
setFirstUnreadId(boundary ? boundary.id : null);
|
||||
}, [id, messages, unread]);
|
||||
|
||||
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
||||
// effective window on each trigger so scrolling up quickly converges to
|
||||
// rendering everything.
|
||||
useEffect(() => {
|
||||
const previous = previousMessageIdsRef.current;
|
||||
if (previous.size > 0 && !stickToBottom) {
|
||||
const addedIncoming = messages.filter(
|
||||
(message) => !previous.has(message.id) && message.senderId !== myId,
|
||||
).length;
|
||||
if (addedIncoming > 0) {
|
||||
setNewMessagesWhileAway((count) => count + addedIncoming);
|
||||
}
|
||||
}
|
||||
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
|
||||
}, [messages, myId, stickToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = loadMoreSentinelRef.current;
|
||||
if (!el) return;
|
||||
@@ -218,12 +233,14 @@ export function ConversationPage() {
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
const attachmentIndex = useMemo(() => collectConversationAttachments(messages), [messages]);
|
||||
|
||||
const senderNameFor = useCallback(
|
||||
(senderId: string): string => {
|
||||
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
|
||||
const profile =
|
||||
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
|
||||
(senderId !== myId ? conversation?.peer ?? null : null);
|
||||
(senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
return profile?.displayName ?? '?';
|
||||
},
|
||||
[conversation, myId, t],
|
||||
@@ -243,7 +260,12 @@ export function ConversationPage() {
|
||||
};
|
||||
}
|
||||
const parsed = parseMessagePayload(target.plaintext);
|
||||
const text = parsed.kind === 'text' ? parsed.text : '';
|
||||
const text =
|
||||
parsed.kind === 'text'
|
||||
? parsed.text
|
||||
: parsed.kind === 'poll'
|
||||
? 'Umfrage: ' + parsed.question
|
||||
: '';
|
||||
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
|
||||
return {
|
||||
id: target.id,
|
||||
@@ -275,9 +297,6 @@ export function ConversationPage() {
|
||||
setForwardTarget(m);
|
||||
}, []);
|
||||
|
||||
// Search matches: messages matching query + filters. Empty query is allowed
|
||||
// when filters are active, so users can e.g. show "all attachments from
|
||||
// alice in the last week" without a text query.
|
||||
const searchActive = useMemo(
|
||||
() =>
|
||||
searchQuery.trim().length > 0 ||
|
||||
@@ -288,10 +307,6 @@ export function ConversationPage() {
|
||||
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
|
||||
);
|
||||
|
||||
// FTS5-backed supplementary results: covers cached messages that aren't in
|
||||
// the currently-loaded window (`messages`). Runs only when there's a text
|
||||
// query — filters alone stay in-memory because they depend on already-
|
||||
// decrypted payload state.
|
||||
const [ftsExtras, setFtsExtras] = useState<DecryptedMessage[]>([]);
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
@@ -317,12 +332,7 @@ export function ConversationPage() {
|
||||
if (!searchActive) return [] as DecryptedMessage[];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
|
||||
// Date inputs cover whole days — bump 'to' to end-of-day.
|
||||
const toTs = searchDateTo
|
||||
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
||||
: null;
|
||||
// Union the live `messages` array with any FTS5-only rows not yet
|
||||
// loaded into memory, keyed by id so we don't double-count.
|
||||
const toTs = searchDateTo ? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1 : null;
|
||||
const seen = new Set<string>();
|
||||
const pool: DecryptedMessage[] = [];
|
||||
for (const m of messages) {
|
||||
@@ -337,9 +347,7 @@ export function ConversationPage() {
|
||||
pool.push(m);
|
||||
}
|
||||
}
|
||||
pool.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
pool.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
return pool.filter((m) => {
|
||||
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
||||
const created = new Date(m.createdAt).getTime();
|
||||
@@ -363,7 +371,6 @@ export function ConversationPage() {
|
||||
searchDateTo,
|
||||
]);
|
||||
|
||||
// Reset/clamp the active match index when the match set changes.
|
||||
useEffect(() => {
|
||||
if (searchMatches.length === 0) {
|
||||
setSearchIdx(0);
|
||||
@@ -372,7 +379,6 @@ export function ConversationPage() {
|
||||
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
|
||||
}, [searchMatches.length]);
|
||||
|
||||
// Auto-jump to current match.
|
||||
useEffect(() => {
|
||||
if (!searchOpen || searchMatches.length === 0) return;
|
||||
const target = searchMatches[searchIdx];
|
||||
@@ -407,7 +413,17 @@ export function ConversationPage() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setStickToBottom(distanceFromBottom < STICK_THRESHOLD);
|
||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
||||
setStickToBottom(nextStick);
|
||||
if (nextStick) setNewMessagesWhileAway(0);
|
||||
}, []);
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
setStickToBottom(true);
|
||||
setNewMessagesWhileAway(0);
|
||||
}, []);
|
||||
|
||||
async function handleSend(e?: React.FormEvent) {
|
||||
@@ -437,10 +453,27 @@ export function ConversationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePollSubmit = useCallback(
|
||||
async (question: string, options: string[]) => {
|
||||
setPollSending(true);
|
||||
setPollError(null);
|
||||
try {
|
||||
const payload = createPollPayload(question, options);
|
||||
await send(payload, [], replyTo?.id ?? null);
|
||||
setPollDialogOpen(false);
|
||||
setReplyTo(null);
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
} catch (err: unknown) {
|
||||
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
|
||||
} finally {
|
||||
setPollSending(false);
|
||||
}
|
||||
},
|
||||
[send, replyTo?.id, notifyStopTyping],
|
||||
);
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
// Pre-compression so heavy phone photos (typically 4-8MB) don't bust the
|
||||
// 10MB limit and don't waste storage/bandwidth. Non-image + animated
|
||||
// files are passed through unchanged.
|
||||
const compressed = await compressImages(files);
|
||||
const next: File[] = [];
|
||||
for (const f of compressed) {
|
||||
@@ -459,16 +492,12 @@ export function ConversationPage() {
|
||||
}
|
||||
|
||||
const { state: callState } = useCall();
|
||||
// Hide the chat header while this conversation hosts an active call — the
|
||||
// call topbar inside the dock already shows the channel name + duration,
|
||||
// and fullscreen cinema needs the whole slot.
|
||||
const callHereActive =
|
||||
(callState.kind === 'connected' ||
|
||||
callState.kind === 'connecting' ||
|
||||
callState.kind === 'outgoing') &&
|
||||
callState.conversationId === id;
|
||||
const incomingHere =
|
||||
callState.kind === 'incoming' && callState.conversationId === id;
|
||||
const incomingHere = callState.kind === 'incoming' && callState.conversationId === id;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -486,7 +515,6 @@ export function ConversationPage() {
|
||||
}
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// leave fires on child enter too; only clear when leaving the page container.
|
||||
if (e.currentTarget === e.target) setIsDraggingFile(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
@@ -500,6 +528,19 @@ export function ConversationPage() {
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
onMediaClick={() => setMediaDrawerOpen((v) => !v)}
|
||||
{...(conversation?.type === 'dm' && conversation.peer
|
||||
? {
|
||||
onProfileClick: (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
setProfilePopover({
|
||||
userId: conversation.peer!.userId,
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
});
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
onSearchClick={() => setSearchOpen((v) => !v)}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
@@ -522,11 +563,15 @@ export function ConversationPage() {
|
||||
members={conversation?.members ?? []}
|
||||
onPrev={() =>
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
searchMatches.length === 0
|
||||
? 0
|
||||
: (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||
)
|
||||
}
|
||||
onNext={() =>
|
||||
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
|
||||
setSearchIdx((cur) =>
|
||||
searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length,
|
||||
)
|
||||
}
|
||||
onClose={() => {
|
||||
setSearchOpen(false);
|
||||
@@ -547,10 +592,29 @@ export function ConversationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaFilesDrawer
|
||||
open={mediaDrawerOpen}
|
||||
index={attachmentIndex}
|
||||
senderNameFor={senderNameFor}
|
||||
onJumpToMessage={(messageId) => {
|
||||
setMediaDrawerOpen(false);
|
||||
jumpToMessage(messageId);
|
||||
}}
|
||||
onClose={() => setMediaDrawerOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
||||
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
||||
someone is already waiting. Hides automatically once we're in. */}
|
||||
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
@@ -573,34 +637,21 @@ export function ConversationPage() {
|
||||
)}
|
||||
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
||||
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
||||
// A "run" is consecutive bubbles from the same sender with
|
||||
// nothing between them. Call-event separators break the run —
|
||||
// a bubble whose immediate next neighbour is a call_event must
|
||||
// anchor the avatar, even if another bubble from the same
|
||||
// sender appears after the separator.
|
||||
const prevRaw = messages[idx - 1];
|
||||
const nextRaw = messages[idx + 1];
|
||||
const prevIsCallEvent =
|
||||
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
|
||||
const nextIsCallEvent =
|
||||
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
|
||||
const grouped =
|
||||
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
||||
// Anchor avatar on the LAST message of a run so it aligns with
|
||||
// the bubble's tail (bottom corner). Tail is bottom-left for
|
||||
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
|
||||
const isLastOfRun =
|
||||
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
||||
// DM fallback: if member lookup fails (e.g. transient sync), fall
|
||||
// back to conversation.peer so the peer's avatar still resolves.
|
||||
const grouped = !!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
||||
const isLastOfRun = !nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
||||
const memberProfile =
|
||||
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
|
||||
const senderProfile =
|
||||
memberProfile ??
|
||||
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
{firstUnreadRef.current === m.id && (
|
||||
{firstUnreadId === m.id && (
|
||||
<div
|
||||
aria-label="Neue Nachrichten"
|
||||
className="my-2 flex items-center gap-3 px-2"
|
||||
@@ -622,6 +673,7 @@ export function ConversationPage() {
|
||||
conversationId={id ?? ''}
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)}
|
||||
showSeen={m.id === lastSeenMessageId}
|
||||
{...(m.senderId === myId
|
||||
? {
|
||||
@@ -666,10 +718,36 @@ export function ConversationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TypingIndicator
|
||||
typingUserIds={typingUserIds}
|
||||
members={conversation?.members ?? []}
|
||||
/>
|
||||
{firstUnreadId && !firstUnreadJumpDismissed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
jumpToMessage(firstUnreadId);
|
||||
setFirstUnreadJumpDismissed(true);
|
||||
}}
|
||||
className="absolute left-1/2 top-[76px] z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-rose-500/30 bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-lg transition hover:bg-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
<span>Zu ungelesen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!stickToBottom || newMessagesWhileAway > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToBottom}
|
||||
className="absolute bottom-[92px] left-1/2 z-30 inline-flex -translate-x-1/2 cursor-pointer items-center gap-2 rounded-full border border-line bg-surface-2 px-3 py-1.5 text-xs font-semibold text-fg shadow-lg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-[#2b2d31] dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 text-accent" />
|
||||
<span>
|
||||
{newMessagesWhileAway > 0
|
||||
? newMessagesWhileAway + ' neue Nachrichten'
|
||||
: 'Zum neuesten'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<TypingIndicator typingUserIds={typingUserIds} members={conversation?.members ?? []} />
|
||||
|
||||
{isDraggingFile && (
|
||||
<div
|
||||
@@ -682,7 +760,7 @@ export function ConversationPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
||||
<form onSubmit={handleSend} className="discord-chat-surface bg-surface-3 px-5 pb-5 pt-2">
|
||||
{sendError && (
|
||||
<div className="mb-2">
|
||||
<Banner>{sendError}</Banner>
|
||||
@@ -690,7 +768,7 @@ export function ConversationPage() {
|
||||
)}
|
||||
|
||||
{replyTo && (
|
||||
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
|
||||
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm dark:bg-[#2b2d31]">
|
||||
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
|
||||
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -704,6 +782,7 @@ export function ConversationPage() {
|
||||
{(() => {
|
||||
if (!replyTo.plaintext) return '…';
|
||||
const p = parseMessagePayload(replyTo.plaintext);
|
||||
if (p.kind === 'poll') return 'Umfrage: ' + p.question;
|
||||
if (p.kind !== 'text') return '';
|
||||
if (!p.text && p.attachments.length > 0) return '📎';
|
||||
return p.text;
|
||||
@@ -714,7 +793,7 @@ export function ConversationPage() {
|
||||
type="button"
|
||||
onClick={() => setReplyTo(null)}
|
||||
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -727,22 +806,19 @@ export function ConversationPage() {
|
||||
<AttachmentPreview
|
||||
key={idx}
|
||||
file={file}
|
||||
onRemove={() =>
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-end gap-2">
|
||||
<div className="discord-composer relative flex items-end gap-1 rounded-xl border border-transparent bg-surface-2 p-1.5 shadow-sm focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
|
||||
{isGroup && mentionState && conversation && (
|
||||
<MentionAutocomplete
|
||||
members={conversation.members}
|
||||
query={mentionState.query}
|
||||
excludeUserId={myId}
|
||||
onSelect={(username) => {
|
||||
// Replace `@{query}` at `start..caret` with `@{username} `.
|
||||
const start = mentionState.start;
|
||||
const before = text.slice(0, start);
|
||||
const afterCaret = text.slice(start + 1 + mentionState.query.length);
|
||||
@@ -750,7 +826,6 @@ export function ConversationPage() {
|
||||
const next = before + inserted + afterCaret;
|
||||
setText(next);
|
||||
setMentionState(null);
|
||||
// Restore caret position after inserted mention.
|
||||
const caret = (before + inserted).length;
|
||||
requestAnimationFrame(() => {
|
||||
const el = composerRef.current;
|
||||
@@ -774,10 +849,22 @@ export function ConversationPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Datei anhängen"
|
||||
title="Datei anhängen"
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPollError(null);
|
||||
setPollDialogOpen(true);
|
||||
}}
|
||||
aria-label="Umfrage erstellen"
|
||||
title="Umfrage erstellen"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<PollIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -786,7 +873,7 @@ export function ConversationPage() {
|
||||
aria-label="Emoji einfügen"
|
||||
title="Emoji einfügen"
|
||||
aria-expanded={emojiOpen}
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<SmileIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -826,16 +913,10 @@ export function ConversationPage() {
|
||||
const next = e.target.value;
|
||||
setText(next);
|
||||
if (next.length > 0) notifyTyping();
|
||||
// Detect an in-progress @mention: find the last '@' before the
|
||||
// caret, with no whitespace between it and the caret. If
|
||||
// present, open the autocomplete with the partial query.
|
||||
const caret = e.target.selectionStart ?? next.length;
|
||||
const before = next.slice(0, caret);
|
||||
const atIdx = before.lastIndexOf('@');
|
||||
if (
|
||||
atIdx >= 0 &&
|
||||
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
|
||||
) {
|
||||
if (atIdx >= 0 && (atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))) {
|
||||
const q = before.slice(atIdx + 1);
|
||||
if (!/\s/.test(q)) {
|
||||
setMentionState({ query: q, start: atIdx });
|
||||
@@ -867,13 +948,13 @@ export function ConversationPage() {
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Nachricht schreiben…"
|
||||
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
className="max-h-40 min-h-[40px] flex-1 resize-none rounded-lg bg-transparent px-2 py-2.5 text-sm text-fg placeholder-fg-muted outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
||||
aria-busy={sending}
|
||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -887,6 +968,16 @@ export function ConversationPage() {
|
||||
onClose={() => setForwardTarget(null)}
|
||||
/>
|
||||
|
||||
<PollComposerDialog
|
||||
open={pollDialogOpen}
|
||||
sending={pollSending}
|
||||
error={pollError}
|
||||
onClose={() => {
|
||||
if (!pollSending) setPollDialogOpen(false);
|
||||
}}
|
||||
onSubmit={handlePollSubmit}
|
||||
/>
|
||||
|
||||
{profilePopover && (
|
||||
<UserProfilePopover
|
||||
userId={profilePopover.userId}
|
||||
@@ -943,7 +1034,7 @@ function SearchBar({
|
||||
}: SearchBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<div className="discord-chat-panel flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
@@ -974,7 +1065,7 @@ function SearchBar({
|
||||
onClick={onPrev}
|
||||
disabled={matches === 0}
|
||||
aria-label="Previous"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -983,7 +1074,7 @@ function SearchBar({
|
||||
onClick={onNext}
|
||||
disabled={matches === 0}
|
||||
aria-label="Next"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -991,7 +1082,7 @@ function SearchBar({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg dark:hover:bg-[#383a40]"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1000,7 +1091,7 @@ function SearchBar({
|
||||
<select
|
||||
value={senderId}
|
||||
onChange={(e) => onSenderChange(e.target.value)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
>
|
||||
<option value="">Alle Sender</option>
|
||||
{members.map((m) => (
|
||||
@@ -1009,7 +1100,7 @@ function SearchBar({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
|
||||
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg dark:bg-[#383a40]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={attachmentsOnly}
|
||||
@@ -1024,7 +1115,7 @@ function SearchBar({
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => onDateFromChange(e.target.value)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
@@ -1033,7 +1124,7 @@ function SearchBar({
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => onDateToChange(e.target.value)}
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent dark:bg-[#383a40]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1041,10 +1132,6 @@ function SearchBar({
|
||||
);
|
||||
}
|
||||
|
||||
// Walks `messages` from `idx + step` skipping call_event entries until a
|
||||
// regular bubble is found or the array boundary is reached. Used to decide
|
||||
// run-grouping for avatar placement so call separators don't bleed into
|
||||
// sender continuity.
|
||||
function computeDeliveryState(args: {
|
||||
messageId: string;
|
||||
isGroup: boolean;
|
||||
@@ -1054,15 +1141,11 @@ function computeDeliveryState(args: {
|
||||
groupRead: Map<string, Set<string>>;
|
||||
groupDelivered: Map<string, Set<string>>;
|
||||
}): 'sent' | 'delivered' | 'read' {
|
||||
// DM: single peer ack flips state.
|
||||
if (!args.isGroup) {
|
||||
if (args.peerReadSet.has(args.messageId)) return 'read';
|
||||
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
|
||||
return 'sent';
|
||||
}
|
||||
// Group: state advances only when ALL recipients have acknowledged. With
|
||||
// 0 recipients (admin-only group), we keep 'sent' so we don't show
|
||||
// misleading completed ticks.
|
||||
if (args.recipientCount === 0) return 'sent';
|
||||
const reads = args.groupRead.get(args.messageId);
|
||||
if (reads && reads.size >= args.recipientCount) return 'read';
|
||||
@@ -1114,9 +1197,7 @@ function PendingBubble({
|
||||
) : (
|
||||
<>
|
||||
<SpinnerIcon className="h-3 w-3 animate-spin" />
|
||||
<span>
|
||||
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
|
||||
</span>
|
||||
<span>{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1147,7 +1228,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
{isImage && url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
@@ -1155,12 +1236,8 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
<span className="truncate font-semibold text-fg" title={file.name}>
|
||||
{file.name || 'Datei'}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
{file.type || 'unbekannt'}
|
||||
</span>
|
||||
<span className="text-fg-muted">
|
||||
{(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
|
||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -10,13 +10,29 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import { MicTestSection } from '../components/MicTestSection';
|
||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
|
||||
import {
|
||||
AVATAR_TARGET_DIM,
|
||||
deleteAvatarObject,
|
||||
uploadAvatarBlob,
|
||||
} from '../lib/avatarUpload';
|
||||
import {
|
||||
BANNER_MAX_INPUT_BYTES,
|
||||
BANNER_TARGET_HEIGHT,
|
||||
BANNER_TARGET_WIDTH,
|
||||
deleteBannerObject,
|
||||
uploadBannerBlob,
|
||||
} from '../lib/bannerUpload';
|
||||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
@@ -97,17 +113,20 @@ export function SettingsPage() {
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('app:settings.section_account')}>
|
||||
<AvatarControls
|
||||
patchProfile={patchProfile}
|
||||
busy={busy}
|
||||
/>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
|
||||
{/* Startup */}
|
||||
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
||||
<AutoStartControls />
|
||||
</Section>
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
@@ -151,6 +170,13 @@ export function SettingsPage() {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Notification sound (new messages) */}
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</Section>
|
||||
|
||||
{/* Ringtone (incoming custom) */}
|
||||
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
@@ -176,6 +202,15 @@ export function SettingsPage() {
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
@@ -219,6 +254,55 @@ export function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AutoStartControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const on = await isAutoStartEnabled();
|
||||
if (!cancelled) setEnabled(on);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setAutoStart(next);
|
||||
setEnabled(next);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'autostart failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toggle
|
||||
label={t('app:settings.autostart', {
|
||||
defaultValue: 'Mit Windows starten',
|
||||
})}
|
||||
hint={t('app:settings.autostart_hint', {
|
||||
defaultValue:
|
||||
'ChatApp automatisch mitstarten wenn du dich am System anmeldest.',
|
||||
})}
|
||||
checked={enabled ?? false}
|
||||
disabled={busy || enabled === null}
|
||||
onChange={(v) => void handleToggle(v)}
|
||||
/>
|
||||
{error && <p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PttControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
|
||||
@@ -319,20 +403,43 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||
}, [capturing, kind]);
|
||||
|
||||
const binding = hotkeys[kind];
|
||||
const toggleLabel =
|
||||
kind === 'mute'
|
||||
? t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' })
|
||||
: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' });
|
||||
const toggleHint =
|
||||
kind === 'mute'
|
||||
? t('app:settings.hotkey_mute_hint', {
|
||||
defaultValue:
|
||||
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||
})
|
||||
: t('app:settings.hotkey_deafen_hint', {
|
||||
defaultValue:
|
||||
'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||
});
|
||||
const labels: Record<VoiceHotkeyKind, { label: string; hint: string }> = {
|
||||
mute: {
|
||||
label: t('app:settings.hotkey_mute_enabled', { defaultValue: 'Mute-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_mute_hint', {
|
||||
defaultValue:
|
||||
'Schaltet dein Mikro an/aus — funktioniert auch wenn ChatApp im Hintergrund ist.',
|
||||
}),
|
||||
},
|
||||
deafen: {
|
||||
label: t('app:settings.hotkey_deafen_enabled', { defaultValue: 'Deafen-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_deafen_hint', {
|
||||
defaultValue: 'Schaltet eingehendes Audio + dein Mikro stumm (wie in Discord).',
|
||||
}),
|
||||
},
|
||||
hangup: {
|
||||
label: t('app:settings.hotkey_hangup_enabled', { defaultValue: 'Auflegen-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_hangup_hint', {
|
||||
defaultValue: 'Beendet den aktiven Anruf sofort.',
|
||||
}),
|
||||
},
|
||||
screenShare: {
|
||||
label: t('app:settings.hotkey_screenshare_enabled', {
|
||||
defaultValue: 'Bildschirmfreigabe-Hotkey',
|
||||
}),
|
||||
hint: t('app:settings.hotkey_screenshare_hint', {
|
||||
defaultValue: 'Startet oder stoppt die Bildschirmfreigabe.',
|
||||
}),
|
||||
},
|
||||
video: {
|
||||
label: t('app:settings.hotkey_video_enabled', { defaultValue: 'Kamera-Hotkey' }),
|
||||
hint: t('app:settings.hotkey_video_hint', {
|
||||
defaultValue: 'Schaltet die Kamera während eines Anrufs an oder aus.',
|
||||
}),
|
||||
},
|
||||
};
|
||||
const toggleLabel = labels[kind].label;
|
||||
const toggleHint = labels[kind].hint;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -549,106 +656,411 @@ interface AvatarControlsProps {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
function DisplayNameControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app', 'auth']);
|
||||
const { profile } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const url = profile?.avatarUrl ?? null;
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!userId) return;
|
||||
function startEdit() {
|
||||
setDraft(profile?.displayName ?? '');
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
// Focus on next tick so the input has mounted.
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmed = draft.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError(
|
||||
t('app:settings.display_name_required', {
|
||||
defaultValue: 'Anzeigename darf nicht leer sein.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (trimmed === profile?.displayName) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const newUrl = await uploadAvatar(userId, file);
|
||||
const oldUrl = url;
|
||||
await patchProfile({ avatarUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
// Best-effort cleanup of the previous file (don't block on it).
|
||||
void deleteAvatarObject(oldUrl).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
await patchProfile({ displayName: trimmed });
|
||||
setEditing(false);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'upload failed');
|
||||
setError(err instanceof Error ? err.message : 'save failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!userId || !url) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
await patchProfile({ avatarUrl: null });
|
||||
void deleteAvatarObject(url).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</dt>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<dd className="max-w-[40ch] truncate text-right text-sm text-fg" title={profile?.displayName ?? ''}>
|
||||
{profile?.displayName ?? '—'}
|
||||
</dd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEdit}
|
||||
disabled={busy || !profile}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2.5 py-1 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:settings.edit', { defaultValue: 'Bearbeiten' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-16 w-16 text-2xl"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-fg-muted">{t('auth:signed_in.display_name')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
maxLength={64}
|
||||
disabled={saving}
|
||||
className="flex-1 min-w-[12rem] rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-sm text-fg outline-none focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:opacity-60 dark:bg-[#313338]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving || busy}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving
|
||||
? t('app:settings.display_name_saving', { defaultValue: 'Speichere…' })
|
||||
: t('app:settings.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={saving}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-[#313338] dark:hover:bg-[#2b2d31]"
|
||||
>
|
||||
{t('app:settings.cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
// Default banner gradient when the user hasn't uploaded their own. Sits on
|
||||
// the same accent + surface tokens as the rest of the app so it never clashes
|
||||
// with theme changes. Used both here in settings and in UserProfilePopover.
|
||||
export const DEFAULT_BANNER_CLASS =
|
||||
'bg-gradient-to-br from-accent/40 via-accent/15 to-surface-3';
|
||||
|
||||
function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile } = useAuth();
|
||||
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [avatarBusy, setAvatarBusy] = useState(false);
|
||||
const [bannerBusy, setBannerBusy] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<string | null>(null);
|
||||
// Crop-dialog plumbing. The picked File lives here until the user
|
||||
// confirms a crop or cancels; on confirm we hand the resulting Blob to
|
||||
// the matching upload helper. Keeping `kind` separate from `file` lets
|
||||
// the same dialog component drive both flows with different aspect
|
||||
// ratios.
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
const bannerUrl = profile?.bannerUrl ?? null;
|
||||
|
||||
// Avatar pick → open crop dialog. Legacy `uploadAvatar` (center-crop) is
|
||||
// kept around for callers that bypass the picker, but the SettingsPage
|
||||
// path always goes through the crop flow now so the user controls the
|
||||
// framing.
|
||||
function openAvatarCrop(file: File) {
|
||||
if (!userId) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setAvatarError('only image files are accepted');
|
||||
return;
|
||||
}
|
||||
setAvatarError(null);
|
||||
setCropFile(file);
|
||||
setCropKind('avatar');
|
||||
}
|
||||
|
||||
async function handleAvatarCropConfirm(blob: Blob) {
|
||||
if (!userId) return;
|
||||
setAvatarBusy(true);
|
||||
setAvatarError(null);
|
||||
try {
|
||||
const newUrl = await uploadAvatarBlob(userId, blob);
|
||||
const oldUrl = avatarUrl;
|
||||
await patchProfile({ avatarUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
void deleteAvatarObject(oldUrl).catch(() => undefined);
|
||||
}
|
||||
closeCropDialog();
|
||||
} catch (err: unknown) {
|
||||
setAvatarError(err instanceof Error ? err.message : 'upload failed');
|
||||
} finally {
|
||||
setAvatarBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCropDialog() {
|
||||
setCropFile(null);
|
||||
setCropKind(null);
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = '';
|
||||
if (bannerInputRef.current) bannerInputRef.current.value = '';
|
||||
}
|
||||
|
||||
async function handleAvatarRemove() {
|
||||
if (!userId || !avatarUrl) return;
|
||||
setAvatarError(null);
|
||||
setAvatarBusy(true);
|
||||
try {
|
||||
await patchProfile({ avatarUrl: null });
|
||||
void deleteAvatarObject(avatarUrl).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setAvatarError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setAvatarBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBannerCrop(file: File) {
|
||||
if (!userId) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setBannerError('only image files are accepted');
|
||||
return;
|
||||
}
|
||||
if (file.size > BANNER_MAX_INPUT_BYTES) {
|
||||
setBannerError('image must be 8 MB or smaller');
|
||||
return;
|
||||
}
|
||||
setBannerError(null);
|
||||
setCropFile(file);
|
||||
setCropKind('banner');
|
||||
}
|
||||
|
||||
async function handleBannerCropConfirm(blob: Blob) {
|
||||
if (!userId) return;
|
||||
setBannerBusy(true);
|
||||
setBannerError(null);
|
||||
try {
|
||||
const newUrl = await uploadBannerBlob(userId, blob);
|
||||
const oldUrl = bannerUrl;
|
||||
await patchProfile({ bannerUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
void deleteBannerObject(oldUrl).catch(() => undefined);
|
||||
}
|
||||
closeCropDialog();
|
||||
} catch (err: unknown) {
|
||||
setBannerError(err instanceof Error ? err.message : 'upload failed');
|
||||
} finally {
|
||||
setBannerBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBannerRemove() {
|
||||
if (!userId || !bannerUrl) return;
|
||||
setBannerError(null);
|
||||
setBannerBusy(true);
|
||||
try {
|
||||
await patchProfile({ bannerUrl: null });
|
||||
void deleteBannerObject(bannerUrl).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setBannerError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setBannerBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayName = profile?.displayName ?? profile?.username;
|
||||
const lockedAll = busy || avatarBusy || bannerBusy;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Live preview — banner with avatar overlapping bottom-left, mirrors
|
||||
how the profile shows up in UserProfilePopover. The avatar row is
|
||||
explicitly stacked above the banner via `relative z-10`; without
|
||||
it, browsers can paint the negatively-margin'd avatar behind the
|
||||
banner's background image when the parent doesn't establish a
|
||||
stacking context. */}
|
||||
<div className="relative overflow-hidden rounded-xl border border-line bg-surface-3">
|
||||
<div
|
||||
className={
|
||||
'relative z-0 aspect-[3/1] w-full bg-cover bg-center ' +
|
||||
(bannerUrl ? '' : DEFAULT_BANNER_CLASS)
|
||||
}
|
||||
style={bannerUrl ? { backgroundImage: 'url("' + bannerUrl + '")' } : undefined}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex items-end gap-3 px-4 pb-3"
|
||||
style={{ marginTop: '-2rem' }}
|
||||
>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
displayName={displayName}
|
||||
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 pb-1">
|
||||
<div className="truncate text-sm font-semibold text-fg">
|
||||
{displayName ?? '—'}
|
||||
</div>
|
||||
{profile?.username && (
|
||||
<div className="truncate text-xs text-fg-muted">@{profile.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Banner controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.banner', { defaultValue: 'Banner' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.banner_hint', {
|
||||
defaultValue: '3:1 Format, max 8 MB. Standard ist ein Farbverlauf.',
|
||||
})}
|
||||
</div>
|
||||
{bannerError && (
|
||||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{bannerError}</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) openBannerCrop(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{bannerBusy
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: bannerUrl
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{bannerUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBannerRemove()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: url
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{url && (
|
||||
{/* Avatar controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
{avatarError && (
|
||||
<div className="mt-1 text-xs text-rose-500 dark:text-rose-300">{avatarError}</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) openAvatarCrop(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy || uploading}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
{avatarBusy
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: avatarUrl
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
)}
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAvatarRemove()}
|
||||
disabled={lockedAll}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageCropDialog
|
||||
open={cropFile !== null && cropKind !== null}
|
||||
file={cropFile}
|
||||
aspect={cropKind === 'banner' ? 3 : 1}
|
||||
outputWidth={cropKind === 'banner' ? BANNER_TARGET_WIDTH : AVATAR_TARGET_DIM}
|
||||
outputHeight={cropKind === 'banner' ? BANNER_TARGET_HEIGHT : AVATAR_TARGET_DIM}
|
||||
title={
|
||||
cropKind === 'banner'
|
||||
? t('app:settings.crop_banner_title', { defaultValue: 'Banner zuschneiden' })
|
||||
: t('app:settings.crop_avatar_title', { defaultValue: 'Profilbild zuschneiden' })
|
||||
}
|
||||
onConfirm={(blob) => {
|
||||
if (cropKind === 'banner') void handleBannerCropConfirm(blob);
|
||||
else if (cropKind === 'avatar') void handleAvatarCropConfirm(blob);
|
||||
}}
|
||||
onClose={closeCropDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -837,6 +1249,49 @@ function SettingRow({ label, children }: { label: string; children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
// Theme picker row inside the Appearance section. Same pill-segmented style
|
||||
// as the language selector so the two siblings read as one control surface.
|
||||
// The toggle was previously a rail icon in the sidebar; moved here so it
|
||||
// sits with the other appearance preferences.
|
||||
function ThemeRow() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const options: Array<{ value: 'light' | 'dark'; label: string }> = [
|
||||
{
|
||||
value: 'light',
|
||||
label: t('app:theme.light', { defaultValue: 'Light' }),
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: t('app:theme.dark', { defaultValue: 'Dark' }),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<SettingRow label={t('app:settings.theme', { defaultValue: 'Design' })}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{options.map((o) => {
|
||||
const active = theme === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(o.value)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
hint,
|
||||
@@ -880,15 +1335,19 @@ function Toggle({
|
||||
|
||||
function AudioDeviceControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
|
||||
const { setAudioInputDevice, setAudioOutputDevice, setVideoInputDevice } = useCall();
|
||||
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||||
const [cameras, setCameras] = useState<MediaDeviceInfo[]>([]);
|
||||
const [inputId, setInputId] = useState<string | null>(
|
||||
() => getAudioSettings().inputDeviceId,
|
||||
);
|
||||
const [outputId, setOutputId] = useState<string | null>(
|
||||
() => getAudioSettings().outputDeviceId,
|
||||
);
|
||||
const [cameraId, setCameraId] = useState<string | null>(
|
||||
() => getAudioSettings().videoInputDeviceId,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
|
||||
'unknown',
|
||||
@@ -899,6 +1358,7 @@ function AudioDeviceControls() {
|
||||
const list = await navigator.mediaDevices.enumerateDevices();
|
||||
setInputs(list.filter((d) => d.kind === 'audioinput'));
|
||||
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
|
||||
setCameras(list.filter((d) => d.kind === 'videoinput'));
|
||||
// If labels are empty, permission hasn't been granted yet — browsers
|
||||
// mask device names until a getUserMedia call succeeds at least once.
|
||||
const hasLabels = list.some(
|
||||
@@ -921,6 +1381,7 @@ function AudioDeviceControls() {
|
||||
const unsubSettings = subscribeAudioSettings((s) => {
|
||||
setInputId(s.inputDeviceId);
|
||||
setOutputId(s.outputDeviceId);
|
||||
setCameraId(s.videoInputDeviceId);
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -963,6 +1424,15 @@ function AudioDeviceControls() {
|
||||
[setAudioOutputDevice],
|
||||
);
|
||||
|
||||
const handleCamera = useCallback(
|
||||
async (id: string) => {
|
||||
const next = id === '' ? null : id;
|
||||
setCameraId(next);
|
||||
await setVideoInputDevice(next);
|
||||
},
|
||||
[setVideoInputDevice],
|
||||
);
|
||||
|
||||
const outputSupported =
|
||||
typeof HTMLAudioElement !== 'undefined' &&
|
||||
typeof HTMLAudioElement.prototype.setSinkId === 'function';
|
||||
@@ -1039,6 +1509,38 @@ function AudioDeviceControls() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">
|
||||
{t('app:settings.camera_title', { defaultValue: 'Kamera' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:settings.camera_hint', {
|
||||
defaultValue:
|
||||
'Bevorzugte Kamera. Bei aktivem Anruf wird live umgeschaltet.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
value={cameraId ?? ''}
|
||||
onChange={(e) => void handleCamera(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
>
|
||||
<option value="">
|
||||
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||
</option>
|
||||
{cameras.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line pt-3">
|
||||
<MicTestSection />
|
||||
</div>
|
||||
|
||||
{permission !== 'granted' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user