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:
@@ -21,7 +21,7 @@ export function AppShell() {
|
||||
}, [session?.user.id, device?.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||
<ShellBackground />
|
||||
<div className="relative z-10 flex min-h-screen w-full">
|
||||
<Sidebar />
|
||||
@@ -36,11 +36,11 @@ export function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
|
||||
// No animation here so message lists stay readable.
|
||||
// Subtle ambient background — only renders in dark mode where the app's
|
||||
// visual language expects depth. Light mode stays clean and flat.
|
||||
function ShellBackground() {
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
|
||||
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
|
||||
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
|
||||
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||
// letter circle. Use this everywhere the app needs to render a profile.
|
||||
|
||||
interface Props {
|
||||
url?: string | null | undefined;
|
||||
displayName?: string | null | undefined;
|
||||
className?: string;
|
||||
// Background gradient classes used when no image is set. Defaults to a
|
||||
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
||||
fallbackClass?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
url,
|
||||
displayName,
|
||||
className = 'h-10 w-10',
|
||||
fallbackClass = 'bg-accent/20 text-accent',
|
||||
alt,
|
||||
}: Props) {
|
||||
if (url) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt ?? displayName ?? ''}
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const letter = (displayName ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div
|
||||
aria-hidden={alt ? undefined : 'true'}
|
||||
className={
|
||||
'flex shrink-0 items-center justify-center rounded-full font-semibold ' +
|
||||
fallbackClass +
|
||||
' ' +
|
||||
className
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
PhoneOffIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
muted: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
onToggleMute: () => void;
|
||||
onToggleShare: () => void;
|
||||
onToggleVideo?: () => void;
|
||||
onHangup: () => void;
|
||||
onOpenParticipants?: () => void;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||
glass?: boolean;
|
||||
/** Disable transient interactions while the call isn't fully connected. */
|
||||
disabledMedia?: boolean;
|
||||
}
|
||||
|
||||
export function CallControls({
|
||||
muted,
|
||||
sharing,
|
||||
video,
|
||||
onToggleMute,
|
||||
onToggleShare,
|
||||
onToggleVideo,
|
||||
onHangup,
|
||||
onOpenParticipants,
|
||||
compact = false,
|
||||
glass = false,
|
||||
disabledMedia = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const wrapBase = glass
|
||||
? 'glass-pill flex items-center justify-center gap-2 rounded-[18px] p-2.5'
|
||||
: 'flex items-center justify-center gap-2 border-t border-line bg-surface-3 p-3';
|
||||
const btnSize = compact ? 'h-9 w-9 rounded-[10px]' : 'h-12 w-12 rounded-[14px]';
|
||||
const hangupSize = compact ? 'h-9 w-[54px] rounded-[10px]' : 'h-12 w-[72px] rounded-[14px]';
|
||||
return (
|
||||
<div className={wrapBase}>
|
||||
<CallButton
|
||||
label={muted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
active={muted}
|
||||
activeTone="danger"
|
||||
onClick={onToggleMute}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</CallButton>
|
||||
{onToggleVideo && (
|
||||
<CallButton
|
||||
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
||||
active={video}
|
||||
activeTone="accent"
|
||||
onClick={onToggleVideo}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<VideoIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
<CallButton
|
||||
label={
|
||||
sharing
|
||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||
}
|
||||
active={sharing}
|
||||
activeTone="accent"
|
||||
onClick={onToggleShare}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
{sharing ? (
|
||||
<MonitorStopIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</CallButton>
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
onClick={onOpenParticipants}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
<CallButton
|
||||
label={t('app:call.hangup')}
|
||||
onClick={onHangup}
|
||||
tone="danger"
|
||||
glass={glass}
|
||||
className={hangupSize}
|
||||
>
|
||||
<PhoneOffIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CallButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
activeTone?: 'accent' | 'danger';
|
||||
tone?: 'default' | 'danger';
|
||||
glass?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CallButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
active,
|
||||
activeTone = 'accent',
|
||||
tone = 'default',
|
||||
glass = false,
|
||||
className = '',
|
||||
children,
|
||||
}: CallButtonProps) {
|
||||
const base =
|
||||
'inline-flex cursor-pointer items-center justify-center border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
let toneClass: string;
|
||||
if (tone === 'danger') {
|
||||
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
||||
} else if (active && activeTone === 'danger') {
|
||||
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
||||
} else if (active) {
|
||||
toneClass = 'bg-accent text-accent-fg border-accent hover:brightness-110';
|
||||
} else if (glass) {
|
||||
toneClass =
|
||||
'border-white/15 bg-white/10 text-white hover:bg-white/15';
|
||||
} else {
|
||||
toneClass = 'border-line bg-surface-2 text-fg hover:bg-surface';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={`${base} ${toneClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { ringtone } from '../lib/ringtone';
|
||||
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import {
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from './icons';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
// Shell-level mount. Handles ringtones + the IncomingCallToast.
|
||||
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
|
||||
// users can keep browsing/typing while a call is live.
|
||||
// Shell-level mount. Responsibilities:
|
||||
// - Start/stop ringtones by call-state transition.
|
||||
// - Toast-style incoming-call banner for conversations the user isn't in
|
||||
// (clicking the toast routes to the docked IncomingCallPanel).
|
||||
// - PiP widget when an active call is live but the user is looking at a
|
||||
// different route.
|
||||
export function CallUI() {
|
||||
const { state } = useCall();
|
||||
|
||||
@@ -34,19 +28,37 @@ export function CallUI() {
|
||||
return () => ringtone.stop();
|
||||
}, []);
|
||||
|
||||
return <IncomingCallToast />;
|
||||
return (
|
||||
<>
|
||||
<IncomingCallToast />
|
||||
<PipCall />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOAST_AVATAR: Record<AvatarColorKey, string> = {
|
||||
violet: 'bg-violet-500/90 text-white',
|
||||
amber: 'bg-amber-500/90 text-white',
|
||||
rose: 'bg-rose-500/90 text-white',
|
||||
teal: 'bg-teal-500/90 text-white',
|
||||
};
|
||||
|
||||
function IncomingCallToast() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const { friendships } = useFriendshipsContext();
|
||||
const { conversations } = useConversationsContext();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
if (state.kind !== 'incoming') return null;
|
||||
|
||||
// When the user is already looking at the target conversation, the docked
|
||||
// IncomingCallPanel handles the UI; hide the toast to avoid double chrome.
|
||||
if (location.pathname === '/chats/' + state.conversationId) return null;
|
||||
|
||||
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
||||
const callerName =
|
||||
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
|
||||
@@ -54,44 +66,55 @@ function IncomingCallToast() {
|
||||
'?';
|
||||
const isGroup = conv?.type === 'group';
|
||||
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
|
||||
const letter = (isGroup ? groupName ?? callerName : callerName)
|
||||
.trim()
|
||||
.charAt(0)
|
||||
.toUpperCase() || '?';
|
||||
const title = isGroup ? groupName ?? callerName : callerName;
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const color = colorKeyFor(state.fromUserId);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label={t('app:call.incoming_title')}
|
||||
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
|
||||
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-call-card-dark"
|
||||
>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
{t('app:call.incoming_title')}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/chats/' + state.conversationId)}
|
||||
className="group block w-full cursor-pointer p-5 text-left transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.incoming_title')}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<div
|
||||
className={
|
||||
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
|
||||
TOAST_AVATAR[color]
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
|
||||
<LockIcon className="h-3 w-3" />
|
||||
<span>
|
||||
{isGroup
|
||||
? t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft Gruppe',
|
||||
})
|
||||
: t('app:call.incoming_from', { name: callerName })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-white">
|
||||
{isGroup ? groupName : callerName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{isGroup
|
||||
? t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft Gruppe',
|
||||
})
|
||||
: t('app:call.incoming_from', { name: callerName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
</button>
|
||||
<div className="flex gap-2 px-5 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={rejectIncoming}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.decline')}</span>
|
||||
@@ -99,7 +122,7 @@ function IncomingCallToast() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void acceptIncoming()}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-500/90 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.accept')}</span>
|
||||
@@ -110,226 +133,77 @@ function IncomingCallToast() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
|
||||
// the app while a call is active / connecting / ringing.
|
||||
// PiP widget — shown when the user has an active call but is browsing
|
||||
// somewhere else. Clicking expands back to the call's conversation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CallBar() {
|
||||
const { state, lastCallConversationId } = useCall();
|
||||
const { conversations } = useConversationsContext();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
|
||||
// Aggregate observer across every conversation the user is in so a
|
||||
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
|
||||
// peer is in a call — not just calls I previously joined.
|
||||
const convIds = conversations.map((c) => c.id);
|
||||
const anyActive = useAnyActiveCall(convIds, myId);
|
||||
|
||||
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
|
||||
return <ActiveCallBar />;
|
||||
}
|
||||
// Prefer the conversation I just left, fall back to any other live call.
|
||||
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
|
||||
if (rejoinId) {
|
||||
return <RejoinCallBar conversationId={rejoinId} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ActiveCallBar() {
|
||||
function PipCall() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, isMuted, hangup, toggleMute } = useCall();
|
||||
const {
|
||||
state,
|
||||
remoteParticipants,
|
||||
remoteScreenShares,
|
||||
hangup,
|
||||
} = useCall();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { conversations } = useConversationsContext();
|
||||
|
||||
const [, forceTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state.kind]);
|
||||
const active =
|
||||
state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing';
|
||||
if (!active) return null;
|
||||
|
||||
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
|
||||
return null;
|
||||
}
|
||||
// Only render when the user is NOT currently viewing the call's
|
||||
// conversation. Inside that conversation the full dock is visible already.
|
||||
if (location.pathname === '/chats/' + state.conversationId) return null;
|
||||
|
||||
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
||||
const title =
|
||||
conv?.type === 'group'
|
||||
? conv.name ?? '—'
|
||||
? conv?.name ?? t('app:chats.new_group')
|
||||
: conv?.peer?.displayName ?? '—';
|
||||
|
||||
const statusLabel =
|
||||
state.kind === 'outgoing'
|
||||
? t('app:call.outgoing_ringing')
|
||||
: state.kind === 'connecting'
|
||||
? t('app:call.connecting')
|
||||
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
|
||||
|
||||
const elapsed =
|
||||
state.kind === 'connected'
|
||||
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
|
||||
: null;
|
||||
const participantCount = 1 + remoteParticipants.length;
|
||||
const someoneSharing = remoteScreenShares.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
||||
<Link
|
||||
to={'/chats/' + state.conversationId}
|
||||
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
||||
{state.kind === 'connecting' ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<SignalIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{title}
|
||||
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
|
||||
<IconTile
|
||||
onClick={toggleMute}
|
||||
disabled={state.kind !== 'connected'}
|
||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
tone={isMuted ? 'amber' : 'neutral'}
|
||||
>
|
||||
{isMuted ? <MicOffIcon className="h-4 w-4" /> : <MicIcon className="h-4 w-4" />}
|
||||
</IconTile>
|
||||
<IconTile onClick={() => void hangup()} label={t('app:call.hangup')} tone="rose">
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
</IconTile>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RejoinCallBar({ conversationId }: { conversationId: string }) {
|
||||
const { t } = useTranslation(['app', 'common']);
|
||||
const { joinActiveCall, dismissLastCall } = useCall();
|
||||
const { conversations } = useConversationsContext();
|
||||
const { session } = useAuth();
|
||||
const active = useCallPresence(conversationId);
|
||||
|
||||
const myId = session?.user.id;
|
||||
const others = active.filter((u) => u !== myId);
|
||||
|
||||
// Presence polling returns [] on the first tick before it syncs, so we can't
|
||||
// treat an initial empty list as "room is empty". Only dismiss after we've
|
||||
// actually seen peers and then watched them leave — and even then confirm
|
||||
// the empty state for a grace period, since presence_diff events can
|
||||
// arrive slightly out of order.
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (others.length > 0) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
if (!visible) return;
|
||||
const id = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
dismissLastCall();
|
||||
}, 2500);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [others.length, visible, dismissLastCall]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const conv = conversations.find((c) => c.id === conversationId) ?? null;
|
||||
const title =
|
||||
conv?.type === 'group'
|
||||
? conv.name ?? '—'
|
||||
: conv?.peer?.displayName ?? '—';
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
||||
<Link
|
||||
to={'/chats/' + conversationId}
|
||||
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
||||
<SignalIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-emerald-300">
|
||||
{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{title} · {others.length}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
|
||||
<IconTile
|
||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
||||
label={t('app:call.join')}
|
||||
tone="emerald"
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
</IconTile>
|
||||
<IconTile onClick={dismissLastCall} label={t('common:close', { defaultValue: 'Schließen' })} tone="neutral">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</IconTile>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IconTileProps {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) {
|
||||
const toneClass =
|
||||
tone === 'rose'
|
||||
? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
|
||||
: tone === 'amber'
|
||||
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40'
|
||||
: tone === 'emerald'
|
||||
? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40'
|
||||
: 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'inline-flex h-9 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
toneClass
|
||||
}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
|
||||
onClick={() => navigate('/chats/' + state.conversationId)}
|
||||
className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
|
||||
{someoneSharing ? (
|
||||
<MonitorShareIcon className="h-5 w-5 text-accent" />
|
||||
) : (
|
||||
<PhoneIcon className="h-5 w-5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-fg">
|
||||
{title} · {participantCount}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
||||
/>
|
||||
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void hangup();
|
||||
}}
|
||||
aria-label={t('app:call.hangup')}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" {...props}>
|
||||
<path d="M5 12v0" />
|
||||
<path d="M9 9v6" />
|
||||
<path d="M13 6v12" />
|
||||
<path d="M17 9v6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const mm = Math.floor(total / 60).toString().padStart(2, '0');
|
||||
const ss = (total % 60).toString().padStart(2, '0');
|
||||
return mm + ':' + ss;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { Avatar } from './Avatar';
|
||||
import {
|
||||
InfoIcon,
|
||||
PhoneIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
|
||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
online: 'bg-emerald-400',
|
||||
online: 'bg-emerald-500',
|
||||
idle: 'bg-amber-400',
|
||||
dnd: 'bg-rose-500',
|
||||
invisible: 'bg-neutral-500',
|
||||
offline: 'bg-neutral-600',
|
||||
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -23,9 +32,7 @@ interface Props {
|
||||
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
|
||||
if (!conversation) {
|
||||
return (
|
||||
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
|
||||
);
|
||||
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,35 +59,43 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
||||
const isDm = conversation.type === 'dm';
|
||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||
|
||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3">
|
||||
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
|
||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||
<div className="relative">
|
||||
{isDm ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
) : peerAvatar ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
{showPresence && peerPresence && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
||||
PRESENCE_DOT[peerPresence]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isDm ? (
|
||||
<>
|
||||
<span>{handle}</span>
|
||||
{showPresence && (
|
||||
<>
|
||||
<span className="mx-1.5 text-neutral-700">·</span>
|
||||
<span className="mx-1.5 text-fg-muted/50">·</span>
|
||||
<span>{presenceLabel}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -93,75 +108,132 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CallHeaderButton conversationId={conversation.id} />
|
||||
<HeaderActionButton label={t('app:chats.search', { defaultValue: 'Suche' })} icon={SearchIcon} />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||
{!isDm && onInfoClick && (
|
||||
<button
|
||||
type="button"
|
||||
<HeaderActionButton
|
||||
label={t('app:group.info_title')}
|
||||
icon={InfoIcon}
|
||||
onClick={onInfoClick}
|
||||
aria-label={t('app:group.info_title')}
|
||||
title={t('app:group.info_title')}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function CallHeaderButton({ conversationId }: { conversationId: string }) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, startCall } = useCall();
|
||||
const busy = state.kind !== 'idle';
|
||||
interface HeaderActionButtonProps {
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'accent';
|
||||
}
|
||||
|
||||
function HeaderActionButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
disabled,
|
||||
tone = 'default',
|
||||
}: HeaderActionButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'accent'
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startCall(conversationId, 'audio')}
|
||||
disabled={busy}
|
||||
aria-label={t('app:call.start_audio')}
|
||||
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CallHeaderButton({
|
||||
conversationId,
|
||||
kind,
|
||||
}: {
|
||||
conversationId: string;
|
||||
kind: 'audio' | 'video';
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, startCall } = useCall();
|
||||
const busy = state.kind !== 'idle';
|
||||
const label =
|
||||
kind === 'video'
|
||||
? t('app:call.start_video', { defaultValue: 'Video-Anruf' })
|
||||
: t('app:call.start_audio');
|
||||
const Icon = kind === 'video' ? VideoIcon : PhoneIcon;
|
||||
return (
|
||||
<HeaderActionButton
|
||||
label={busy ? t('app:call.busy') : label}
|
||||
icon={Icon}
|
||||
onClick={() => void startCall(conversationId, kind)}
|
||||
disabled={busy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
|
||||
const active = useCallPresence(conversationId);
|
||||
|
||||
const myId = session?.user.id;
|
||||
// Source of truth for "am I in this call" is the CallContext state, not
|
||||
// presence — my own presence entry can lag behind the room join, which
|
||||
// would otherwise make the banner flash while I'm already connected.
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversationId;
|
||||
|
||||
// Only show banner when other people are in it and I'm not.
|
||||
const othersIn = active.filter((u) => u !== myId);
|
||||
if (iAmIn || othersIn.length === 0) return null;
|
||||
// Fallback signal: I just left this conv with peers still inside. Covers the
|
||||
// brief window after hangup where presence may not have re-synced yet (the
|
||||
// realtime channel can churn while ConversationHeader remounts).
|
||||
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
||||
|
||||
// Once presence confirms the room is empty, drop the "just left" hint so the
|
||||
// banner hides cleanly instead of sticking forever.
|
||||
useEffect(() => {
|
||||
if (!justLeft) return;
|
||||
if (active.length === 0) return;
|
||||
if (othersIn.length === 0) {
|
||||
// Active reports only us (or something stale) — wait, then dismiss.
|
||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
return undefined;
|
||||
}, [justLeft, active.length, othersIn.length, dismissLastCall]);
|
||||
|
||||
if (iAmIn) return null;
|
||||
if (othersIn.length === 0 && !justLeft) return null;
|
||||
|
||||
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
|
||||
const busy = state.kind !== 'idle';
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100">
|
||||
<PhoneIcon className="h-4 w-4 text-emerald-300" />
|
||||
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
|
||||
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
|
||||
<span className="flex-1">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Active call · {{count}} in room',
|
||||
count: othersIn.length,
|
||||
count,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
||||
{conversation.members.map((m) => {
|
||||
const name = m.profile?.displayName ?? '?';
|
||||
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
||||
const avatarUrl = m.profile?.avatarUrl ?? null;
|
||||
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<li
|
||||
key={m.userId}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter}
|
||||
</div>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white">
|
||||
{name}
|
||||
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
||||
onClick={() => void handleAdd(f.userId)}
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter}
|
||||
</div>
|
||||
{f.avatarUrl ? (
|
||||
<img
|
||||
src={f.avatarUrl}
|
||||
alt=""
|
||||
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white">{f.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||
import { type CallMode, useCall } from '../context/CallContext';
|
||||
import {
|
||||
getPttSettings,
|
||||
type PttSettings,
|
||||
subscribePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
PhoneOffIcon,
|
||||
SpinnerIcon,
|
||||
} from './icons';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
|
||||
// Discord-style in-call dock rendered above the message list. Renders three
|
||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||
// The fullscreen variant absolute-positions itself over the conversation
|
||||
// container so the rail+chat-list remain visible on the left.
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
// Discord-style call widget rendered above the message list when the user is
|
||||
// in the current conversation's call. Shows participant avatars + controls.
|
||||
interface Tile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
muted: boolean;
|
||||
video: boolean;
|
||||
sharing: boolean;
|
||||
remoteSharing: boolean;
|
||||
}
|
||||
|
||||
export function InCallPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
state,
|
||||
room,
|
||||
remoteParticipants,
|
||||
isMuted,
|
||||
isE2EEActive,
|
||||
toggleMute,
|
||||
hangup,
|
||||
isScreenSharing,
|
||||
remoteScreenShares,
|
||||
callMode,
|
||||
focusedId,
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
hangup,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
} = useCall();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
|
||||
state.conversationId === conversation.id;
|
||||
if (!active) return null;
|
||||
|
||||
const remoteIds = new Set<string>(
|
||||
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
|
||||
);
|
||||
const tiles: ParticipantTileData[] = [];
|
||||
if (myId) {
|
||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||
tiles.push({
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
speaking: false,
|
||||
muted: isMuted,
|
||||
});
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteIds.has(m.userId)) continue;
|
||||
tiles.push({
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
speaking: false,
|
||||
muted: false,
|
||||
});
|
||||
}
|
||||
const tiles = buildTiles({
|
||||
conversation,
|
||||
myId,
|
||||
remoteIdentities: remoteParticipants.map((p) => p.identity),
|
||||
isMuted,
|
||||
isScreenSharing,
|
||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||
});
|
||||
|
||||
const duration =
|
||||
state.kind === 'connected'
|
||||
? <LiveDuration startedAt={state.startedAt} />
|
||||
: null;
|
||||
|
||||
const statusLabel =
|
||||
state.kind === 'outgoing'
|
||||
@@ -86,268 +86,491 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
const sharingTile = tiles.find((p) => p.sharing);
|
||||
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
|
||||
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
|
||||
|
||||
const controls = (
|
||||
<CallControls
|
||||
muted={isMuted}
|
||||
sharing={isScreenSharing}
|
||||
video={false}
|
||||
onToggleMute={toggleMute}
|
||||
onToggleShare={() => void toggleScreenShare()}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
disabledMedia={state.kind !== 'connected'}
|
||||
/>
|
||||
);
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
return (
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
onExit={() => setCallMode('grid')}
|
||||
onFocusTile={(id) => {
|
||||
setFocusedId(id);
|
||||
}}
|
||||
controls={controls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
conversation.type === 'group'
|
||||
? conversation.name ?? t('app:chats.new_group')
|
||||
: conversation.peer?.displayName ?? '—';
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
||||
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
|
||||
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
|
||||
style={{ height: '50%' }}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
|
||||
{state.kind === 'connecting' ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="uppercase tracking-wide">{statusLabel}</span>
|
||||
{isE2EEActive && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
|
||||
title={t('app:call.e2ee_active_hint', {
|
||||
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
|
||||
})}
|
||||
>
|
||||
<LockIcon className="h-3 w-3" />
|
||||
E2EE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{tiles.map((p) => (
|
||||
<ParticipantTile key={p.userId} {...p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{remoteScreenShares.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{remoteScreenShares.map((s) => {
|
||||
const member = conversation.members.find((m) => m.userId === s.participantId);
|
||||
return (
|
||||
<ScreenShareViewer
|
||||
key={s.track.sid ?? s.participantId}
|
||||
share={s}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? s.participantName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<ControlButton
|
||||
onClick={toggleMute}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isMuted}
|
||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
tone={isMuted ? 'amber' : 'neutral'}
|
||||
>
|
||||
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void toggleScreenShare()}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isScreenSharing}
|
||||
label={
|
||||
isScreenSharing
|
||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||
}
|
||||
tone={isScreenSharing ? 'emerald' : 'neutral'}
|
||||
>
|
||||
{isScreenSharing ? (
|
||||
<MonitorStopIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
<UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||
<span className="tabular-nums text-fg-muted">
|
||||
{duration ?? statusLabel}
|
||||
{state.kind !== 'connected' && (
|
||||
<SpinnerIcon className="ml-1 inline h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isE2EEActive && (
|
||||
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<LockIcon className="h-3 w-3" />
|
||||
<span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void hangup()}
|
||||
label={t('app:call.hangup')}
|
||||
tone="rose"
|
||||
>
|
||||
<PhoneOffIcon className="h-5 w-5" />
|
||||
</ControlButton>
|
||||
</div>
|
||||
<ModeToggles mode={callMode} onChange={setCallMode} />
|
||||
</div>
|
||||
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
mode={callMode}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
|
||||
{controls}
|
||||
|
||||
<PttHint />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BuildArgs {
|
||||
conversation: ConversationSummary;
|
||||
myId: string | null;
|
||||
remoteIdentities: string[];
|
||||
isMuted: boolean;
|
||||
isScreenSharing: boolean;
|
||||
remoteSharerIds: Set<string>;
|
||||
}
|
||||
|
||||
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
function buildTiles({
|
||||
conversation,
|
||||
myId,
|
||||
remoteIdentities,
|
||||
isMuted,
|
||||
isScreenSharing,
|
||||
remoteSharerIds,
|
||||
}: BuildArgs): Tile[] {
|
||||
const remoteSet = new Set(remoteIdentities);
|
||||
const out: Tile[] = [];
|
||||
if (myId) {
|
||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||
out.push({
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
muted: isMuted,
|
||||
video: false,
|
||||
sharing: isScreenSharing,
|
||||
remoteSharing: false,
|
||||
});
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteSet.has(m.userId)) continue;
|
||||
const sharing = remoteSharerIds.has(m.userId);
|
||||
out.push({
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
muted: false,
|
||||
video: false,
|
||||
sharing,
|
||||
remoteSharing: sharing,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function LiveDuration({ startedAt }: { startedAt: string }) {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!watching) return;
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const track: RemoteTrack = share.track;
|
||||
track.attach(el);
|
||||
return () => {
|
||||
track.detach(el);
|
||||
};
|
||||
}, [share.track, watching]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
};
|
||||
function formatElapsed(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const hh = Math.floor(total / 3600);
|
||||
const mm = Math.floor((total % 3600) / 60);
|
||||
const ss = total % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
|
||||
function ModeToggles({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: CallMode;
|
||||
onChange: (mode: CallMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<div className="flex items-center gap-1">
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={mode === 'fullscreen'}
|
||||
onClick={() => onChange('fullscreen')}
|
||||
label="Vollbild"
|
||||
>
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||
(active
|
||||
? 'border-accent bg-accent text-accent-fg'
|
||||
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-200">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t('app:call.is_sharing_screen', {
|
||||
name: displayName,
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
mode: CallMode;
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
track: import('livekit-client').RemoteTrack;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
mode,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
const others = tiles.filter((p) => p.userId !== speaker.userId);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<FocusedTile
|
||||
tile={speaker}
|
||||
e2ee={e2ee}
|
||||
speaking={activeSpeakers.has(speaker.userId)}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<div className="flex h-[110px] gap-2.5 overflow-x-auto">
|
||||
{others.map((p) => (
|
||||
<div key={p.userId} className="h-full min-w-[160px]">
|
||||
<CallParticipantTile
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className={
|
||||
'block cursor-zoom-in bg-black ' +
|
||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/90 text-white shadow-lg transition group-hover:scale-105">
|
||||
<PlayIcon className="ml-0.5 h-6 w-6" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-white/90">
|
||||
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/20 to-emerald-500/20" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
<CallParticipantTile
|
||||
key={p.userId}
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
function FocusedTile({
|
||||
tile,
|
||||
e2ee,
|
||||
speaking,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
speaking: boolean;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
}) {
|
||||
// When the focused participant is remotely sharing their screen, embed the
|
||||
// real video stream rather than the fake-window placeholder.
|
||||
if (tile.remoteSharing) {
|
||||
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
||||
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
||||
if (share) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ScreenShareViewer
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<div className="h-full">
|
||||
<CallParticipantTile
|
||||
userId={tile.userId}
|
||||
displayName={tile.displayName}
|
||||
avatarUrl={tile.avatarUrl}
|
||||
me={tile.self}
|
||||
muted={tile.muted}
|
||||
speaking={speaking}
|
||||
sharing={tile.sharing}
|
||||
video={tile.video}
|
||||
e2ee={e2ee}
|
||||
focused
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
function gridColsFor(n: number): string {
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
return 'grid-cols-3 grid-rows-2';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen cinema mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FullscreenProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
onExit: () => void;
|
||||
onFocusTile: (id: string) => void;
|
||||
controls: React.ReactNode;
|
||||
}
|
||||
|
||||
function FullscreenCall({
|
||||
tiles,
|
||||
speaker,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
onExit: _onExit,
|
||||
onFocusTile,
|
||||
controls,
|
||||
}: FullscreenProps) {
|
||||
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
|
||||
const [hintGone, setHintGone] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||
return () => window.clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{speaker && (
|
||||
<div className="absolute inset-0">
|
||||
{speaker.remoteSharing ? (
|
||||
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
|
||||
) : (
|
||||
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
|
||||
<CallParticipantTile
|
||||
userId={speaker.userId}
|
||||
displayName={speaker.displayName}
|
||||
avatarUrl={speaker.avatarUrl}
|
||||
me={speaker.self}
|
||||
muted={speaker.muted}
|
||||
speaking={activeSpeakers.has(speaker.userId)}
|
||||
sharing={speaker.sharing}
|
||||
video={speaker.video}
|
||||
e2ee={e2ee}
|
||||
focused
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (
|
||||
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
|
||||
{others.slice(0, 4).map((p) => (
|
||||
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
|
||||
<CallParticipantTile
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hintGone && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
||||
>
|
||||
Esc zum Verlassen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenShare({
|
||||
speaker,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
}: {
|
||||
speaker: Tile;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
}) {
|
||||
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
|
||||
const member = conversationMembers.find((m) => m.userId === speaker.userId);
|
||||
if (!share) return null;
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ScreenShareViewer
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? speaker.displayName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,95 +579,11 @@ function PttHint() {
|
||||
useEffect(() => subscribePttSettings(setPtt), []);
|
||||
if (!ptt.enabled) return null;
|
||||
return (
|
||||
<p className="mt-3 text-center text-[11px] text-neutral-500">
|
||||
<p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
|
||||
Push-to-Talk:
|
||||
<kbd className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300">
|
||||
<kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
|
||||
{ptt.keyLabel}
|
||||
</kbd>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParticipantTileData {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
speaking: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
function ParticipantTile(p: ParticipantTileData) {
|
||||
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex w-28 flex-col items-center gap-2">
|
||||
<div
|
||||
className={
|
||||
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
|
||||
(p.speaking
|
||||
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
|
||||
: 'ring-white/10')
|
||||
}
|
||||
>
|
||||
{p.avatarUrl ? (
|
||||
<img
|
||||
src={p.avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{letter}</span>
|
||||
)}
|
||||
{p.muted && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
|
||||
>
|
||||
<MicOffIcon className="h-3 w-3 text-ink-950" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
|
||||
{p.displayName}
|
||||
{p.self && (
|
||||
<span className="ml-1 text-neutral-500">· Du</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlButtonProps {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ControlButton({ onClick, label, tone, disabled, children }: ControlButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'rose'
|
||||
? 'bg-rose-500 text-white hover:bg-rose-400 focus-visible:ring-rose-400/50'
|
||||
: tone === 'amber'
|
||||
? 'bg-amber-500/90 text-ink-950 hover:bg-amber-400 focus-visible:ring-amber-400/50'
|
||||
: tone === 'emerald'
|
||||
? 'bg-emerald-500/85 text-white hover:bg-emerald-400 focus-visible:ring-emerald-400/50'
|
||||
: 'bg-white/10 text-neutral-100 hover:bg-white/20 focus-visible:ring-brand-400/40';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const PULSE_BG: Record<AvatarColorKey, string> = {
|
||||
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||
};
|
||||
|
||||
// Docked incoming-call panel: replaces the chat header, chat + composer stay
|
||||
// visible and usable below (per user decision). Full-bleed overlay lives in
|
||||
// the shell-level toast for cross-conversation ring notifications.
|
||||
export function IncomingCallPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const { friendships } = useFriendshipsContext();
|
||||
|
||||
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
||||
|
||||
const isGroup = conversation.type === 'group';
|
||||
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
|
||||
const callerName =
|
||||
callerProfile?.displayName ??
|
||||
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
||||
'?';
|
||||
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
|
||||
const title = isGroup ? groupName ?? callerName : callerName;
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const avatarUrl = isGroup
|
||||
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
|
||||
: callerProfile?.avatarUrl ?? null;
|
||||
const colorKey = colorKeyFor(state.fromUserId);
|
||||
const avatarTone = PULSE_BG[colorKey];
|
||||
const mediaLabel =
|
||||
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
|
||||
|
||||
return (
|
||||
<section
|
||||
role="region"
|
||||
aria-label={t('app:call.incoming_title')}
|
||||
className="relative overflow-hidden border-b border-line bg-surface-3"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
|
||||
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
|
||||
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.incoming_title')}
|
||||
</p>
|
||||
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||
style={{ animationDelay: '1s' }}
|
||||
/>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={title}
|
||||
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
|
||||
avatarTone
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<LockIcon className="h-3.5 w-3.5" />
|
||||
<span>E2E verschlüsselt · {mediaLabel}</span>
|
||||
{isGroup && (
|
||||
<>
|
||||
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft an',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-5 flex w-full max-w-[360px] gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={rejectIncoming}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.decline')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void acceptIncoming()}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.accept')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
|
||||
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
|
||||
@@ -23,6 +24,10 @@ interface Props {
|
||||
message: DecryptedMessage;
|
||||
mine: boolean;
|
||||
groupedWithPrev: boolean;
|
||||
/** Last message of a run from this sender — anchor avatar on this row. */
|
||||
isLastOfRun?: boolean;
|
||||
senderDisplayName?: string | null | undefined;
|
||||
senderAvatarUrl?: string | null | undefined;
|
||||
conversationId: string;
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
@@ -33,6 +38,9 @@ export function MessageBubble({
|
||||
message,
|
||||
mine,
|
||||
groupedWithPrev,
|
||||
isLastOfRun = false,
|
||||
senderDisplayName,
|
||||
senderAvatarUrl,
|
||||
conversationId,
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
@@ -143,8 +151,13 @@ export function MessageBubble({
|
||||
|
||||
if (message.deletedAt) {
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
/>
|
||||
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
||||
{t('app:chats.deleted')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,14 +169,19 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
||||
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
|
||||
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
@@ -179,10 +197,10 @@ export function MessageBubble({
|
||||
}}
|
||||
rows={2}
|
||||
autoFocus
|
||||
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
|
||||
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
|
||||
/>
|
||||
{editError && (
|
||||
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200">
|
||||
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
@@ -193,7 +211,7 @@ export function MessageBubble({
|
||||
setEditing(false);
|
||||
setEditError(null);
|
||||
}}
|
||||
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel')}
|
||||
</button>
|
||||
@@ -201,7 +219,7 @@ export function MessageBubble({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleEditSave()}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white 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 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
||||
@@ -211,14 +229,14 @@ export function MessageBubble({
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
|
||||
'break-words px-3.5 py-2 text-sm ' +
|
||||
(mine
|
||||
? 'bg-brand-500/85 text-white'
|
||||
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
|
||||
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
|
||||
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
|
||||
}
|
||||
>
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic text-neutral-400">…cannot decrypt</span>
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
) : (
|
||||
<>
|
||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
||||
@@ -230,7 +248,7 @@ export function MessageBubble({
|
||||
<div
|
||||
className={
|
||||
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
||||
(mine ? 'text-brand-100/70' : 'text-neutral-500')
|
||||
(mine ? 'text-accent-fg/75' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
<span>{time}</span>
|
||||
@@ -242,7 +260,7 @@ export function MessageBubble({
|
||||
)}
|
||||
|
||||
{showSeen && mine && !editing && !message.deletedAt && (
|
||||
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
|
||||
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
|
||||
{t('app:chats.seen')}
|
||||
</p>
|
||||
)}
|
||||
@@ -255,10 +273,10 @@ export function MessageBubble({
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
className={
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(r.mine
|
||||
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
|
||||
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
|
||||
? 'border-accent/40 bg-accent/20 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
@@ -275,7 +293,7 @@ export function MessageBubble({
|
||||
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl">
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
|
||||
<ActionButton
|
||||
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
@@ -306,7 +324,7 @@ export function MessageBubble({
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
className={
|
||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
|
||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
|
||||
(mine ? 'right-0' : 'left-0')
|
||||
}
|
||||
>
|
||||
@@ -315,7 +333,7 @@ export function MessageBubble({
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => void handlePickEmoji(e)}
|
||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
@@ -323,7 +341,7 @@ export function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
|
||||
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -336,6 +354,27 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarSlot({
|
||||
show,
|
||||
url,
|
||||
displayName,
|
||||
}: {
|
||||
show: boolean;
|
||||
url: string | null;
|
||||
displayName: string | null;
|
||||
}) {
|
||||
if (!show) {
|
||||
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
||||
}
|
||||
return (
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={displayName ?? ''}
|
||||
className="h-8 w-8 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CallEventRow({
|
||||
parsed,
|
||||
mine,
|
||||
@@ -351,8 +390,8 @@ function CallEventRow({
|
||||
|
||||
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
||||
const tone = isMissed
|
||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||
|
||||
const label =
|
||||
status === 'ended'
|
||||
@@ -408,10 +447,10 @@ function ActionButton({
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={
|
||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(tone === 'danger'
|
||||
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
|
||||
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
|
||||
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={
|
||||
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' +
|
||||
'relative w-full animate-slide-up rounded-2xl border border-line bg-surface-3 shadow-xl ' +
|
||||
width
|
||||
}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
|
||||
<header className="flex items-center justify-between border-b border-line px-6 py-4">
|
||||
<h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { RemoteScreenShare } from '../context/CallContext';
|
||||
import { MonitorShareIcon } from './icons';
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||
// Lifted out of the old InCallPanel so the new CallDock stays lean.
|
||||
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
useEffect(() => {
|
||||
if (!watching) return;
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
const track: RemoteTrack = share.track;
|
||||
track.attach(el);
|
||||
return () => {
|
||||
track.detach(el);
|
||||
};
|
||||
}, [share.track, watching]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/30 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">
|
||||
{t('app:call.is_sharing_screen', {
|
||||
name: displayName,
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className={
|
||||
'block cursor-zoom-in bg-black ' +
|
||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg transition group-hover:scale-105">
|
||||
<PlayIcon className="ml-0.5 h-6 w-6" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-white/90">
|
||||
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-emerald-500/20" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-accent/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
||||
: t('app:chats.typing_many', { count: typingUserIds.length });
|
||||
|
||||
return (
|
||||
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400">
|
||||
<div className="bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<TypingDots />
|
||||
<span>{text}</span>
|
||||
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
||||
function TypingDots() {
|
||||
return (
|
||||
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
|
||||
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" />
|
||||
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
|
||||
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
|
||||
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite]" />
|
||||
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
|
||||
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,22 +57,22 @@ export function UpdateToast() {
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
|
||||
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-line bg-surface-3 p-4 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/25 text-brand-200 ring-1 ring-brand-400/30">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-white">
|
||||
<p className="text-sm font-semibold text-fg">
|
||||
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
|
||||
{state.version ? ' · v' + state.version : ''}
|
||||
</p>
|
||||
{state.notes && (
|
||||
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
|
||||
<p className="mt-0.5 line-clamp-3 text-xs text-fg-muted">{state.notes}</p>
|
||||
)}
|
||||
{state.error && (
|
||||
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
|
||||
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{state.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{!installing && (
|
||||
@@ -80,7 +80,7 @@ export function UpdateToast() {
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('common:close', { defaultValue: 'Schließen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -90,7 +90,7 @@ export function UpdateToast() {
|
||||
type="button"
|
||||
onClick={() => void handleInstall()}
|
||||
disabled={installing}
|
||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
|
||||
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ChevronDownIcon } from './icons';
|
||||
|
||||
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
||||
|
||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
online: 'bg-emerald-400',
|
||||
online: 'bg-emerald-500',
|
||||
idle: 'bg-amber-400',
|
||||
dnd: 'bg-rose-500',
|
||||
invisible: 'bg-neutral-500',
|
||||
offline: 'bg-neutral-600',
|
||||
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||
};
|
||||
|
||||
function avatarLetter(input: string | undefined): string {
|
||||
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||
}
|
||||
|
||||
export function UserBar() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, refreshProfile } = useAuth();
|
||||
@@ -53,34 +50,38 @@ export function UserBar() {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<div className="relative 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">
|
||||
{avatarLetter(profile?.displayName ?? profile?.username)}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-2 ' +
|
||||
PRESENCE_DOT[presence]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
<p className="truncate text-sm font-medium text-fg">
|
||||
{profile?.displayName ?? '—'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{profile?.username ? '@' + profile.username : '—'}
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{t('app:presence.' + presence)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
|
||||
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
|
||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
{PRESENCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -90,12 +91,12 @@ export function UserBar() {
|
||||
aria-checked={opt === presence}
|
||||
onClick={() => void changePresence(opt)}
|
||||
disabled={busy}
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
|
||||
<span className="flex-1">{t('app:presence.' + opt)}</span>
|
||||
{opt === presence && (
|
||||
<span className="text-xs text-brand-300">●</span>
|
||||
<span className="text-xs text-accent">●</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -336,6 +336,89 @@ export function MonitorStopIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function GridIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function FocusIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<rect x="8" y="8" width="8" height="8" rx="1" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function MaximizeIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
||||
<path d="M20 9V5a1 1 0 0 0-1-1h-4" />
|
||||
<path d="M4 15v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M20 15v4a1 1 0 0 1-1 1h-4" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="2" y="6" width="15" height="12" rx="2" />
|
||||
<path d="m22 8-5 4 5 4V8Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrownIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddUserIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M19 8v6M22 11h-6" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoMark(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user