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:
@@ -4,107 +4,176 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { CallBar } from './CallUI';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
ChatBubbleIcon,
|
||||
GearIcon,
|
||||
LogoLockup,
|
||||
LogoMark,
|
||||
MoonIcon,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
SunIcon,
|
||||
UsersIcon,
|
||||
} from './icons';
|
||||
import { UserBar } from './UserBar';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
badge?: 'friends' | 'chats';
|
||||
}
|
||||
|
||||
const BASE_NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
|
||||
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
|
||||
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
|
||||
const PRIMARY_NAV: NavItem[] = [
|
||||
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
|
||||
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
|
||||
];
|
||||
const ADMIN_NAV_ITEM: NavItem = {
|
||||
const ADMIN_NAV: NavItem = {
|
||||
to: '/admin',
|
||||
labelKey: 'app:nav.admin',
|
||||
icon: ShieldIcon,
|
||||
};
|
||||
|
||||
// Clean-Rail 72px icon rail. Brand logo top → primary tabs → spacer →
|
||||
// settings + theme toggle + sign-out at bottom. Active tab shows a pill
|
||||
// indicator anchored to the left edge of the rail.
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { signOut, profile } = useAuth();
|
||||
const { incomingCount } = useFriendshipsContext();
|
||||
const { totalUnread } = useConversationsContext();
|
||||
const { theme, toggle } = useTheme();
|
||||
|
||||
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS;
|
||||
const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
|
||||
className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
|
||||
>
|
||||
<div className="flex items-center px-5 pb-3 pt-5">
|
||||
<LogoLockup tone="dark" className="h-8 w-auto" aria-label="Netralax" />
|
||||
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
|
||||
<LogoMark className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<nav className="mt-2 flex flex-col gap-0.5 px-3">
|
||||
<div className="my-3 h-px w-8 bg-line" aria-hidden="true" />
|
||||
|
||||
<nav className="flex flex-col items-center gap-2">
|
||||
{navItems.map((item) => {
|
||||
const badge =
|
||||
item.to === '/friends'
|
||||
const badgeCount =
|
||||
item.badge === 'friends'
|
||||
? incomingCount
|
||||
: item.to === '/chats'
|
||||
: item.badge === 'chats'
|
||||
? totalUnread
|
||||
: 0;
|
||||
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
|
||||
return (
|
||||
<NavLink
|
||||
<RailNavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
aria-label={ariaLabel}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
|
||||
isActive
|
||||
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
|
||||
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
|
||||
<span className="flex-1">{t(item.labelKey)}</span>
|
||||
{badge > 0 && <NavBadge count={badge} />}
|
||||
</NavLink>
|
||||
label={t(item.labelKey)}
|
||||
icon={item.icon}
|
||||
badge={badgeCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="border-t border-white/5 p-3">
|
||||
<CallBar />
|
||||
<UserBar />
|
||||
<button
|
||||
type="button"
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RailNavLink
|
||||
to="/settings"
|
||||
label={t('app:nav.settings')}
|
||||
icon={GearIcon}
|
||||
/>
|
||||
<RailIconButton
|
||||
label={theme === 'dark' ? t('app:theme.light', { defaultValue: 'Light mode' }) : t('app:theme.dark', { defaultValue: 'Dark mode' })}
|
||||
onClick={toggle}
|
||||
icon={theme === 'dark' ? SunIcon : MoonIcon}
|
||||
/>
|
||||
<RailIconButton
|
||||
label={t('app:sidebar.sign_out')}
|
||||
onClick={() => void signOut()}
|
||||
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
||||
>
|
||||
<SignOutIcon className="h-4 w-4" />
|
||||
<span>{t('app:sidebar.sign_out')}</span>
|
||||
</button>
|
||||
icon={SignOutIcon}
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBadge({ count }: { count: number }) {
|
||||
interface RailNavLinkProps {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
||||
const ariaLabel = badge > 0 ? `${label} (${badge})` : label;
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
aria-label={ariaLabel}
|
||||
title={label}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||
isActive
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -left-4 h-6 w-1 rounded-r-full bg-accent"
|
||||
/>
|
||||
)}
|
||||
<Icon style={{ width: '20px', height: '20px' }} />
|
||||
{badge > 0 && <RailBadge count={badge} />}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface RailIconButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
tone?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'text-fg-muted hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
|
||||
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg focus-visible:ring-accent/50';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition focus:outline-none focus-visible:ring-2 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
<Icon style={{ width: '20px', height: '20px' }} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RailBadge({ count }: { count: number }) {
|
||||
const display = count > 99 ? '99+' : String(count);
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]"
|
||||
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2"
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user