initial
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
<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">· 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user