Compare commits

..

5 Commits

Author SHA1 Message Date
byGalax 4db65993d5 feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

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

Bump tauri version 0.5.0 -> 0.6.0
2026-04-20 00:26:19 +02:00
byGalax 0ca29952ba feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 23:04:03 +02:00
byGalax ff5ea274b9 fix(keysync): swallow expected supabase errors by code/status, not just message
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:08:54 +02:00
byGalax b0f9f1dada fix(keysync): silence expected RLS rejections during best-effort backfill
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:03:49 +02:00
byGalax 961ac2dde5 fix: idempotent conv-key upsert + guard tauri-only notification calls
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:00:21 +02:00
61 changed files with 5606 additions and 1108 deletions
+16 -2
View File
@@ -4,9 +4,23 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>ChatApp</title> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Netralax</title>
<script>
// Apply persisted / system theme before paint to avoid FOUC.
(function () {
try {
var stored = localStorage.getItem('netralax.theme');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head> </head>
<body class="bg-[#0b0b0f] text-white antialiased"> <body class="bg-surface text-fg antialiased">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp02">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
<g clip-path="url(#cp02)">
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
</g>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.4.1", "version": "0.6.0",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+3
View File
@@ -7,6 +7,7 @@ import { AuthProvider } from './context/AuthContext';
import { CallProvider } from './context/CallContext'; import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext'; import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext'; import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { AdminPage } from './pages/AdminPage'; import { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage'; import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { AuthPage } from './pages/AuthPage'; import { AuthPage } from './pages/AuthPage';
@@ -18,6 +19,7 @@ import { SettingsPage } from './pages/SettingsPage';
export function App() { export function App() {
return ( return (
<ThemeProvider>
<AuthProvider> <AuthProvider>
<FriendshipsProvider> <FriendshipsProvider>
<ConversationsProvider> <ConversationsProvider>
@@ -51,5 +53,6 @@ export function App() {
</ConversationsProvider> </ConversationsProvider>
</FriendshipsProvider> </FriendshipsProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider>
); );
} }
+4 -4
View File
@@ -21,7 +21,7 @@ export function AppShell() {
}, [session?.user.id, device?.id]); }, [session?.user.id, device?.id]);
return ( 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 /> <ShellBackground />
<div className="relative z-10 flex min-h-screen w-full"> <div className="relative z-10 flex min-h-screen w-full">
<Sidebar /> <Sidebar />
@@ -36,11 +36,11 @@ export function AppShell() {
); );
} }
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs. // Subtle ambient background — only renders in dark mode where the app's
// No animation here so message lists stay readable. // visual language expects depth. Light mode stays clean and flat.
function ShellBackground() { function ShellBackground() {
return ( 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="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 -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" /> <div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
+45
View File
@@ -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>
);
}
+103 -229
View File
@@ -1,26 +1,20 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
import { ringtone } from '../lib/ringtone'; import { ringtone } from '../lib/ringtone';
import { useAnyActiveCall } from '../lib/useAnyActiveCall'; import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { useCallPresence } from '../lib/useCallPresence'; import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
import {
MicIcon,
MicOffIcon,
PhoneIcon,
PhoneOffIcon,
SpinnerIcon,
XIcon,
} from './icons';
// Shell-level mount. Handles ringtones + the IncomingCallToast. // Shell-level mount. Responsibilities:
// The persistent CallBar (active-call widget) is rendered inside Sidebar so // - Start/stop ringtones by call-state transition.
// users can keep browsing/typing while a call is live. // - 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() { export function CallUI() {
const { state } = useCall(); const { state } = useCall();
@@ -34,19 +28,37 @@ export function CallUI() {
return () => ringtone.stop(); 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() { function IncomingCallToast() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall(); const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext(); const { friendships } = useFriendshipsContext();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const navigate = useNavigate();
const location = useLocation();
if (state.kind !== 'incoming') return null; 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 conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const callerName = const callerName =
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ?? conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
@@ -54,44 +66,55 @@ function IncomingCallToast() {
'?'; '?';
const isGroup = conv?.type === 'group'; const isGroup = conv?.type === 'group';
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null; const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
const letter = (isGroup ? groupName ?? callerName : callerName) const title = isGroup ? groupName ?? callerName : callerName;
.trim() const letter = title.trim().charAt(0).toUpperCase() || '?';
.charAt(0) const color = colorKeyFor(state.fromUserId);
.toUpperCase() || '?';
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="false" aria-modal="false"
aria-label={t('app:call.incoming_title')} 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"> <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')} {t('app:call.incoming_title')}
</p> </p>
<div className="mt-3 flex items-center gap-3"> <div className="mt-2 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"> <div
className={
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
TOAST_AVATAR[color]
}
>
{letter} {letter}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white"> <p className="truncate font-display text-base font-semibold text-fg">{title}</p>
{isGroup ? groupName : callerName} <p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
</p> <LockIcon className="h-3 w-3" />
<p className="truncate text-xs text-neutral-400"> <span>
{isGroup {isGroup
? t('app:call.incoming_group_from', { ? t('app:call.incoming_group_from', {
name: callerName, name: callerName,
defaultValue: callerName + ' ruft Gruppe', defaultValue: callerName + ' ruft Gruppe',
}) })
: t('app:call.incoming_from', { name: callerName })} : t('app:call.incoming_from', { name: callerName })}
</span>
</p> </p>
</div> </div>
</div> </div>
<div className="mt-4 flex gap-2"> </button>
<div className="flex gap-2 px-5 pb-5">
<button <button
type="button" type="button"
onClick={rejectIncoming} 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" /> <PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span> <span>{t('app:call.decline')}</span>
@@ -99,7 +122,7 @@ function IncomingCallToast() {
<button <button
type="button" type="button"
onClick={() => void acceptIncoming()} 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" /> <PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span> <span>{t('app:call.accept')}</span>
@@ -110,226 +133,77 @@ function IncomingCallToast() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing // PiP widget — shown when the user has an active call but is browsing
// the app while a call is active / connecting / ringing. // somewhere else. Clicking expands back to the call's conversation.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CallBar() { function PipCall() {
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() {
const { t } = useTranslation(['app']); 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 { conversations } = useConversationsContext();
const [, forceTick] = useState(0); const active =
useEffect(() => { state.kind === 'connected' ||
if (state.kind !== 'connected') return; state.kind === 'connecting' ||
const id = window.setInterval(() => forceTick((v) => v + 1), 1000); state.kind === 'outgoing';
return () => window.clearInterval(id); if (!active) return null;
}, [state.kind]);
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') { // Only render when the user is NOT currently viewing the call's
return null; // 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 conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const title = const title =
conv?.type === 'group' conv?.type === 'group'
? conv.name ?? '—' ? conv?.name ?? t('app:chats.new_group')
: conv?.peer?.displayName ?? '—'; : conv?.peer?.displayName ?? '—';
const participantCount = 1 + remoteParticipants.length;
const statusLabel = const someoneSharing = remoteScreenShares.length > 0;
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;
return ( return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80"> <div
<Link role="dialog"
to={'/chats/' + state.conversationId} aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none" onClick={() => navigate('/chats/' + state.conversationId)}
aria-label={statusLabel} 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"
> >
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300"> <div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
{state.kind === 'connecting' ? ( {someoneSharing ? (
<SpinnerIcon className="h-4 w-4" /> <MonitorShareIcon className="h-5 w-5 text-accent" />
) : ( ) : (
<SignalIcon className="h-4 w-4" /> <PhoneIcon className="h-5 w-5 text-accent" />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p> <p className="truncate text-[13px] font-semibold text-fg">
<p className="truncate text-xs text-neutral-400"> {title} · {participantCount}
{title} </p>
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>} <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> </p>
</div> </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 <button
type="button" type="button"
onClick={onClick} onClick={(e) => {
disabled={disabled} e.stopPropagation();
aria-label={label} void hangup();
title={label} }}
className={ aria-label={t('app:call.hangup')}
'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 ' + 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"
toneClass
}
> >
{children} <PhoneOffIcon className="h-4 w-4" />
</button> </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 { ConversationSummary } from '@chat-app/shared/chat';
import type { PresenceState } from '@chat-app/shared/supabase'; import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence'; 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> = { const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400', online: 'bg-emerald-500',
idle: 'bg-amber-400', idle: 'bg-amber-400',
dnd: 'bg-rose-500', dnd: 'bg-rose-500',
invisible: 'bg-neutral-500', invisible: 'bg-neutral-500',
offline: 'bg-neutral-600', offline: 'bg-neutral-400 dark:bg-neutral-600',
}; };
interface Props { interface Props {
@@ -23,9 +32,7 @@ interface Props {
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) { export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
if (!conversation) { if (!conversation) {
return ( return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
);
} }
return ( return (
@@ -52,35 +59,43 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
const isDm = conversation.type === 'dm'; const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?'); const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : ''; 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. // Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible'; const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : ''; const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
return ( return (
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3"> <header className="flex items-center gap-3 border-b border-line bg-surface-3 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"> <div className="relative">
{isDm ? letter : <UsersIcon className="h-5 w-5" />} {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 && ( {showPresence && peerPresence && (
<span <span
aria-hidden="true" aria-hidden="true"
className={ 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] PRESENCE_DOT[peerPresence]
} }
/> />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">{title}</p> <p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<p className="truncate text-xs text-neutral-500"> <p className="truncate text-xs text-fg-muted">
{isDm ? ( {isDm ? (
<> <>
<span>{handle}</span> <span>{handle}</span>
{showPresence && ( {showPresence && (
<> <>
<span className="mx-1.5 text-neutral-700">·</span> <span className="mx-1.5 text-fg-muted/50">·</span>
<span>{presenceLabel}</span> <span>{presenceLabel}</span>
</> </>
)} )}
@@ -93,75 +108,132 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
</p> </p>
</div> </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 && ( {!isDm && onInfoClick && (
<button <HeaderActionButton
type="button" label={t('app:group.info_title')}
icon={InfoIcon}
onClick={onInfoClick} 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> </header>
); );
} }
function CallHeaderButton({ conversationId }: { conversationId: string }) { interface HeaderActionButtonProps {
const { t } = useTranslation(['app']); label: string;
const { state, startCall } = useCall(); icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
const busy = state.kind !== 'idle'; 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 ( return (
<button <button
type="button" type="button"
onClick={() => void startCall(conversationId, 'audio')} onClick={onClick}
disabled={busy} disabled={disabled}
aria-label={t('app:call.start_audio')} aria-label={label}
title={busy ? t('app:call.busy') : t('app:call.start_audio')} title={label}
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" 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> </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 }) { function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { session } = useAuth(); const { session } = useAuth();
const { state, joinActiveCall } = useCall(); const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
const active = useCallPresence(conversationId); const active = useCallPresence(conversationId);
const myId = session?.user.id; 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 = const iAmIn =
(state.kind === 'connected' || (state.kind === 'connected' ||
state.kind === 'connecting' || state.kind === 'connecting' ||
state.kind === 'outgoing') && state.kind === 'outgoing') &&
state.conversationId === conversationId; state.conversationId === conversationId;
// Only show banner when other people are in it and I'm not.
const othersIn = active.filter((u) => u !== myId); 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'; const busy = state.kind !== 'idle';
return ( 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"> <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-300" /> <PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
<span className="flex-1"> <span className="flex-1">
{t('app:call.active_in_conv', { {t('app:call.active_in_conv', {
defaultValue: 'Active call · {{count}} in room', defaultValue: 'Active call · {{count}} in room',
count: othersIn.length, count,
})} })}
</span> </span>
<button <button
type="button" type="button"
onClick={() => void joinActiveCall(conversationId, 'audio')} onClick={() => void joinActiveCall(conversationId, 'audio')}
disabled={busy} 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 ? ( {busy ? (
<SpinnerIcon className="h-3.5 w-3.5" /> <SpinnerIcon className="h-3.5 w-3.5" />
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
{conversation.members.map((m) => { {conversation.members.map((m) => {
const name = m.profile?.displayName ?? '?'; const name = m.profile?.displayName ?? '?';
const handle = m.profile?.username ? '@' + m.profile.username : ''; const handle = m.profile?.username ? '@' + m.profile.username : '';
const avatarUrl = m.profile?.avatarUrl ?? null;
const letter = name.trim().charAt(0).toUpperCase() || '?'; const letter = name.trim().charAt(0).toUpperCase() || '?';
return ( return (
<li <li
key={m.userId} key={m.userId}
className="flex items-center gap-3 rounded-lg px-2 py-1.5" className="flex items-center gap-3 rounded-lg px-2 py-1.5"
> >
{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"> <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} {letter}
</div> </div>
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-white"> <p className="truncate text-sm text-white">
{name} {name}
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
onClick={() => void handleAdd(f.userId)} 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" 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"
> >
{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"> <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} {letter}
</div> </div>
)}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p> <p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p> <p className="truncate text-xs text-neutral-500">@{f.username}</p>
+488 -349
View File
@@ -1,46 +1,60 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteTrack } from 'livekit-client'; import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { type RemoteScreenShare, useCall } from '../context/CallContext'; import { type CallMode, useCall } from '../context/CallContext';
import { import {
getPttSettings, getPttSettings,
type PttSettings, type PttSettings,
subscribePttSettings, subscribePttSettings,
} from '../lib/pttSettings'; } from '../lib/pttSettings';
import { import { useActiveSpeakers } from '../lib/useActiveSpeakers';
LockIcon, import { CallControls } from './CallControls';
MicIcon, import { CallParticipantTile } from './CallParticipantTile';
MicOffIcon, import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
MonitorShareIcon, import { ScreenShareViewer } from './ScreenShareViewer';
MonitorStopIcon,
PhoneOffIcon,
SpinnerIcon,
} from './icons';
// 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 { interface Props {
conversation: ConversationSummary; conversation: ConversationSummary;
} }
// Discord-style call widget rendered above the message list when the user is interface Tile {
// in the current conversation's call. Shows participant avatars + controls. userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
video: boolean;
sharing: boolean;
remoteSharing: boolean;
}
export function InCallPanel({ conversation }: Props) { export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { const {
state, state,
room,
remoteParticipants, remoteParticipants,
isMuted, isMuted,
isE2EEActive, isE2EEActive,
toggleMute,
hangup,
isScreenSharing, isScreenSharing,
remoteScreenShares, remoteScreenShares,
callMode,
focusedId,
toggleMute,
toggleScreenShare, toggleScreenShare,
hangup,
setCallMode,
setFocusedId,
} = useCall(); } = useCall();
const { session } = useAuth(); const { session } = useAuth();
const myId = session?.user.id ?? null; const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const active = const active =
(state.kind === 'connected' || (state.kind === 'connected' ||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
state.conversationId === conversation.id; state.conversationId === conversation.id;
if (!active) return null; if (!active) return null;
const remoteIds = new Set<string>( const tiles = buildTiles({
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)), conversation,
); myId,
const tiles: ParticipantTileData[] = []; remoteIdentities: remoteParticipants.map((p) => p.identity),
if (myId) { isMuted,
const me = conversation.members.find((m) => m.userId === myId) ?? null; isScreenSharing,
tiles.push({ remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
speaking: false,
muted: isMuted,
}); });
}
for (const m of conversation.members) { const duration =
if (m.userId === myId) continue; state.kind === 'connected'
if (!remoteIds.has(m.userId)) continue; ? <LiveDuration startedAt={state.startedAt} />
tiles.push({ : null;
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
speaking: false,
muted: false,
});
}
const statusLabel = const statusLabel =
state.kind === 'outgoing' 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.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected'); : 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 ( return (
<section <section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })} 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"> <div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
{state.kind === 'connecting' ? ( <div className="flex min-w-0 flex-col gap-0.5">
<SpinnerIcon className="h-3.5 w-3.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="relative flex h-2 w-2"> <span className="truncate">{title}</span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" /> <span className="text-fg-muted/50" aria-hidden="true">·</span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" /> <span className="tabular-nums text-fg-muted">
</span> {duration ?? statusLabel}
{state.kind !== 'connected' && (
<SpinnerIcon className="ml-1 inline h-3 w-3" />
)} )}
<span className="uppercase tracking-wide">{statusLabel}</span> </span>
</div>
{isE2EEActive && ( {isE2EEActive && (
<span <div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
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" /> <LockIcon className="h-3 w-3" />
E2EE <span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
</span> </div>
)} )}
</div> </div>
<ModeToggles mode={callMode} onChange={setCallMode} />
<div className="flex flex-wrap justify-center gap-4">
{tiles.map((p) => (
<ParticipantTile key={p.userId} {...p} />
))}
</div> </div>
{remoteScreenShares.length > 0 && ( <CallStage
<div className="mt-4 space-y-3"> tiles={tiles}
{remoteScreenShares.map((s) => { speaker={speaker}
const member = conversation.members.find((m) => m.userId === s.participantId); mode={callMode}
return ( activeSpeakers={activeSpeakers}
<ScreenShareViewer e2ee={isE2EEActive}
key={s.track.sid ?? s.participantId} remoteScreenShares={remoteScreenShares}
share={s} conversationMembers={conversation.members}
avatarUrl={member?.profile?.avatarUrl ?? null} onFocusTile={(id) => {
displayName={member?.profile?.displayName ?? s.participantName} setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
compact
/> />
);
})}
</div>
)}
<div className="mt-5 flex items-center justify-center gap-2"> {controls}
<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" />
)}
</ControlButton>
<ControlButton
onClick={() => void hangup()}
label={t('app:call.hangup')}
tone="rose"
>
<PhoneOffIcon className="h-5 w-5" />
</ControlButton>
</div>
<PttHint /> <PttHint />
</section> </section>
); );
} }
interface ScreenShareViewerProps { // ---------------------------------------------------------------------------
share: RemoteScreenShare; // Helpers
avatarUrl: string | null; // ---------------------------------------------------------------------------
displayName: string;
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
remoteIdentities: string[];
isMuted: boolean;
isScreenSharing: boolean;
remoteSharerIds: Set<string>;
} }
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) { function buildTiles({
const { t } = useTranslation(['app']); conversation,
const videoRef = useRef<HTMLVideoElement | null>(null); myId,
const containerRef = useRef<HTMLDivElement | null>(null); remoteIdentities,
const [watching, setWatching] = useState(false); isMuted,
const [isFullscreen, setIsFullscreen] = useState(false); isScreenSharing,
const letter = displayName.trim().charAt(0).toUpperCase() || '?'; 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(() => { useEffect(() => {
if (!watching) return; const id = window.setInterval(() => tick((v) => v + 1), 1000);
const el = videoRef.current; return () => window.clearInterval(id);
if (!el) return; }, []);
const track: RemoteTrack = share.track; return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
track.attach(el); }
return () => {
track.detach(el);
};
}, [share.track, watching]);
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 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={
'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')
}
>
{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>
);
}
// Grid
const gridClass = gridColsFor(tiles.length);
return (
<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 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 (
<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 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(() => { useEffect(() => {
const onChange = () => { const id = window.setTimeout(() => setHintGone(true), 3500);
setIsFullscreen(document.fullscreenElement === containerRef.current); return () => window.clearTimeout(id);
};
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 ( return (
<div <div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface">
ref={containerRef} <div className="relative min-h-0 flex-1">
className={ {speaker && (
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' + <div className="absolute inset-0">
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '') {speaker.remoteSharing ? (
} <FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
>
<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>
</>
)}
</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 <div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
type="button" <CallParticipantTile
onClick={() => setWatching(true)} userId={speaker.userId}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })} displayName={speaker.displayName}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none" avatarUrl={speaker.avatarUrl}
style={{ aspectRatio: '16 / 9' }} me={speaker.self}
> muted={speaker.muted}
<BlurredTile avatarUrl={avatarUrl} letter={letter} /> speaking={activeSpeakers.has(speaker.userId)}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40"> sharing={speaker.sharing}
<div className="flex flex-col items-center gap-2"> video={speaker.video}
<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"> e2ee={e2ee}
<PlayIcon className="ml-0.5 h-6 w-6" /> focused
</span> />
<span className="text-xs font-medium text-white/90">
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
</span>
</div> </div>
</div>
</button>
)} )}
</div> </div>
); )}
}
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) { {others.length > 0 && (
if (avatarUrl) { <div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
return ( {others.slice(0, 4).map((p) => (
<> <div key={p.userId} className="h-[100px] backdrop-blur-xl">
<img <CallParticipantTile
src={avatarUrl} userId={p.userId}
alt="" displayName={p.displayName}
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl" 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 className="absolute inset-0 bg-gradient-to-br from-brand-500/20 to-emerald-500/20" /> </div>
</> ))}
); </div>
} )}
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"> {!hintGone && (
<span <div
aria-hidden="true" aria-hidden="true"
className="text-[120px] font-display font-bold text-white/20 blur-[2px]" 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"
> >
{letter} Esc zum Verlassen
</span> </div>
)}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
{controls}
</div>
</div>
</div> </div>
); );
} }
function PlayIcon(props: React.SVGProps<SVGSVGElement>) { 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 ( return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}> <div className="h-full w-full">
<path d="M8 5v14l11-7z" /> <ScreenShareViewer
</svg> share={share}
); avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
} displayName={member?.profile?.displayName ?? speaker.displayName}
/>
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) { </div>
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>
); );
} }
@@ -356,95 +579,11 @@ function PttHint() {
useEffect(() => subscribePttSettings(setPtt), []); useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null; if (!ptt.enabled) return null;
return ( 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:&nbsp; Push-to-Talk:&nbsp;
<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} {ptt.keyLabel}
</kbd> </kbd>
</p> </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">·&nbsp;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>
);
}
+66 -27
View File
@@ -14,6 +14,7 @@ import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentImage } from './AttachmentImage'; import { AttachmentImage } from './AttachmentImage';
import { Avatar } from './Avatar';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏']; const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
@@ -23,6 +24,10 @@ interface Props {
message: DecryptedMessage; message: DecryptedMessage;
mine: boolean; mine: boolean;
groupedWithPrev: 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; conversationId: string;
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; onToggleReaction: (emoji: string) => Promise<void>;
@@ -33,6 +38,9 @@ export function MessageBubble({
message, message,
mine, mine,
groupedWithPrev, groupedWithPrev,
isLastOfRun = false,
senderDisplayName,
senderAvatarUrl,
conversationId, conversationId,
reactions, reactions,
onToggleReaction, onToggleReaction,
@@ -143,8 +151,13 @@ export function MessageBubble({
if (message.deletedAt) { if (message.deletedAt) {
return ( return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}> <div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<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"> <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')} {t('app:chats.deleted')}
</div> </div>
</div> </div>
@@ -156,14 +169,19 @@ export function MessageBubble({
} }
return ( 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 <div
className={ 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 ? ( {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 <textarea
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) => setEditText(e.target.value)}
@@ -179,10 +197,10 @@ export function MessageBubble({
}} }}
rows={2} rows={2}
autoFocus 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 && ( {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} {editError}
</p> </p>
)} )}
@@ -193,7 +211,7 @@ export function MessageBubble({
setEditing(false); setEditing(false);
setEditError(null); 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')} {t('app:friends.action_cancel')}
</button> </button>
@@ -201,7 +219,7 @@ export function MessageBubble({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => void handleEditSave()} 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" />} {busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{t('common:save', { defaultValue: 'Save' })}</span> <span>{t('common:save', { defaultValue: 'Save' })}</span>
@@ -211,14 +229,14 @@ export function MessageBubble({
) : ( ) : (
<div <div
className={ className={
'break-words rounded-2xl px-3.5 py-2 text-sm ' + 'break-words px-3.5 py-2 text-sm ' +
(mine (mine
? 'bg-brand-500/85 text-white' ? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
: 'border border-white/5 bg-ink-900/70 text-neutral-100') : 'rounded-[18px_18px_18px_4px] border border-line text-fg')
} }
> >
{message.plaintext === null ? ( {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>} {bodyText.length > 0 && <div>{bodyText}</div>}
@@ -230,7 +248,7 @@ export function MessageBubble({
<div <div
className={ className={
'mt-1 flex items-center gap-1.5 text-[10px] ' + '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> <span>{time}</span>
@@ -242,7 +260,7 @@ export function MessageBubble({
)} )}
{showSeen && mine && !editing && !message.deletedAt && ( {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')} {t('app:chats.seen')}
</p> </p>
)} )}
@@ -255,10 +273,10 @@ export function MessageBubble({
type="button" type="button"
onClick={() => void onToggleReaction(r.emoji)} onClick={() => void onToggleReaction(r.emoji)}
className={ 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 (r.mine
? 'border-brand-400/40 bg-brand-500/20 text-brand-100' ? 'border-accent/40 bg-accent/20 text-accent'
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10') : 'border-line bg-surface-2 text-fg hover:bg-surface-3')
} }
> >
<span>{r.emoji}</span> <span>{r.emoji}</span>
@@ -275,7 +293,7 @@ export function MessageBubble({
(mine ? 'right-full pr-2' : 'left-full pl-2') (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 <ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })} label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)} onClick={() => setPickerOpen((v) => !v)}
@@ -306,7 +324,7 @@ export function MessageBubble({
ref={pickerRef} ref={pickerRef}
role="menu" role="menu"
className={ 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') (mine ? 'right-0' : 'left-0')
} }
> >
@@ -315,7 +333,7 @@ export function MessageBubble({
key={e} key={e}
type="button" type="button"
onClick={() => void handlePickEmoji(e)} 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} {e}
</button> </button>
@@ -323,7 +341,7 @@ export function MessageBubble({
<button <button
type="button" type="button"
onClick={() => setPickerOpen(false)} 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" /> <XIcon className="h-4 w-4" />
</button> </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({ function CallEventRow({
parsed, parsed,
mine, mine,
@@ -351,8 +390,8 @@ function CallEventRow({
const Icon = isMissed ? PhoneOffIcon : PhoneIcon; const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
const tone = isMissed const tone = isMissed
? 'border-rose-500/20 bg-rose-500/10 text-rose-200' ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200'; : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
const label = const label =
status === 'ended' status === 'ended'
@@ -408,10 +447,10 @@ function ActionButton({
title={label} title={label}
onClick={onClick} onClick={onClick}
className={ 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' (tone === 'danger'
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200' ? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100') : 'text-fg-muted hover:bg-surface-2 hover:text-fg')
} }
> >
{icon} {icon}
+5 -5
View File
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
onClick={onClose} 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 <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className={ 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 width
} }
> >
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4"> <header className="flex items-center justify-between border-b border-line px-6 py-4">
<h2 className="font-display text-lg font-semibold text-white">{title}</h2> <h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" 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" /> <XIcon className="h-4 w-4" />
</button> </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>
);
}
+114 -45
View File
@@ -4,107 +4,176 @@ import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
import { CallBar } from './CallUI'; import { useTheme } from '../context/ThemeContext';
import { import {
ChatBubbleIcon, ChatBubbleIcon,
GearIcon, GearIcon,
LogoLockup, LogoMark,
MoonIcon,
ShieldIcon, ShieldIcon,
SignOutIcon, SignOutIcon,
SunIcon,
UsersIcon, UsersIcon,
} from './icons'; } from './icons';
import { UserBar } from './UserBar';
interface NavItem { interface NavItem {
to: string; to: string;
labelKey: string; labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: 'friends' | 'chats';
} }
const BASE_NAV_ITEMS: NavItem[] = [ const PRIMARY_NAV: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon }, { to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon }, { to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
]; ];
const ADMIN_NAV_ITEM: NavItem = { const ADMIN_NAV: NavItem = {
to: '/admin', to: '/admin',
labelKey: 'app:nav.admin', labelKey: 'app:nav.admin',
icon: ShieldIcon, 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() { export function Sidebar() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { signOut, profile } = useAuth(); const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext(); const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext(); 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 ( return (
<aside <aside
aria-label="Primary navigation" 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"> <div className="flex h-10 w-10 items-center justify-center" title="Netralax">
<LogoLockup tone="dark" className="h-8 w-auto" aria-label="Netralax" /> <LogoMark className="h-8 w-8" />
</div> </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) => { {navItems.map((item) => {
const badge = const badgeCount =
item.to === '/friends' item.badge === 'friends'
? incomingCount ? incomingCount
: item.to === '/chats' : item.badge === 'chats'
? totalUnread ? totalUnread
: 0; : 0;
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
return ( return (
<NavLink <RailNavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
aria-label={ariaLabel} label={t(item.labelKey)}
className={({ isActive }) => icon={item.icon}
[ badge={badgeCount}
'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>
); );
})} })}
</nav> </nav>
<div className="flex-1" /> <div className="flex-1" />
<div className="border-t border-white/5 p-3"> <div className="flex flex-col items-center gap-2">
<CallBar /> <RailNavLink
<UserBar /> to="/settings"
<button label={t('app:nav.settings')}
type="button" 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()} 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" icon={SignOutIcon}
> tone="danger"
<SignOutIcon className="h-4 w-4" /> />
<span>{t('app:sidebar.sign_out')}</span>
</button>
</div> </div>
</aside> </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); const display = count > 99 ? '99+' : String(count);
return ( return (
<span <span
aria-hidden="true" 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} {display}
</span> </span>
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
: t('app:chats.typing_many', { count: typingUserIds.length }); : t('app:chats.typing_many', { count: typingUserIds.length });
return ( 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"> <span className="inline-flex items-center gap-2">
<TypingDots /> <TypingDots />
<span>{text}</span> <span>{text}</span>
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
function TypingDots() { function TypingDots() {
return ( return (
<span aria-hidden="true" className="inline-flex items-center gap-0.5"> <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-fg-muted [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-fg-muted [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] [animation-delay:0.3s]" />
</span> </span>
); );
} }
+7 -7
View File
@@ -57,22 +57,22 @@ export function UpdateToast() {
<div <div
role="status" role="status"
aria-live="polite" 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 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" /> <SparklesIcon className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <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' })} {t('app:update.available', { defaultValue: 'Update verfügbar' })}
{state.version ? ' · v' + state.version : ''} {state.version ? ' · v' + state.version : ''}
</p> </p>
{state.notes && ( {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 && ( {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> </div>
{!installing && ( {!installing && (
@@ -80,7 +80,7 @@ export function UpdateToast() {
type="button" type="button"
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
aria-label={t('common:close', { defaultValue: 'Schließen' })} 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" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
@@ -90,7 +90,7 @@ export function UpdateToast() {
type="button" type="button"
onClick={() => void handleInstall()} onClick={() => void handleInstall()}
disabled={installing} 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 ? ( {installing ? (
<> <>
+18 -17
View File
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons'; import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline']; const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = { const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400', online: 'bg-emerald-500',
idle: 'bg-amber-400', idle: 'bg-amber-400',
dnd: 'bg-rose-500', dnd: 'bg-rose-500',
invisible: 'bg-neutral-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() { export function UserBar() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth(); const { profile, refreshProfile } = useAuth();
@@ -53,34 +50,38 @@ export function UserBar() {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} 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"> <div className="relative">
{avatarLetter(profile?.displayName ?? profile?.username)} <Avatar
url={profile?.avatarUrl ?? null}
displayName={profile?.displayName ?? profile?.username}
className="h-9 w-9 text-sm"
/>
<span <span
className={ 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] PRESENCE_DOT[presence]
} }
/> />
</div> </div>
<div className="min-w-0 flex-1"> <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 ?? '—'} {profile?.displayName ?? '—'}
</p> </p>
<p className="truncate text-xs text-neutral-400"> <p className="truncate text-xs text-fg-muted">
{profile?.username ? '@' + profile.username : '—'} {t('app:presence.' + presence)}
</p> </p>
</div> </div>
<ChevronDownIcon <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> </button>
{open && ( {open && (
<div <div
role="menu" 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) => ( {PRESENCE_OPTIONS.map((opt) => (
<button <button
@@ -90,12 +91,12 @@ export function UserBar() {
aria-checked={opt === presence} aria-checked={opt === presence}
onClick={() => void changePresence(opt)} onClick={() => void changePresence(opt)}
disabled={busy} 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={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span> <span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && ( {opt === presence && (
<span className="text-xs text-brand-300"></span> <span className="text-xs text-accent"></span>
)} )}
</button> </button>
))} ))}
+83
View File
@@ -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) { export function LogoMark(props: IconProps) {
return ( return (
<svg <svg
+49
View File
@@ -90,6 +90,10 @@ export interface RemoteScreenShare {
participantName: string; participantName: string;
} }
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue { interface CallContextValue {
state: CallState; state: CallState;
room: Room | null; room: Room | null;
@@ -102,6 +106,9 @@ interface CallContextValue {
// Remembers the conversation of the last call we left so a sidebar widget // Remembers the conversation of the last call we left so a sidebar widget
// can show "still live — rejoin" while peers stay in the room. // can show "still live — rejoin" while peers stay in the room.
lastCallConversationId: string | null; lastCallConversationId: string | null;
// Clean-Rail UI state — display mode + focused participant id.
callMode: CallMode;
focusedId: string | null;
// Actions: // Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
@@ -111,6 +118,8 @@ interface CallContextValue {
toggleMute: () => void; toggleMute: () => void;
toggleScreenShare: () => Promise<void>; toggleScreenShare: () => Promise<void>;
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
} }
const CallContext = createContext<CallContextValue | null>(null); const CallContext = createContext<CallContextValue | null>(null);
@@ -135,6 +144,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isScreenSharing, setIsScreenSharing] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]); const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null); const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null); const presenceChannelRef = useRef<RealtimeChannel | null>(null);
@@ -954,6 +965,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]); }, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
// Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused.
useEffect(() => {
if (state.kind === 'idle' || state.kind === 'error') {
setCallModeState('grid');
setFocusedIdState(null);
}
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (callMode !== 'fullscreen') return;
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
const value = useMemo<CallContextValue>( const value = useMemo<CallContextValue>(
() => ({ () => ({
state, state,
@@ -964,6 +1005,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
isScreenSharing, isScreenSharing,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode,
focusedId,
startCall, startCall,
joinActiveCall, joinActiveCall,
acceptIncoming, acceptIncoming,
@@ -972,6 +1015,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
dismissLastCall, dismissLastCall,
setCallMode,
setFocusedId,
}), }),
[ [
state, state,
@@ -982,6 +1027,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
isScreenSharing, isScreenSharing,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode,
focusedId,
startCall, startCall,
joinActiveCall, joinActiveCall,
acceptIncoming, acceptIncoming,
@@ -990,6 +1037,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
dismissLastCall, dismissLastCall,
setCallMode,
setFocusedId,
], ],
); );
+53
View File
@@ -0,0 +1,53 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { applyTheme, getInitialTheme, setThemePersisted, type Theme } from '../lib/theme';
interface ThemeContextValue {
theme: Theme;
toggle: () => void;
setTheme: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
setThemePersisted(next);
setThemeState(next);
}, []);
const toggle = useCallback(() => {
setThemeState((prev) => {
const next: Theme = prev === 'dark' ? 'light' : 'dark';
setThemePersisted(next);
return next;
});
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, toggle, setTheme }),
[theme, toggle, setTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+77
View File
@@ -0,0 +1,77 @@
import { supabase } from './supabase';
const BUCKET = 'profile-avatars';
const MAX_DIM = 512;
const QUALITY = 0.85;
// Resizes the source image to a centred-cropped square ≤ MAX_DIM and
// re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare).
async function resizeToSquare(file: File): Promise<Blob> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('image load failed'));
i.src = url;
});
const minSide = Math.min(img.naturalWidth, img.naturalHeight);
const sx = (img.naturalWidth - minSide) / 2;
const sy = (img.naturalHeight - minSide) / 2;
const target = Math.min(MAX_DIM, minSide);
const canvas = document.createElement('canvas');
canvas.width = target;
canvas.height = target;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/webp', QUALITY),
);
if (blob) return blob;
const jpeg = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
);
if (!jpeg) throw new Error('canvas toBlob returned null');
return jpeg;
} finally {
URL.revokeObjectURL(url);
}
}
export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted');
}
const blob = await resizeToSquare(file);
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
// Random filename so old uploads don't get overwritten before we update
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image.
const name =
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
contentType: blob.type,
cacheControl: '604800',
upsert: false,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
return pub.publicUrl;
}
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
// Public URLs look like
// https://<host>/storage/v1/object/public/profile-avatars/<path>
// Extract <path> and remove.
const marker = '/object/public/' + BUCKET + '/';
const idx = publicUrl.indexOf(marker);
if (idx === -1) return;
const path = publicUrl.slice(idx + marker.length);
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
}
+29 -5
View File
@@ -24,19 +24,24 @@ function rawFrom(table: string) {
return (supabase as unknown as { from: (t: string) => any }).from(table); return (supabase as unknown as { from: (t: string) => any }).from(table);
} }
// Module-level flag — gap-fill runs once per (user, device) combo per
// process lifetime. Page reloads / route changes don't re-trigger it.
const backfilledKey = new Set<string>();
export function startConversationKeySync( export function startConversationKeySync(
ownUserId: string, ownUserId: string,
ownDeviceId: string, ownDeviceId: string,
): () => void { ): () => void {
let cancelled = false; let cancelled = false;
let priv: Uint8Array | null = null; let priv: Uint8Array | null = null;
const dedupeKey = ownUserId + ':' + ownDeviceId;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => { void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
if (cancelled) return; if (cancelled) return;
priv = pk; priv = pk;
if (!priv) return; if (!priv) return;
// Run a full backfill once we have the private key — covers devices that if (backfilledKey.has(dedupeKey)) return;
// registered while we were offline. backfilledKey.add(dedupeKey);
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv }); await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
}); });
@@ -164,9 +169,12 @@ async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<vo
try { try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx); await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
} catch (err: unknown) { } catch (err: unknown) {
// Most common: this device hasn't been wrapped for us yet either, so // Backfill is best-effort. Most common silent failures:
// tryGetConvKey couldn't unwrap. Another peer with the key will fill // - tryGetConvKey couldn't unwrap (another peer will fill the gap).
// the gap when they hit syncAllExistingGaps. // - RLS rejects because the recipient's owner is a pending (not-yet
// accepted) DM member, or was removed from the conv.
// Both are recoverable / expected, swallow without spam.
if (isExpectedShareFailure(err)) continue;
console.warn('keySync: shareConvKeyToDevice gap-fill failed', { console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
convId, convId,
recipient: dev.id, recipient: dev.id,
@@ -176,6 +184,22 @@ async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<vo
} }
} }
function isExpectedShareFailure(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as { message?: string; code?: string; details?: string; status?: number };
const code = (e.code ?? '').toString();
const status = e.status;
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
return (
status === 403 ||
code === '42501' || // postgres: insufficient_privilege (RLS)
code === '23505' || // unique_violation
haystack.includes('row-level security') ||
haystack.includes('does not have it yet') ||
haystack.includes('Forbidden')
);
}
async function wrapForOneDevice( async function wrapForOneDevice(
ctx: SyncCtx, ctx: SyncCtx,
newDeviceId: string, newDeviceId: string,
+8 -1
View File
@@ -4,6 +4,8 @@ import {
sendNotification, sendNotification,
} from '@tauri-apps/plugin-notification'; } from '@tauri-apps/plugin-notification';
import { isTauriRuntime } from './globalShortcut';
// Tracks whether permission has already been requested this session so we // Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS. // don't spam the OS prompt. Actual permission state lives in the OS.
let permissionChecked = false; let permissionChecked = false;
@@ -12,6 +14,11 @@ let permissionGranted = false;
export async function ensureNotificationPermission(): Promise<boolean> { export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted; if (permissionChecked) return permissionGranted;
permissionChecked = true; permissionChecked = true;
if (!isTauriRuntime()) {
// Web preview / Chrome — Tauri notification plugin not available.
permissionGranted = false;
return false;
}
try { try {
let granted = await isPermissionGranted(); let granted = await isPermissionGranted();
if (!granted) { if (!granted) {
@@ -20,7 +27,6 @@ export async function ensureNotificationPermission(): Promise<boolean> {
} }
permissionGranted = granted; permissionGranted = granted;
} catch (err: unknown) { } catch (err: unknown) {
// Not running under Tauri (e.g. web preview) — fall back silently.
permissionGranted = false; permissionGranted = false;
console.warn('notification permission check failed', err); console.warn('notification permission check failed', err);
} }
@@ -40,6 +46,7 @@ interface NotifyOpts {
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> { export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
if (!force && isAppFocused()) return; if (!force && isAppFocused()) return;
if (!isTauriRuntime()) return;
const granted = await ensureNotificationPermission(); const granted = await ensureNotificationPermission();
if (!granted) return; if (!granted) return;
try { try {
+44
View File
@@ -0,0 +1,44 @@
// Clean-Rail theme: persisted `.dark` class on <html>. Default follows the
// system preference; user-toggle persists to localStorage and wins over it.
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'netralax.theme';
function readStoredTheme(): Theme | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark') return raw;
} catch {
/* localStorage unavailable (private mode / sandbox) */
}
return null;
}
function writeStoredTheme(theme: Theme): void {
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* localStorage unavailable */
}
}
function systemPrefersDark(): boolean {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export function getInitialTheme(): Theme {
return readStoredTheme() ?? (systemPrefersDark() ? 'dark' : 'light');
}
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
}
export function setThemePersisted(theme: Theme): void {
writeStoredTheme(theme);
applyTheme(theme);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Participant, Room } from 'livekit-client';
import { RoomEvent } from 'livekit-client';
import { useEffect, useState } from 'react';
// Subscribe to LiveKit ActiveSpeakersChanged to drive the speaking-ring pulse
// on participant tiles. Returns the set of currently speaking identities
// (includes local participant when they're active).
export function useActiveSpeakers(room: Room | null): Set<string> {
const [ids, setIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
if (!room) {
setIds(new Set());
return;
}
const update = (speakers: Participant[]) => {
const next = new Set<string>();
for (const s of speakers) {
if (s.identity) next.add(s.identity);
}
setIds(next);
};
// Seed from current state.
update(room.activeSpeakers ?? []);
room.on(RoomEvent.ActiveSpeakersChanged, update);
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, update);
};
}, [room]);
return ids;
}
-94
View File
@@ -1,94 +0,0 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Observes the `call-presence:<conversationId>` channel for every conversation
// the user is a member of and returns the first one (if any) that currently
// has peers other than the viewer in the call. Enables a "call is live —
// rejoin" affordance in the sidebar for conversations the viewer never
// joined, mirroring Discord's active-voice indicator.
//
// Uses polling on each channel's `presenceState()` to side-step Supabase's
// topic-based channel dedupe (see useCallPresence.ts).
export function useAnyActiveCall(
conversationIds: readonly string[],
myId: string | null,
): { conversationId: string; userIds: string[] } | null {
const [byConv, setByConv] = useState<Record<string, string[]>>({});
const key = conversationIds.join(',');
useEffect(() => {
if (conversationIds.length === 0) {
setByConv({});
return;
}
const tracked: Array<{ id: string; ch: RealtimeChannel; owns: boolean }> = [];
for (const id of conversationIds) {
const ch = supabase.channel('call-presence:' + id, {
config: {
presence: {
key: 'obs-' + Math.random().toString(36).slice(2, 8),
enabled: true,
},
},
});
const owns = ch.state === 'closed';
if (owns) void ch.subscribe();
tracked.push({ id, ch, owns });
}
const resync = () => {
setByConv((prev) => {
const next: Record<string, string[]> = {};
let changed = false;
for (const { id, ch } of tracked) {
const presState = ch.presenceState() as Record<
string,
Array<Record<string, unknown>>
>;
const ids = new Set<string>();
for (const list of Object.values(presState)) {
for (const e of list) {
const uid = e?.userId;
if (typeof uid === 'string') ids.add(uid);
}
}
const arr = Array.from(ids).sort();
next[id] = arr;
const prevArr = prev[id] ?? [];
if (prevArr.length !== arr.length || !arr.every((v, i) => v === prevArr[i])) {
changed = true;
}
}
// Also pick up removed conv ids.
if (!changed && Object.keys(prev).length !== Object.keys(next).length) {
changed = true;
}
return changed ? next : prev;
});
};
resync();
const pollId = window.setInterval(resync, 1500);
return () => {
window.clearInterval(pollId);
for (const { ch, owns } of tracked) {
if (owns) void supabase.removeChannel(ch);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
for (const id of conversationIds) {
const users = byConv[id] ?? [];
const others = myId ? users.filter((u) => u !== myId) : users;
if (others.length > 0) {
return { conversationId: id, userIds: others };
}
}
return null;
}
+32 -21
View File
@@ -6,11 +6,15 @@ import { supabase } from './supabase';
// the set of userIds currently in that call. Independent of whether the // the set of userIds currently in that call. Independent of whether the
// viewer is in the room — used to show "Active call · Join" affordances. // viewer is in the room — used to show "Active call · Join" affordances.
// //
// Supabase realtime 2.103+ dedupes channels by topic: calling // Why presence-callbacks + no-removeChannel:
// `supabase.channel(topic)` returns an existing channel if one already exists. // - Supabase realtime 2.103+ dedupes channels by topic. CallContext owns
// That means an observer can't safely register `.on('presence', ...)` because // the same channel during a live call to track its own presence.
// the tracker (CallContext) may have already subscribed it. We side-step this // - If this hook removed the channel on unmount (e.g. when ConversationHeader
// by polling `presenceState()` on the (shared or fresh) channel. // hides during an active call), the live tracking connection dies and the
// banner never shows the rejoin option afterwards.
// - Binding our own presence callbacks (sync/join/leave) ensures the local
// presence state is updated even when CallContext also listens on the same
// channel — multiple callbacks can co-exist.
export function useCallPresence(conversationId: string | undefined): string[] { export function useCallPresence(conversationId: string | undefined): string[] {
const [activeUserIds, setActiveUserIds] = useState<string[]>([]); const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
@@ -24,23 +28,13 @@ export function useCallPresence(conversationId: string | undefined): string[] {
config: { config: {
presence: { presence: {
key: 'observer-' + Math.random().toString(36).slice(2, 8), key: 'observer-' + Math.random().toString(36).slice(2, 8),
// Supabase realtime only sends presence_state/diff events to a // Server only routes presence to channels that opt-in. Without this
// channel that has presence enabled. Without `enabled: true` (and // flag we'd receive zero presence_state/presence_diff messages.
// no `.on('presence', ...)` bindings) the server treats the channel
// as non-presence and `presenceState()` never populates.
enabled: true, enabled: true,
}, },
}, },
}); });
// If Supabase returned a fresh (closed) channel, we own it and must
// subscribe/remove it. If it returned an already-subscribed channel
// (tracker owns it), we just read state.
const ownsChannel = channel.state === 'closed';
if (ownsChannel) {
void channel.subscribe();
}
const resync = () => { const resync = () => {
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>; const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
const ids = new Set<string>(); const ids = new Set<string>();
@@ -57,14 +51,31 @@ export function useCallPresence(conversationId: string | undefined): string[] {
}); });
}; };
// Supabase realtime forbids `.on()` after `.subscribe()`. So we only bind
// callbacks when we own the subscribe (channel was 'closed'). For deduped
// shared channels, the polling fallback below covers state changes.
if (channel.state === 'closed') {
channel
.on('presence', { event: 'sync' }, resync)
.on('presence', { event: 'join' }, resync)
.on('presence', { event: 'leave' }, resync);
void channel.subscribe();
}
// Initial poll covers the case where the channel is already 'joined' (we
// dedupe'd onto an existing subscription) and won't fire a fresh sync.
resync(); resync();
const pollId = window.setInterval(resync, 1500); // Belt-and-suspenders: a slow poll catches any drift between callback and
// server state (e.g. if a presence event was dropped during reconnect).
const pollId = window.setInterval(resync, 3000);
return () => { return () => {
window.clearInterval(pollId); window.clearInterval(pollId);
if (ownsChannel) { // Don't removeChannel — the channel is shared with CallContext (and any
void supabase.removeChannel(channel); // sibling observers) via Supabase's topic dedupe. Removing it would
} // tear down a live call's presence tracker. The channel naturally
// outlives the hook; leaks are bounded by the small number of
// conversations a user holds open in a session.
}; };
}, [conversationId]); }, [conversationId]);
+39 -37
View File
@@ -16,6 +16,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { import {
AlertIcon, AlertIcon,
CopyIcon, CopyIcon,
@@ -64,9 +65,10 @@ export function AdminPage() {
}, [refresh]); }, [refresh]);
return ( return (
<div className="h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header> <header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-white"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:admin.title')} {t('app:admin.title')}
</h1> </h1>
</header> </header>
@@ -74,16 +76,16 @@ export function AdminPage() {
{error && ( {error && (
<div <div
role="alert" role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100" className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
> >
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p> <p className="min-w-0 flex-1 break-words">{error}</p>
</div> </div>
)} )}
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-sm text-neutral-500"> <div className="flex items-center gap-2 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4 text-brand-400" /> <SpinnerIcon className="h-4 w-4 text-accent" />
</div> </div>
) : ( ) : (
<> <>
@@ -93,14 +95,15 @@ export function AdminPage() {
</> </>
)} )}
</div> </div>
</div>
); );
} }
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl"> <section className="rounded-2xl border border-line bg-surface-2 p-5">
<header className="mb-4 flex items-center justify-between"> <header className="mb-4 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2> <h2 className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">{title}</h2>
{action} {action}
</header> </header>
{children} {children}
@@ -199,7 +202,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
type="button" type="button"
onClick={() => void handleCreate()} onClick={() => void handleCreate()}
disabled={busy} disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:opacity-60"
> >
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />} {busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
<span>{t('app:admin.invites_create')}</span> <span>{t('app:admin.invites_create')}</span>
@@ -207,11 +210,11 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
} }
> >
{invites.length === 0 ? ( {invites.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p> <p className="text-sm text-fg-muted">{t('app:admin.invites_empty')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wide text-neutral-500"> <thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
<tr> <tr>
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th> <th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th> <th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
@@ -220,7 +223,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
<th className="py-2" /> <th className="py-2" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-line">
{invites.map((inv) => { {invites.map((inv) => {
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false; const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
const status = inv.disabled const status = inv.disabled
@@ -229,10 +232,10 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
? t('app:admin.invite_status_expired') ? t('app:admin.invite_status_expired')
: t('app:admin.invite_status_active'); : t('app:admin.invite_status_active');
const statusColor = inv.disabled const statusColor = inv.disabled
? 'text-rose-300' ? 'text-rose-600 dark:text-rose-300'
: expired : expired
? 'text-amber-300' ? 'text-amber-600 dark:text-amber-300'
: 'text-emerald-300'; : 'text-emerald-600 dark:text-emerald-300';
const usesText = inv.usesLimit const usesText = inv.usesLimit
? inv.usesCount + '/' + inv.usesLimit ? inv.usesCount + '/' + inv.usesLimit
: inv.usesCount + '/∞'; : inv.usesCount + '/∞';
@@ -241,9 +244,9 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
: t('app:admin.invite_expires_never'); : t('app:admin.invite_expires_never');
return ( return (
<tr key={inv.code} className="py-2"> <tr key={inv.code} className="py-2">
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td> <td className="py-2 pr-3 font-mono text-xs text-fg">{inv.code}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td> <td className="py-2 pr-3 text-xs text-fg-muted">{usesText}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td> <td className="py-2 pr-3 text-xs text-fg-muted">{expiresText}</td>
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td> <td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
<td className="py-2 text-right"> <td className="py-2 text-right">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
@@ -260,7 +263,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
<button <button
type="button" type="button"
onClick={() => void handleToggle(inv.code, inv.disabled)} onClick={() => void handleToggle(inv.code, inv.disabled)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10" className="rounded-md border border-line bg-surface-3 px-2 py-1 text-[11px] font-medium text-fg transition hover:brightness-95"
> >
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')} {inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
</button> </button>
@@ -301,20 +304,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
return ( return (
<Section title={t('app:admin.users_title')}> <Section title={t('app:admin.users_title')}>
{users.length === 0 ? ( {users.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p> <p className="text-sm text-fg-muted">{t('app:admin.users_empty')}</p>
) : ( ) : (
<ul className="divide-y divide-white/5"> <ul className="divide-y divide-line">
{users.map((u) => { {users.map((u) => {
const letter =
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return ( return (
<li key={u.userId} className="flex items-center gap-3 py-3"> <li key={u.userId} className="flex items-center gap-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30"> <Avatar
{letter} displayName={u.displayName ?? u.username}
</div> className="h-9 w-9 text-sm"
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p> <p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{u.username}</p> <p className="truncate text-xs text-fg-muted">@{u.username}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<FlagChip <FlagChip
@@ -361,8 +363,8 @@ function Toggle({
return ( return (
<label className="flex cursor-pointer items-start justify-between gap-4"> <label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span> <span className="block text-sm text-fg">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>} {hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
</span> </span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0"> <span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input <input
@@ -372,8 +374,8 @@ function Toggle({
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className="peer sr-only" className="peer sr-only"
/> />
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" /> <span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" /> <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
</span> </span>
</label> </label>
); );
@@ -399,8 +401,8 @@ function IconButton({
className={ className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' + 'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
(tone === 'danger' (tone === 'danger'
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40' ? 'border-rose-500/40 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 focus-visible:ring-rose-400/40 dark:text-rose-300'
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40') : 'border-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/40')
} }
> >
{children} {children}
@@ -421,15 +423,15 @@ function FlagChip({
}) { }) {
const activeClass = const activeClass =
tone === 'danger' tone === 'danger'
? 'border-rose-400/40 bg-rose-500/20 text-rose-100' ? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
: 'border-brand-400/40 bg-brand-500/20 text-brand-100'; : 'border-accent/40 bg-accent/20 text-accent';
return ( return (
<button <button
type="button" type="button"
onClick={() => onToggle(!active)} onClick={() => onToggle(!active)}
className={ className={
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10') (active ? activeClass : 'border-line bg-surface-3 text-fg-muted hover:brightness-95')
} }
> >
{label} {label}
+121 -41
View File
@@ -5,7 +5,14 @@ import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom'; import { NavLink, Outlet, useParams } from 'react-router-dom';
import { CreateGroupDialog } from '../components/CreateGroupDialog'; import { CreateGroupDialog } from '../components/CreateGroupDialog';
import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons'; import {
AddUserIcon,
ChatBubbleIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
} from '../components/icons';
import { UserBar } from '../components/UserBar';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
@@ -13,6 +20,7 @@ export function ChatsPage() {
const { conversations, loading, error, refresh, unread } = useConversationsContext(); const { conversations, loading, error, refresh, unread } = useConversationsContext();
const { id: activeId } = useParams<{ id: string }>(); const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [query, setQuery] = useState('');
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...conversations].sort((a, b) => { return [...conversations].sort((a, b) => {
@@ -22,14 +30,26 @@ export function ChatsPage() {
}); });
}, [conversations]); }, [conversations]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return sorted;
return sorted.filter((c) => {
const title = (c.type === 'dm' ? c.peer?.displayName : c.name) ?? '';
const handle = c.type === 'dm' ? c.peer?.username ?? '' : '';
return title.toLowerCase().includes(q) || handle.toLowerCase().includes(q);
});
}, [sorted, query]);
return ( return (
<div className="flex h-full"> <div className="flex h-full">
<ConversationList <ConversationList
items={sorted} items={filtered}
loading={loading} loading={loading}
error={error} error={error}
activeId={activeId} activeId={activeId}
unread={unread} unread={unread}
query={query}
onQueryChange={setQuery}
onAccept={async (id) => { onAccept={async (id) => {
try { try {
await acceptDm(supabase, id); await acceptDm(supabase, id);
@@ -41,7 +61,7 @@ export function ChatsPage() {
}} }}
onNewGroup={() => setCreateOpen(true)} onNewGroup={() => setCreateOpen(true)}
/> />
<div className="flex-1 border-l border-white/5"> <div className="flex-1 border-l border-line bg-surface-3">
<Outlet /> <Outlet />
</div> </div>
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
@@ -49,32 +69,38 @@ export function ChatsPage() {
); );
} }
interface ConversationListProps {
items: ConversationSummary[];
loading: boolean;
error: string | null;
activeId: string | undefined;
unread: Record<string, number>;
query: string;
onQueryChange: (q: string) => void;
onAccept: (id: string) => void;
onNewGroup: () => void;
}
function ConversationList({ function ConversationList({
items, items,
loading, loading,
error, error,
activeId, activeId,
unread, unread,
query,
onQueryChange,
onAccept, onAccept,
onNewGroup, onNewGroup,
}: { }: ConversationListProps) {
items: ConversationSummary[];
loading: boolean;
error: string | null;
activeId: string | undefined;
unread: Record<string, number>;
onAccept: (id: string) => void;
onNewGroup: () => void;
}) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
return ( return (
<aside <aside
aria-label="Conversations" aria-label="Conversations"
className="flex h-full w-[320px] shrink-0 flex-col bg-ink-900/40" className="flex h-full w-[320px] shrink-0 flex-col bg-surface-2"
> >
<header className="flex items-center justify-between px-4 pb-3 pt-5"> <header className="flex items-center justify-between px-4 pb-3 pt-5">
<h2 className="font-display text-base font-semibold tracking-tight text-white"> <h2 className="font-display text-base font-semibold tracking-tight text-fg">
{t('app:nav.chats')} {t('app:nav.chats')}
</h2> </h2>
<button <button
@@ -82,29 +108,45 @@ function ConversationList({
onClick={onNewGroup} onClick={onNewGroup}
aria-label={t('app:chats.new_group')} aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')} title={t('app:chats.new_group')}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-medium text-neutral-300 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-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
<UsersIcon className="h-3.5 w-3.5" /> <AddUserIcon className="h-4 w-4" />
<PlusIcon className="h-3 w-3" />
</button> </button>
</header> </header>
<div className="px-3 pb-2">
<label className="relative block">
<span className="sr-only">{t('app:chats.search', { defaultValue: 'Suche' })}</span>
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted"
/>
<input
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={t('app:chats.search_placeholder', { defaultValue: 'Suche…' })}
className="w-full rounded-lg border border-line bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</label>
</div>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500"> <div className="flex items-center gap-2 px-4 py-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div> </div>
) : error ? ( ) : error ? (
<p className="px-4 py-2 text-xs text-rose-300">{error}</p> <p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
<ChatBubbleIcon className="h-5 w-5" /> <ChatBubbleIcon className="h-5 w-5" />
</div> </div>
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p> <p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p>
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p> <p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p>
</div> </div>
) : ( ) : (
<ul className="flex-1 overflow-y-auto px-2 pb-4"> <ul className="flex-1 overflow-y-auto px-2 pb-2">
{items.map((c) => ( {items.map((c) => (
<li key={c.id}> <li key={c.id}>
<ConversationRow <ConversationRow
@@ -117,6 +159,10 @@ function ConversationList({
))} ))}
</ul> </ul>
)} )}
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
<UserBar />
</div>
</aside> </aside>
); );
} }
@@ -136,16 +182,22 @@ function ConversationRow({
const title = const title =
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?'); item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : ''; const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?'; const avatarUrl =
item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
const preview =
item.type === 'dm' ? handle : t('app:chats.group_preview', {
count: item.members.length,
defaultValue: `${item.members.length} Mitglieder`,
});
if (!item.acceptedByMe) { if (!item.acceptedByMe) {
return ( return (
<div className="my-1 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3"> <div className="my-1 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar letter={letter} /> <ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{title}</p> <p className="truncate text-sm font-semibold text-fg">{title}</p>
<p className="truncate text-xs text-amber-200/80"> <p className="truncate text-xs text-amber-600 dark:text-amber-200/80">
{t('app:friends.incoming_request')} {t('app:friends.incoming_request')}
</p> </p>
</div> </div>
@@ -153,7 +205,7 @@ function ConversationRow({
<button <button
type="button" type="button"
onClick={() => onAccept(item.id)} onClick={() => onAccept(item.id)}
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40" className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 dark:text-amber-100"
> >
{t('app:friends.action_accept')} {t('app:friends.action_accept')}
</button> </button>
@@ -165,26 +217,28 @@ function ConversationRow({
<NavLink <NavLink
to={'/chats/' + item.id} to={'/chats/' + item.id}
className={ className={
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5') (active
? 'bg-accent/15 text-fg'
: 'text-fg hover:bg-surface-3/70')
} }
> >
<Avatar letter={letter} /> <ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <p
className={ className={
'truncate text-sm ' + 'truncate text-sm ' +
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white') (unreadCount > 0 ? 'font-bold' : 'font-semibold')
} }
> >
{title} {title}
</p> </p>
<p className="truncate text-xs text-neutral-500">{handle}</p> <p className="truncate text-xs text-fg-muted">{preview}</p>
</div> </div>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
aria-label={'Unread: ' + unreadCount} aria-label={'Unread: ' + unreadCount}
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" className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg"
> >
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
</span> </span>
@@ -193,9 +247,35 @@ function ConversationRow({
); );
} }
function Avatar({ letter }: { letter: string }) { function ConvAvatar({
url,
title,
isGroup,
}: {
url: string | null;
title: string;
isGroup: boolean;
}) {
if (url) {
return ( return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30"> <img
src={url}
alt=""
className="h-9 w-9 shrink-0 rounded-full object-cover"
draggable={false}
/>
);
}
if (isGroup) {
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-4 w-4" />
</div>
);
}
const letter = title.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent">
{letter} {letter}
</div> </div>
); );
@@ -206,13 +286,13 @@ export function ChatsEmptyState() {
return ( return (
<div className="flex h-full items-center justify-center p-10"> <div className="flex h-full items-center justify-center p-10">
<div className="max-w-md text-center"> <div className="max-w-md text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
<ChatBubbleIcon className="h-6 w-6" /> <ChatBubbleIcon className="h-6 w-6" />
</div> </div>
<h2 className="font-display text-2xl font-semibold tracking-tight text-white"> <h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:chats.select_prompt')} {t('app:chats.select_prompt')}
</h2> </h2>
<p className="mt-2 text-sm text-neutral-400">{t('app:chats.select_subtitle')}</p> <p className="mt-2 text-sm text-fg-muted">{t('app:chats.select_subtitle')}</p>
</div> </div>
</div> </div>
); );
+36 -13
View File
@@ -7,9 +7,11 @@ import { ConversationHeader } from '../components/ConversationHeader';
import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons'; import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MessageBubble } from '../components/MessageBubble'; import { MessageBubble } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator'; import { TypingIndicator } from '../components/TypingIndicator';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useConversationMessages } from '../lib/useConversationMessages'; import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions'; import { useMessageReactions } from '../lib/useMessageReactions';
@@ -157,14 +159,27 @@ export function ConversationPage() {
} }
const isGroup = conversation?.type === 'group'; const isGroup = conversation?.type === 'group';
const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration,
// and fullscreen cinema needs the whole slot.
const callHereActive =
(callState.kind === 'connected' ||
callState.kind === 'connecting' ||
callState.kind === 'outgoing') &&
callState.conversationId === id;
const incomingHere =
callState.kind === 'incoming' && callState.conversationId === id;
return ( return (
<div className="relative flex h-full flex-col"> <div className="relative flex h-full flex-col">
{!callHereActive && (
<ConversationHeader <ConversationHeader
conversation={conversation} conversation={conversation}
peerPresence={peerPresence} peerPresence={peerPresence}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
/> />
)}
{isGroup && conversation && ( {isGroup && conversation && (
<GroupInfoPanel <GroupInfoPanel
@@ -174,28 +189,36 @@ export function ConversationPage() {
/> />
)} )}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />} {conversation && <InCallPanel conversation={conversation} />}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4"> <div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-xs text-neutral-500"> <div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div> </div>
) : error ? ( ) : error ? (
<Banner>{error}</Banner> <Banner>{error}</Banner>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<p className="text-center text-sm text-neutral-500"></p> <p className="text-center text-sm text-fg-muted"></p>
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{messages.map((m, idx) => { {messages.map((m, idx) => {
const prev = messages[idx - 1]; const prev = messages[idx - 1];
const next = messages[idx + 1];
const grouped = idx > 0 && prev?.senderId === m.senderId; const grouped = idx > 0 && prev?.senderId === m.senderId;
const isLastOfRun = !next || next.senderId !== m.senderId;
const senderProfile =
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
return ( return (
<li key={m.id}> <li key={m.id}>
<MessageBubble <MessageBubble
message={m} message={m}
mine={m.senderId === myId} mine={m.senderId === myId}
groupedWithPrev={grouped} groupedWithPrev={grouped}
isLastOfRun={isLastOfRun}
senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''} conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
@@ -213,7 +236,7 @@ export function ConversationPage() {
members={conversation?.members ?? []} members={conversation?.members ?? []}
/> />
<form onSubmit={handleSend} className="border-t border-white/5 p-4"> <form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
{sendError && ( {sendError && (
<div className="mb-2"> <div className="mb-2">
<Banner>{sendError}</Banner> <Banner>{sendError}</Banner>
@@ -248,7 +271,7 @@ export function ConversationPage() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen" aria-label="Bild anhängen"
title="Bild anhängen" title="Bild anhängen"
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
@@ -265,14 +288,14 @@ export function ConversationPage() {
} }
}} }}
rows={1} rows={1}
placeholder="…" placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/> />
<button <button
type="submit" type="submit"
disabled={sending || (text.trim().length === 0 && attachments.length === 0)} disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
aria-busy={sending} aria-busy={sending}
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 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="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent 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"
> >
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />} {sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
</button> </button>
@@ -286,9 +309,9 @@ function Banner({ children }: { children: React.ReactNode }) {
return ( return (
<div <div
role="alert" role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100" className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
> >
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{children}</p> <p className="min-w-0 flex-1 break-words">{children}</p>
</div> </div>
); );
@@ -302,7 +325,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
return () => URL.revokeObjectURL(u); return () => URL.revokeObjectURL(u);
}, [file]); }, [file]);
return ( return (
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60"> <div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
{url ? ( {url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" /> <img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : ( ) : (
@@ -312,7 +335,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
type="button" type="button"
onClick={onRemove} onClick={onRemove}
aria-label="Entfernen" aria-label="Entfernen"
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white" className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
> >
<XIcon className="h-3 w-3" /> <XIcon className="h-3 w-3" />
</button> </button>
+39 -44
View File
@@ -10,7 +10,9 @@ import {
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '../components/Avatar';
import { import {
AlertIcon, AlertIcon,
ChatBubbleIcon, ChatBubbleIcon,
@@ -28,6 +30,7 @@ type Tab = 'friends' | 'pending' | 'requests';
export function FriendsPage() { export function FriendsPage() {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
const { friendships, loading, error, refresh } = useFriendshipsContext(); const { friendships, loading, error, refresh } = useFriendshipsContext();
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('friends'); const [tab, setTab] = useState<Tab>('friends');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
@@ -104,16 +107,17 @@ export function FriendsPage() {
); );
return ( return (
<div className="h-full bg-surface-3 text-fg">
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8"> <div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4"> <header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:friends.title')} {t('app:friends.title')}
</h1> </h1>
</header> </header>
<div className="space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" /> <SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
<input <input
type="search" type="search"
value={query} value={query}
@@ -122,7 +126,7 @@ export function FriendsPage() {
autoComplete="off" autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
spellCheck={false} spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="w-full rounded-lg border border-line bg-surface-2 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/> />
</div> </div>
{query.length > 0 && ( {query.length > 0 && (
@@ -138,7 +142,7 @@ export function FriendsPage() {
)} )}
</div> </div>
<div className="flex gap-2 border-b border-white/5"> <div className="flex gap-2 border-b border-line">
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}> <TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
{t('app:friends.tab_friends')} · {accepted.length} {t('app:friends.tab_friends')} · {accepted.length}
</TabButton> </TabButton>
@@ -165,7 +169,7 @@ export function FriendsPage() {
onMessage={() => onMessage={() =>
performAction('msg-' + f.peer.userId, async () => { performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId); const id = await createDm(supabase, f.peer.userId);
navigateToChat(id); navigate('/chats/' + id);
}) })
} }
onUnfriend={() => onUnfriend={() =>
@@ -207,15 +211,10 @@ export function FriendsPage() {
/> />
)} )}
</div> </div>
</div>
); );
} }
function navigateToChat(id: string): void {
// Push history + dispatch popstate so React Router re-evaluates the route.
window.history.pushState(null, '', '/chats/' + id);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string { function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const code = extractErrorCode(err); const code = extractErrorCode(err);
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') }); if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
@@ -241,8 +240,8 @@ function TabButton({
className={ className={
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' + '-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
(active (active
? 'border-brand-400 text-white' ? 'border-accent text-fg'
: 'border-transparent text-neutral-400 hover:text-neutral-200') : 'border-transparent text-fg-muted hover:text-fg')
} }
> >
{children} {children}
@@ -262,10 +261,11 @@ function FriendList({
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500"> <div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-fg-muted">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400"> <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-2 text-fg-muted">
<UsersIcon className="h-5 w-5" /> <UsersIcon className="h-5 w-5" />
</div> </div>
<p>{t(emptyKey)}</p> <p>{t(emptyKey)}</p>
</div> </div>
); );
@@ -283,26 +283,21 @@ function FriendList({
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) { function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
return ( return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm"> <div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<Avatar profile={profile} /> <Avatar
url={profile.avatarUrl}
displayName={profile.displayName ?? profile.username}
className="h-9 w-9 text-sm"
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p> <p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{profile.username}</p> <p className="truncate text-xs text-fg-muted">@{profile.username}</p>
</div> </div>
<div className="flex shrink-0 gap-2">{children}</div> <div className="flex shrink-0 gap-2">{children}</div>
</div> </div>
); );
} }
function Avatar({ profile }: { profile: ProfileBrief }) {
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter || '?'}
</div>
);
}
function FriendActions({ function FriendActions({
busy, busy,
onMessage, onMessage,
@@ -371,7 +366,7 @@ function PrimaryButton({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon} {busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
<span>{children}</span> <span>{children}</span>
@@ -393,7 +388,7 @@ function SecondaryButton({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg transition hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy && <SpinnerIcon className="h-3.5 w-3.5" />} {busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span> <span>{children}</span>
@@ -415,7 +410,7 @@ function DangerButton({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
> >
{busy && <SpinnerIcon className="h-3.5 w-3.5" />} {busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span> <span>{children}</span>
@@ -425,8 +420,8 @@ function DangerButton({
function LoadingRow() { function LoadingRow() {
return ( return (
<div className="flex items-center gap-3 text-sm text-neutral-500"> <div className="flex items-center gap-3 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4 text-brand-400" /> <SpinnerIcon className="h-4 w-4 text-accent" />
<span></span> <span></span>
</div> </div>
); );
@@ -440,14 +435,14 @@ function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.Re
className={ className={
'flex items-start gap-3 rounded-lg border p-3 text-sm ' + 'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
(isError (isError
? 'border-rose-500/20 bg-rose-500/10 text-rose-100' ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100') : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
} }
> >
{isError ? ( {isError ? (
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
) : ( ) : (
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" /> <CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
)} )}
<p className="min-w-0 flex-1 break-words">{children}</p> <p className="min-w-0 flex-1 break-words">{children}</p>
</div> </div>
@@ -474,24 +469,24 @@ function SearchResults({
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
if (query.length < 2) { if (query.length < 2) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>; return <p className="text-xs text-fg-muted">{t('app:friends.search_min_chars')}</p>;
} }
if (searching) { if (searching) {
return ( return (
<div className="flex items-center gap-2 text-xs text-neutral-500"> <div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
<span></span> <span></span>
</div> </div>
); );
} }
if (error) return <Banner kind="error">{error}</Banner>; if (error) return <Banner kind="error">{error}</Banner>;
if (results.length === 0) { if (results.length === 0) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>; return <p className="text-xs text-fg-muted">{t('app:friends.search_no_results')}</p>;
} }
return ( return (
<div> <div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500"> <p className="mb-2 text-xs font-medium uppercase tracking-[0.1em] text-fg-muted">
{t('app:friends.search_results_title')} {t('app:friends.search_results_title')}
</p> </p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
@@ -530,7 +525,7 @@ function SearchActionButton({
if (existing?.status === 'accepted') { if (existing?.status === 'accepted') {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200"> <span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-200">
<CheckCircleIcon className="h-3.5 w-3.5" /> <CheckCircleIcon className="h-3.5 w-3.5" />
{t('app:friends.already_friends')} {t('app:friends.already_friends')}
</span> </span>
@@ -539,7 +534,7 @@ function SearchActionButton({
if (existing?.status === 'pending' && existing.direction === 'outgoing') { if (existing?.status === 'pending' && existing.direction === 'outgoing') {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300"> <span className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg-muted">
{t('app:friends.request_sent')} {t('app:friends.request_sent')}
</span> </span>
); );
+166 -47
View File
@@ -4,12 +4,14 @@ import {
SUPPORTED_LOCALES, SUPPORTED_LOCALES,
type SupportedLocale, type SupportedLocale,
} from '@chat-app/shared/i18n'; } from '@chat-app/shared/i18n';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { LockIcon } from '../components/icons'; import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth'; import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup'; import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { import {
@@ -74,15 +76,20 @@ export function SettingsPage() {
} }
return ( return (
<div className="h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2"> <header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:settings.title')} {t('app:settings.title')}
</h1> </h1>
</header> </header>
{/* Account */} {/* Account */}
<Section title={t('app:settings.section_account')}> <Section title={t('app:settings.section_account')}>
<AvatarControls
patchProfile={patchProfile}
busy={busy}
/>
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} /> <Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} /> <Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono /> <Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
@@ -91,7 +98,7 @@ export function SettingsPage() {
{/* Appearance */} {/* Appearance */}
<Section title={t('app:settings.section_appearance')}> <Section title={t('app:settings.section_appearance')}>
<SettingRow label={t('app:settings.language')}> <SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1"> <div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
{SUPPORTED_LOCALES.map((locale) => { {SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale; const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return ( return (
@@ -101,10 +108,10 @@ export function SettingsPage() {
disabled={busy} disabled={busy}
onClick={() => void handleLocaleChange(locale)} onClick={() => void handleLocaleChange(locale)}
className={ className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active (active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40' ? 'bg-accent text-accent-fg'
: 'text-neutral-400 hover:text-neutral-200') : 'text-fg-muted hover:text-fg')
} }
> >
{LOCALE_LABELS[locale]} {LOCALE_LABELS[locale]}
@@ -136,10 +143,10 @@ export function SettingsPage() {
{/* Voice / Push-to-Talk + Audio Quality + E2EE */} {/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}> <Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioQualityControls /> <AudioQualityControls />
<div className="mt-3 border-t border-white/5 pt-3"> <div className="mt-3 border-t border-line pt-3">
<PttControls /> <PttControls />
</div> </div>
<div className="mt-3 border-t border-white/5 pt-3"> <div className="mt-3 border-t border-line pt-3">
<CallE2EEControls /> <CallE2EEControls />
</div> </div>
</Section> </Section>
@@ -152,8 +159,8 @@ export function SettingsPage() {
{/* Devices */} {/* Devices */}
<Section title={t('app:settings.section_devices')}> <Section title={t('app:settings.section_devices')}>
{device && ( {device && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4"> <div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200"> <div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
<LockIcon className="h-4 w-4" /> <LockIcon className="h-4 w-4" />
{t('app:settings.this_device')} {t('app:settings.this_device')}
</div> </div>
@@ -172,12 +179,13 @@ export function SettingsPage() {
<button <button
type="button" type="button"
onClick={() => void signOut()} onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-4 py-2.5 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/50" className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
> >
{t('app:settings.sign_out')} {t('app:settings.sign_out')}
</button> </button>
</Section> </Section>
</div> </div>
</div>
); );
} }
@@ -223,10 +231,10 @@ function PttControls() {
type="button" type="button"
onClick={() => setCapturing((v) => !v)} onClick={() => setCapturing((v) => !v)}
className={ className={
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(capturing (capturing
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse' ? 'border-accent bg-accent/20 text-fg animate-pulse'
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700') : 'border-line bg-surface-3 text-fg hover:brightness-95')
} }
> >
{capturing {capturing
@@ -283,7 +291,7 @@ function AudioQualityControls() {
return ( return (
<> <>
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}> <SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1"> <div className="inline-flex rounded-lg border border-line bg-surface-2 p-1">
{AUDIO_QUALITY_ORDER.map((q) => { {AUDIO_QUALITY_ORDER.map((q) => {
const active = cfg.quality === q; const active = cfg.quality === q;
return ( return (
@@ -292,10 +300,10 @@ function AudioQualityControls() {
type="button" type="button"
onClick={() => updateAudioSettings({ quality: q })} onClick={() => updateAudioSettings({ quality: q })}
className={ className={
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active (active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40' ? 'bg-accent text-accent-fg'
: 'text-neutral-400 hover:text-neutral-200') : 'text-fg-muted hover:text-fg')
} }
> >
{labels[q]} {labels[q]}
@@ -304,7 +312,7 @@ function AudioQualityControls() {
})} })}
</div> </div>
</SettingRow> </SettingRow>
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400"> <div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} /> <Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} /> <Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
@@ -312,7 +320,7 @@ function AudioQualityControls() {
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} /> <Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
</div> </div>
</div> </div>
<p className="text-xs text-neutral-500"> <p className="text-xs text-fg-muted">
{cfg.quality === 'hifi' {cfg.quality === 'hifi'
? t('app:settings.audio_hifi_hint', { ? t('app:settings.audio_hifi_hint', {
defaultValue: defaultValue:
@@ -327,6 +335,115 @@ function AudioQualityControls() {
); );
} }
interface AvatarControlsProps {
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
busy: boolean;
}
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
const { t } = useTranslation(['app']);
const { profile } = useAuth();
const inputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const userId = profile?.userId;
const url = profile?.avatarUrl ?? null;
async function handleFile(file: File) {
if (!userId) return;
setError(null);
setUploading(true);
try {
const newUrl = await uploadAvatar(userId, file);
const oldUrl = url;
await patchProfile({ avatarUrl: newUrl });
if (oldUrl) {
// Best-effort cleanup of the previous file (don't block on it).
void deleteAvatarObject(oldUrl).catch(() => {
/* ignore */
});
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'upload failed');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleRemove() {
if (!userId || !url) return;
setError(null);
setUploading(true);
try {
await patchProfile({ avatarUrl: null });
void deleteAvatarObject(url).catch(() => undefined);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'remove failed');
} finally {
setUploading(false);
}
}
return (
<div className="flex items-center gap-4">
<Avatar
url={url}
displayName={profile?.displayName ?? profile?.username}
className="h-16 w-16 text-2xl"
/>
<div className="flex flex-1 flex-col gap-1">
<div className="text-sm font-medium text-fg">
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
</div>
<div className="text-xs text-fg-muted">
{t('app:settings.avatar_hint', {
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
})}
</div>
{error && (
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy || uploading}
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{uploading
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
: url
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
</button>
{url && (
<button
type="button"
onClick={() => void handleRemove()}
disabled={busy || uploading}
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
</button>
)}
</div>
);
}
function DeviceKeyBackupControls() { function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { profile, device } = useAuth(); const { profile, device } = useAuth();
@@ -392,11 +509,11 @@ function DeviceKeyBackupControls() {
} }
return ( return (
<div className="mt-4 rounded-xl border border-white/10 bg-ink-900/40 p-4"> <div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-fg">
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })} {t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
</div> </div>
<p className="mt-1 text-xs text-neutral-400"> <p className="mt-1 text-xs text-fg-muted">
{t('app:settings.device_key_backup_hint', { {t('app:settings.device_key_backup_hint', {
defaultValue: defaultValue:
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.', 'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
@@ -404,7 +521,7 @@ function DeviceKeyBackupControls() {
</p> </p>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500"> <div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{t('app:settings.backup_export', { defaultValue: 'Export' })} {t('app:settings.backup_export', { defaultValue: 'Export' })}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -413,13 +530,13 @@ function DeviceKeyBackupControls() {
value={exportPass} value={exportPass}
onChange={(e) => setExportPass(e.target.value)} onChange={(e) => setExportPass(e.target.value)}
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })} placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none" className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/> />
<button <button
type="button" type="button"
disabled={busy || exportPass.length < 8 || !canRun} disabled={busy || exportPass.length < 8 || !canRun}
onClick={() => void handleExport()} onClick={() => void handleExport()}
className="cursor-pointer rounded-lg bg-brand-500/80 px-4 text-sm font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50" className="cursor-pointer rounded-lg bg-accent px-4 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
> >
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })} {t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
</button> </button>
@@ -430,13 +547,13 @@ function DeviceKeyBackupControls() {
value={backupOut} value={backupOut}
onClick={(e) => (e.target as HTMLTextAreaElement).select()} onClick={(e) => (e.target as HTMLTextAreaElement).select()}
rows={3} rows={3}
className="w-full rounded-lg border border-emerald-500/30 bg-ink-950/60 p-2 font-mono text-[10px] text-emerald-200" className="w-full rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2 font-mono text-[10px] text-emerald-700 dark:text-emerald-200"
/> />
)} )}
</div> </div>
<div className="mt-4 space-y-2 border-t border-white/5 pt-4"> <div className="mt-4 space-y-2 border-t border-line pt-4">
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500"> <div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{t('app:settings.backup_import', { defaultValue: 'Import' })} {t('app:settings.backup_import', { defaultValue: 'Import' })}
</div> </div>
<textarea <textarea
@@ -446,7 +563,7 @@ function DeviceKeyBackupControls() {
placeholder={t('app:settings.backup_blob_placeholder', { placeholder={t('app:settings.backup_blob_placeholder', {
defaultValue: 'chatapp-backup-v1.…', defaultValue: 'chatapp-backup-v1.…',
})} })}
className="w-full rounded-lg border border-white/10 bg-ink-800 p-2 font-mono text-[11px] text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none" className="w-full rounded-lg border border-line bg-surface-3 p-2 font-mono text-[11px] text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -454,13 +571,13 @@ function DeviceKeyBackupControls() {
value={importPass} value={importPass}
onChange={(e) => setImportPass(e.target.value)} onChange={(e) => setImportPass(e.target.value)}
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })} placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none" className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/> />
<button <button
type="button" type="button"
disabled={busy || !importBlob || !importPass || !canRun} disabled={busy || !importBlob || !importPass || !canRun}
onClick={() => void handleImport()} onClick={() => void handleImport()}
className="cursor-pointer rounded-lg bg-emerald-500/80 px-4 text-sm font-semibold text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50" className="cursor-pointer rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
> >
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })} {t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
</button> </button>
@@ -471,7 +588,9 @@ function DeviceKeyBackupControls() {
<p <p
className={ className={
'mt-3 text-xs ' + 'mt-3 text-xs ' +
(msg.kind === 'ok' ? 'text-emerald-300' : 'text-rose-300') (msg.kind === 'ok'
? 'text-emerald-600 dark:text-emerald-300'
: 'text-rose-600 dark:text-rose-300')
} }
> >
{msg.text} {msg.text}
@@ -497,7 +616,7 @@ function ScreenShareControls() {
onChange={(e) => onChange={(e) =>
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset }) updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
} }
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs text-fg focus:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
{PRESET_ORDER.map((p) => ( {PRESET_ORDER.map((p) => (
<option key={p} value={p}> <option key={p} value={p}>
@@ -506,7 +625,7 @@ function ScreenShareControls() {
))} ))}
</select> </select>
</SettingRow> </SettingRow>
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400"> <div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} /> <Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
<Stat <Stat
@@ -516,7 +635,7 @@ function ScreenShareControls() {
<Stat label="Framerate" value={params.framerate + ' fps'} /> <Stat label="Framerate" value={params.framerate + ' fps'} />
</div> </div>
</div> </div>
<p className="text-xs text-neutral-500"> <p className="text-xs text-fg-muted">
{t('app:settings.screen_share_hint', { {t('app:settings.screen_share_hint', {
defaultValue: defaultValue:
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.', 'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
@@ -529,8 +648,8 @@ function ScreenShareControls() {
function Stat({ label, value }: { label: string; value: string }) { function Stat({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div> <div className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">{label}</div>
<div className="mt-0.5 font-mono text-neutral-200">{value}</div> <div className="mt-0.5 font-mono text-fg">{value}</div>
</div> </div>
); );
} }
@@ -544,8 +663,8 @@ function formatBitrate(kbps: number): string {
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl"> <section className="rounded-2xl border border-line bg-surface-2 p-5">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400"> <h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{title} {title}
</h2> </h2>
<div className="space-y-3">{children}</div> <div className="space-y-3">{children}</div>
@@ -556,10 +675,10 @@ function Section({ title, children }: { title: string; children: React.ReactNode
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) { function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<dt className="text-sm text-neutral-500">{label}</dt> <dt className="text-sm text-fg-muted">{label}</dt>
<dd <dd
className={ className={
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' + 'max-w-[60%] truncate text-right text-sm text-fg ' +
(mono ? 'font-mono text-xs' : '') (mono ? 'font-mono text-xs' : '')
} }
title={value} title={value}
@@ -573,7 +692,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="text-sm text-neutral-200">{label}</span> <span className="text-sm text-fg">{label}</span>
{children} {children}
</div> </div>
); );
@@ -595,8 +714,8 @@ function Toggle({
return ( return (
<label className="flex cursor-pointer items-start justify-between gap-4"> <label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span> <span className="block text-sm text-fg">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>} {hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
</span> </span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0"> <span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input <input
@@ -606,8 +725,8 @@ function Toggle({
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className="peer sr-only" className="peer sr-only"
/> />
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" /> <span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" /> <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
</span> </span>
</label> </label>
); );
+45 -3
View File
@@ -4,11 +4,42 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { /* ---------------------------------------------------------------------------
* Clean-Rail design tokens.
*
* `--bg-rgb / --fg-rgb / --accent-rgb` are *space-separated rgb triplets*
* (no `rgb(...)` wrapper) so Tailwind's `rgb(var(--x) / <alpha-value>)`
* alpha modifier works (e.g. `bg-surface/40`).
*
* `--line` is a full color value with baked-in alpha — theme decides whether
* borders are inked-on-white or white-on-ink.
* ------------------------------------------------------------------------- */
:root { :root {
--bg-rgb: 250 250 249;
--bg-2-rgb: 244 244 242;
--bg-3-rgb: 255 255 255;
--fg-rgb: 10 10 15;
--fg-muted-rgb: 115 115 128;
--accent-rgb: 79 70 229;
--accent-fg-rgb: 255 255 255;
--line: rgba(10, 10, 15, 0.08);
color-scheme: light;
}
.dark {
--bg-rgb: 5 5 7;
--bg-2-rgb: 17 17 24;
--bg-3-rgb: 10 10 15;
--fg-rgb: 244 244 245;
--fg-muted-rgb: 138 138 153;
--accent-rgb: 109 115 255;
--accent-fg-rgb: 255 255 255;
--line: rgba(255, 255, 255, 0.07);
color-scheme: dark; color-scheme: dark;
} }
@layer base {
html, html,
body, body,
#root { #root {
@@ -16,12 +47,12 @@
} }
body { body {
@apply bg-ink-950 text-neutral-100 font-sans antialiased; @apply bg-surface text-fg font-sans antialiased;
font-feature-settings: 'cv11', 'ss01'; font-feature-settings: 'cv11', 'ss01';
} }
::selection { ::selection {
@apply bg-brand-500/40 text-white; @apply bg-accent/40 text-white;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -46,4 +77,15 @@
background-size: 32px 32px; background-size: 32px 32px;
background-position: -1px -1px; background-position: -1px -1px;
} }
/* Glass pill used for the fullscreen call controls bar. */
.glass-pill {
background: rgba(10, 10, 15, 0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-chip {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(10px);
color: #fff;
}
} }
+42
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'class',
content: [ content: [
'./index.html', './index.html',
'./src/**/*.{ts,tsx}', './src/**/*.{ts,tsx}',
@@ -51,12 +52,28 @@ export default {
800: '#312E81', 800: '#312E81',
900: '#1E1B4B', 900: '#1E1B4B',
}, },
// Clean-Rail design tokens. RGB-tuple form so alpha modifiers
// (e.g. bg-surface/50) work under both themes.
surface: 'rgb(var(--bg-rgb) / <alpha-value>)',
'surface-2': 'rgb(var(--bg-2-rgb) / <alpha-value>)',
'surface-3': 'rgb(var(--bg-3-rgb) / <alpha-value>)',
fg: 'rgb(var(--fg-rgb) / <alpha-value>)',
'fg-muted': 'rgb(var(--fg-muted-rgb) / <alpha-value>)',
accent: 'rgb(var(--accent-rgb) / <alpha-value>)',
'accent-fg': 'rgb(var(--accent-fg-rgb) / <alpha-value>)',
// Line has baked-in theme-specific alpha — direct var, no modifier.
line: 'var(--line)',
}, },
animation: { animation: {
'blob-a': 'blob 22s ease-in-out infinite', 'blob-a': 'blob 22s ease-in-out infinite',
'blob-b': 'blob 28s ease-in-out infinite reverse', 'blob-b': 'blob 28s ease-in-out infinite reverse',
'fade-in': 'fadeIn 400ms ease-out', 'fade-in': 'fadeIn 400ms ease-out',
'slide-up': 'slideUp 350ms ease-out', 'slide-up': 'slideUp 350ms ease-out',
'pulse-ring': 'pulseRing 2s ease-out infinite',
'audio-pulse': 'audioPulse 1.3s ease-out infinite',
'live-dot': 'liveDotPulse 1.5s ease-in-out infinite',
'slide-in-call': 'slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)',
'fs-hint': 'fsHintFade 3.5s ease-out forwards',
}, },
keyframes: { keyframes: {
blob: { blob: {
@@ -72,9 +89,34 @@ export default {
from: { opacity: '0', transform: 'translateY(8px)' }, from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' }, to: { opacity: '1', transform: 'translateY(0)' },
}, },
pulseRing: {
'0%': { transform: 'scale(0.85)', opacity: '0.6' },
'100%': { transform: 'scale(1.25)', opacity: '0' },
},
audioPulse: {
'0%': { transform: 'scale(1)', opacity: '0.8' },
'100%': { transform: 'scale(1.3)', opacity: '0' },
},
liveDotPulse: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.4' },
},
slideInCall: {
from: { opacity: '0', transform: 'translateY(20px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
fsHintFade: {
'0%': { opacity: '0', transform: 'translate(-50%, -6px)' },
'15%, 75%': { opacity: '1', transform: 'translate(-50%, 0)' },
'100%': { opacity: '0', transform: 'translate(-50%, -6px)' },
},
}, },
boxShadow: { boxShadow: {
glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)', glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)',
'call-card': '0 20px 60px rgba(0,0,0,0.1)',
'call-card-dark': '0 20px 60px rgba(0,0,0,0.5)',
'pip-call': '0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)',
'accept-btn': '0 4px 14px rgba(22,163,74,0.3)',
}, },
}, },
}, },
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>ChatApp Redesign — 6 Varianten</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Fraunces:wght@600;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles/base.css">
<link rel="stylesheet" href="styles/variants.css">
<link rel="stylesheet" href="styles/rail.css">
<link rel="stylesheet" href="styles/call.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<!-- TWEAKS state -->
<script>
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"showCall": false,
"density": "normal",
"font": "default"
}/*EDITMODE-END*/;
</script>
<!-- Variant switcher -->
<div class="variant-switcher" id="variant-switcher"></div>
<div class="variant-container" id="root"></div>
<!-- Tweaks panel -->
<div class="tweaks-panel" id="tweaks-panel">
<h3>Tweaks</h3>
<div class="tweaks-row">
<label>Incoming call overlay</label>
<select id="tweak-call">
<option value="false">Off</option>
<option value="true">Show</option>
</select>
</div>
<div class="tweaks-row">
<label>Content density</label>
<select id="tweak-density">
<option value="normal">Normal</option>
<option value="compact">Compact</option>
<option value="spacious">Spacious</option>
</select>
</div>
<div class="tweaks-row" style="font-size: 11px; opacity: 0.6; margin-top: 16px;">
Tipp: Klicke oben auf die Varianten um zu wechseln.
</div>
</div>
<script src="components/icons.jsx" type="text/babel"></script>
<script src="components/data.jsx" type="text/babel"></script>
<script src="components/call.jsx" type="text/babel"></script>
<script src="components/app.jsx" type="text/babel"></script>
<script type="text/babel">
const VARIANTS = [
{ key: 'clean', label: 'Clean Minimal', layout: 'topnav' },
{ key: 'clean-rail', label: 'Clean Rail', baseKey: 'clean', layout: 'rail' },
{ key: 'playful', label: 'Playful', layout: 'topnav' },
{ key: 'y2k', label: 'Y2K Glass', layout: 'topnav' },
{ key: 'cyber', label: 'Cyberpunk', layout: 'topnav' },
{ key: 'warm', label: 'Warm Soft', layout: 'topnav' },
{ key: 'brutal', label: 'Brutal Gaming', layout: 'topnav' },
];
const App = () => {
const [activeVariant, setActiveVariant] = React.useState(() => {
return localStorage.getItem('chatapp-variant') || 'clean';
});
const [isDark, setIsDark] = React.useState(() => {
return localStorage.getItem('chatapp-dark') === 'true';
});
const [tweaks, setTweaks] = React.useState(TWEAK_DEFAULTS);
React.useEffect(() => {
localStorage.setItem('chatapp-variant', activeVariant);
}, [activeVariant]);
React.useEffect(() => {
localStorage.setItem('chatapp-dark', String(isDark));
}, [isDark]);
// Tweaks wiring
React.useEffect(() => {
const handler = (e) => {
if (e.data?.type === '__activate_edit_mode') {
document.getElementById('tweaks-panel').classList.add('open');
} else if (e.data?.type === '__deactivate_edit_mode') {
document.getElementById('tweaks-panel').classList.remove('open');
}
};
window.addEventListener('message', handler);
window.parent.postMessage({type: '__edit_mode_available'}, '*');
const callSel = document.getElementById('tweak-call');
const densSel = document.getElementById('tweak-density');
callSel.value = String(tweaks.showCall);
densSel.value = tweaks.density;
callSel.onchange = () => {
const v = callSel.value === 'true';
setTweaks(t => ({...t, showCall: v}));
window.parent.postMessage({type: '__edit_mode_set_keys', edits: {showCall: v}}, '*');
};
densSel.onchange = () => {
setTweaks(t => ({...t, density: densSel.value}));
window.parent.postMessage({type: '__edit_mode_set_keys', edits: {density: densSel.value}}, '*');
};
return () => window.removeEventListener('message', handler);
}, []);
// Render variant switcher buttons
React.useEffect(() => {
const sw = document.getElementById('variant-switcher');
sw.innerHTML = '';
VARIANTS.forEach((v, i) => {
const b = document.createElement('button');
b.textContent = `${String(i+1).padStart(2,'0')} ${v.label}`;
if (v.key === activeVariant) b.classList.add('active');
b.onclick = () => setActiveVariant(v.key);
sw.appendChild(b);
});
}, [activeVariant]);
const idx = VARIANTS.findIndex(v => v.key === activeVariant);
const variant = { ...VARIANTS[idx], idx };
return (
<ChatApp
key={activeVariant}
variant={variant}
showCall={tweaks.showCall}
showProfile={false}
isDark={isDark}
onToggleDark={() => setIsDark(d => !d)}
/>
);
};
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+314
View File
@@ -0,0 +1,314 @@
# Handoff: ChatApp — Clean Rail Redesign + Discord-Style Call UI
## Overview
This package contains the full design spec for redesigning the existing ChatApp (Netralax) interface around a **Clean Rail** layout aesthetic, plus a complete **Discord-style call system** (incoming call, in-call dock, picture-in-picture, fullscreen cinema mode).
The redesign covers:
1. The main chat interface (icon-rail sidebar + chat list + conversation area) in both **light** and **dark** mode.
2. The call UX: incoming call overlay → active call docked above the chat (so users can keep typing while on a call) → fullscreen cinema mode → minimizable PiP bubble.
## About the Design Files
The files in this bundle are **design references created in HTML/React/CSS** — prototypes showing the intended look, layout, and behavior. They are **not production code** to copy verbatim.
Your task is to **recreate these designs inside the existing ChatApp codebase**, using its established patterns (existing React components, styling conventions, state management, icon library, etc.). If some tokens or helper components don't exist yet in the codebase, add them but keep them aligned with what's already there.
The design files are organized as:
- `ChatApp Redesign.html` — entry HTML that boots the prototype
- `components/app.jsx` — main layout (rail + chat list + chat area, plus call state machine)
- `components/call.jsx` — all call UI components (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
- `components/icons.jsx` — SVG icon set used
- `components/data.jsx` — dummy chat/message data
- `styles/base.css` — shared base tokens + layout primitives
- `styles/variants.css``variant-clean` light + dark token definitions (this is the look you're targeting)
- `styles/rail.css` — rail-layout specifics
- `styles/call.css`**all call UI styles** (the most important file for the call feature)
- `screenshots/` — reference images of each state
## Fidelity
**High-fidelity.** Exact colors, radii, spacing, typography and motion are specified. Recreate pixel-perfectly using the codebase's existing component library — but prefer the codebase's primitives over duplicating the prototype's raw CSS where equivalents exist.
---
## Design Tokens (Clean Rail)
### Colors — Light mode (`.variant-clean`)
```
--bg: #fafaf9 (app background)
--bg-2: #f4f4f2 (secondary surfaces, hover, search input)
--bg-3: #ffffff (elevated surfaces — chat list, chat area, cards)
--fg: #0a0a0f (primary text)
--fg-muted: #737380 (secondary text, icons)
--line: rgba(10,10,15,0.08) (dividers, borders)
--accent: #4F46E5 (indigo — primary actions, active states, my-bubble)
--accent-fg: #ffffff
```
### Colors — Dark mode (`.variant-clean.dark`)
```
--bg: #050507
--bg-2: #111118
--bg-3: #0A0A0F
--fg: #f4f4f5
--fg-muted: #8a8a99
--line: rgba(255,255,255,0.07)
--accent: #6D73FF (slightly lighter indigo for contrast)
--accent-fg: #ffffff
```
### Status / Semantic Colors (both modes)
```
Speaking/Online/Success: #16a34a light · #22c55e / #4ade80 dark
Danger/Decline/Hangup: #dc2626 (hover #b91c1c) · #fb7185 dark accent
Warning/Idle: #f59e0b
Live/Recording: #ef4444
E2EE indicator (light): #16a34a (dark: #4ade80)
```
### Avatar color swatches (assigned per chat)
```
violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD)
amber: bg #fde68a / fg #78350f
rose: bg #fecdd3 / fg #881337
teal: bg #99f6e4 / fg #134e4a (dark: #134e4a / #99f6e4)
```
### Typography
- Family: `'Inter', system-ui, sans-serif` — Inter is the base. Display/headline use `'Outfit'` (participant name in IncomingCall).
- Scale:
- Chat title / participant name large: 24px / 700
- Primary labels, chat name: 1415px / 600
- Body (bubbles, messages): 14px / 400500
- Meta, timestamps, presence: 1112px / 400500
- Rail tooltips, badges: 1011px / 500600
- Uppercase eyebrow (e.g. "EINGEHENDER ANRUF"): 12px / 600 / letter-spacing 0.12em
### Spacing
- Container padding: 16px (call-stage), 1420px (topbars, composer)
- Tile gap in grid: 810px
- Control-button row gap: 810px
- Border radius scale: 4 (small), 8 (tiles/badges), 10 (buttons), 14 (cards, large buttons), 18 (bubbles, bottom-fs controls), 24 (modal/card)
### Shadows
- Elevated card light: `0 20px 60px rgba(0,0,0,0.1)`
- Elevated card dark: `0 20px 60px rgba(0,0,0,0.5)`
- Accent button: `0 4px 14px rgba(22,163,74,0.3)` (green accept) / same pattern for accent
- PiP glow: `0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)`
### Motion
- Standard UI transition: `all 0.15s`
- Pulse ring (incoming call): `2s ease-out infinite`, scale 0.85→1.25, opacity 0.6→0
- Audio pulse (speaking ring): `1.3s ease-out infinite`, scale 1→1.3
- Live dot: `1.5s ease-in-out infinite`, opacity 1↔0.4
- PiP entrance: `slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)`
- Fullscreen hint fade: 3.5s total (015% fade in, 75100% fade out)
---
## Screens / Views
### 1. Main Chat — Clean Rail Layout
**Structure (left → right):**
- **Icon Rail** (72px wide): brand logo at top, tab buttons (Chats / Friends / Admin), spacer, Settings + theme-toggle at bottom. Active tab shows a vertical pill indicator on the left edge.
- **Chat List** (280px wide): header with title + "new chat" icon, search input, list of `ChatItem`s (avatar + name + time + preview, optional unread dot), `rail-user` footer showing own profile + quick mute/settings.
- **Chat Area** (flex, fills rest): chat header (counterpart avatar + name + presence/handle + search/voice/video buttons), messages scroll area, composer at bottom.
**Chat header actions (right side):** search icon, phone icon, video icon. Phone triggers voice call (`callScreen → 'active'`, `callState.video = false`), Video triggers video call (`callState.video = true`).
**Message bubbles:**
- Mine (outgoing): filled accent bg, white text, radius `18px 18px 4px 18px`.
- Theirs (incoming): transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`.
- Avatars appear only on the **last message of a run** from that sender.
- Reactions: pill row below bubble, `.mine` = filled accent.
- System events ("Kaiwandi hat einen Anruf gestartet — Anruf beitreten"): centered `event-pill`, muted bg, muted fg, 11px.
**Composer:** plus button (attach), rounded input bar with inline emoji + mic icons, send button.
---
### 2. Incoming Call Overlay
Full-bleed overlay replacing the chat area (takes over `.chat-area` space), radial-gradient background tinted with accent.
Centered card (420px wide):
- Eyebrow "EINGEHENDER ANRUF" (uppercase, 12px, tracked)
- Large avatar (96px) with **two pulsing rings** (staggered 1s)
- Caller name (24px / 700 / Outfit)
- Subtitle row: lock icon + "E2E verschlüsselt · Voice Call"
- Two side-by-side action buttons:
- **Ablehnen**: transparent, red border/text, hover = 8% red bg
- **Annehmen**: green #16a34a, white, shadow
### 3. Active Call — Docked (Default)
**CRITICAL layout decision:** The active call does NOT take over the chat area. Instead it renders as a **dock above the messages**, taking roughly 50% of the chat-area height (min 280px, max 420px). The chat header is **hidden** while the dock is active (redundant — call topbar shows the channel name). Messages + composer remain fully visible and usable below, so users can keep chatting during the call. This is the Discord desktop pattern.
**Dock content:**
- **Call topbar**: left side shows group icon + "Die Squad" + duration (tabular-nums), with E2E verschlüsselt below in success-green. Right side has 3 mode toggles: Grid / Focus / Fullscreen.
- **Call stage**: fills the remaining space, renders either:
- **Grid**: CSS grid, 2×2 for 4 people, 3×2 for 56, 1fr 1fr for 2.
- **Focus**: main speaker tile takes most height, bottom strip with 110px-tall smaller tiles for others.
- **Fullscreen**: switches to `mode === 'fullscreen'` — see screen 4.
- **Call controls bar** at the bottom of the dock: Mic, Video, Screen-share, Participants, **Hangup** (wider, red). Compact 36px buttons when docked.
### 4. Active Call — Fullscreen Cinema Mode
When mode = `fullscreen`:
- Entire call-dock container becomes absolute-positioned and takes over everything below the window bar (chat list still visible on the left).
- The **call topbar is hidden** entirely.
- Main speaker fills the whole frame, no border/radius.
- Participants strip moves to a **floating column in the bottom-right** (`.fs-strip`): 180px wide, 100px tall tiles, glass-blurred bg.
- **Call controls bar floats** at bottom center — glass pill: `rgba(10,10,15,0.7)` + `backdrop-filter: blur(20px)` + subtle border + 18px radius.
- **"Esc zum Verlassen" hint** fades in top-center on entry and auto-fades out over 3.5s. Pressing Esc or clicking Grid/Focus buttons exits.
- **No visible X button** (intentional — keyboard-only exit, confirmed design decision).
### 5. Participant Tile — Shared Component
A participant is either in **audio-only**, **video**, or **sharing screen** mode.
- Tile border: 1px `var(--line)`, radius 14 (10 in dock). Hover → border becomes accent. `.speaking` → 2px green border + green glow shadow. `.focused` → accent border.
- **Audio-only**: centered avatar (72px large, 44px small). When `speaking`, a green ring pulses around the avatar (`::before` pseudo).
- **Video**: placeholder stub (gradient background + centered avatar, since real video not in scope here).
- **Screenshare**: renders a "fake window" — chrome row with 3 dots, content row with grey skeleton lines + one accent-tinted block. A glass badge top-left: monitor icon + "Max teilt Bildschirm" (badge hidden on small tiles).
- **Meta row** (absolute, bottom 8px, glass): left shows crown (if me) + name + lock (if E2EE), right shows muted mic icon (red chip) and/or sharing icon (green chip).
### 6. Picture-in-Picture (PiP)
A compact 260px pill docked bottom-right of the chat area:
- 44px preview square (avatar of speaker, or mini shared-window placeholder if someone's sharing)
- Title "Die Squad · {participantCount}"
- Subtitle row: red pulsing dot + "Live · tippe zum Öffnen"
- Circular red hangup button on the right
- Whole thing is clickable → expands back to active call
- Entrance animation: slide from bottom + fade in (`slideInCall`)
- Shadow: standard + 3px accent glow ring
---
## Interactions & Behavior
### Call State Machine (lives in `ChatApp`)
```js
// Primary state
const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip'
const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen'
const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId);
const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration });
```
### Triggers & Transitions
- Chat-header **phone icon** click → `setCallScreen('active')`, `callState.video = false`
- Chat-header **video icon** click → `setCallScreen('active')`, `callState.video = true`
- Grid tile click → `setFocusedId(id); setCallMode('focus')`
- Focus/Grid/Fullscreen buttons in call topbar → set mode directly
- Incoming "Annehmen" → `setCallScreen('active')`
- Incoming "Ablehnen" → `setCallScreen('none')`
- Hangup → `setCallScreen('none')`
- PiP clicked → `setCallScreen('active')`
- PiP hangup (circle red btn) → `stopPropagation()` + `setCallScreen('none')`
- **Esc key** (global listener while `callScreen === 'active' && callMode === 'fullscreen'`) → `setCallMode('grid')`
### Chat Header Visibility Rule
```
if (callScreen === 'active' && callMode !== 'fullscreen') → hide chat header
else → show chat header
```
### Keyboard
- `Esc` exits fullscreen (only). No other global keybindings.
---
## State Management
Data shapes to wire up (see `components/call.jsx`):
```ts
type Participant = {
id: string;
name: string;
initial: string;
color: 'violet' | 'amber' | 'rose' | 'teal';
speaking: boolean;
muted: boolean;
video: boolean;
sharing?: boolean;
me?: boolean;
e2ee: boolean;
};
type CallState = {
muted: boolean;
sharing: boolean;
video: boolean;
duration: string; // "HH:MM:SS"
};
```
In production these all come from your real-time signaling layer (WebRTC, LiveKit, etc.). The prototype uses static data for layout purposes only — wire them to your call store.
---
## Assets
No custom imagery. Everything is SVG icons (inlined in `components/icons.jsx`) and CSS. Fonts from Google Fonts: `Inter`, `Outfit`.
Icons used in this scope: Chat, Friends, Shield, Settings, Logo, AddUser, Search, Phone, Video, Plus, Smile, Mic, MicOff, Send, Sun, Moon, Monitor, Lock, Crown, Grid, Focus, Maximize, PhoneOff.
Replace with your codebase's icon library if one exists (e.g. lucide-react, heroicons) — names match lucide conventions.
---
## Implementation Checklist
1. **Tokens**: add the Clean-variant light + dark CSS variables to your theme layer. If you already have a token system, map them to your existing names; otherwise create `--bg`, `--bg-2`, `--bg-3`, `--fg`, `--fg-muted`, `--line`, `--accent`, `--accent-fg`.
2. **Icon rail + chat list + chat area**: refactor the current top-nav layout into the 3-column rail layout. Use your existing `Avatar`, `ChatItem`, `Message` components — only restyle.
3. **Message bubbles**: outlined `them` / filled `me`, asymmetric radius, avatars only on last-of-run.
4. **Call state machine**: add the four `useState` hooks to whatever component owns the chat screen. Hook the Esc key effect.
5. **IncomingCallScreen**: new component, full-bleed overlay of `.chat-area`.
6. **Call dock**: new component, renders above `.messages`. Hide chat-header while docked.
7. **ParticipantTile**: audio / video / sharing modes, speaking ring, meta overlay.
8. **Fullscreen mode**: switch call container to position absolute, floating strip + floating controls + Esc hint.
9. **PiP**: minimizable widget bottom-right of chat area.
10. **Wire real data**: replace `CALL_PARTICIPANTS` dummy with your live call-session participants.
---
## Files in This Handoff
```
design_handoff_chatapp/
├── README.md (this file)
├── ChatApp Redesign.html (entry — open in browser to see the prototype)
├── components/
│ ├── app.jsx (chat app + rail layout + call state machine)
│ ├── call.jsx (Incoming / Active / Pip / ParticipantTile / CallControls)
│ ├── icons.jsx (all SVG icons)
│ └── data.jsx (dummy chats, messages)
├── styles/
│ ├── base.css
│ ├── variants.css (Clean light + dark tokens — this is your theme)
│ ├── rail.css (rail layout)
│ └── call.css (all call UI — highest priority file)
└── screenshots/
├── 01-chat-light.png
├── 03-chat-dark.png
├── 04-incoming-dark.png
├── 05-incall-grid-dark.png
├── 06-incall-focus-dark.png
├── 07-incall-fullscreen-dark.png
├── 09-pip-dark.png
└── 11-incall-light.png
```
---
## Notes for Claude Code
- The Clean Rail variant is **Variante 2** in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal), they were design exploration and not the chosen direction.
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Tooltip`, use them.
- Duration ("00:12:47") should tick live from the call start timestamp, not be static.
- For WebRTC/media handling: this package specifies **UI only**. The media/signaling layer is out of scope and already exists in the codebase (see `CallUI.tsx`, `InCallPanel.tsx`).
+439
View File
@@ -0,0 +1,439 @@
// Main ChatApp component — layout shared across all variants
const Avatar = ({ chat, size = '' }) => (
<div className={`avatar ${size}`} data-color={chat.color}>
{chat.initial}
{chat.presence && <div className={`presence ${chat.presence}`}></div>}
</div>
);
const ChatItem = ({ chat, onClick }) => (
<div className={`chat-item ${chat.active ? 'active' : ''}`} onClick={onClick}>
<Avatar chat={chat} />
<div className="chat-item-body">
<div className="chat-item-name">
<span>{chat.name}</span>
<span className="chat-item-time">{chat.time}</span>
</div>
<div className="chat-item-preview">
{chat.preview}
{chat.unread && <span className="unread-dot" style={{background: 'var(--accent, #a78bfa)', display: 'inline-block'}}></span>}
</div>
</div>
</div>
);
const Reactions = ({ reactions }) => (
<div className="reactions">
{reactions.map((r, i) => (
<div key={i} className={`reaction ${r.mine ? 'mine' : ''}`}>
<span>{r.emoji}</span>
<span className="count">{r.count}</span>
</div>
))}
</div>
);
const Bubble = ({ msg }) => (
<div className={`bubble ${msg.from}`}>
{msg.text}
<span className="ts">{msg.time}{msg.read && msg.from === 'me' ? ' · Gelesen' : ''}</span>
</div>
);
const MediaBubble = ({ msg }) => (
<div className="bubble me" style={{padding: 4}}>
<div className="media-preview">
<div className="media-image">{msg.label}</div>
<div className="media-meta">
<span>{msg.filename}</span>
<span>{msg.size}</span>
</div>
</div>
<span className="ts" style={{paddingLeft: 8, paddingBottom: 4}}>{msg.time}</span>
</div>
);
const EventPill = ({ evt }) => {
const IconComp = evt.icon === 'phone' ? Icon.Phone : Icon.PhoneOff;
return (
<div className="event-row">
<div className={`event-pill ${evt.kind === 'rejected' ? 'rejected' : ''}`}>
<IconComp />
<span>{evt.text}</span>
{evt.duration && <span>· {evt.duration}</span>}
<span>· {evt.time}</span>
</div>
</div>
);
};
const TypingIndicator = () => (
<div className="bubble them" style={{padding: '10px 14px'}}>
<div className="typing"><span></span><span></span><span></span></div>
</div>
);
const MessageItem = ({ msg, showAvatar, chat, myChat }) => {
if (msg.type === 'event') return <EventPill evt={msg} />;
const wrapWithAvatar = (inner) => (
<div className={`message-row ${msg.from}`}>
{msg.from === 'them' && (
<div className="message-avatar-slot">
{showAvatar && <Avatar chat={{...chat, presence: null}} size="sm" />}
</div>
)}
<div className={`message-group ${msg.from}`}>
{inner}
</div>
{msg.from === 'me' && (
<div className="message-avatar-slot">
{showAvatar && <Avatar chat={{...myChat, presence: null}} size="sm" />}
</div>
)}
</div>
);
if (msg.type === 'typing') {
return wrapWithAvatar(<TypingIndicator />);
}
if (msg.type === 'media') {
return wrapWithAvatar(<MediaBubble msg={msg} />);
}
return wrapWithAvatar(
<>
<Bubble msg={msg} />
{msg.reactions && <Reactions reactions={msg.reactions} />}
</>
);
};
const ProfileCard = ({ chat, position }) => (
<div className="profile-card" style={{top: position.top, left: position.left}}>
<div className="profile-card-banner"></div>
<div className="profile-card-body">
<div className="profile-card-avatar" data-color={chat.color} style={{background: 'var(--accent, #a78bfa)', color: '#fff'}}>
{chat.initial}
</div>
<div className="profile-card-name">{chat.name}</div>
<div className="profile-card-handle">{chat.handle}</div>
<div className="profile-card-meta">
<div>🟢 Online · seit 2h</div>
<div>📍 Beigetreten am 14. Mär 2026</div>
<div>🎮 Zockt gerade: Valorant</div>
</div>
</div>
</div>
);
const CallOverlay = ({ chat }) => (
<div className="call-overlay">
<Avatar chat={{...chat, presence: null}} size="lg" />
<div className="call-info">
<div className="call-name">Max ruft an</div>
<div className="call-sub">Voice Call · klingelt</div>
</div>
<div className="call-actions">
<button className="call-btn decline"><Icon.PhoneOff /></button>
<button className="call-btn accept"><Icon.Phone /></button>
</div>
</div>
);
const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
const [activeChat, setActiveChat] = React.useState(CHATS[0]);
const [activeTab, setActiveTab] = React.useState('chats');
// Call state: 'none' | 'incoming' | 'active' | 'pip'
const [callScreen, setCallScreen] = React.useState('none');
const [callMode, setCallMode] = React.useState('grid'); // grid | focus | fullscreen
const [focusedId, setFocusedId] = React.useState('max');
const [callState, setCallState] = React.useState({
muted: false, sharing: false, video: false, duration: '00:12:47'
});
// Esc exits fullscreen
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape' && callScreen === 'active' && callMode === 'fullscreen') {
setCallMode('grid');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callScreen, callMode]);
const chats = CHATS.map(c => ({...c, active: c.id === activeChat.id}));
const messagesEndRef = React.useRef(null);
React.useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.parentElement.scrollTop = messagesEndRef.current.parentElement.scrollHeight;
}
}, []);
return (
<div className={`variant variant-${variant.baseKey || variant.key} ${isDark ? 'dark' : ''} ${variant.layout === 'rail' ? 'layout-rail' : 'layout-topnav'} active`} data-screen-label={`0${variant.idx+1} ${variant.label}`}>
<div className="app-window" style={{position: 'relative'}}>
<div className="window-bar">
<div className="dots">
<div className="dot" style={{background: '#ff5f57'}}></div>
<div className="dot" style={{background: '#febc2e'}}></div>
<div className="dot" style={{background: '#28c840'}}></div>
</div>
<div className="title">ChatApp {variant.label}</div>
<div style={{width: 52}}></div>
</div>
<div className="app-body">
{variant.layout === 'rail' ? (
<div className="rail-layout">
{/* Icon rail left */}
<div className="icon-rail">
<div className="rail-brand" title="Netralax"><Icon.Logo /></div>
<div className="rail-divider"></div>
<button className={`rail-btn ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')} title="Chats">
<Icon.Chat />
{activeTab === 'chats' && <div className="rail-pill"></div>}
</button>
<button className={`rail-btn ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')} title="Freunde">
<Icon.Friends />
{activeTab === 'friends' && <div className="rail-pill"></div>}
</button>
<button className={`rail-btn ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')} title="Admin">
<Icon.Shield />
{activeTab === 'admin' && <div className="rail-pill"></div>}
</button>
<div style={{flex: 1}}></div>
<button className={`rail-btn ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')} title="Einstellungen">
<Icon.Settings />
</button>
<button className="rail-btn" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'}>
{isDark ? <Icon.Sun /> : <Icon.Moon />}
</button>
</div>
{/* Chatlist + user profile at bottom */}
<div className="chat-list rail-chatlist">
<div className="chat-list-header">
<div className="chat-list-title">Chats</div>
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
</div>
<input className="chat-search" placeholder="Suche…" />
<div className="chat-items">
{chats.map(c => (
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
))}
</div>
<div className="rail-user">
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
<div style={{fontSize: 12, flex: 1, minWidth: 0}}>
<div style={{fontWeight: 600}}>dennis</div>
<div style={{opacity: 0.6, fontSize: 10}}> Online</div>
</div>
<button className="icon-btn" title="Mute"><Icon.Mic /></button>
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
</div>
</div>
{/* Chat area (unchanged below) */}
<div className="chat-area">
{!(callScreen === 'active' && callMode !== 'fullscreen') && (
<div className="chat-header">
<Avatar chat={activeChat} />
<div className="chat-header-info">
<div className="chat-header-name">{activeChat.name}</div>
<div className="chat-header-status">
{activeChat.presence === 'online' ? '● Online' :
activeChat.presence === 'idle' ? '● Abwesend' :
activeChat.presence === 'dnd' ? '● Nicht stören' :
'● Offline · zuletzt vor 2h'}
{' · '}{activeChat.handle}
</div>
</div>
<div className="chat-header-actions">
<button className="icon-btn" title="Suche"><Icon.Search /></button>
<button className="icon-btn" title="Voice" onClick={() => setCallScreen('active')}><Icon.Phone /></button>
<button className="icon-btn" title="Video" onClick={() => { setCallState(s => ({...s, video: true})); setCallScreen('active'); }}><Icon.Video /></button>
</div>
</div>
)}
{/* Call dock — rendered above messages, Discord-style */}
{callScreen === 'active' && callMode !== 'fullscreen' && (
<div className="call-dock">
<ActiveCallScreen
participants={CALL_PARTICIPANTS}
mode={callMode}
setMode={setCallMode}
focused={focusedId}
setFocused={setFocusedId}
onHangup={() => setCallScreen('none')}
callState={callState}
setCallState={setCallState}
/>
</div>
)}
<div className="messages">
{MESSAGES.map((m, i) => {
const next = MESSAGES[i+1];
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
const myChat = { initial: 'D', color: 'teal' };
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
})}
<div ref={messagesEndRef} />
</div>
<div className="composer">
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
<div className="composer-box">
<input placeholder="Nachricht schreiben…" />
<button className="icon-btn"><Icon.Smile /></button>
<button className="icon-btn"><Icon.Mic /></button>
</div>
<button className="composer-btn"><Icon.Send /></button>
</div>
</div>
</div>
) : (
<>
<div className="top-nav">
<div className="nav-brand">
<Icon.Logo />
<span>Netralax</span>
</div>
<div className="nav-tabs">
<button className={`nav-tab ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')}>
<Icon.Chat /> Chats
</button>
<button className={`nav-tab ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')}>
<Icon.Friends /> Freunde
</button>
<button className={`nav-tab ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')}>
<Icon.Settings /> Einstellungen
</button>
<button className={`nav-tab ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')}>
<Icon.Shield /> Admin
</button>
</div>
<div className="nav-user">
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
<div style={{fontSize: 12}}>
<div style={{fontWeight: 600}}>dennis</div>
<div style={{opacity: 0.6, fontSize: 10}}>@dennis</div>
</div>
<Icon.Chevron />
</div>
{variant.key === 'clean' && (
<button className="icon-btn theme-toggle" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'} style={{marginLeft: 6}}>
{isDark ? <Icon.Sun /> : <Icon.Moon />}
</button>
)}
</div>
<div className="main-area">
<div className="chat-list">
<div className="chat-list-header">
<div className="chat-list-title">Chats</div>
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
</div>
<input className="chat-search" placeholder="Suche…" />
<div className="chat-items">
{chats.map(c => (
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
))}
</div>
</div>
<div className="chat-area">
<div className="chat-header">
<Avatar chat={activeChat} />
<div className="chat-header-info">
<div className="chat-header-name">{activeChat.name}</div>
<div className="chat-header-status">
{activeChat.presence === 'online' ? '● Online' :
activeChat.presence === 'idle' ? '● Abwesend' :
activeChat.presence === 'dnd' ? '● Nicht stören' :
'● Offline · zuletzt vor 2h'}
{' · '}{activeChat.handle}
</div>
</div>
<div className="chat-header-actions">
<button className="icon-btn" title="Suche"><Icon.Search /></button>
<button className="icon-btn" title="Voice"><Icon.Phone /></button>
<button className="icon-btn" title="Video"><Icon.Video /></button>
</div>
</div>
<div className="messages">
{MESSAGES.map((m, i) => {
const next = MESSAGES[i+1];
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
const myChat = { initial: 'D', color: 'teal' };
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
})}
<div ref={messagesEndRef} />
</div>
<div className="composer">
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
<div className="composer-box">
<input placeholder="Nachricht schreiben…" />
<button className="icon-btn"><Icon.Smile /></button>
<button className="icon-btn"><Icon.Mic /></button>
</div>
<button className="composer-btn"><Icon.Send /></button>
</div>
</div>
</div>
</>
)}
</div>
{/* Call screen switcher — only in Clean Rail */}
{variant.layout === 'rail' && (
<div className="screen-switcher">
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
<button className={callScreen === 'active' ? 'active' : ''} onClick={() => setCallScreen('active')}>In-Call</button>
<button className={callScreen === 'pip' ? 'active' : ''} onClick={() => setCallScreen('pip')}>PiP</button>
</div>
)}
{/* Incoming call is a full overlay */}
{callScreen === 'incoming' && (
<div className="call-screen">
<IncomingCallScreen
chat={activeChat}
onAccept={() => setCallScreen('active')}
onDecline={() => setCallScreen('none')}
/>
</div>
)}
{/* Fullscreen call hides chat entirely */}
{callScreen === 'active' && callMode === 'fullscreen' && (
<div className="call-screen">
<ActiveCallScreen
participants={CALL_PARTICIPANTS}
mode={callMode}
setMode={setCallMode}
focused={focusedId}
setFocused={setFocusedId}
onHangup={() => setCallScreen('none')}
callState={callState}
setCallState={setCallState}
/>
</div>
)}
{callScreen === 'pip' && (
<PipCall
participants={CALL_PARTICIPANTS}
onExpand={() => setCallScreen('active')}
onHangup={() => setCallScreen('none')}
/>
)}
{showCall && callScreen === 'none' && <CallOverlay chat={activeChat} />}
</div>
</div>
);
};
Object.assign(window, { ChatApp, Avatar, ChatItem, MessageItem, CallOverlay });
+194
View File
@@ -0,0 +1,194 @@
// Call UI components — Discord-style, matched to Clean-Rail aesthetic
const CALL_PARTICIPANTS = [
{ id: 'dennis', name: 'dennis', initial: 'D', color: 'teal', speaking: false, muted: false, video: false, me: true, e2ee: true },
{ id: 'max', name: 'Max', initial: 'M', color: 'teal', speaking: true, muted: false, video: true, sharing: true, e2ee: true },
{ id: 'lena', name: 'Lena', initial: 'L', color: 'amber', speaking: false, muted: true, video: false, e2ee: true },
{ id: 'tom', name: 'Tom', initial: 'T', color: 'violet', speaking: false, muted: false, video: false, e2ee: true },
{ id: 'nico', name: 'nico', initial: 'N', color: 'teal', speaking: true, muted: false, video: false, e2ee: true },
];
const ParticipantTile = ({ p, focused, onClick, small }) => (
<div className={`participant-tile ${p.speaking ? 'speaking' : ''} ${focused ? 'focused' : ''} ${small ? 'small' : ''}`} onClick={onClick}>
{p.video || p.sharing ? (
<div className="participant-video">
{p.sharing ? (
<div className="screenshare-stub">
<div className="ss-window">
<div className="ss-chrome"><span></span><span></span><span></span></div>
<div className="ss-content">
<div className="ss-line" style={{width: '60%'}}></div>
<div className="ss-line" style={{width: '80%'}}></div>
<div className="ss-line" style={{width: '40%'}}></div>
<div className="ss-block"></div>
<div className="ss-line" style={{width: '70%'}}></div>
</div>
</div>
<div className="ss-badge">
<Icon.Monitor /> {p.name} teilt Bildschirm
</div>
</div>
) : (
<div className="video-stub">
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
</div>
)}
</div>
) : (
<div className="participant-audio">
<div className={`audio-ring ${p.speaking ? 'pulse' : ''}`}>
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
</div>
</div>
)}
<div className="participant-meta">
<div className="participant-name">
{p.me && <Icon.Crown />}
<span>{p.name}{p.me ? ' (du)' : ''}</span>
{p.e2ee && <span className="e2ee-dot" title="End-to-End verschlüsselt"><Icon.Lock /></span>}
</div>
<div className="participant-icons">
{p.muted && <span className="pi muted"><Icon.MicOff /></span>}
{p.sharing && <span className="pi sharing"><Icon.Monitor /></span>}
</div>
</div>
</div>
);
const CallControls = ({ muted, onToggleMute, sharing, onToggleShare, video, onToggleVideo, onHangup, compact }) => (
<div className={`call-controls ${compact ? 'compact' : ''}`}>
<button className={`cc-btn ${muted ? 'active-danger' : ''}`} onClick={onToggleMute} title={muted ? 'Unmute' : 'Mute'}>
{muted ? <Icon.MicOff /> : <Icon.Mic />}
</button>
<button className={`cc-btn ${video ? 'active-brand' : ''}`} onClick={onToggleVideo} title="Video">
<Icon.Video />
</button>
<button className={`cc-btn ${sharing ? 'active-brand' : ''}`} onClick={onToggleShare} title="Bildschirm teilen">
<Icon.Monitor />
</button>
<button className="cc-btn" title="Teilnehmer"><Icon.Friends /></button>
<button className="cc-btn hangup" onClick={onHangup} title="Auflegen"><Icon.PhoneOff /></button>
</div>
);
const IncomingCallScreen = ({ chat, onAccept, onDecline }) => (
<div className="incoming-call">
<div className="incoming-card">
<div className="incoming-ringing">Eingehender Anruf</div>
<div className="incoming-avatar">
<div className="pulse-ring"></div>
<div className="pulse-ring d2"></div>
<Avatar chat={{initial: chat.initial, color: chat.color}} size="lg" />
</div>
<div className="incoming-name">{chat.name}</div>
<div className="incoming-sub">
<Icon.Lock /> E2E verschlüsselt · Voice Call
</div>
<div className="incoming-actions">
<button className="incoming-btn decline" onClick={onDecline}>
<Icon.PhoneOff /> Ablehnen
</button>
<button className="incoming-btn accept" onClick={onAccept}>
<Icon.Phone /> Annehmen
</button>
</div>
</div>
</div>
);
const ActiveCallScreen = ({ participants, mode, setMode, focused, setFocused, onHangup, callState, setCallState }) => {
const speaker = participants.find(p => p.id === focused) || participants.find(p => p.sharing) || participants[0];
const others = participants.filter(p => p.id !== speaker.id);
const sharingParticipant = participants.find(p => p.sharing);
return (
<div className={`active-call mode-${mode}`}>
<div className="call-topbar">
<div className="call-topbar-left">
<div className="call-title">
<Icon.Friends />
<span>Die Squad</span>
<span className="call-dot">·</span>
<span className="call-duration">{callState.duration}</span>
</div>
<div className="call-e2ee"><Icon.Lock /> E2E verschlüsselt</div>
</div>
<div className="call-topbar-right">
<button className={`cc-btn sm ${mode === 'grid' ? 'active-brand' : ''}`} onClick={() => setMode('grid')} title="Grid"><Icon.Grid /></button>
<button className={`cc-btn sm ${mode === 'focus' ? 'active-brand' : ''}`} onClick={() => setMode('focus')} title="Fokus"><Icon.Focus /></button>
<button className={`cc-btn sm ${mode === 'fullscreen' ? 'active-brand' : ''}`} onClick={() => setMode('fullscreen')} title="Vollbild"><Icon.Maximize /></button>
</div>
</div>
<div className="call-stage">
{mode === 'grid' && (
<div className={`grid grid-${participants.length}`}>
{participants.map(p => (
<ParticipantTile key={p.id} p={p} onClick={() => { setFocused(p.id); setMode('focus'); }} />
))}
</div>
)}
{mode === 'focus' && (
<div className="focus-layout">
<div className="focus-main">
<ParticipantTile p={speaker} focused />
</div>
<div className="focus-strip">
{others.map(p => (
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
))}
</div>
</div>
)}
{mode === 'fullscreen' && (
<div className="fullscreen-layout">
<ParticipantTile p={speaker} focused />
<div className="fs-strip">
{others.slice(0,4).map(p => (
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
))}
</div>
<div className="fs-hint">Esc zum Verlassen</div>
</div>
)}
</div>
<CallControls
muted={callState.muted}
onToggleMute={() => setCallState(s => ({...s, muted: !s.muted}))}
sharing={callState.sharing}
onToggleShare={() => setCallState(s => ({...s, sharing: !s.sharing}))}
video={callState.video}
onToggleVideo={() => setCallState(s => ({...s, video: !s.video}))}
onHangup={onHangup}
/>
</div>
);
};
const PipCall = ({ onExpand, onHangup, participants }) => {
const speaker = participants.find(p => p.sharing) || participants.find(p => p.speaking) || participants[0];
return (
<div className="pip-call" onClick={onExpand}>
<div className="pip-preview">
{speaker.sharing ? (
<div className="screenshare-stub small"><div className="ss-window mini"></div></div>
) : (
<Avatar chat={{initial: speaker.initial, color: speaker.color}} size="sm" />
)}
</div>
<div className="pip-info">
<div className="pip-title">Die Squad · {participants.length}</div>
<div className="pip-sub">
<span className="live-dot"></span>
Live · tippe zum Öffnen
</div>
</div>
<button className="pip-hangup" onClick={(e) => { e.stopPropagation(); onHangup(); }}>
<Icon.PhoneOff />
</button>
</div>
);
};
Object.assign(window, { CALL_PARTICIPANTS, ParticipantTile, CallControls, IncomingCallScreen, ActiveCallScreen, PipCall });
@@ -0,0 +1,28 @@
// Sample data
const CHATS = [
{ id: 'test', name: 'test', handle: '@test', color: 'violet', initial: 'T', preview: 'Hallo 👋', time: '21:13', unread: false, presence: 'offline', active: true },
{ id: 'max', name: 'Max', handle: '@maxx', color: 'teal', initial: 'M', preview: 'bro du musst das sehen', time: '20:44', unread: true, presence: 'online' },
{ id: 'squad', name: 'Die Squad', handle: '5 Mitglieder', color: 'rose', initial: 'S', preview: 'leon: bin gleich da', time: '19:12', unread: true, presence: 'online', isGroup: true },
{ id: 'lena', name: 'Lena', handle: '@lenae', color: 'amber', initial: 'L', preview: 'Du: ok passt', time: 'Gestern', unread: false, presence: 'idle' },
{ id: 'tom', name: 'Tom', handle: '@tomg', color: 'violet', initial: 'T', preview: 'sent a photo', time: 'Gestern', unread: false, presence: 'dnd' },
{ id: 'nico', name: 'nico', handle: '@nicoo', color: 'teal', initial: 'N', preview: 'gg wp', time: 'Mo.', unread: false, presence: 'offline' },
];
const MESSAGES = [
{ type: 'message', from: 'them', text: 'Hi', time: '20:12' },
{ type: 'message', from: 'them', text: 'Lol', time: '20:14' },
{ type: 'message', from: 'them', text: 'Wie gehts', time: '20:24' },
{ type: 'message', from: 'them', text: 'einer da?', time: '20:25' },
{ type: 'message', from: 'them', text: 'Hallo', time: '21:03', reactions: [{emoji: '👋', count: 1, mine: true}] },
{ type: 'event', kind: 'call', icon: 'phone', text: 'Eingehender Anruf', duration: '1:01', time: '21:06' },
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
{ type: 'message', from: 'me', text: 'sry war afk', time: '21:09', reactions: [{emoji: '😅', count: 2}, {emoji: '❤️', count: 1, mine: true}] },
{ type: 'media', from: 'me', filename: 'IMG_4120.jpg', size: '2.4 MB', label: '[ PLACEHOLDER IMAGE ]', time: '21:10' },
{ type: 'message', from: 'them', text: 'pushhh sick', time: '21:12' },
{ type: 'message', from: 'me', text: 'Hallo', time: '21:13', read: true },
{ type: 'typing', from: 'them' },
];
Object.assign(window, { CHATS, MESSAGES });
@@ -0,0 +1,87 @@
// Shared SVG icon components
const Icon = {
Logo: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M4 6 L12 2 L20 6 L20 18 L12 22 L4 18 Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
<path d="M4 6 L12 10 L20 6" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
<path d="M12 10 L12 22" stroke="currentColor" strokeWidth="1.8"/>
</svg>
),
Chat: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
),
Friends: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
),
Settings: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
),
Shield: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
),
Phone: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
),
PhoneOff: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/></svg>
),
Video: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
),
Search: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
),
Plus: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
),
Send: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
),
AddUser: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
),
Chevron: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
),
Paperclip: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
),
Smile: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
),
Mic: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
),
Sun: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
),
Moon: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
),
MicOff: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
),
Monitor: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
),
Lock: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
),
Crown: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16l-2-8 5 4 4-7 4 7 5-4-2 8z"/></svg>
),
Grid: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
),
Focus: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="1"/><rect x="3" y="18" width="4" height="3" rx="0.5"/><rect x="9" y="18" width="4" height="3" rx="0.5"/><rect x="15" y="18" width="4" height="3" rx="0.5"/></svg>
),
Maximize: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
),
Minimize: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
),
};
Object.assign(window, { Icon });
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+459
View File
@@ -0,0 +1,459 @@
/* ================= BASE ================= */
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
min-height: 100vh;
overflow-x: hidden;
}
body {
font-family: var(--font-body, 'Inter', system-ui, sans-serif);
background: var(--bg, #0e0e12);
color: var(--fg, #eaeaea);
-webkit-font-smoothing: antialiased;
transition: background 0.3s ease;
}
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, textarea { font-family: inherit; }
/* Variant switcher — top fixed */
.variant-switcher {
position: fixed;
top: 12px; left: 50%; transform: translateX(-50%);
z-index: 100;
display: flex; gap: 6px;
padding: 6px;
background: rgba(20,20,24,0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
}
.variant-switcher button {
padding: 8px 14px;
font-size: 12px;
font-weight: 500;
color: rgba(255,255,255,0.6);
border-radius: 9px;
transition: all 0.2s;
white-space: nowrap;
font-family: 'Inter', system-ui, sans-serif;
}
.variant-switcher button:hover { color: #fff; background: rgba(255,255,255,0.05); }
.variant-switcher button.active {
color: #fff;
background: rgba(255,255,255,0.12);
}
.variant-container {
padding-top: 72px;
min-height: 100vh;
}
/* Variant wrapper */
.variant {
display: none;
width: 100%;
}
.variant.active { display: block; }
/* Device frame — desktop window */
.app-window {
width: min(1320px, 96vw);
margin: 0 auto 60px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 30px 80px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
height: 820px;
display: flex;
flex-direction: column;
}
.window-bar {
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0 14px;
gap: 8px;
}
.window-bar .dots { display: flex; gap: 7px; }
.window-bar .dot { width: 12px; height: 12px; border-radius: 50%; }
.window-bar .title {
flex: 1; text-align: center; font-size: 12px;
letter-spacing: 0.02em;
}
.app-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.top-nav {
height: 56px;
display: flex;
align-items: center;
padding: 0 20px;
gap: 20px;
flex-shrink: 0;
}
.nav-brand {
display: flex; align-items: center; gap: 10px;
font-weight: 700;
letter-spacing: -0.02em;
}
.nav-tabs {
display: flex;
gap: 4px;
flex: 1;
margin-left: 20px;
}
.nav-tab {
padding: 8px 14px;
font-size: 13px;
border-radius: 8px;
display: flex; align-items: center; gap: 8px;
transition: all 0.15s;
}
.nav-user {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 4px 4px;
border-radius: 999px;
}
.main-area {
flex: 1;
display: grid;
grid-template-columns: 320px 1fr;
overflow: hidden;
min-height: 0;
}
/* Chat list */
.chat-list {
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-list-header {
padding: 16px 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-list-title { font-weight: 600; font-size: 14px; letter-spacing: 0.01em; }
.chat-search {
margin: 0 14px 10px;
padding: 9px 12px;
border-radius: 10px;
font-size: 13px;
outline: none;
border: none;
}
.chat-items { flex: 1; overflow-y: auto; padding: 4px 10px; }
.chat-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
margin-bottom: 2px;
transition: background 0.15s;
}
.chat-item-body { flex: 1; min-width: 0; }
.chat-item-name { font-weight: 600; font-size: 14px; display: flex; justify-content: space-between; align-items: center; }
.chat-item-preview {
font-size: 12px;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.chat-item-time { font-size: 10px; opacity: 0.5; font-weight: 400; }
.unread-dot {
width: 8px; height: 8px; border-radius: 50%;
margin-left: 6px;
flex-shrink: 0;
}
/* Avatar */
.avatar {
width: 38px; height: 38px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
position: relative;
}
.avatar.sm { width: 28px; height: 28px; font-size: 11px; }
.avatar.lg { width: 44px; height: 44px; font-size: 16px; }
.presence {
position: absolute;
bottom: -1px; right: -1px;
width: 12px; height: 12px;
border-radius: 50%;
border: 2px solid var(--bg, #0e0e12);
}
.presence.online { background: #22c55e; }
.presence.idle { background: #f59e0b; }
.presence.dnd { background: #ef4444; }
.presence.offline { background: #71717a; }
/* Chat area */
.chat-area {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.chat-header {
height: 64px;
padding: 0 22px;
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.chat-header-info { flex: 1; min-width: 0; }
.chat-header-name { font-weight: 600; font-size: 15px; }
.chat-header-status { font-size: 11px; opacity: 0.6; margin-top: 2px; }
.chat-header-actions { display: flex; gap: 4px; }
.icon-btn {
width: 34px; height: 34px;
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px 26px;
display: flex;
flex-direction: column;
gap: 4px;
}
.message-row {
display: flex;
gap: 8px;
align-items: flex-end;
margin-bottom: 14px;
width: 100%;
}
.message-row.me { justify-content: flex-end; }
.message-row.them { justify-content: flex-start; }
.message-avatar-slot {
width: 28px;
flex-shrink: 0;
}
.message-row .message-group {
margin-bottom: 0;
min-width: 0;
max-width: calc(100% - 40px);
flex: 1 1 auto;
align-items: flex-start;
}
.message-row.me .message-group { align-items: flex-end; }
.message-row .bubble { max-width: 100%; width: fit-content; }
.message-row.me .bubble { margin-left: auto; }
.message-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 14px; }
.message-group.me { align-items: flex-end; }
.message-group.them { align-items: flex-start; }
.message-sender {
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
opacity: 0.7;
padding: 0 4px;
}
.bubble {
max-width: 70%;
min-width: 60px;
padding: 10px 14px;
border-radius: 18px;
font-size: 14px;
line-height: 1.45;
position: relative;
word-wrap: break-word;
white-space: normal;
}
.bubble .ts {
font-size: 10px;
opacity: 0.5;
margin-top: 2px;
display: block;
white-space: nowrap;
}
.reactions {
display: flex;
gap: 4px;
margin-top: 4px;
flex-wrap: wrap;
}
.reaction {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
display: flex; align-items: center; gap: 4px;
cursor: pointer;
transition: all 0.15s;
}
.reaction .count { font-weight: 600; opacity: 0.8; }
/* System events (calls etc) */
.event-row { display: flex; justify-content: center; margin: 10px 0; }
.event-pill {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.event-pill svg { width: 12px; height: 12px; }
/* Media preview */
.media-preview {
width: 240px;
border-radius: 14px;
overflow: hidden;
position: relative;
}
.media-preview .media-image {
width: 100%;
aspect-ratio: 4/3;
background: repeating-linear-gradient(
45deg,
rgba(255,255,255,0.04),
rgba(255,255,255,0.04) 10px,
rgba(255,255,255,0.07) 10px,
rgba(255,255,255,0.07) 20px
);
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-family: 'JetBrains Mono', monospace; opacity: 0.5;
letter-spacing: 0.1em;
}
.media-preview .media-meta {
padding: 10px 12px;
font-size: 11px;
display: flex; justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
opacity: 0.7;
}
/* Composer */
.composer {
padding: 14px 20px 18px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.composer-box {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 4px 4px 12px;
border-radius: 12px;
min-height: 44px;
}
.composer-box input {
flex: 1;
background: none;
border: none;
outline: none;
color: inherit;
font-size: 14px;
padding: 8px 0;
}
.composer-btn {
width: 36px; height: 36px;
border-radius: 9px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
/* Incoming call overlay */
.call-overlay {
position: absolute;
right: 24px;
bottom: 100px;
width: 300px;
border-radius: 16px;
padding: 16px;
display: flex;
align-items: center;
gap: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
animation: slideInCall 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes slideInCall {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.call-info { flex: 1; min-width: 0; }
.call-name { font-weight: 600; font-size: 14px; }
.call-sub { font-size: 11px; opacity: 0.7; margin-top: 2px; }
.call-actions { display: flex; gap: 6px; }
.call-btn {
width: 36px; height: 36px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: white;
}
.call-btn.accept { background: #22c55e; }
.call-btn.decline { background: #ef4444; }
/* Profile hover card */
.profile-card {
position: absolute;
z-index: 10;
width: 280px;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
}
.profile-card-banner { height: 64px; }
.profile-card-body { padding: 14px; margin-top: -20px; position: relative; }
.profile-card-avatar {
width: 60px; height: 60px;
border-radius: 50%;
border: 4px solid var(--bg);
display: flex; align-items: center; justify-content: center;
font-weight: 700;
font-size: 22px;
}
.profile-card-name { font-weight: 700; font-size: 16px; margin-top: 10px; }
.profile-card-handle { font-size: 12px; opacity: 0.6; }
.profile-card-meta {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.08);
font-size: 11px;
display: flex; flex-direction: column; gap: 6px;
opacity: 0.8;
}
/* Scrollbars */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
+529
View File
@@ -0,0 +1,529 @@
/* ============ CALL UI — Clean Rail matched ============ */
/* Discord-style call dock: call takes top portion, chat continues below */
.call-dock {
flex-shrink: 0;
height: 50%;
max-height: 420px;
min-height: 280px;
border-bottom: 1px solid var(--line);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-2);
}
.call-dock > .active-call {
flex: 1;
min-height: 0;
}
.call-dock .call-topbar { padding: 8px 14px; }
.call-dock .call-stage { padding: 10px; }
.call-dock .grid { gap: 8px; }
.call-dock .participant-tile { border-radius: 10px; }
.call-dock .audio-ring .avatar.lg { width: 52px; height: 52px; font-size: 20px; }
.call-dock .participant-meta { padding: 4px 8px; font-size: 11px; bottom: 6px; left: 6px; right: 6px; }
.call-dock .participant-name { font-size: 11px; }
.call-dock .pi { width: 18px; height: 18px; }
/* When docked, compact the controls */
.call-dock .call-controls { padding: 8px; gap: 8px; }
.call-dock .cc-btn { width: 36px; height: 36px; border-radius: 10px; }
.call-dock .cc-btn.hangup { width: 54px; }
.call-dock .ss-content { padding: 8px; gap: 4px; }
.call-dock .ss-line { height: 4px; }
.call-dock .ss-block { height: 20px; }
.call-dock .ss-badge { font-size: 9px; padding: 3px 7px; }
/* Screen switcher pill (top-right of window) */
.screen-switcher {
position: absolute;
top: 52px;
right: 20px;
z-index: 50;
display: flex;
gap: 4px;
padding: 4px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 10px;
font-size: 11px;
}
.screen-switcher button {
padding: 6px 10px;
border-radius: 7px;
color: var(--fg-muted);
font-weight: 500;
}
.screen-switcher button.active { background: var(--accent); color: #fff; }
/* Call stage overlay */
.call-screen {
position: absolute;
inset: 38px 0 0 0; /* below window bar */
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* === Incoming Call === */
.incoming-call {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 30% 20%, rgba(79,70,229,0.15), transparent 50%),
radial-gradient(circle at 70% 80%, rgba(109,115,255,0.1), transparent 50%),
var(--bg);
}
.incoming-card {
width: 420px;
padding: 40px 32px 28px;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 24px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
}
.variant-clean.dark .incoming-card { box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.incoming-ringing {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--fg-muted);
font-weight: 600;
}
.incoming-avatar {
margin: 22px auto 18px;
width: 112px; height: 112px;
position: relative;
display: flex; align-items: center; justify-content: center;
}
.incoming-avatar .avatar.lg {
width: 96px; height: 96px;
font-size: 40px;
font-weight: 700;
border: 3px solid var(--bg-3);
}
.pulse-ring {
position: absolute;
inset: 0;
border: 2px solid var(--accent);
border-radius: 50%;
opacity: 0;
animation: pulseRing 2s ease-out infinite;
}
.pulse-ring.d2 { animation-delay: 1s; }
@keyframes pulseRing {
0% { transform: scale(0.85); opacity: 0.6; }
100% { transform: scale(1.25); opacity: 0; }
}
.incoming-name {
font-size: 24px;
font-weight: 700;
font-family: 'Outfit', 'Inter', sans-serif;
letter-spacing: -0.02em;
}
.incoming-sub {
margin-top: 6px;
font-size: 12px;
color: var(--fg-muted);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.incoming-actions {
margin-top: 28px;
display: flex;
gap: 12px;
justify-content: center;
}
.incoming-btn {
flex: 1;
padding: 14px 20px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.15s;
}
.incoming-btn.decline {
background: transparent;
color: #dc2626;
border: 1px solid rgba(220,38,38,0.3);
}
.variant-clean.dark .incoming-btn.decline { color: #fb7185; border-color: rgba(251,113,133,0.3); }
.incoming-btn.decline:hover { background: rgba(220,38,38,0.08); }
.incoming-btn.accept {
background: #16a34a;
color: #fff;
box-shadow: 0 4px 14px rgba(22,163,74,0.3);
}
.incoming-btn.accept:hover { background: #15803d; }
/* === Active Call === */
.active-call {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, var(--bg-2), var(--bg));
}
.call-topbar {
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
background: var(--bg-3);
}
.call-topbar-left { display: flex; flex-direction: column; gap: 2px; }
.call-title {
display: flex; align-items: center; gap: 8px;
font-weight: 600; font-size: 14px;
}
.call-dot { opacity: 0.4; }
.call-duration { font-variant-numeric: tabular-nums; color: var(--fg-muted); font-size: 13px; }
.call-e2ee {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: #16a34a; font-weight: 500;
}
.variant-clean.dark .call-e2ee { color: #4ade80; }
.call-topbar-right { display: flex; gap: 4px; }
.call-stage {
flex: 1;
padding: 16px;
overflow: hidden;
display: flex;
}
/* Participant tiles */
.participant-tile {
position: relative;
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
}
.participant-tile:hover { border-color: var(--accent); }
.participant-tile.speaking { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,0.25); }
.participant-tile.focused { border-color: var(--accent); }
.participant-tile.small { min-width: 140px; }
.participant-audio, .participant-video {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
overflow: hidden;
}
.audio-ring {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.audio-ring .avatar.lg {
width: 72px; height: 72px;
font-size: 28px;
font-weight: 700;
}
.participant-tile.small .audio-ring .avatar.lg { width: 44px; height: 44px; font-size: 18px; }
.audio-ring.pulse::before {
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid #22c55e;
animation: audioPulse 1.3s ease-out infinite;
}
@keyframes audioPulse {
0% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(1.3); opacity: 0; }
}
.video-stub {
width: 100%; height: 100%;
background:
radial-gradient(circle at 30% 40%, rgba(109,115,255,0.2), transparent 60%),
linear-gradient(135deg, #1a1a24, #0A0A0F);
display: flex;
align-items: center;
justify-content: center;
}
.variant-clean:not(.dark) .video-stub { background: linear-gradient(135deg, #e0e7ff, #c7d2fe); }
/* Screenshare stub */
.screenshare-stub {
width: 100%; height: 100%;
position: relative;
background: #0A0A0F;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.variant-clean:not(.dark) .screenshare-stub { background: #111118; }
.ss-window {
width: 90%; height: 85%;
background: #1a1a24;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.06);
}
.ss-chrome {
height: 20px;
background: #252533;
display: flex;
align-items: center;
padding: 0 8px;
gap: 4px;
}
.ss-chrome span { width: 6px; height: 6px; border-radius: 50%; background: #3A3A4D; }
.ss-content { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
.ss-line { height: 6px; background: #3A3A4D; border-radius: 3px; }
.ss-block { height: 40px; background: rgba(109,115,255,0.25); border-radius: 4px; margin: 4px 0; }
.participant-tile.small .ss-content { padding: 6px; gap: 3px; }
.participant-tile.small .ss-line { height: 3px; }
.participant-tile.small .ss-block { height: 14px; }
.ss-badge {
position: absolute;
top: 12px; left: 12px;
padding: 4px 10px;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(8px);
color: #fff;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
display: flex; align-items: center; gap: 5px;
}
.participant-tile.small .ss-badge { display: none; }
.participant-meta {
position: absolute;
bottom: 8px; left: 8px; right: 8px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(10px);
border-radius: 8px;
color: #fff;
}
.participant-tile.small .participant-meta { padding: 4px 8px; font-size: 10px; }
.participant-name {
display: flex; align-items: center; gap: 5px;
font-size: 12px;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.e2ee-dot { opacity: 0.7; display: flex; }
.participant-icons { display: flex; gap: 4px; }
.pi {
width: 20px; height: 20px;
border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.pi.muted { background: rgba(220,38,38,0.8); }
.pi.sharing { background: rgba(34,197,94,0.8); }
/* Grid layouts */
.grid {
display: grid;
gap: 10px;
width: 100%; height: 100%;
}
.grid-1 { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); }
.grid-5, .grid-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); }
/* Focus layout */
.focus-layout {
width: 100%; height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.focus-main { flex: 1; min-height: 0; }
.focus-main .participant-tile { height: 100%; }
.focus-strip {
height: 110px;
display: flex;
gap: 10px;
overflow-x: auto;
}
.focus-strip .participant-tile { min-width: 160px; height: 100%; }
/* Fullscreen layout (hides topbar, pure cinema) */
.active-call.mode-fullscreen .call-topbar { display: none; }
.active-call.mode-fullscreen .call-stage { padding: 0; position: relative; }
.fullscreen-layout {
width: 100%; height: 100%;
position: relative;
}
.fullscreen-layout > .participant-tile { width: 100%; height: 100%; border-radius: 0; border: none; }
.fs-strip {
position: absolute;
bottom: 90px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
width: 180px;
}
.fs-strip .participant-tile { height: 100px; backdrop-filter: blur(20px); background: rgba(0,0,0,0.4); }
/* Call controls bar */
.call-controls {
padding: 16px;
display: flex;
justify-content: center;
gap: 10px;
background: var(--bg-3);
border-top: 1px solid var(--line);
}
.active-call.mode-fullscreen .call-controls {
position: absolute;
bottom: 16px; left: 50%; transform: translateX(-50%);
background: rgba(10,10,15,0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 18px;
padding: 10px;
}
.cc-btn {
width: 48px; height: 48px;
border-radius: 14px;
background: var(--bg-2);
color: var(--fg);
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
border: 1px solid var(--line);
}
.cc-btn:hover { background: var(--bg); transform: translateY(-1px); }
.cc-btn.sm { width: 32px; height: 32px; border-radius: 8px; }
.cc-btn.active-brand { background: var(--accent); color: #fff; border-color: var(--accent); }
.cc-btn.active-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.cc-btn.hangup { background: #dc2626; color: #fff; border-color: #dc2626; width: 72px; }
.cc-btn.hangup:hover { background: #b91c1c; }
.active-call.mode-fullscreen .cc-btn { background: rgba(255,255,255,0.08); color: #fff; border-color: rgba(255,255,255,0.1); }
.active-call.mode-fullscreen .cc-btn.active-brand { background: var(--accent); border-color: var(--accent); }
.active-call.mode-fullscreen .cc-btn.active-danger { background: #dc2626; border-color: #dc2626; }
.active-call.mode-fullscreen .cc-btn.hangup { background: #dc2626; border-color: #dc2626; }
/* Exit fullscreen button */
.fs-exit {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
padding: 8px 14px;
background: rgba(10,10,15,0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px;
color: #fff;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.15s;
}
.fs-exit:hover { background: rgba(10,10,15,0.9); transform: translateY(-1px); }
/* Fullscreen hint — subtle, fades in then out */
.fs-hint {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 6px 14px;
background: rgba(10,10,15,0.6);
backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
color: rgba(255,255,255,0.7);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
z-index: 10;
animation: fsHintFade 3.5s ease-out forwards;
pointer-events: none;
}
@keyframes fsHintFade {
0% { opacity: 0; transform: translate(-50%, -6px); }
15%, 75% { opacity: 1; transform: translate(-50%, 0); }
100% { opacity: 0; transform: translate(-50%, -6px); }
}
/* PiP in chat view */
.pip-call {
position: absolute;
bottom: 82px; right: 20px;
width: 260px;
padding: 10px;
display: flex;
align-items: center;
gap: 10px;
background: var(--bg-3);
border: 1px solid var(--accent);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15);
cursor: pointer;
z-index: 30;
animation: slideInCall 0.4s cubic-bezier(0.22,1,0.36,1);
transition: transform 0.15s;
}
.pip-call:hover { transform: translateY(-2px); }
.pip-preview {
width: 44px; height: 44px;
border-radius: 10px;
background: var(--bg-2);
display: flex; align-items: center; justify-content: center;
overflow: hidden;
}
.pip-preview .ss-window.mini {
width: 36px; height: 28px;
background: #1a1a24;
border-radius: 4px;
}
.pip-info { flex: 1; min-width: 0; }
.pip-title { font-weight: 600; font-size: 13px; }
.pip-sub {
font-size: 11px; color: var(--fg-muted);
margin-top: 2px;
display: flex; align-items: center; gap: 5px;
}
.live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #ef4444;
animation: liveDotPulse 1.5s ease-in-out infinite;
}
@keyframes liveDotPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pip-hangup {
width: 32px; height: 32px;
border-radius: 50%;
background: #dc2626;
color: #fff;
display: flex; align-items: center; justify-content: center;
}
+53
View File
@@ -0,0 +1,53 @@
/* Rail layout — Discord-style icon sidebar */
.rail-layout {
flex: 1;
display: grid;
grid-template-columns: 64px 280px 1fr;
overflow: hidden;
min-height: 0;
}
.icon-rail {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
gap: 6px;
}
.rail-brand {
width: 44px; height: 44px;
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
background: var(--accent, #4F46E5);
color: #fff;
margin-bottom: 4px;
}
.rail-divider { width: 32px; height: 2px; background: rgba(128,128,128,0.2); border-radius: 1px; margin: 4px 0; }
.rail-btn {
width: 44px; height: 44px;
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
color: var(--fg-muted);
position: relative;
transition: all 0.15s;
}
.rail-btn:hover { background: rgba(128,128,128,0.1); color: var(--fg); border-radius: 12px; }
.rail-btn.active { background: var(--accent); color: #fff; border-radius: 12px; }
.rail-pill {
position: absolute;
left: -12px; top: 50%; transform: translateY(-50%);
width: 4px; height: 24px;
background: var(--fg);
border-radius: 0 3px 3px 0;
}
.rail-chatlist { display: flex; flex-direction: column; }
.rail-chatlist .chat-items { flex: 1; }
.rail-user {
padding: 10px 12px;
display: flex; align-items: center; gap: 10px;
flex-shrink: 0;
}
.variant-clean.layout-rail .icon-rail { background: var(--bg-2); border-right: 1px solid var(--line); }
.variant-clean.layout-rail .rail-chatlist { background: var(--bg-3); border-right: 1px solid var(--line); }
.variant-clean.layout-rail .rail-user { background: var(--bg-2); border-top: 1px solid var(--line); }
.variant-clean.layout-rail .rail-brand { background: var(--accent); color: #fff; }
+461
View File
@@ -0,0 +1,461 @@
/* ================= VARIANT 1: CLEAN MINIMAL (Light) ================= */
.variant-clean {
--bg: #fafaf9;
--bg-2: #f4f4f2;
--bg-3: #ffffff;
--fg: #0a0a0f;
--fg-muted: #737380;
--line: rgba(10,10,15,0.08);
--accent: #4F46E5;
--accent-fg: #ffffff;
background: var(--bg);
color: var(--fg);
font-family: 'Inter', system-ui, sans-serif;
}
.variant-clean .nav-brand { font-family: 'Outfit', 'Inter', sans-serif; font-weight: 700; letter-spacing: -0.02em; }
/* Dark mode for Clean */
.variant-clean.dark {
--bg: #050507;
--bg-2: #111118;
--bg-3: #0A0A0F;
--fg: #f4f4f5;
--fg-muted: #8a8a99;
--line: rgba(255,255,255,0.07);
--accent: #6D73FF;
--accent-fg: #ffffff;
}
.variant-clean.dark .app-window { background: var(--bg-3); box-shadow: 0 30px 80px rgba(0,0,0,0.5), 0 0 0 1px var(--line); }
.variant-clean.dark .avatar { background: #1A1A24; color: #DDE0FF; }
.variant-clean.dark .avatar[data-color="violet"] { background: #312E81; color: #C4B5FD; }
.variant-clean.dark .avatar[data-color="amber"] { background: #422e05; color: #fde68a; }
.variant-clean.dark .avatar[data-color="rose"] { background: #4a1d29; color: #fecdd3; }
.variant-clean.dark .avatar[data-color="teal"] { background: #134e4a; color: #99f6e4; }
.variant-clean.dark .bubble.them { border-color: rgba(255,255,255,0.1); }
.variant-clean.dark .bubble.me { background: var(--accent); color: #fff; }
.variant-clean.dark .nav-tab.active { background: var(--accent); }
.variant-clean.dark .reaction.mine { background: var(--accent); }
.variant-clean.dark .composer-btn { background: var(--accent); }
.variant-clean .app-window { background: var(--bg-3); box-shadow: 0 30px 80px rgba(0,0,0,0.08), 0 0 0 1px var(--line); }
.variant-clean .window-bar { background: var(--bg-2); border-bottom: 1px solid var(--line); }
.variant-clean .window-bar .title { color: var(--fg-muted); font-weight: 500; }
.variant-clean .top-nav { border-bottom: 1px solid var(--line); background: var(--bg-3); }
.variant-clean .nav-brand svg { color: #0a0a0a; }
.variant-clean .nav-tab { color: var(--fg-muted); }
.variant-clean .nav-tab:hover { background: var(--bg-2); color: var(--fg); }
.variant-clean .nav-tab.active { background: var(--accent); color: var(--accent-fg); }
.variant-clean .nav-user { border: 1px solid var(--line); }
.variant-clean .chat-list { border-right: 1px solid var(--line); background: var(--bg-3); }
.variant-clean .chat-list-header { border-bottom: 1px solid var(--line); }
.variant-clean .chat-search { background: var(--bg-2); color: var(--fg); }
.variant-clean .chat-item:hover { background: var(--bg-2); }
.variant-clean .chat-item.active { background: var(--bg-2); }
.variant-clean .avatar { background: #e5e5e5; color: #0a0a0a; }
.variant-clean .avatar[data-color="violet"] { background: #ddd6fe; color: #5b21b6; }
.variant-clean .avatar[data-color="amber"] { background: #fde68a; color: #78350f; }
.variant-clean .avatar[data-color="rose"] { background: #fecdd3; color: #881337; }
.variant-clean .avatar[data-color="teal"] { background: #99f6e4; color: #134e4a; }
.variant-clean .chat-header { border-bottom: 1px solid var(--line); }
.variant-clean .icon-btn { color: var(--fg-muted); }
.variant-clean .icon-btn:hover { background: var(--bg-2); color: var(--fg); }
.variant-clean .bubble.me { background: var(--accent); color: var(--accent-fg); border-radius: 18px 18px 4px 18px; }
.variant-clean .bubble.them { background: transparent; color: var(--fg); border: 1px solid var(--line); border-radius: 18px 18px 18px 4px; }
.variant-clean .reaction { background: var(--bg-2); border: 1px solid var(--line); }
.variant-clean .reaction.mine { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.variant-clean .event-pill { background: var(--bg-2); color: var(--fg-muted); }
.variant-clean .composer { border-top: 1px solid var(--line); }
.variant-clean .composer-box { background: var(--bg-2); }
.variant-clean .composer-btn { background: var(--accent); color: var(--accent-fg); }
.variant-clean .presence { border-color: var(--bg-3); }
/* ================= VARIANT 2: PLAYFUL DISCORD-VIBES ================= */
.variant-playful {
--bg: #1a1625;
--bg-2: #2a2338;
--bg-3: #221b30;
--fg: #f5f3ff;
--fg-muted: #9691b3;
--accent: #a78bfa;
--accent-2: #f472b6;
--accent-3: #60a5fa;
background: radial-gradient(ellipse at top left, #3d1a54 0%, #1a1625 50%);
color: var(--fg);
font-family: 'Plus Jakarta Sans', 'Inter', sans-serif;
}
.variant-playful .app-window { background: var(--bg-3); }
.variant-playful .window-bar { background: rgba(0,0,0,0.25); }
.variant-playful .window-bar .title { color: #fff; font-weight: 700; font-size: 13px; }
.variant-playful .top-nav { background: linear-gradient(90deg, #2a1a48 0%, #1a1625 100%); border-bottom: 1px solid rgba(255,255,255,0.05); }
.variant-playful .nav-brand { font-size: 18px; background: linear-gradient(135deg, #f472b6, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.variant-playful .nav-tab { color: var(--fg-muted); font-weight: 600; }
.variant-playful .nav-tab:hover { background: rgba(255,255,255,0.06); color: #fff; }
.variant-playful .nav-tab.active { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; box-shadow: 0 4px 14px rgba(167,139,250,0.4); }
.variant-playful .nav-user { background: rgba(255,255,255,0.05); }
.variant-playful .chat-list { background: rgba(0,0,0,0.15); border-right: 1px solid rgba(255,255,255,0.04); }
.variant-playful .chat-search { background: rgba(0,0,0,0.3); color: #fff; }
.variant-playful .chat-item:hover { background: rgba(255,255,255,0.04); }
.variant-playful .chat-item.active { background: linear-gradient(90deg, rgba(167,139,250,0.2), rgba(244,114,182,0.1)); }
.variant-playful .avatar { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; font-weight: 800; }
.variant-playful .avatar[data-color="teal"] { background: linear-gradient(135deg, #34d399, #60a5fa); }
.variant-playful .avatar[data-color="amber"] { background: linear-gradient(135deg, #fbbf24, #f472b6); }
.variant-playful .avatar[data-color="rose"] { background: linear-gradient(135deg, #fb7185, #a78bfa); }
.variant-playful .avatar[data-color="violet"] { background: linear-gradient(135deg, #a78bfa, #60a5fa); }
.variant-playful .chat-header { background: rgba(0,0,0,0.2); border-bottom: 1px solid rgba(255,255,255,0.05); }
.variant-playful .icon-btn:hover { background: rgba(255,255,255,0.08); }
.variant-playful .messages { background: radial-gradient(ellipse at bottom right, rgba(244,114,182,0.06), transparent 50%); }
.variant-playful .bubble.me { background: linear-gradient(135deg, #a78bfa, #8b5cf6); color: #fff; border-radius: 20px 20px 4px 20px; box-shadow: 0 4px 14px rgba(167,139,250,0.25); }
.variant-playful .bubble.them { background: rgba(255,255,255,0.06); color: #fff; border-radius: 20px 20px 20px 4px; backdrop-filter: blur(10px); }
.variant-playful .reaction { background: rgba(255,255,255,0.08); }
.variant-playful .reaction.mine { background: rgba(167,139,250,0.3); border: 1px solid #a78bfa; }
.variant-playful .event-pill { background: rgba(52,211,153,0.15); color: #34d399; }
.variant-playful .event-pill.rejected { background: rgba(251,113,133,0.15); color: #fb7185; }
.variant-playful .composer { background: rgba(0,0,0,0.2); }
.variant-playful .composer-box { background: rgba(255,255,255,0.06); }
.variant-playful .composer-btn { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; box-shadow: 0 4px 14px rgba(167,139,250,0.4); }
.variant-playful .presence { border-color: var(--bg-3); }
/* ================= VARIANT 3: Y2K GLASSMORPHISM ================= */
.variant-y2k {
--bg: #0a0a1f;
--fg: #f8fafc;
--fg-muted: rgba(248,250,252,0.6);
--accent: #c7d2fe;
color: var(--fg);
font-family: 'Space Grotesk', 'Inter', sans-serif;
background:
radial-gradient(ellipse 80% 50% at 20% 20%, rgba(168,85,247,0.4), transparent),
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(59,130,246,0.35), transparent),
radial-gradient(ellipse 70% 60% at 50% 50%, rgba(236,72,153,0.2), transparent),
#0a0a1f;
}
.variant-y2k .app-window {
background: rgba(255,255,255,0.04);
backdrop-filter: blur(30px);
-webkit-backdrop-filter: blur(30px);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 30px 80px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.15);
}
.variant-y2k .window-bar { background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08); }
.variant-y2k .window-bar .title { color: rgba(255,255,255,0.7); font-weight: 500; letter-spacing: 0.08em; font-size: 11px; text-transform: uppercase; }
.variant-y2k .top-nav { background: rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.08); }
.variant-y2k .nav-brand { font-weight: 700; letter-spacing: -0.03em; font-size: 18px; }
.variant-y2k .nav-tab {
color: var(--fg-muted);
font-weight: 500;
border: 1px solid transparent;
}
.variant-y2k .nav-tab:hover { background: rgba(255,255,255,0.06); color: #fff; border-color: rgba(255,255,255,0.1); }
.variant-y2k .nav-tab.active {
background: rgba(255,255,255,0.15);
color: #fff;
border-color: rgba(255,255,255,0.25);
backdrop-filter: blur(10px);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.2);
}
.variant-y2k .nav-user { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); }
.variant-y2k .chat-list { background: rgba(255,255,255,0.02); border-right: 1px solid rgba(255,255,255,0.08); }
.variant-y2k .chat-search { background: rgba(255,255,255,0.06); color: #fff; border: 1px solid rgba(255,255,255,0.08); }
.variant-y2k .chat-item:hover { background: rgba(255,255,255,0.05); }
.variant-y2k .chat-item.active {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.15);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
}
.variant-y2k .avatar {
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
color: #312e81;
border: 1px solid rgba(255,255,255,0.3);
}
.variant-y2k .avatar[data-color="violet"] { background: linear-gradient(135deg, #e9d5ff, #c4b5fd); color: #4c1d95; }
.variant-y2k .avatar[data-color="rose"] { background: linear-gradient(135deg, #fecdd3, #f9a8d4); color: #831843; }
.variant-y2k .avatar[data-color="teal"] { background: linear-gradient(135deg, #a7f3d0, #7dd3fc); color: #064e3b; }
.variant-y2k .avatar[data-color="amber"] { background: linear-gradient(135deg, #fde68a, #fdba74); color: #7c2d12; }
.variant-y2k .chat-header { background: rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.08); }
.variant-y2k .icon-btn:hover { background: rgba(255,255,255,0.08); }
.variant-y2k .bubble.me {
background: linear-gradient(135deg, rgba(199,210,254,0.9), rgba(196,181,253,0.8));
color: #1e1b4b;
border-radius: 20px 20px 6px 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.3);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.4), 0 4px 14px rgba(124,58,237,0.2);
}
.variant-y2k .bubble.them {
background: rgba(255,255,255,0.08);
color: #fff;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 20px 20px 20px 6px;
backdrop-filter: blur(20px);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
}
.variant-y2k .reaction { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
.variant-y2k .reaction.mine { background: rgba(199,210,254,0.25); border-color: rgba(199,210,254,0.5); }
.variant-y2k .event-pill { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
.variant-y2k .event-pill.rejected { background: rgba(251,113,133,0.15); color: #fda4af; border-color: rgba(251,113,133,0.3); }
.variant-y2k .composer { background: rgba(255,255,255,0.02); border-top: 1px solid rgba(255,255,255,0.08); }
.variant-y2k .composer-box { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
.variant-y2k .composer-btn { background: linear-gradient(135deg, #e0e7ff, #c4b5fd); color: #312e81; border: 1px solid rgba(255,255,255,0.3); box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); }
.variant-y2k .presence { border-color: #0a0a1f; }
/* ================= VARIANT 4: CYBERPUNK NEON ================= */
.variant-cyber {
--bg: #05050a;
--fg: #e0e0e8;
--fg-muted: #6b7280;
--neon-cyan: #00f0ff;
--neon-magenta: #ff00aa;
--neon-lime: #b6ff3d;
color: var(--fg);
font-family: 'JetBrains Mono', 'Courier New', monospace;
background:
repeating-linear-gradient(0deg, transparent 0, transparent 2px, rgba(0,240,255,0.015) 2px, rgba(0,240,255,0.015) 4px),
radial-gradient(ellipse at center, #0c0c18 0%, #05050a 70%);
}
.variant-cyber .app-window {
background: #08080f;
border: 1px solid rgba(0,240,255,0.2);
box-shadow: 0 0 40px rgba(255,0,170,0.15), 0 0 80px rgba(0,240,255,0.08);
}
.variant-cyber .window-bar { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
.variant-cyber .window-bar .title { color: var(--neon-cyan); font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; }
.variant-cyber .window-bar .title::before { content: "// "; opacity: 0.5; }
.variant-cyber .top-nav { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
.variant-cyber .nav-brand { color: var(--neon-cyan); text-transform: uppercase; letter-spacing: 0.1em; font-size: 13px; text-shadow: 0 0 10px rgba(0,240,255,0.5); }
.variant-cyber .nav-tab { color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; font-weight: 500; border: 1px solid transparent; }
.variant-cyber .nav-tab:hover { color: var(--neon-cyan); border-color: rgba(0,240,255,0.2); }
.variant-cyber .nav-tab.active { color: var(--neon-lime); border-color: var(--neon-lime); background: rgba(182,255,61,0.05); box-shadow: 0 0 10px rgba(182,255,61,0.2); }
.variant-cyber .nav-tab.active::before { content: "> "; }
.variant-cyber .nav-user { border: 1px solid rgba(0,240,255,0.15); }
.variant-cyber .chat-list { background: #05050a; border-right: 1px solid rgba(0,240,255,0.15); }
.variant-cyber .chat-list-title { color: var(--neon-cyan); text-transform: uppercase; letter-spacing: 0.15em; font-size: 11px; }
.variant-cyber .chat-list-title::before { content: "[ "; opacity: 0.5; }
.variant-cyber .chat-list-title::after { content: " ]"; opacity: 0.5; }
.variant-cyber .chat-search { background: #0c0c18; border: 1px solid rgba(0,240,255,0.15); color: var(--fg); }
.variant-cyber .chat-item:hover { background: rgba(0,240,255,0.04); }
.variant-cyber .chat-item.active { background: rgba(0,240,255,0.06); border-left: 2px solid var(--neon-cyan); padding-left: 10px; }
.variant-cyber .chat-item-name { color: var(--fg); }
.variant-cyber .avatar {
background: #0c0c18;
border: 1px solid var(--neon-cyan);
color: var(--neon-cyan);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
text-shadow: 0 0 8px rgba(0,240,255,0.5);
}
.variant-cyber .avatar[data-color="violet"] { border-color: var(--neon-magenta); color: var(--neon-magenta); text-shadow: 0 0 8px rgba(255,0,170,0.5); }
.variant-cyber .avatar[data-color="amber"] { border-color: var(--neon-lime); color: var(--neon-lime); text-shadow: 0 0 8px rgba(182,255,61,0.5); }
.variant-cyber .avatar[data-color="rose"] { border-color: #ff5500; color: #ff5500; text-shadow: 0 0 8px rgba(255,85,0,0.5); }
.variant-cyber .avatar[data-color="teal"] { border-color: var(--neon-cyan); color: var(--neon-cyan); }
.variant-cyber .chat-header { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
.variant-cyber .chat-header-name { color: var(--neon-cyan); font-family: 'JetBrains Mono', monospace; }
.variant-cyber .chat-header-name::before { content: "@"; opacity: 0.5; }
.variant-cyber .icon-btn { color: var(--neon-cyan); }
.variant-cyber .icon-btn:hover { background: rgba(0,240,255,0.1); }
.variant-cyber .bubble.me {
background: transparent;
color: var(--neon-magenta);
border: 1px solid var(--neon-magenta);
border-radius: 4px;
box-shadow: 0 0 15px rgba(255,0,170,0.15), inset 0 0 15px rgba(255,0,170,0.05);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.variant-cyber .bubble.them {
background: transparent;
color: var(--neon-cyan);
border: 1px solid var(--neon-cyan);
border-radius: 4px;
box-shadow: 0 0 15px rgba(0,240,255,0.15), inset 0 0 15px rgba(0,240,255,0.05);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.variant-cyber .bubble .ts { color: rgba(255,255,255,0.4); font-size: 9px; letter-spacing: 0.1em; }
.variant-cyber .reaction { background: #0c0c18; border: 1px solid rgba(0,240,255,0.2); border-radius: 2px; color: var(--neon-lime); }
.variant-cyber .reaction.mine { border-color: var(--neon-lime); box-shadow: 0 0 10px rgba(182,255,61,0.2); }
.variant-cyber .event-pill { background: transparent; border: 1px dashed var(--neon-lime); color: var(--neon-lime); border-radius: 2px; text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
.variant-cyber .event-pill.rejected { border-color: var(--neon-magenta); color: var(--neon-magenta); }
.variant-cyber .composer { background: #05050a; border-top: 1px solid rgba(0,240,255,0.15); }
.variant-cyber .composer-box { background: #0c0c18; border: 1px solid rgba(0,240,255,0.2); border-radius: 4px; }
.variant-cyber .composer-box::before { content: ">"; color: var(--neon-lime); margin-right: 6px; font-weight: bold; }
.variant-cyber .composer-btn { background: var(--neon-lime); color: #05050a; border-radius: 4px; box-shadow: 0 0 15px rgba(182,255,61,0.3); }
.variant-cyber .presence { border-color: #08080f; }
/* ================= VARIANT 5: WARM SOFT ================= */
.variant-warm {
--bg: #f7f2e9;
--bg-2: #efe6d4;
--bg-3: #fdfaf3;
--fg: #3d2e1f;
--fg-muted: #8a7560;
--accent: #c75d3a;
--accent-2: #e8a87c;
--line: rgba(61,46,31,0.1);
background: var(--bg);
color: var(--fg);
font-family: 'Inter', sans-serif;
}
.variant-warm .nav-brand { font-family: 'Fraunces', 'Playfair Display', serif; font-weight: 700; font-size: 20px; letter-spacing: -0.02em; }
.variant-warm .chat-header-name { font-family: 'Fraunces', 'Playfair Display', serif; font-size: 17px; }
.variant-warm .chat-list-title { font-family: 'Fraunces', serif; font-size: 16px; font-weight: 600; }
.variant-warm .app-window { background: var(--bg-3); box-shadow: 0 20px 60px rgba(61,46,31,0.12); border: 1px solid var(--line); }
.variant-warm .window-bar { background: var(--bg-2); border-bottom: 1px solid var(--line); }
.variant-warm .window-bar .title { color: var(--fg-muted); font-weight: 500; }
.variant-warm .top-nav { background: var(--bg-3); border-bottom: 1px solid var(--line); }
.variant-warm .nav-tab { color: var(--fg-muted); font-weight: 500; border-radius: 999px; }
.variant-warm .nav-tab:hover { background: var(--bg-2); color: var(--fg); }
.variant-warm .nav-tab.active { background: var(--accent); color: #fff; }
.variant-warm .nav-user { background: var(--bg-2); border-radius: 999px; }
.variant-warm .chat-list { background: var(--bg-2); }
.variant-warm .chat-search { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); border-radius: 999px; }
.variant-warm .chat-item { border-radius: 14px; }
.variant-warm .chat-item:hover { background: rgba(255,255,255,0.5); }
.variant-warm .chat-item.active { background: var(--bg-3); box-shadow: 0 2px 8px rgba(61,46,31,0.06); }
.variant-warm .avatar { background: #e8a87c; color: #7c3a1e; font-family: 'Fraunces', serif; font-weight: 700; }
.variant-warm .avatar[data-color="violet"] { background: #bfa4c9; color: #4a2a5c; }
.variant-warm .avatar[data-color="amber"] { background: #e8c87c; color: #5c3e1a; }
.variant-warm .avatar[data-color="rose"] { background: #e8a4a4; color: #5c1e1e; }
.variant-warm .avatar[data-color="teal"] { background: #a4c9bf; color: #1e4a3e; }
.variant-warm .chat-header { background: var(--bg-3); border-bottom: 1px solid var(--line); }
.variant-warm .icon-btn:hover { background: var(--bg-2); }
.variant-warm .messages { background: var(--bg-3); }
.variant-warm .bubble.me { background: var(--accent); color: #fff; border-radius: 20px 20px 6px 20px; }
.variant-warm .bubble.them { background: var(--bg-2); color: var(--fg); border-radius: 20px 20px 20px 6px; }
.variant-warm .reaction { background: var(--bg-2); border: 1px solid var(--line); }
.variant-warm .reaction.mine { background: var(--accent); color: #fff; }
.variant-warm .event-pill { background: var(--bg-2); color: var(--fg-muted); border: 1px solid var(--line); }
.variant-warm .event-pill.rejected { background: rgba(199,93,58,0.1); color: var(--accent); }
.variant-warm .composer { background: var(--bg-3); border-top: 1px solid var(--line); }
.variant-warm .composer-box { background: var(--bg-2); border-radius: 999px; padding-left: 16px; }
.variant-warm .composer-btn { background: var(--accent); color: #fff; border-radius: 50%; }
.variant-warm .presence { border-color: var(--bg-3); }
/* ================= VARIANT 6: BRUTAL GAMING ================= */
.variant-brutal {
--bg: #0d0d0d;
--bg-2: #161616;
--bg-3: #1a1a1a;
--fg: #ffffff;
--fg-muted: #808080;
--lime: #d4ff3d;
--line: #2a2a2a;
background: var(--bg);
color: var(--fg);
font-family: 'Inter', sans-serif;
}
.variant-brutal .nav-brand {
font-family: 'Archivo Black', 'Inter', sans-serif;
font-weight: 900;
font-size: 22px;
text-transform: uppercase;
letter-spacing: -0.03em;
}
.variant-brutal .chat-header-name { font-weight: 800; font-size: 16px; text-transform: uppercase; letter-spacing: -0.01em; }
.variant-brutal .chat-list-title { font-weight: 900; font-size: 18px; text-transform: uppercase; letter-spacing: -0.02em; }
.variant-brutal .app-window { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 12px 12px 0 var(--lime); }
.variant-brutal .window-bar { background: var(--bg); border-bottom: 2px solid var(--lime); }
.variant-brutal .window-bar .title { color: var(--lime); font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; }
.variant-brutal .top-nav { background: var(--bg); border-bottom: 2px solid var(--line); }
.variant-brutal .nav-tab { color: var(--fg-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; font-size: 12px; border-radius: 0; border: 2px solid transparent; }
.variant-brutal .nav-tab:hover { color: var(--fg); background: var(--bg-2); }
.variant-brutal .nav-tab.active { background: var(--lime); color: #000; }
.variant-brutal .nav-user { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; }
.variant-brutal .chat-list { background: var(--bg); border-right: 2px solid var(--line); }
.variant-brutal .chat-search { background: var(--bg-2); border: 2px solid var(--line); color: var(--fg); border-radius: 0; font-weight: 500; }
.variant-brutal .chat-item { border-radius: 0; border-left: 4px solid transparent; }
.variant-brutal .chat-item:hover { background: var(--bg-2); }
.variant-brutal .chat-item.active { background: var(--bg-2); border-left-color: var(--lime); }
.variant-brutal .chat-item-name { font-weight: 800; text-transform: uppercase; font-size: 13px; letter-spacing: -0.01em; }
.variant-brutal .avatar { background: var(--lime); color: #000; border-radius: 0; font-weight: 900; font-family: 'Archivo Black', sans-serif; }
.variant-brutal .avatar[data-color="violet"] { background: #a78bfa; }
.variant-brutal .avatar[data-color="amber"] { background: #fbbf24; }
.variant-brutal .avatar[data-color="rose"] { background: #fb7185; }
.variant-brutal .avatar[data-color="teal"] { background: #2dd4bf; }
.variant-brutal .chat-header { background: var(--bg); border-bottom: 2px solid var(--line); }
.variant-brutal .icon-btn { border-radius: 0; border: 2px solid var(--line); }
.variant-brutal .icon-btn:hover { background: var(--lime); color: #000; border-color: var(--lime); }
.variant-brutal .bubble.me { background: var(--lime); color: #000; border-radius: 0; font-weight: 600; border: 2px solid #000; box-shadow: 4px 4px 0 #fff; }
.variant-brutal .bubble.them { background: var(--fg); color: #000; border-radius: 0; font-weight: 600; border: 2px solid var(--fg); box-shadow: 4px 4px 0 var(--lime); }
.variant-brutal .bubble .ts { color: rgba(0,0,0,0.6); font-weight: 700; font-size: 10px; text-transform: uppercase; }
.variant-brutal .reaction { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; font-weight: 700; }
.variant-brutal .reaction.mine { background: var(--lime); color: #000; border-color: var(--lime); }
.variant-brutal .event-pill { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; font-size: 11px; }
.variant-brutal .event-pill.rejected { border-color: #fb7185; color: #fb7185; }
.variant-brutal .composer { background: var(--bg); border-top: 2px solid var(--line); }
.variant-brutal .composer-box { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; }
.variant-brutal .composer-btn { background: var(--lime); color: #000; border-radius: 0; font-weight: 900; }
.variant-brutal .presence { border-color: var(--bg); border-width: 3px; }
/* Profile card & call overlay — inherit from variant */
.variant-clean .profile-card { background: #fff; border: 1px solid var(--line); }
.variant-clean .profile-card-banner { background: linear-gradient(135deg, #e5e5e5, #d4d4d4); }
.variant-clean .profile-card-avatar { border-color: #fff; }
.variant-clean .profile-card-meta { border-top-color: var(--line); color: var(--fg-muted); }
.variant-clean .call-overlay { background: #fff; border: 1px solid var(--line); color: var(--fg); }
.variant-playful .profile-card { background: linear-gradient(180deg, #2a1a48, #1a1625); border: 1px solid rgba(255,255,255,0.08); }
.variant-playful .profile-card-banner { background: linear-gradient(135deg, #a78bfa, #f472b6); }
.variant-playful .profile-card-avatar { border-color: #2a1a48; }
.variant-playful .call-overlay { background: linear-gradient(135deg, #2a1a48, #1a1625); border: 1px solid rgba(167,139,250,0.3); }
.variant-y2k .profile-card { background: rgba(255,255,255,0.08); backdrop-filter: blur(30px); border: 1px solid rgba(255,255,255,0.15); }
.variant-y2k .profile-card-banner { background: linear-gradient(135deg, #c4b5fd, #f9a8d4); }
.variant-y2k .profile-card-avatar { border-color: #1a1a2e; }
.variant-y2k .call-overlay { background: rgba(255,255,255,0.08); backdrop-filter: blur(30px); border: 1px solid rgba(255,255,255,0.15); }
.variant-cyber .profile-card { background: #0c0c18; border: 1px solid var(--neon-cyan); box-shadow: 0 0 30px rgba(0,240,255,0.2); }
.variant-cyber .profile-card-banner { background: repeating-linear-gradient(90deg, #00f0ff, #00f0ff 2px, transparent 2px, transparent 8px), linear-gradient(135deg, #ff00aa 0%, #00f0ff 100%); }
.variant-cyber .profile-card-avatar { border-color: #0c0c18; border-radius: 4px; }
.variant-cyber .call-overlay { background: #0c0c18; border: 1px solid var(--neon-lime); box-shadow: 0 0 30px rgba(182,255,61,0.2); }
.variant-warm .profile-card { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); }
.variant-warm .profile-card-banner { background: linear-gradient(135deg, #e8a87c, #c75d3a); }
.variant-warm .profile-card-avatar { border-color: var(--bg-3); }
.variant-warm .profile-card-meta { border-top-color: var(--line); }
.variant-warm .call-overlay { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); }
.variant-brutal .profile-card { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 8px 8px 0 var(--lime); }
.variant-brutal .profile-card-banner { background: var(--lime); }
.variant-brutal .profile-card-avatar { border-color: var(--bg); border-radius: 0; border-width: 3px; }
.variant-brutal .profile-card-meta { border-top-color: var(--line); }
.variant-brutal .call-overlay { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 6px 6px 0 var(--lime); }
/* Typing indicator */
.typing { display: inline-flex; gap: 3px; padding: 4px 2px; }
.typing span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.4; animation: typingDot 1.3s infinite; }
.typing span:nth-child(2) { animation-delay: 0.15s; }
.typing span:nth-child(3) { animation-delay: 0.3s; }
@keyframes typingDot {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
/* Tweaks panel */
.tweaks-panel {
position: fixed;
bottom: 20px;
right: 20px;
width: 280px;
background: rgba(20,20,24,0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 14px;
padding: 16px;
z-index: 200;
color: #fff;
font-family: 'Inter', system-ui, sans-serif;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
display: none;
}
.tweaks-panel.open { display: block; }
.tweaks-panel h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.7; margin-bottom: 14px; font-weight: 600; }
.tweaks-row { margin-bottom: 12px; }
.tweaks-row label { display: block; font-size: 11px; opacity: 0.6; margin-bottom: 6px; }
.tweaks-row input[type="color"] { width: 100%; height: 34px; border-radius: 8px; background: transparent; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; }
.tweaks-row input[type="range"] { width: 100%; }
.tweaks-row select { width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 8px; border-radius: 8px; font-size: 12px; }
+34 -23
View File
@@ -126,9 +126,17 @@ async function fetchKeyBundle(
}; };
} }
// Strips the leading `\x` postgres bytea hex prefix so the RPC's
// `decode(text, 'hex')` accepts it.
function hexNoPrefix(bytes: Uint8Array): string {
return bytesToPgHex(bytes).slice(2);
}
// Bootstraps a brand-new conv-key, wrapping it for every member device that // Bootstraps a brand-new conv-key, wrapping it for every member device that
// currently exists (including the caller's own devices). Used the first time // currently exists (including the caller's own devices). Used the first time
// a conversation needs a key, or when rotation is requested. // a conversation needs a key, or when rotation is requested. All inserts go
// through `share_conv_keys` (SECURITY DEFINER) — silently skips invalid
// recipients, no per-row 403 console spam.
export async function bootstrapConvKey( export async function bootstrapConvKey(
client: AppSupabaseClient, client: AppSupabaseClient,
conversationId: string, conversationId: string,
@@ -141,27 +149,24 @@ export async function bootstrapConvKey(
throw new Error('cannot bootstrap conv key — no recipient devices'); throw new Error('cannot bootstrap conv key — no recipient devices');
} }
const rows: Array<{ const bundles: Array<{ recipient_device_id: string; encrypted_key: string; nonce: string }> = [];
conversation_id: string;
recipient_device_id: string;
key_version: number;
sender_device_id: string;
encrypted_key: string;
nonce: string;
}> = [];
for (const r of recipients) { for (const r of recipients) {
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey); const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
rows.push({ bundles.push({
conversation_id: conversationId,
recipient_device_id: r.deviceId, recipient_device_id: r.deviceId,
key_version: keyVersion, encrypted_key: hexNoPrefix(wrapped.ciphertext),
sender_device_id: own.deviceId, nonce: hexNoPrefix(wrapped.nonce),
encrypted_key: bytesToPgHex(wrapped.ciphertext),
nonce: bytesToPgHex(wrapped.nonce),
}); });
} }
const { error } = await rawFrom(client, 'conversation_keys').insert(rows); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
const { error } = await rpc.call(client, 'share_conv_keys', {
p_conv_id: conversationId,
p_sender_device_id: own.deviceId,
p_key_version: keyVersion,
p_bundles: bundles,
});
if (error) throw error; if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey }; const handle = { conversationId, keyVersion, key: convKey };
@@ -262,15 +267,21 @@ export async function shareConvKeyToDevice(
recipientPublicKey, recipientPublicKey,
own.privateKey, own.privateKey,
); );
const { error } = await rawFrom(client, 'conversation_keys').insert({ // eslint-disable-next-line @typescript-eslint/no-explicit-any
conversation_id: conversationId, const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
const { error } = await rpc.call(client, 'share_conv_keys', {
p_conv_id: conversationId,
p_sender_device_id: own.deviceId,
p_key_version: version,
p_bundles: [
{
recipient_device_id: recipientDeviceId, recipient_device_id: recipientDeviceId,
key_version: version, encrypted_key: hexNoPrefix(wrapped.ciphertext),
sender_device_id: own.deviceId, nonce: hexNoPrefix(wrapped.nonce),
encrypted_key: bytesToPgHex(wrapped.ciphertext), },
nonce: bytesToPgHex(wrapped.nonce), ],
}); });
if (error && !String(error.message ?? '').includes('duplicate')) throw error; if (error) throw error;
} }
// Re-exports for convenience. // Re-exports for convenience.
@@ -0,0 +1,109 @@
-- 1) Test-Daten-Wipe: alle conversation/message Daten weg, User+Devices+
-- Friendships+Invites bleiben erhalten so dass bestehende Logins weiter
-- funktionieren. Cascade räumt mitlaufende rows (envelopes, attachments,
-- reactions, reads, conversation_keys, conversation_members).
truncate
public.message_reactions,
public.message_reads,
public.message_attachments,
public.messages,
public.conversation_keys,
public.conversation_members,
public.conversations
restart identity cascade;
-- 2) RPC: share conv-key bundles to multiple recipients atomically.
-- Runs as SECURITY DEFINER so RLS doesn't reject individual rows. The
-- function itself enforces the same invariants as the policy. This avoids
-- the per-row 403 console spam we got with direct inserts.
create or replace function public.share_conv_keys(
p_conv_id uuid,
p_sender_device_id uuid,
p_key_version int,
p_bundles jsonb
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
caller uuid := auth.uid();
bundle jsonb;
recipient_user_id uuid;
inserted int := 0;
recipient_did uuid;
enc_key_hex text;
nonce_hex text;
begin
if caller is null then
raise exception 'not authenticated';
end if;
-- Caller must be an accepted member of the conversation.
if not exists (
select 1
from public.conversation_members
where conversation_id = p_conv_id
and user_id = caller
and accepted = true
) then
raise exception 'caller is not an accepted member of %', p_conv_id;
end if;
-- Sender device must belong to the caller.
if not exists (
select 1
from public.devices
where id = p_sender_device_id
and user_id = caller
) then
raise exception 'sender_device % not owned by caller', p_sender_device_id;
end if;
-- Iterate bundles and only insert ones whose recipient is a current
-- accepted member. Silently skip otherwise (no error → no 403 noise).
for bundle in
select * from jsonb_array_elements(p_bundles)
loop
recipient_did := (bundle->>'recipient_device_id')::uuid;
enc_key_hex := bundle->>'encrypted_key';
nonce_hex := bundle->>'nonce';
select user_id
into recipient_user_id
from public.devices
where id = recipient_did;
if recipient_user_id is null then continue; end if;
if not exists (
select 1
from public.conversation_members
where conversation_id = p_conv_id
and user_id = recipient_user_id
and accepted = true
) then continue; end if;
insert into public.conversation_keys
(conversation_id, recipient_device_id, key_version,
sender_device_id, encrypted_key, nonce)
values
(p_conv_id, recipient_did, p_key_version,
p_sender_device_id,
decode(enc_key_hex, 'hex'),
decode(nonce_hex, 'hex'))
on conflict (conversation_id, recipient_device_id, key_version)
do nothing;
if found then
inserted := inserted + 1;
end if;
end loop;
return inserted;
end;
$$;
revoke execute on function public.share_conv_keys(uuid, uuid, int, jsonb) from public, anon;
grant execute on function public.share_conv_keys(uuid, uuid, int, jsonb) to authenticated;
@@ -0,0 +1,54 @@
-- Profile-Avatar storage bucket.
--
-- Layout: <user_id>/<filename>.webp
-- Public read so peers can see each other's avatar without auth roundtrip
-- (avatars are non-sensitive). Write/update/delete restricted to the owning
-- user via path prefix matching their auth.uid().
insert into storage.buckets (id, name, public)
values ('profile-avatars', 'profile-avatars', true)
on conflict (id) do nothing;
-- Read: anyone authenticated can view any avatar (public bucket also lets
-- unauth fetch by URL but our RLS keeps the table-level policy explicit).
drop policy if exists profile_avatars_select on storage.objects;
create policy profile_avatars_select
on storage.objects
for select
to authenticated, anon
using (bucket_id = 'profile-avatars');
-- Write only into your own folder (first path segment must equal auth.uid()).
drop policy if exists profile_avatars_insert_own on storage.objects;
create policy profile_avatars_insert_own
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'profile-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
drop policy if exists profile_avatars_update_own on storage.objects;
create policy profile_avatars_update_own
on storage.objects
for update
to authenticated
using (
bucket_id = 'profile-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
with check (
bucket_id = 'profile-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
drop policy if exists profile_avatars_delete_own on storage.objects;
create policy profile_avatars_delete_own
on storage.objects
for delete
to authenticated
using (
bucket_id = 'profile-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);