259 lines
8.0 KiB
TypeScript
259 lines
8.0 KiB
TypeScript
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
import type { PresenceState } from '@chat-app/shared/supabase';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useCall } from '../context/CallContext';
|
|
import { useNickname } from '../lib/friendNicknames';
|
|
import type { PeerPresence } from '../lib/usePeerPresence';
|
|
import { Avatar } from './Avatar';
|
|
import {
|
|
GridIcon,
|
|
InfoIcon,
|
|
PhoneIcon,
|
|
SearchIcon,
|
|
UsersIcon,
|
|
VideoIcon,
|
|
} from './icons';
|
|
import { PinnedMessagesPill } from './PinnedMessagesPill';
|
|
|
|
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;
|
|
onMediaClick?: () => void;
|
|
onProfileClick?: (ev: React.MouseEvent) => void;
|
|
onSearchClick?: () => void;
|
|
pinnedCount?: number;
|
|
onOpenPinned?: () => void;
|
|
}
|
|
|
|
export function ConversationHeader({
|
|
conversation,
|
|
peerPresence,
|
|
onInfoClick,
|
|
onMediaClick,
|
|
onProfileClick,
|
|
onSearchClick,
|
|
pinnedCount = 0,
|
|
onOpenPinned,
|
|
}: Props) {
|
|
if (!conversation) {
|
|
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<HeaderBar
|
|
conversation={conversation}
|
|
peerPresence={peerPresence}
|
|
pinnedCount={pinnedCount}
|
|
{...(onInfoClick ? { onInfoClick } : {})}
|
|
{...(onMediaClick ? { onMediaClick } : {})}
|
|
{...(onProfileClick ? { onProfileClick } : {})}
|
|
{...(onSearchClick ? { onSearchClick } : {})}
|
|
{...(onOpenPinned ? { onOpenPinned } : {})}
|
|
/>
|
|
{/* Voice-channel rail rendered separately in ConversationPage so it
|
|
can sit between the message surface and the in-call dock. */}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface HeaderBarProps {
|
|
conversation: ConversationSummary;
|
|
peerPresence: PeerPresence | null;
|
|
onInfoClick?: () => void;
|
|
onMediaClick?: () => void;
|
|
onProfileClick?: (ev: React.MouseEvent) => void;
|
|
onSearchClick?: () => void;
|
|
pinnedCount?: number;
|
|
onOpenPinned?: () => void;
|
|
}
|
|
|
|
function HeaderBar({
|
|
conversation,
|
|
peerPresence,
|
|
onInfoClick,
|
|
onMediaClick,
|
|
onProfileClick,
|
|
onSearchClick,
|
|
pinnedCount = 0,
|
|
onOpenPinned,
|
|
}: HeaderBarProps) {
|
|
const { t } = useTranslation(['app']);
|
|
|
|
const isDm = conversation.type === 'dm';
|
|
// For DMs, route the peer's display name through the per-viewer nickname
|
|
// override. Falls back to the real displayName when no nickname is set.
|
|
const peerName = useNickname(
|
|
conversation.peer?.userId,
|
|
conversation.peer?.displayName ?? '?',
|
|
);
|
|
const title = isDm ? peerName : (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="discord-chat-surface flex min-h-[65px] items-center gap-3 border-b border-line bg-surface-3 px-5 py-3">
|
|
<button
|
|
type="button"
|
|
data-user-popover-trigger={isDm ? true : undefined}
|
|
onClick={isDm ? onProfileClick : undefined}
|
|
disabled={!isDm || !onProfileClick}
|
|
aria-label={isDm ? 'Profil öffnen' : title}
|
|
title={isDm ? 'Profil öffnen' : title}
|
|
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
|
>
|
|
{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]
|
|
}
|
|
/>
|
|
)}
|
|
</button>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
|
|
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
|
<PinnedMessagesPill count={pinnedCount} onClick={() => onOpenPinned?.()} />
|
|
</div>
|
|
<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 } : {})}
|
|
/>
|
|
<HeaderActionButton
|
|
label="Medien & Dateien"
|
|
icon={GridIcon}
|
|
{...(onMediaClick ? { onClick: onMediaClick } : {})}
|
|
/>
|
|
<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 dark:hover:bg-[#383a40]';
|
|
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}
|
|
/>
|
|
);
|
|
}
|
|
|