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:
@@ -0,0 +1,254 @@
|
||||
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
|
||||
|
||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||
|
||||
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
|
||||
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
|
||||
// light and dark modes. Dark pairs invert to keep contrast readable.
|
||||
violet: {
|
||||
bg: 'bg-violet-200 dark:bg-violet-900/60',
|
||||
fg: 'text-violet-800 dark:text-violet-200',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-200 dark:bg-amber-900/60',
|
||||
fg: 'text-amber-900 dark:text-amber-200',
|
||||
},
|
||||
rose: {
|
||||
bg: 'bg-rose-200 dark:bg-rose-900/60',
|
||||
fg: 'text-rose-900 dark:text-rose-200',
|
||||
},
|
||||
teal: {
|
||||
bg: 'bg-teal-200 dark:bg-teal-900/60',
|
||||
fg: 'text-teal-900 dark:text-teal-200',
|
||||
},
|
||||
};
|
||||
|
||||
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
|
||||
|
||||
// Stable per-user color from identity string — keeps avatars visually
|
||||
// consistent across re-mounts without needing a color field on the profile.
|
||||
export function colorKeyFor(id: string): AvatarColorKey {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
|
||||
}
|
||||
|
||||
export interface ParticipantTileProps {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
me: boolean;
|
||||
muted: boolean;
|
||||
speaking: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
e2ee: boolean;
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onOpenScreenShare?: () => void;
|
||||
}
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
const {
|
||||
displayName,
|
||||
me,
|
||||
muted,
|
||||
speaking,
|
||||
sharing,
|
||||
video,
|
||||
e2ee,
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
onOpenScreenShare,
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
const borderClass = speaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||
: focused
|
||||
? 'border-accent'
|
||||
: 'border-line hover:border-accent';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||
(onClick ? 'cursor-pointer ' : '') +
|
||||
borderClass +
|
||||
(small ? ' min-w-[140px]' : '')
|
||||
}
|
||||
>
|
||||
{sharing ? (
|
||||
<ScreenshareStub
|
||||
displayName={displayName}
|
||||
small={small}
|
||||
onOpen={onOpenScreenShare}
|
||||
/>
|
||||
) : video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{me ? ' (du)' : ''}
|
||||
</span>
|
||||
{e2ee && (
|
||||
<span
|
||||
aria-label="E2E verschlüsselt"
|
||||
title="E2E verschlüsselt"
|
||||
className="flex opacity-70"
|
||||
>
|
||||
<LockIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{muted && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white">
|
||||
<MicOffIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
{sharing && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white">
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioContent({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
speaking,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
const key = colorKeyFor(userId);
|
||||
const colors = AVATAR_COLORS[key];
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
|
||||
<div
|
||||
className={
|
||||
'relative flex items-center justify-center rounded-full ' +
|
||||
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
||||
}
|
||||
>
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||
/>
|
||||
)}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="relative h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
|
||||
colors.bg + ' ' + colors.fg + ' ' +
|
||||
(small ? 'text-lg' : 'text-2xl')
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoStub({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
// Video capture playback is out of scope for this UI iteration — show the
|
||||
// audio-avatar gradient background as a placeholder so the layout is stable
|
||||
// when a participant enables video.
|
||||
const key = colorKeyFor(userId);
|
||||
const colors = AVATAR_COLORS[key];
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center rounded-full font-bold ' +
|
||||
colors.bg + ' ' + colors.fg + ' ' +
|
||||
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
||||
) : (
|
||||
letter
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenshareStubProps {
|
||||
displayName: string;
|
||||
small: boolean;
|
||||
onOpen?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
// Fake browser window placeholder — click to open the real <video> viewer.
|
||||
// Matches the design spec's "screenshare-stub" look.
|
||||
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
onOpen
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-label={`${displayName} teilt Bildschirm`}
|
||||
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
|
||||
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
</div>
|
||||
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
|
||||
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
|
||||
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
</div>
|
||||
</div>
|
||||
{!small && (
|
||||
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
<span>{displayName} teilt Bildschirm</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user