- {inv.code}
- {usesText}
- {expiresText}
+ {inv.code}
+ {usesText}
+ {expiresText}
{status}
@@ -260,7 +263,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
void handleToggle(inv.code, inv.disabled)}
- className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10"
+ className="rounded-md border border-line bg-surface-3 px-2 py-1 text-[11px] font-medium text-fg transition hover:brightness-95"
>
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
@@ -301,20 +304,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
return (
{users.length === 0 ? (
- {t('app:admin.users_empty')}
+ {t('app:admin.users_empty')}
) : (
-
+
{users.map((u) => {
- const letter =
- (u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
-
- {letter}
-
+
-
{u.displayName}
-
@{u.username}
+
{u.displayName}
+
@{u.username}
- {label}
- {hint && {hint} }
+ {label}
+ {hint && {hint} }
onChange(e.target.checked)}
className="peer sr-only"
/>
-
-
+
+
);
@@ -399,8 +401,8 @@ function IconButton({
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
(tone === 'danger'
- ? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
- : 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40')
+ ? 'border-rose-500/40 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 focus-visible:ring-rose-400/40 dark:text-rose-300'
+ : 'border-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/40')
}
>
{children}
@@ -421,15 +423,15 @@ function FlagChip({
}) {
const activeClass =
tone === 'danger'
- ? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
- : 'border-brand-400/40 bg-brand-500/20 text-brand-100';
+ ? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
+ : 'border-accent/40 bg-accent/20 text-accent';
return (
onToggle(!active)}
className={
- 'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
- (active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
+ 'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
+ (active ? activeClass : 'border-line bg-surface-3 text-fg-muted hover:brightness-95')
}
>
{label}
diff --git a/apps/desktop/src/pages/ChatsPage.tsx b/apps/desktop/src/pages/ChatsPage.tsx
index 81b3b81..755c979 100644
--- a/apps/desktop/src/pages/ChatsPage.tsx
+++ b/apps/desktop/src/pages/ChatsPage.tsx
@@ -5,7 +5,14 @@ import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { CreateGroupDialog } from '../components/CreateGroupDialog';
-import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons';
+import {
+ AddUserIcon,
+ ChatBubbleIcon,
+ SearchIcon,
+ SpinnerIcon,
+ UsersIcon,
+} from '../components/icons';
+import { UserBar } from '../components/UserBar';
import { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase';
@@ -13,6 +20,7 @@ export function ChatsPage() {
const { conversations, loading, error, refresh, unread } = useConversationsContext();
const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false);
+ const [query, setQuery] = useState('');
const sorted = useMemo(() => {
return [...conversations].sort((a, b) => {
@@ -22,14 +30,26 @@ export function ChatsPage() {
});
}, [conversations]);
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ 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 ?? '' : '';
+ return title.toLowerCase().includes(q) || handle.toLowerCase().includes(q);
+ });
+ }, [sorted, query]);
+
return (
{
try {
await acceptDm(supabase, id);
@@ -41,7 +61,7 @@ export function ChatsPage() {
}}
onNewGroup={() => setCreateOpen(true)}
/>
-
+
setCreateOpen(false)} />
@@ -49,32 +69,38 @@ export function ChatsPage() {
);
}
+interface ConversationListProps {
+ items: ConversationSummary[];
+ loading: boolean;
+ error: string | null;
+ activeId: string | undefined;
+ unread: Record;
+ query: string;
+ onQueryChange: (q: string) => void;
+ onAccept: (id: string) => void;
+ onNewGroup: () => void;
+}
+
function ConversationList({
items,
loading,
error,
activeId,
unread,
+ query,
+ onQueryChange,
onAccept,
onNewGroup,
-}: {
- items: ConversationSummary[];
- loading: boolean;
- error: string | null;
- activeId: string | undefined;
- unread: Record;
- onAccept: (id: string) => void;
- onNewGroup: () => void;
-}) {
+}: ConversationListProps) {
const { t } = useTranslation(['app']);
return (
-
+
{t('app:nav.chats')}
-
-
+
+
+
+ {t('app:chats.search', { defaultValue: 'Suche' })}
+
+ 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"
+ />
+
+
+
{loading ? (
-
-
+
+
) : error ? (
-
{error}
+
{error}
) : items.length === 0 ? (
-
+
-
{t('app:chats.empty_title')}
-
{t('app:chats.empty_subtitle')}
+
{t('app:chats.empty_title')}
+
{t('app:chats.empty_subtitle')}
) : (
-
+
{items.map((c) => (
)}
+
+
+
+
);
}
@@ -136,16 +182,22 @@ function ConversationRow({
const title =
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
- const letter = title.trim().charAt(0).toUpperCase() || '?';
+ 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`,
+ });
if (!item.acceptedByMe) {
return (
-
+
-
+
-
{title}
-
+
{title}
+
{t('app:friends.incoming_request')}
@@ -153,7 +205,7 @@ function ConversationRow({
onAccept(item.id)}
- className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40"
+ className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 dark:text-amber-100"
>
{t('app:friends.action_accept')}
@@ -165,26 +217,28 @@ function ConversationRow({
-
+
0 ? 'font-bold text-white' : 'font-semibold text-white')
+ (unreadCount > 0 ? 'font-bold' : 'font-semibold')
}
>
{title}
-
{handle}
+
{preview}
{unreadCount > 0 && (
{unreadCount > 99 ? '99+' : unreadCount}
@@ -193,9 +247,35 @@ function ConversationRow({
);
}
-function Avatar({ letter }: { letter: string }) {
+function ConvAvatar({
+ url,
+ title,
+ isGroup,
+}: {
+ url: string | null;
+ title: string;
+ isGroup: boolean;
+}) {
+ if (url) {
+ return (
+
+ );
+ }
+ if (isGroup) {
+ return (
+
+
+
+ );
+ }
+ const letter = title.trim().charAt(0).toUpperCase() || '?';
return (
-
+
{letter}
);
@@ -206,13 +286,13 @@ export function ChatsEmptyState() {
return (
-
+
-
+
{t('app:chats.select_prompt')}
- {t('app:chats.select_subtitle')}
+
{t('app:chats.select_subtitle')}
);
diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx
index 9c52f38..0a51cac 100644
--- a/apps/desktop/src/pages/ConversationPage.tsx
+++ b/apps/desktop/src/pages/ConversationPage.tsx
@@ -7,9 +7,11 @@ import { ConversationHeader } from '../components/ConversationHeader';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
+import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MessageBubble } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator';
import { useAuth } from '../context/AuthContext';
+import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
@@ -157,14 +159,27 @@ export function ConversationPage() {
}
const isGroup = conversation?.type === 'group';
+ const { state: callState } = useCall();
+ // Hide the chat header while this conversation hosts an active call — the
+ // call topbar inside the dock already shows the channel name + duration,
+ // 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;
return (
-
setInfoPanelOpen(true) } : {})}
- />
+ {!callHereActive && (
+ setInfoPanelOpen(true) } : {})}
+ />
+ )}
{isGroup && conversation && (
)}
+ {incomingHere && conversation && }
{conversation && }
-
+
{loading ? (
-
-
+
+
) : error ? (
{error}
) : messages.length === 0 ? (
-
…
+
…
) : (
{messages.map((m, idx) => {
const prev = messages[idx - 1];
+ const next = messages[idx + 1];
const grouped = idx > 0 && prev?.senderId === m.senderId;
+ const isLastOfRun = !next || next.senderId !== m.senderId;
+ const senderProfile =
+ conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
return (
toggleReaction(m.id, emoji)}
@@ -213,7 +236,7 @@ export function ConversationPage() {
members={conversation?.members ?? []}
/>
-