672c8738c7
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
non-images as file cards
User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
online/idle/dnd (with message set); falls back to localized presence
label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)
DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify
Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items
@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles
Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body
Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
localStorage
- Trigger button next to + and voice buttons in composer
Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip
Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
side
- Focus flicker: visibility/online wake refresh throttled to 30s,
focus listener dropped, loading flag only on first fetch
263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
import type { PresenceState } from '@chat-app/shared/supabase';
|
|
import { useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useCall } from '../context/CallContext';
|
|
import { useCallPresence } from '../lib/useCallPresence';
|
|
import type { PeerPresence } from '../lib/usePeerPresence';
|
|
import { Avatar } from './Avatar';
|
|
import {
|
|
InfoIcon,
|
|
PhoneIcon,
|
|
SearchIcon,
|
|
SpinnerIcon,
|
|
UsersIcon,
|
|
VideoIcon,
|
|
} from './icons';
|
|
|
|
const PRESENCE_DOT: Record<PresenceState, string> = {
|
|
online: 'bg-emerald-500',
|
|
idle: 'bg-amber-400',
|
|
dnd: 'bg-rose-500',
|
|
invisible: 'bg-neutral-500',
|
|
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
|
};
|
|
|
|
interface Props {
|
|
conversation: ConversationSummary | null;
|
|
peerPresence: PeerPresence | null;
|
|
onInfoClick?: () => void;
|
|
onSearchClick?: () => void;
|
|
}
|
|
|
|
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
|
|
if (!conversation) {
|
|
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<HeaderBar
|
|
conversation={conversation}
|
|
peerPresence={peerPresence}
|
|
{...(onInfoClick ? { onInfoClick } : {})}
|
|
{...(onSearchClick ? { onSearchClick } : {})}
|
|
/>
|
|
<ActiveCallBanner conversationId={conversation.id} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface HeaderBarProps {
|
|
conversation: ConversationSummary;
|
|
peerPresence: PeerPresence | null;
|
|
onInfoClick?: () => void;
|
|
onSearchClick?: () => void;
|
|
}
|
|
|
|
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: 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 peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
|
|
|
// Hide presence when peer chose invisible — reciprocal privacy.
|
|
const peerState = peerPresence?.state ?? null;
|
|
const showPresence = isDm && peerState && peerState !== 'invisible';
|
|
// Subtitle priority: custom status_message when online (or idle/dnd), else
|
|
// the localized presence label. Offline always wins → just "Offline".
|
|
const presenceLabel = (() => {
|
|
if (!peerState) return '';
|
|
if (peerState === 'offline') return t('app:presence.offline');
|
|
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
|
|
return peerPresence.statusMessage.trim();
|
|
}
|
|
return t('app:presence.' + peerState);
|
|
})();
|
|
|
|
return (
|
|
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
|
<div className="relative">
|
|
{isDm ? (
|
|
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
|
) : peerAvatar ? (
|
|
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
|
) : (
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
|
<UsersIcon className="h-5 w-5" />
|
|
</div>
|
|
)}
|
|
{showPresence && peerState && (
|
|
<span
|
|
aria-hidden="true"
|
|
className={
|
|
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
|
PRESENCE_DOT[peerState]
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
|
<p className="truncate text-xs text-fg-muted">
|
|
{isDm ? (
|
|
<>
|
|
<span>{handle}</span>
|
|
{showPresence && (
|
|
<>
|
|
<span className="mx-1.5 text-fg-muted/50">·</span>
|
|
<span>{presenceLabel}</span>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{conversation.members.length} {t('app:nav.friends').toLowerCase()}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<HeaderActionButton
|
|
label={t('app:chats.search', { defaultValue: 'Suche' })}
|
|
icon={SearchIcon}
|
|
{...(onSearchClick ? { onClick: onSearchClick } : {})}
|
|
/>
|
|
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
|
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
|
{!isDm && onInfoClick && (
|
|
<HeaderActionButton
|
|
label={t('app:group.info_title')}
|
|
icon={InfoIcon}
|
|
onClick={onInfoClick}
|
|
/>
|
|
)}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
interface HeaderActionButtonProps {
|
|
label: string;
|
|
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
|
onClick?: () => void;
|
|
disabled?: boolean;
|
|
tone?: 'default' | 'accent';
|
|
}
|
|
|
|
function HeaderActionButton({
|
|
label,
|
|
icon: Icon,
|
|
onClick,
|
|
disabled,
|
|
tone = 'default',
|
|
}: HeaderActionButtonProps) {
|
|
const toneClass =
|
|
tone === 'accent'
|
|
? 'text-accent hover:bg-accent/10'
|
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
title={label}
|
|
className={
|
|
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
|
toneClass
|
|
}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function CallHeaderButton({
|
|
conversationId,
|
|
kind,
|
|
}: {
|
|
conversationId: string;
|
|
kind: 'audio' | 'video';
|
|
}) {
|
|
const { t } = useTranslation(['app']);
|
|
const { state, startCall } = useCall();
|
|
const busy = state.kind !== 'idle';
|
|
const label =
|
|
kind === 'video'
|
|
? t('app:call.start_video', { defaultValue: 'Video-Anruf' })
|
|
: t('app:call.start_audio');
|
|
const Icon = kind === 'video' ? VideoIcon : PhoneIcon;
|
|
return (
|
|
<HeaderActionButton
|
|
label={busy ? t('app:call.busy') : label}
|
|
icon={Icon}
|
|
onClick={() => void startCall(conversationId, kind)}
|
|
disabled={busy}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
|
const { t } = useTranslation(['app']);
|
|
const { session } = useAuth();
|
|
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
|
|
const active = useCallPresence(conversationId);
|
|
|
|
const myId = session?.user.id;
|
|
const iAmIn =
|
|
(state.kind === 'connected' ||
|
|
state.kind === 'connecting' ||
|
|
state.kind === 'outgoing') &&
|
|
state.conversationId === conversationId;
|
|
|
|
const othersIn = active.filter((u) => u !== myId);
|
|
// Fallback signal: I just left this conv with peers still inside. Covers the
|
|
// brief window after hangup where presence may not have re-synced yet (the
|
|
// realtime channel can churn while ConversationHeader remounts).
|
|
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
|
|
|
// Once presence confirms the room is empty, drop the "just left" hint so the
|
|
// banner hides cleanly instead of sticking forever. Grace window handles the
|
|
// brief gap between hangup and presence re-sync so we don't flicker.
|
|
useEffect(() => {
|
|
if (!justLeft) return;
|
|
if (othersIn.length > 0) return; // still live — keep banner
|
|
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
|
return () => window.clearTimeout(id);
|
|
}, [justLeft, othersIn.length, dismissLastCall]);
|
|
|
|
if (iAmIn) return null;
|
|
if (othersIn.length === 0 && !justLeft) return null;
|
|
|
|
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
|
|
const busy = state.kind !== 'idle';
|
|
return (
|
|
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
|
|
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
|
|
<span className="flex-1">
|
|
{t('app:call.active_in_conv', {
|
|
defaultValue: 'Active call · {{count}} in room',
|
|
count,
|
|
})}
|
|
</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-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{busy ? (
|
|
<SpinnerIcon className="h-3.5 w-3.5" />
|
|
) : (
|
|
<PhoneIcon className="h-3.5 w-3.5" />
|
|
)}
|
|
<span>{t('app:call.join', { defaultValue: 'Join' })}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|