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