Files
Remote-Terminal-for-MeshCore/frontend/src/components/ChatHeader.tsx

422 lines
17 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Channel,
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
RadioConfig,
} from '../types';
interface ChatHeaderProps {
conversation: Conversation;
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
onTrace: () => void;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
onOpenChannelInfo?: (channelKey: string) => void;
}
export function ChatHeader({
conversation,
contacts,
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
onTrace,
onPathDiscovery,
onToggleNotifications,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
onDeleteContact,
onOpenContactInfo,
onOpenChannelInfo,
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
const [contactStatusInline, setContactStatusInline] = useState(true);
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
const keyTextRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
setPathDiscoveryOpen(false);
setChannelOverrideOpen(false);
}, [conversation.id]);
const activeChannel =
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
: undefined;
const activeFloodScopeOverride =
conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null;
const activeFloodScopeLabel = activeFloodScopeOverride
? stripRegionScopePrefix(activeFloodScopeOverride)
: null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const activeContact =
conversation.type === 'contact'
? contacts.find((contact) => contact.public_key === conversation.id)
: null;
const activeContactIsPrefixOnly = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const favoriteTitle =
conversation.type === 'contact'
? isFavorite(favorites, 'contact', conversation.id)
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites';
const handleEditFloodScopeOverride = () => {
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
setChannelOverrideOpen(true);
};
const handleOpenConversationInfo = () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
return;
}
if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
};
useEffect(() => {
if (conversation.type !== 'contact') {
setContactStatusInline(true);
return;
}
const measure = () => {
const keyElement = keyTextRef.current;
if (!keyElement) return;
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
setContactStatusInline(!isTruncated);
};
measure();
const onResize = () => {
window.requestAnimationFrame(measure);
};
window.addEventListener('resize', onResize);
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
window.requestAnimationFrame(measure);
});
if (keyTextRef.current?.parentElement) {
observer.observe(keyTextRef.current.parentElement);
}
}
return () => {
window.removeEventListener('resize', onResize);
observer?.disconnect();
};
}, [conversation.id, conversation.type, showKey]);
return (
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<button
type="button"
className="avatar-action-button flex-shrink-0 cursor-pointer rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
aria-label={`View info for ${conversation.name}`}
>
<ContactAvatar
name={conversation.name}
publicKey={conversation.id}
size={28}
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
clickable
/>
</button>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
ref={keyTextRef}
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel'
? conversation.id.toLowerCase()
: conversation.id}
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && contactStatusInline && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
title={
activeContactIsPrefixOnly
? 'Path Discovery unavailable until the full contact key is known'
: 'Path Discovery. Send a routed probe and inspect the forward and return paths'
}
aria-label="Path Discovery"
disabled={activeContactIsPrefixOnly}
>
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
>
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : 'text-muted-foreground'}`}
aria-hidden="true"
/>
{activeFloodScopeDisplay && (
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
{activeFloodScopeDisplay}
</span>
)}
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
title={favoriteTitle}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
</button>
)}
{!(conversation.type === 'channel' && isPublicChannelKey(conversation.id)) && (
<button
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
if (conversation.type === 'channel') {
onDeleteChannel(conversation.id);
} else {
onDeleteContact(conversation.id);
}
}}
title="Delete"
aria-label="Delete"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
{conversation.type === 'contact' && activeContact && (
<ContactPathDiscoveryModal
open={pathDiscoveryOpen}
onClose={() => setPathDiscoveryOpen(false)}
contact={activeContact}
contacts={contacts}
radioName={config?.name ?? null}
onDiscover={onPathDiscovery}
/>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<ChannelFloodScopeOverrideModal
open={channelOverrideOpen}
onClose={() => setChannelOverrideOpen(false)}
roomName={conversation.name}
currentOverride={activeFloodScopeDisplay}
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
/>
)}
</header>
);
}