feat: redesign + avatars + theme + call presence fixes (v0.6.0)
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:
@@ -16,6 +16,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import {
|
||||
AlertIcon,
|
||||
CopyIcon,
|
||||
@@ -64,9 +65,10 @@ export function AdminPage() {
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<header>
|
||||
<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:admin.title')}
|
||||
</h1>
|
||||
</header>
|
||||
@@ -74,16 +76,16 @@ export function AdminPage() {
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
||||
>
|
||||
<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" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -92,15 +94,16 @@ export function AdminPage() {
|
||||
<UsersSection users={users} onRefresh={refresh} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">{title}</h2>
|
||||
{action}
|
||||
</header>
|
||||
{children}
|
||||
@@ -199,7 +202,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={busy}
|
||||
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 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 disabled:opacity-60"
|
||||
>
|
||||
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
|
||||
<span>{t('app:admin.invites_create')}</span>
|
||||
@@ -207,11 +210,11 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
}
|
||||
>
|
||||
{invites.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p>
|
||||
<p className="text-sm text-fg-muted">{t('app:admin.invites_empty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-[10px] uppercase tracking-wide text-neutral-500">
|
||||
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
|
||||
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
|
||||
@@ -220,7 +223,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
<tbody className="divide-y divide-line">
|
||||
{invites.map((inv) => {
|
||||
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
|
||||
const status = inv.disabled
|
||||
@@ -229,10 +232,10 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
? t('app:admin.invite_status_expired')
|
||||
: t('app:admin.invite_status_active');
|
||||
const statusColor = inv.disabled
|
||||
? 'text-rose-300'
|
||||
? 'text-rose-600 dark:text-rose-300'
|
||||
: expired
|
||||
? 'text-amber-300'
|
||||
: 'text-emerald-300';
|
||||
? 'text-amber-600 dark:text-amber-300'
|
||||
: 'text-emerald-600 dark:text-emerald-300';
|
||||
const usesText = inv.usesLimit
|
||||
? inv.usesCount + '/' + inv.usesLimit
|
||||
: inv.usesCount + '/∞';
|
||||
@@ -241,9 +244,9 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
: t('app:admin.invite_expires_never');
|
||||
return (
|
||||
<tr key={inv.code} className="py-2">
|
||||
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td>
|
||||
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td>
|
||||
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-fg">{inv.code}</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">{usesText}</td>
|
||||
<td className="py-2 pr-3 text-xs text-fg-muted">{expiresText}</td>
|
||||
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
|
||||
<td className="py-2 text-right">
|
||||
<div className="inline-flex gap-1">
|
||||
@@ -260,7 +263,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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')}
|
||||
</button>
|
||||
@@ -301,20 +304,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
||||
return (
|
||||
<Section title={t('app:admin.users_title')}>
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p>
|
||||
<p className="text-sm text-fg-muted">{t('app:admin.users_empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/5">
|
||||
<ul className="divide-y divide-line">
|
||||
{users.map((u) => {
|
||||
const letter =
|
||||
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<li key={u.userId} className="flex items-center gap-3 py-3">
|
||||
<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>
|
||||
<Avatar
|
||||
displayName={u.displayName ?? u.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
|
||||
<p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
|
||||
<p className="truncate text-xs text-fg-muted">@{u.username}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FlagChip
|
||||
@@ -361,8 +363,8 @@ function Toggle({
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||||
<span className="block text-sm text-fg">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||
</span>
|
||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||
<input
|
||||
@@ -372,8 +374,8 @@ function Toggle({
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
<span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
|
||||
Reference in New Issue
Block a user