feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled

Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
  CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
  - useCallPresence: bind presence callbacks only when we own subscribe
    (Supabase forbids .on() after .subscribe() on shared dedup'd channels)
  - useCallPresence: never removeChannel — channel is shared with CallContext
    so tearing it down on ConversationHeader unmount killed live tracking
  - ActiveCallBanner: lastCallConversationId fallback so banner shows
    instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall

Bump tauri version 0.5.0 -> 0.6.0
This commit is contained in:
2026-04-20 00:26:19 +02:00
parent 0ca29952ba
commit 4db65993d5
33 changed files with 2459 additions and 1094 deletions
+40 -45
View File
@@ -10,7 +10,9 @@ import {
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '../components/Avatar';
import {
AlertIcon,
ChatBubbleIcon,
@@ -28,6 +30,7 @@ type Tab = 'friends' | 'pending' | 'requests';
export function FriendsPage() {
const { t } = useTranslation(['app', 'errors']);
const { friendships, loading, error, refresh } = useFriendshipsContext();
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('friends');
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
@@ -104,16 +107,17 @@ export function FriendsPage() {
);
return (
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<div className="h-full bg-surface-3 text-fg">
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:friends.title')}
</h1>
</header>
<div className="space-y-3">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
<input
type="search"
value={query}
@@ -122,7 +126,7 @@ export function FriendsPage() {
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
className="w-full rounded-lg border border-line bg-surface-2 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
{query.length > 0 && (
@@ -138,7 +142,7 @@ export function FriendsPage() {
)}
</div>
<div className="flex gap-2 border-b border-white/5">
<div className="flex gap-2 border-b border-line">
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
{t('app:friends.tab_friends')} · {accepted.length}
</TabButton>
@@ -165,7 +169,7 @@ export function FriendsPage() {
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigateToChat(id);
navigate('/chats/' + id);
})
}
onUnfriend={() =>
@@ -206,16 +210,11 @@ export function FriendsPage() {
)}
/>
)}
</div>
</div>
);
}
function navigateToChat(id: string): void {
// Push history + dispatch popstate so React Router re-evaluates the route.
window.history.pushState(null, '', '/chats/' + id);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const code = extractErrorCode(err);
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
@@ -241,8 +240,8 @@ function TabButton({
className={
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
(active
? 'border-brand-400 text-white'
: 'border-transparent text-neutral-400 hover:text-neutral-200')
? 'border-accent text-fg'
: 'border-transparent text-fg-muted hover:text-fg')
}
>
{children}
@@ -262,10 +261,11 @@ function FriendList({
const { t } = useTranslation(['app']);
if (items.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400">
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-fg-muted">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-2 text-fg-muted">
<UsersIcon className="h-5 w-5" />
</div>
<p>{t(emptyKey)}</p>
</div>
);
@@ -283,26 +283,21 @@ function FriendList({
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm">
<Avatar profile={profile} />
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<Avatar
url={profile.avatarUrl}
displayName={profile.displayName ?? profile.username}
className="h-9 w-9 text-sm"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
<p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
<p className="truncate text-xs text-fg-muted">@{profile.username}</p>
</div>
<div className="flex shrink-0 gap-2">{children}</div>
</div>
);
}
function Avatar({ profile }: { profile: ProfileBrief }) {
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter || '?'}
</div>
);
}
function FriendActions({
busy,
onMessage,
@@ -371,7 +366,7 @@ function PrimaryButton({
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
<span>{children}</span>
@@ -393,7 +388,7 @@ function SecondaryButton({
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg transition hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
@@ -415,7 +410,7 @@ function DangerButton({
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
@@ -425,8 +420,8 @@ function DangerButton({
function LoadingRow() {
return (
<div className="flex items-center gap-3 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
<div className="flex items-center gap-3 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4 text-accent" />
<span></span>
</div>
);
@@ -440,14 +435,14 @@ function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.Re
className={
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
(isError
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
}
>
{isError ? (
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
) : (
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
)}
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
@@ -474,24 +469,24 @@ function SearchResults({
const { t } = useTranslation(['app']);
if (query.length < 2) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>;
return <p className="text-xs text-fg-muted">{t('app:friends.search_min_chars')}</p>;
}
if (searching) {
return (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
<div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
<span></span>
</div>
);
}
if (error) return <Banner kind="error">{error}</Banner>;
if (results.length === 0) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>;
return <p className="text-xs text-fg-muted">{t('app:friends.search_no_results')}</p>;
}
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
<p className="mb-2 text-xs font-medium uppercase tracking-[0.1em] text-fg-muted">
{t('app:friends.search_results_title')}
</p>
<ul className="space-y-1.5">
@@ -530,7 +525,7 @@ function SearchActionButton({
if (existing?.status === 'accepted') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-200">
<CheckCircleIcon className="h-3.5 w-3.5" />
{t('app:friends.already_friends')}
</span>
@@ -539,7 +534,7 @@ function SearchActionButton({
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
<span className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg-muted">
{t('app:friends.request_sent')}
</span>
);