This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { ensureNotificationPermission } from '../lib/osNotify';
import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar';
import { UpdateToast } from './UpdateToast';
export function AppShell() {
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
void ensureNotificationPermission();
}, []);
return (
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<ShellBackground />
<div className="relative z-10 flex min-h-screen w-full">
<Sidebar />
<main className="relative flex-1 overflow-hidden">
<div className="h-screen overflow-y-auto">
<Outlet />
</div>
</main>
</div>
<CallUI />
<UpdateToast />
</div>
);
}
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
// No animation here so message lists stay readable.
function ShellBackground() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.6)_100%)]" />
</div>
);
}
@@ -0,0 +1,115 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
export function AttachmentImage({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-28 w-28 items-center justify-center rounded-lg border border-white/10 bg-ink-800/60">
<SpinnerIcon className="h-5 w-5 text-brand-400" />
</div>
);
}
return (
<>
<button
type="button"
onClick={() => setLightboxOpen(true)}
aria-label="Bild öffnen"
className="mt-2 block w-fit max-w-full cursor-pointer overflow-hidden rounded-lg border border-white/10 bg-ink-800/40 transition hover:border-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<img
src={blobUrl}
alt="attachment"
loading="lazy"
className="block h-auto max-h-80 w-auto max-w-full object-contain"
/>
</button>
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
</>
);
}
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt="attachment full"
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { ringtone } from '../lib/ringtone';
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
import { useCallPresence } from '../lib/useCallPresence';
import {
MicIcon,
MicOffIcon,
PhoneIcon,
PhoneOffIcon,
SpinnerIcon,
XIcon,
} from './icons';
// Shell-level mount. Handles ringtones + the IncomingCallToast.
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
// users can keep browsing/typing while a call is live.
export function CallUI() {
const { state } = useCall();
useEffect(() => {
if (state.kind === 'outgoing') ringtone.start('outgoing');
else if (state.kind === 'incoming') ringtone.start('incoming');
else ringtone.stop();
}, [state.kind]);
useEffect(() => {
return () => ringtone.stop();
}, []);
return <IncomingCallToast />;
}
// ---------------------------------------------------------------------------
function IncomingCallToast() {
const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext();
const { conversations } = useConversationsContext();
if (state.kind !== 'incoming') return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
'?';
const isGroup = conv?.type === 'group';
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
const letter = (isGroup ? groupName ?? callerName : callerName)
.trim()
.charAt(0)
.toUpperCase() || '?';
return (
<div
role="dialog"
aria-modal="false"
aria-label={t('app:call.incoming_title')}
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:call.incoming_title')}
</p>
<div className="mt-3 flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">
{isGroup ? groupName : callerName}
</p>
<p className="truncate text-xs text-neutral-400">
{isGroup
? t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft Gruppe',
})
: t('app:call.incoming_from', { name: callerName })}
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
>
<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-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"
>
<PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span>
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
// the app while a call is active / connecting / ringing.
// ---------------------------------------------------------------------------
export function CallBar() {
const { state, lastCallConversationId } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const myId = session?.user.id ?? null;
// Aggregate observer across every conversation the user is in so a
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
// peer is in a call — not just calls I previously joined.
const convIds = conversations.map((c) => c.id);
const anyActive = useAnyActiveCall(convIds, myId);
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
return <ActiveCallBar />;
}
// Prefer the conversation I just left, fall back to any other live call.
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
if (rejoinId) {
return <RejoinCallBar conversationId={rejoinId} />;
}
return null;
}
function ActiveCallBar() {
const { t } = useTranslation(['app']);
const { state, isMuted, hangup, toggleMute } = useCall();
const { conversations } = useConversationsContext();
const [, forceTick] = useState(0);
useEffect(() => {
if (state.kind !== 'connected') return;
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, [state.kind]);
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
return null;
}
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
: conv?.peer?.displayName ?? '—';
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
const elapsed =
state.kind === 'connected'
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
: null;
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + state.conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
aria-label={statusLabel}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
{state.kind === 'connecting' ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<SignalIcon className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
<p className="truncate text-xs text-neutral-400">
{title}
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
</p>
</div>
</Link>
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
<IconTile
onClick={toggleMute}
disabled={state.kind !== 'connected'}
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
tone={isMuted ? 'amber' : 'neutral'}
>
{isMuted ? <MicOffIcon className="h-4 w-4" /> : <MicIcon className="h-4 w-4" />}
</IconTile>
<IconTile onClick={() => void hangup()} label={t('app:call.hangup')} tone="rose">
<PhoneOffIcon className="h-4 w-4" />
</IconTile>
</div>
</div>
);
}
function RejoinCallBar({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app', 'common']);
const { joinActiveCall, dismissLastCall } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
const others = active.filter((u) => u !== myId);
// Presence polling returns [] on the first tick before it syncs, so we can't
// treat an initial empty list as "room is empty". Only dismiss after we've
// actually seen peers and then watched them leave — and even then confirm
// the empty state for a grace period, since presence_diff events can
// arrive slightly out of order.
const [visible, setVisible] = useState(false);
useEffect(() => {
if (others.length > 0) {
setVisible(true);
return;
}
if (!visible) return;
const id = window.setTimeout(() => {
setVisible(false);
dismissLastCall();
}, 2500);
return () => window.clearTimeout(id);
}, [others.length, visible, dismissLastCall]);
if (!visible) return null;
const conv = conversations.find((c) => c.id === conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
: conv?.peer?.displayName ?? '—';
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
<SignalIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">
{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}
</p>
<p className="truncate text-xs text-neutral-400">
{title} · {others.length}
</p>
</div>
</Link>
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
<IconTile
onClick={() => void joinActiveCall(conversationId, 'audio')}
label={t('app:call.join')}
tone="emerald"
>
<PhoneIcon className="h-4 w-4" />
</IconTile>
<IconTile onClick={dismissLastCall} label={t('common:close', { defaultValue: 'Schließen' })} tone="neutral">
<XIcon className="h-4 w-4" />
</IconTile>
</div>
</div>
);
}
interface IconTileProps {
onClick: () => void;
label: string;
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
disabled?: boolean;
children: React.ReactNode;
}
function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) {
const toneClass =
tone === 'rose'
? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
: tone === 'amber'
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40'
: tone === 'emerald'
? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40'
: 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
'inline-flex h-9 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
>
{children}
</button>
);
}
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;
}
@@ -0,0 +1,175 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons';
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
};
interface Props {
conversation: ConversationSummary | null;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
}
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
if (!conversation) {
return (
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
);
}
return (
<>
<HeaderBar
conversation={conversation}
peerPresence={peerPresence}
{...(onInfoClick ? { onInfoClick } : {})}
/>
<ActiveCallBanner conversationId={conversation.id} />
</>
);
}
interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
}
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?';
// Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
return (
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3">
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
{showPresence && peerPresence && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
PRESENCE_DOT[peerPresence]
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
<p className="truncate text-xs text-neutral-500">
{isDm ? (
<>
<span>{handle}</span>
{showPresence && (
<>
<span className="mx-1.5 text-neutral-700">·</span>
<span>{presenceLabel}</span>
</>
)}
</>
) : (
<>
{conversation.members.length} {t('app:nav.friends').toLowerCase()}
</>
)}
</p>
</div>
<CallHeaderButton conversationId={conversation.id} />
{!isDm && onInfoClick && (
<button
type="button"
onClick={onInfoClick}
aria-label={t('app:group.info_title')}
title={t('app:group.info_title')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<InfoIcon className="h-4 w-4" />
</button>
)}
</header>
);
}
function CallHeaderButton({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
return (
<button
type="button"
onClick={() => void startCall(conversationId, 'audio')}
disabled={busy}
aria-label={t('app:call.start_audio')}
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50"
>
<PhoneIcon className="h-4 w-4" />
</button>
);
}
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
// Source of truth for "am I in this call" is the CallContext state, not
// presence — my own presence entry can lag behind the room join, which
// would otherwise make the banner flash while I'm already connected.
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversationId;
// Only show banner when other people are in it and I'm not.
const othersIn = active.filter((u) => u !== myId);
if (iAmIn || othersIn.length === 0) return null;
const busy = state.kind !== 'idle';
return (
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-300" />
<span className="flex-1">
{t('app:call.active_in_conv', {
defaultValue: 'Active call · {{count}} in room',
count: othersIn.length,
})}
</span>
<button
type="button"
onClick={() => void joinActiveCall(conversationId, 'audio')}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PhoneIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:call.join', { defaultValue: 'Join' })}</span>
</button>
</div>
);
}
@@ -0,0 +1,152 @@
import { createGroup } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
import { AlertIcon, CheckCircleIcon, SpinnerIcon, UsersIcon } from './icons';
import { Modal } from './Modal';
interface Props {
open: boolean;
onClose: () => void;
}
export function CreateGroupDialog({ open, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const navigate = useNavigate();
const { friendships } = useFriendshipsContext();
const acceptedFriends = useMemo(
() => friendships.filter((f) => f.status === 'accepted').map((f) => f.peer),
[friendships],
);
const [name, setName] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle(userId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (busy || name.trim().length === 0) return;
setBusy(true);
setError(null);
try {
const id = await createGroup({
client: supabase,
name,
memberUserIds: Array.from(selected),
});
onClose();
setName('');
setSelected(new Set());
navigate('/chats/' + id);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title={t('app:group.create_title')}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:group.create_name_label')}
</label>
<input
type="text"
required
maxLength={64}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('app:group.create_name_placeholder')}
className="w-full rounded-lg border border-white/10 bg-ink-800 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"
/>
</div>
<div className="space-y-2">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:group.create_members_label')}{' '}
<span className="text-neutral-500">({selected.size})</span>
</label>
{acceptedFriends.length === 0 ? (
<div className="flex items-center gap-3 rounded-lg border border-white/5 bg-ink-800/60 p-4 text-xs text-neutral-400">
<UsersIcon className="h-4 w-4" />
<span>{t('app:group.create_members_empty')}</span>
</div>
) : (
<ul className="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-ink-800/40 p-1">
{acceptedFriends.map((f) => {
const active = selected.has(f.userId);
const letter =
(f.displayName ?? f.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<li key={f.userId}>
<button
type="button"
onClick={() => toggle(f.userId)}
aria-pressed={active}
className={
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
</div>
{active && <CheckCircleIcon className="h-4 w-4 text-brand-300" />}
</button>
</li>
);
})}
</ul>
)}
</div>
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<button
type="submit"
disabled={busy || name.trim().length === 0}
className="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-4 py-2.5 text-sm font-semibold text-white shadow-glow 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"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'app:group.create_cta_loading' : 'app:group.create_cta')}</span>
</button>
</form>
</Modal>
);
}
@@ -0,0 +1,127 @@
import type { DeviceRecord } from '@chat-app/shared/auth';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
defaultName: string;
onRegistered: (device: DeviceRecord) => void;
}
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
const { t } = useTranslation(['auth', 'errors']);
const nameId = useId();
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setBusy(true);
setError(null);
try {
const device = await registerCurrentDevice({ userId, name: trimmed });
onRegistered(device);
} catch (err: unknown) {
const code = extractErrorCode(err);
if (code) {
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
} else if (err instanceof Error) {
setError(err.message);
} else {
setError(t('errors:generic'));
}
} finally {
setBusy(false);
}
},
[name, userId, onRegistered, t],
);
const platform = detectDesktopPlatform();
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
<LockIcon className="h-5 w-5 text-brand-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
</div>
</div>
<div className="mt-6 space-y-1.5">
<label
htmlFor={nameId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
>
{t('auth:device.name_label')}
</label>
<input
id={nameId}
type="text"
required
autoFocus
maxLength={64}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth:device.name_placeholder')}
className="w-full rounded-lg border border-white/10 bg-ink-800 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"
/>
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
</div>
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
</div>
</div>
<button
type="submit"
disabled={busy || name.trim().length === 0}
aria-busy={busy}
className="group mt-6 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-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('auth:device.cta_loading')}</span>
</>
) : (
<>
<span>{t('auth:device.cta')}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<p className="mt-4 text-center text-xs text-neutral-500">
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
</p>
</form>
);
}
@@ -0,0 +1,240 @@
import { addGroupMember, type ConversationSummary, leaveGroup } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
import { AlertIcon, PlusIcon, SignOutIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
conversation: ConversationSummary;
}
export function GroupInfoPanel({ open, onClose, conversation }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session } = useAuth();
const { friendships } = useFriendshipsContext();
const navigate = useNavigate();
const [busyLeave, setBusyLeave] = useState(false);
const [busyAddId, setBusyAddId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const myId = session?.user.id;
const myRole =
conversation.members.find((m) => m.userId === myId)?.role ?? conversation.myRole;
const canAdd = myRole === 'admin' || myRole === 'mod';
const addableFriends = useMemo(() => {
const memberIds = new Set(conversation.members.map((m) => m.userId));
return friendships
.filter((f) => f.status === 'accepted')
.map((f) => f.peer)
.filter((p) => !memberIds.has(p.userId));
}, [friendships, conversation.members]);
async function handleAdd(userId: string) {
if (busyAddId) return;
setBusyAddId(userId);
setError(null);
try {
await addGroupMember(supabase, conversation.id, userId);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusyAddId(null);
}
}
async function handleLeave() {
if (busyLeave) return;
if (!window.confirm(t('app:group.info_leave_confirm'))) return;
setBusyLeave(true);
setError(null);
try {
await leaveGroup(supabase, conversation.id);
onClose();
navigate('/chats');
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusyLeave(false);
}
}
if (!open) return null;
const roleLabel = (role: string) =>
role === 'admin'
? t('app:group.info_role_admin')
: role === 'mod'
? t('app:group.info_role_mod')
: t('app:group.info_role_member');
return (
<aside
role="dialog"
aria-label={t('app:group.info_title')}
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
>
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
<div>
<p className="text-xs uppercase tracking-wide text-neutral-500">
{t('app:group.info_title')}
</p>
<h3 className="mt-0.5 font-display text-base font-semibold text-white">
{conversation.name ?? '—'}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 space-y-6 overflow-y-auto p-5">
<section>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:group.info_members')} ({conversation.members.length})
</h4>
<ul className="space-y-1">
{conversation.members.map((m) => {
const name = m.profile?.displayName ?? '?';
const handle = m.profile?.username ? '@' + m.profile.username : '';
const letter = name.trim().charAt(0).toUpperCase() || '?';
return (
<li
key={m.userId}
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">
{name}
{m.userId === myId && (
<span className="ml-1.5 text-xs text-neutral-500">· you</span>
)}
</p>
<p className="truncate text-xs text-neutral-500">{handle}</p>
</div>
<span
className={
'rounded-full border px-2 py-0.5 text-[10px] font-medium ' +
(m.role === 'admin'
? 'border-brand-400/30 bg-brand-500/15 text-brand-200'
: m.role === 'mod'
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
: 'border-white/10 bg-white/5 text-neutral-300')
}
>
{roleLabel(m.role)}
</span>
</li>
);
})}
</ul>
</section>
{canAdd && (
<section>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:group.info_add_title')}
</h4>
{addableFriends.length === 0 ? (
<div className="flex items-center gap-3 rounded-lg border border-white/5 bg-ink-800/60 p-3 text-xs text-neutral-400">
<UsersIcon className="h-4 w-4" />
<span>{t('app:group.info_add_empty')}</span>
</div>
) : (
<>
<p className="mb-2 text-xs text-neutral-500">
{t('app:group.info_add_help')}
</p>
<ul className="space-y-1">
{addableFriends.map((f) => {
const letter =
(f.displayName ?? f.username ?? '?').trim().charAt(0).toUpperCase() || '?';
const busy = busyAddId === f.userId;
return (
<li key={f.userId}>
<button
type="button"
disabled={busy}
onClick={() => void handleAdd(f.userId)}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
</div>
{busy ? (
<SpinnerIcon className="h-4 w-4 text-brand-400" />
) : (
<PlusIcon className="h-4 w-4 text-neutral-400" />
)}
</button>
</li>
);
})}
</ul>
</>
)}
</section>
)}
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
</div>
<footer className="border-t border-white/5 p-4">
<button
type="button"
onClick={() => void handleLeave()}
disabled={busyLeave}
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 disabled:opacity-60"
>
{busyLeave ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<SignOutIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:group.info_leave')}</span>
</button>
</footer>
</aside>
);
}
+450
View File
@@ -0,0 +1,450 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { type RemoteScreenShare, useCall } from '../context/CallContext';
import {
getPttSettings,
type PttSettings,
subscribePttSettings,
} from '../lib/pttSettings';
import {
LockIcon,
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
PhoneOffIcon,
SpinnerIcon,
} from './icons';
interface Props {
conversation: ConversationSummary;
}
// Discord-style call widget rendered above the message list when the user is
// in the current conversation's call. Shows participant avatars + controls.
export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const {
state,
remoteParticipants,
isMuted,
isE2EEActive,
toggleMute,
hangup,
isScreenSharing,
remoteScreenShares,
toggleScreenShare,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const active =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversation.id;
if (!active) return null;
const remoteIds = new Set<string>(
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
);
const tiles: ParticipantTileData[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
tiles.push({
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
speaking: false,
muted: isMuted,
});
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
if (!remoteIds.has(m.userId)) continue;
tiles.push({
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
speaking: false,
muted: false,
});
}
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: remoteParticipants.length === 0
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
return (
<section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
>
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
{state.kind === 'connecting' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
</span>
)}
<span className="uppercase tracking-wide">{statusLabel}</span>
{isE2EEActive && (
<span
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
title={t('app:call.e2ee_active_hint', {
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
})}
>
<LockIcon className="h-3 w-3" />
E2EE
</span>
)}
</div>
<div className="flex flex-wrap justify-center gap-4">
{tiles.map((p) => (
<ParticipantTile key={p.userId} {...p} />
))}
</div>
{remoteScreenShares.length > 0 && (
<div className="mt-4 space-y-3">
{remoteScreenShares.map((s) => {
const member = conversation.members.find((m) => m.userId === s.participantId);
return (
<ScreenShareViewer
key={s.track.sid ?? s.participantId}
share={s}
avatarUrl={member?.profile?.avatarUrl ?? null}
displayName={member?.profile?.displayName ?? s.participantName}
/>
);
})}
</div>
)}
<div className="mt-5 flex items-center justify-center gap-2">
<ControlButton
onClick={toggleMute}
disabled={state.kind !== 'connected'}
active={isMuted}
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
tone={isMuted ? 'amber' : 'neutral'}
>
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</ControlButton>
<ControlButton
onClick={() => void toggleScreenShare()}
disabled={state.kind !== 'connected'}
active={isScreenSharing}
label={
isScreenSharing
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
}
tone={isScreenSharing ? 'emerald' : 'neutral'}
>
{isScreenSharing ? (
<MonitorStopIcon className="h-5 w-5" />
) : (
<MonitorShareIcon className="h-5 w-5" />
)}
</ControlButton>
<ControlButton
onClick={() => void hangup()}
label={t('app:call.hangup')}
tone="rose"
>
<PhoneOffIcon className="h-5 w-5" />
</ControlButton>
</div>
<PttHint />
</section>
);
}
interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
}
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
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/20 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/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
type="button"
onClick={() => setWatching(true)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}
>
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
<div className="flex flex-col items-center gap-2">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/90 text-white shadow-lg transition group-hover:scale-105">
<PlayIcon className="ml-0.5 h-6 w-6" />
</span>
<span className="text-xs font-medium text-white/90">
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
</span>
</div>
</div>
</button>
)}
</div>
);
}
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
if (avatarUrl) {
return (
<>
<img
src={avatarUrl}
alt=""
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/20 to-emerald-500/20" />
</>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
<span
aria-hidden="true"
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
>
{letter}
</span>
</div>
);
}
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>
);
}
function PttHint() {
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null;
return (
<p className="mt-3 text-center text-[11px] text-neutral-500">
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">
{ptt.keyLabel}
</kbd>
</p>
);
}
interface ParticipantTileData {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
speaking: boolean;
muted: boolean;
}
function ParticipantTile(p: ParticipantTileData) {
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex w-28 flex-col items-center gap-2">
<div
className={
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
(p.speaking
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
: 'ring-white/10')
}
>
{p.avatarUrl ? (
<img
src={p.avatarUrl}
alt=""
className="h-full w-full rounded-2xl object-cover"
/>
) : (
<span aria-hidden="true">{letter}</span>
)}
{p.muted && (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
>
<MicOffIcon className="h-3 w-3 text-ink-950" />
</span>
)}
</div>
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
{p.displayName}
{p.self && (
<span className="ml-1 text-neutral-500">·&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,45 @@
import { changeLocale, SUPPORTED_LOCALES, type SupportedLocale } from '@chat-app/shared/i18n';
import { useTranslation } from 'react-i18next';
const LABELS: Record<SupportedLocale, string> = {
en: 'EN',
de: 'DE',
};
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
const { i18n } = useTranslation();
const current = (i18n.resolvedLanguage ?? i18n.language) as SupportedLocale;
return (
<div
role="group"
aria-label="Language"
className={
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' +
(compact ? '' : 'backdrop-blur')
}
>
{SUPPORTED_LOCALES.map((locale) => {
const active = locale === current;
return (
<button
key={locale}
type="button"
aria-pressed={active}
onClick={() => {
if (!active) void changeLocale(locale);
}}
className={
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' +
(active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:text-neutral-200')
}
>
{LABELS[locale]}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,418 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type DecryptedMessage,
editEncryptedMessage,
parseMessagePayload,
softDeleteMessage,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentImage } from './AttachmentImage';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
interface Props {
message: DecryptedMessage;
mine: boolean;
groupedWithPrev: boolean;
conversationId: string;
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean;
}
export function MessageBubble({
message,
mine,
groupedWithPrev,
conversationId,
reactions,
onToggleReaction,
showSeen = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(initialText);
const [busy, setBusy] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const createdAt = new Date(message.createdAt);
const age = Date.now() - createdAt.getTime();
const withinEditWindow = age < EDIT_WINDOW_MS;
const bodyText = initialText;
const attachments = initialAttachments;
const canEdit =
parsed.kind === 'text' &&
mine &&
withinEditWindow &&
!message.deletedAt &&
attachments.length === 0;
const canDelete = parsed.kind === 'text' && mine && !message.deletedAt;
const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(
createdAt,
);
useEffect(() => {
if (!pickerOpen) return;
function onClickOutside(e: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setPickerOpen(false);
}
}
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [pickerOpen]);
const handleEditSave = useCallback(async () => {
if (!session || !device) return;
const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) {
setEditing(false);
setEditError(null);
return;
}
setBusy(true);
setEditError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
await editEncryptedMessage({
client: supabase,
messageId: message.id,
conversationId,
newPlaintext: trimmed,
senderPrivateKey: priv,
});
setEditing(false);
} catch (err: unknown) {
const code = extractErrorCode(err);
setEditError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
const handleDelete = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
await softDeleteMessage(supabase, message.id);
} catch (err: unknown) {
console.error('delete failed', err);
} finally {
setBusy(false);
}
}, [busy, message.id]);
const handlePickEmoji = useCallback(
async (emoji: string) => {
setPickerOpen(false);
try {
await onToggleReaction(emoji);
} catch (err: unknown) {
console.error('toggleReaction failed', err);
}
},
[onToggleReaction],
);
if (message.deletedAt) {
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
{t('app:chats.deleted')}
</div>
</div>
);
}
if (parsed.kind === 'call_event') {
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
}
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div
className={
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
}
>
{editing ? (
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleEditSave();
}
if (e.key === 'Escape') {
setEditing(false);
setEditError(null);
}
}}
rows={2}
autoFocus
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
/>
{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">
{editError}
</p>
)}
<div className="mt-1.5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setEditing(false);
setEditError(null);
}}
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
>
{t('app:friends.action_cancel')}
</button>
<button
type="button"
disabled={busy}
onClick={() => void handleEditSave()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{t('common:save', { defaultValue: 'Save' })}</span>
</button>
</div>
</div>
) : (
<div
className={
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
(mine
? 'bg-brand-500/85 text-white'
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
}
>
{message.plaintext === null ? (
<span className="italic text-neutral-400">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) => (
<AttachmentImage key={a.id} handle={a} />
))}
</>
)}
<div
className={
'mt-1 flex items-center gap-1.5 text-[10px] ' +
(mine ? 'text-brand-100/70' : 'text-neutral-500')
}
>
<span>{time}</span>
{message.editedAt && !message.deletedAt && (
<span className="italic">· {t('app:chats.edited')}</span>
)}
</div>
</div>
)}
{showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
{t('app:chats.seen')}
</p>
)}
{reactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.map((r) => (
<button
key={r.emoji}
type="button"
onClick={() => void onToggleReaction(r.emoji)}
className={
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(r.mine
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
}
>
<span>{r.emoji}</span>
<span className="text-[10px] font-medium">{r.count}</span>
</button>
))}
</div>
)}
{!editing && (
<div
className={
'pointer-events-none absolute top-0 z-20 opacity-0 transition group-hover:pointer-events-auto group-hover:opacity-100 ' +
(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">
<ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)}
icon={<SmileIcon className="h-4 w-4" />}
/>
{canEdit && (
<ActionButton
label="Edit"
onClick={() => {
setEditText(message.plaintext ?? '');
setEditing(true);
}}
icon={<PencilIcon className="h-4 w-4" />}
/>
)}
{canDelete && (
<ActionButton
label="Delete"
onClick={() => void handleDelete()}
icon={<TrashIcon className="h-4 w-4" />}
tone="danger"
/>
)}
</div>
{pickerOpen && (
<div
ref={pickerRef}
role="menu"
className={
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
(mine ? 'right-0' : 'left-0')
}
>
{EMOJI_CHOICES.map((e) => (
<button
key={e}
type="button"
onClick={() => void handlePickEmoji(e)}
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
{e}
</button>
))}
<button
type="button"
onClick={() => setPickerOpen(false)}
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
>
<XIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
function CallEventRow({
parsed,
mine,
time,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
time: string;
}) {
const { t } = useTranslation(['app']);
const status = parsed.status;
const isMissed = status === 'missed' || status === 'declined';
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
const tone = isMissed
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
const label =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: status === 'missed'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
return (
<div className="my-2 flex justify-center">
<div
className={
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium ' + tone
}
>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
{duration && <span className="font-mono text-[11px] opacity-80">· {duration}</span>}
<span className="text-[10px] opacity-60">· {time}</span>
</div>
</div>
);
}
function formatDuration(totalSec: number): string {
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m === 0) return s + 's';
return m + ':' + s.toString().padStart(2, '0');
}
function ActionButton({
label,
onClick,
icon,
tone,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
tone?: 'danger';
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(tone === 'danger'
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
}
>
{icon}
</button>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { XIcon } from './icons';
interface Props {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
size?: 'md' | 'lg';
}
export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
const width = size === 'lg' ? 'max-w-xl' : 'max-w-md';
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className={
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' +
width
}
>
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="max-h-[75vh] overflow-y-auto p-6">{children}</div>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { CallBar } from './CallUI';
import {
ChatBubbleIcon,
GearIcon,
LogoMark,
ShieldIcon,
SignOutIcon,
UsersIcon,
} from './icons';
import { UserBar } from './UserBar';
interface NavItem {
to: string;
labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
}
const BASE_NAV_ITEMS: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
];
const ADMIN_NAV_ITEM: NavItem = {
to: '/admin',
labelKey: 'app:nav.admin',
icon: ShieldIcon,
};
export function Sidebar() {
const { t } = useTranslation(['app', 'common']);
const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext();
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS;
return (
<aside
aria-label="Primary navigation"
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
>
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight text-white">
{t('common:app_name')}
</span>
</div>
<nav className="mt-2 flex flex-col gap-0.5 px-3">
{navItems.map((item) => {
const badge =
item.to === '/friends'
? incomingCount
: item.to === '/chats'
? totalUnread
: 0;
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
return (
<NavLink
key={item.to}
to={item.to}
aria-label={ariaLabel}
className={({ isActive }) =>
[
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
isActive
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
].join(' ')
}
>
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
<span className="flex-1">{t(item.labelKey)}</span>
{badge > 0 && <NavBadge count={badge} />}
</NavLink>
);
})}
</nav>
<div className="flex-1" />
<div className="border-t border-white/5 p-3">
<CallBar />
<UserBar />
<button
type="button"
onClick={() => void signOut()}
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
>
<SignOutIcon className="h-4 w-4" />
<span>{t('app:sidebar.sign_out')}</span>
</button>
</div>
</aside>
);
}
function NavBadge({ count }: { count: number }) {
const display = count > 99 ? '99+' : String(count);
return (
<span
aria-hidden="true"
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]"
>
{display}
</span>
);
}
@@ -0,0 +1,40 @@
import type { ConversationMember } from '@chat-app/shared/chat';
import { useTranslation } from 'react-i18next';
interface Props {
typingUserIds: string[];
members: ConversationMember[];
}
export function TypingIndicator({ typingUserIds, members }: Props) {
const { t } = useTranslation(['app']);
if (typingUserIds.length === 0) return null;
const names = typingUserIds
.map((id) => members.find((m) => m.userId === id)?.profile?.displayName)
.filter((n): n is string => typeof n === 'string' && n.length > 0);
const text =
names.length === 1
? t('app:chats.typing_one', { name: names[0] })
: t('app:chats.typing_many', { count: typingUserIds.length });
return (
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400">
<span className="inline-flex items-center gap-2">
<TypingDots />
<span>{text}</span>
</span>
</div>
);
}
function TypingDots() {
return (
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
</span>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
checkForUpdate,
IDLE_UPDATE_STATE,
installUpdate,
type UpdateState,
} from '../lib/appUpdates';
import { SparklesIcon, SpinnerIcon, XIcon } from './icons';
// Light-weight update UX: check once on mount, then every hour while the
// app is open. When an update exists show a toast with "Install & Restart"
// and a dismiss button. Dismiss is session-scoped — on next launch we check
// again.
export function UpdateToast() {
const { t } = useTranslation(['app']);
const [state, setState] = useState<UpdateState>(IDLE_UPDATE_STATE);
const [dismissed, setDismissed] = useState(false);
const [installing, setInstalling] = useState(false);
const [progressPct, setProgressPct] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const run = async () => {
const next = await checkForUpdate();
if (!cancelled) setState(next);
};
void run();
const id = window.setInterval(run, 60 * 60 * 1000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
if (!state.available || dismissed) return null;
const handleInstall = async () => {
setInstalling(true);
setProgressPct(0);
try {
await installUpdate((downloaded, total) => {
if (total && total > 0) setProgressPct((downloaded / total) * 100);
});
} catch (err: unknown) {
setState((s) => ({
...s,
error: err instanceof Error ? err.message : 'install failed',
}));
setInstalling(false);
setProgressPct(null);
}
};
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
>
<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">
<SparklesIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
{state.version ? ' · v' + state.version : ''}
</p>
{state.notes && (
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
)}
{state.error && (
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
)}
</div>
{!installing && (
<button
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common:close', { defaultValue: 'Schließen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
type="button"
onClick={() => void handleInstall()}
disabled={installing}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{installing ? (
<>
<SpinnerIcon className="h-3.5 w-3.5" />
<span>
{progressPct != null
? t('app:update.downloading', { defaultValue: 'Lade…' }) +
' ' +
Math.round(progressPct) +
'%'
: t('app:update.installing', { defaultValue: 'Installiere…' })}
</span>
</>
) : (
<span>{t('app:update.install', { defaultValue: 'Installieren & Neustarten' })}</span>
)}
</button>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
};
function avatarLetter(input: string | undefined): string {
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
}
export function UserBar() {
const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline';
async function changePresence(next: PresenceState) {
if (busy || next === presence) {
setOpen(false);
return;
}
setBusy(true);
try {
await updateOwnProfile(supabase, { presenceState: next });
await refreshProfile();
} catch (err: unknown) {
console.error('updatePresence failed', err);
} finally {
setBusy(false);
setOpen(false);
}
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{avatarLetter(profile?.displayName ?? profile?.username)}
<span
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
PRESENCE_DOT[presence]
}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-neutral-400">
{profile?.username ? '@' + profile.username : '—'}
</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
/>
</button>
{open && (
<div
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
>
{PRESENCE_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
role="menuitemradio"
aria-checked={opt === presence}
onClick={() => void changePresence(opt)}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
>
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && (
<span className="text-xs text-brand-300"></span>
)}
</button>
))}
</div>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { SpinnerIcon } from './icons';
function FullScreenSpinner({ label }: { label?: string }) {
return (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
<div className="flex items-center gap-3 text-neutral-400">
<SpinnerIcon className="h-5 w-5 text-brand-400" />
{label && <span className="text-sm font-medium">{label}</span>}
</div>
</main>
);
}
// Forces a session. Sends to /auth otherwise.
export function RequireAuth() {
const { session, ready } = useAuth();
const loc = useLocation();
if (!ready) return <FullScreenSpinner />;
if (!session) return <Navigate to="/auth" replace state={{ from: loc.pathname }} />;
return <Outlet />;
}
// Forces a registered device on this install. Sends to /device otherwise.
export function RequireDevice() {
const { device, deviceLookupDone } = useAuth();
if (!deviceLookupDone) return <FullScreenSpinner />;
if (!device) return <Navigate to="/device" replace />;
return <Outlet />;
}
// Admin-only route gate. Non-admins bounce to /chats — RLS still enforces
// server-side, this is purely a UX shortcut.
export function RequireAdmin() {
const { profile } = useAuth();
if (profile && !profile.isAdmin) return <Navigate to="/chats" replace />;
return <Outlet />;
}
+367
View File
@@ -0,0 +1,367 @@
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
type IconProps = React.SVGProps<SVGSVGElement>;
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function MailIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</Base>
);
}
export function AtIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</Base>
);
}
export function TicketIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
<path d="M9 6v12" strokeDasharray="2 3" />
</Base>
);
}
export function ArrowRightIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</Base>
);
}
export function CheckCircleIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.5 2.5 4.5-5" />
</Base>
);
}
export function AlertIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
</Base>
);
}
export function ShieldIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
<path d="m9.5 12.5 2 2 3.5-4" />
</Base>
);
}
export function LockIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</Base>
);
}
export function SparklesIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 3v4" />
<path d="M12 17v4" />
<path d="M3 12h4" />
<path d="M17 12h4" />
<path d="m6 6 2 2" />
<path d="m16 16 2 2" />
<path d="m6 18 2-2" />
<path d="m16 8 2-2" />
</Base>
);
}
export function SpinnerIcon(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
{...props}
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2.5"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="0.8s"
repeatCount="indefinite"
/>
</path>
</svg>
);
}
export function ChatBubbleIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</Base>
);
}
export function UsersIcon(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="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Base>
);
}
export function GearIcon(props: IconProps) {
return (
<Base {...props}>
<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 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 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 1 1-2.83-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 1 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 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 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 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</Base>
);
}
export function SearchIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</Base>
);
}
export function PlusIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 5v14M5 12h14" />
</Base>
);
}
export function SignOutIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</Base>
);
}
export function MenuIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 6h18M3 12h18M3 18h18" />
</Base>
);
}
export function ChevronDownIcon(props: IconProps) {
return (
<Base {...props}>
<path d="m6 9 6 6 6-6" />
</Base>
);
}
export function PencilIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</Base>
);
}
export function TrashIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 6h18" />
<path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</Base>
);
}
export function SmileIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</Base>
);
}
export function CopyIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</Base>
);
}
export function PhoneIcon(props: IconProps) {
return (
<Base {...props}>
<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-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 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-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
</Base>
);
}
export function PhoneOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 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-3.48-3.05" />
<path d="M22 2 2 22" />
<path d="M6.12 2H4.11A2 2 0 0 0 2.12 4.18c.17 1.39.5 2.72 1 3.97" />
</Base>
);
}
export function MicIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<path d="M12 19v4" />
<path d="M8 23h8" />
</Base>
);
}
export function MicOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M1 1l22 22" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<path d="M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M17 16.95A7 7 0 0 1 5 12v-2" />
<path d="M19 10v2a7 7 0 0 1-.11 1.23" />
<path d="M12 19v4" />
<path d="M8 23h8" />
</Base>
);
}
export function InfoIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</Base>
);
}
export function XIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</Base>
);
}
export function MonitorShareIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
<path d="M12 12V7M9.5 9.5L12 7l2.5 2.5" />
</Base>
);
}
export function MonitorStopIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
<path d="M9 9h6v3H9z" fill="currentColor" />
</Base>
);
}
export function LogoMark(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#818CF8" />
<stop offset="1" stopColor="#4F46E5" />
</linearGradient>
</defs>
<path
d="M6 9a5 5 0 0 1 5-5h10a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5h-5.5L9 27v-5H11a5 5 0 0 1-5-5V9Z"
fill="url(#logo-grad)"
/>
<path
d="M12 14h8M12 10h8"
stroke="#0A0A0F"
strokeWidth="1.75"
strokeLinecap="round"
strokeOpacity="0.5"
/>
</svg>
);
}