mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-02 03:23:00 +02:00
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
|
|
|
|
import { ChatHeader } from './ChatHeader';
|
|
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
|
import { MessageList } from './MessageList';
|
|
import { RawPacketFeedView } from './RawPacketFeedView';
|
|
import { RoomServerPanel } from './RoomServerPanel';
|
|
import { TracePane } from './TracePane';
|
|
import type {
|
|
Channel,
|
|
Contact,
|
|
Conversation,
|
|
HealthStatus,
|
|
Message,
|
|
PathDiscoveryResponse,
|
|
RawPacket,
|
|
RadioConfig,
|
|
RadioTraceHopRequest,
|
|
RadioTraceResponse,
|
|
} from '../types';
|
|
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
|
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
|
import {
|
|
getContactDisplayName,
|
|
isPrefixOnlyContact,
|
|
isUnknownFullKeyContact,
|
|
} from '../utils/pubkey';
|
|
|
|
const RepeaterDashboard = lazy(() =>
|
|
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
|
|
);
|
|
const MapView = lazy(() => import('./MapView').then((m) => ({ default: m.MapView })));
|
|
const VisualizerView = lazy(() =>
|
|
import('./VisualizerView').then((m) => ({ default: m.VisualizerView }))
|
|
);
|
|
|
|
interface ConversationPaneProps {
|
|
activeConversation: Conversation | null;
|
|
contacts: Contact[];
|
|
channels: Channel[];
|
|
rawPackets: RawPacket[];
|
|
rawPacketStatsSession: RawPacketStatsSessionState;
|
|
config: RadioConfig | null;
|
|
health: HealthStatus | null;
|
|
notificationsSupported: boolean;
|
|
notificationsEnabled: boolean;
|
|
notificationsPermission: NotificationPermission | 'unsupported';
|
|
messages: Message[];
|
|
preSorted?: boolean;
|
|
messagesLoading: boolean;
|
|
loadingOlder: boolean;
|
|
hasOlderMessages: boolean;
|
|
unreadMarkerLastReadAt?: number | null;
|
|
targetMessageId: number | null;
|
|
hasNewerMessages: boolean;
|
|
loadingNewer: boolean;
|
|
messageInputRef: Ref<MessageInputHandle>;
|
|
onTrace: () => Promise<void>;
|
|
onRunTracePath: (
|
|
hopHashBytes: 1 | 2 | 4,
|
|
hops: RadioTraceHopRequest[]
|
|
) => Promise<RadioTraceResponse>;
|
|
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
|
onDeleteContact: (publicKey: string) => Promise<void>;
|
|
onDeleteChannel: (key: string) => Promise<void>;
|
|
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
|
onSetChannelPathHashModeOverride?: (
|
|
channelKey: string,
|
|
pathHashModeOverride: number | null
|
|
) => Promise<void>;
|
|
onSelectConversation: (conversation: Conversation) => void;
|
|
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
|
onOpenChannelInfo: (channelKey: string) => void;
|
|
onSenderClick: (sender: string) => void;
|
|
onChannelReferenceClick?: (channelName: string) => void;
|
|
onLoadOlder: () => Promise<void>;
|
|
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
|
onTargetReached: () => void;
|
|
onLoadNewer: () => Promise<void>;
|
|
onJumpToBottom: () => void;
|
|
onDismissUnreadMarker: () => void;
|
|
onSendMessage: (text: string) => Promise<void>;
|
|
onToggleNotifications: () => void;
|
|
pushSupported?: boolean;
|
|
pushSubscribed?: boolean;
|
|
pushEnabledForConversation?: boolean;
|
|
onTogglePush?: () => void;
|
|
onOpenPushSettings?: () => void;
|
|
trackedTelemetryRepeaters: string[];
|
|
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
|
repeaterAutoLoginKey: string | null;
|
|
onClearRepeaterAutoLogin: () => void;
|
|
}
|
|
|
|
function LoadingPane({ label }: { label: string }) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">{label}</div>
|
|
);
|
|
}
|
|
|
|
function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'prefix-only' }) {
|
|
if (variant === 'prefix-only') {
|
|
return (
|
|
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
We've received a message from this sender but don't have their full identity yet.
|
|
Sending is disabled until their identity is confirmed — this usually happens
|
|
automatically when they next advertise.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
|
This sender's profile details (name, location) haven't arrived yet. They will fill
|
|
in automatically when the sender's next advert is heard.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ConversationPane({
|
|
activeConversation,
|
|
contacts,
|
|
channels,
|
|
rawPackets,
|
|
rawPacketStatsSession,
|
|
config,
|
|
health,
|
|
notificationsSupported,
|
|
notificationsEnabled,
|
|
notificationsPermission,
|
|
messages,
|
|
preSorted,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
unreadMarkerLastReadAt,
|
|
targetMessageId,
|
|
hasNewerMessages,
|
|
loadingNewer,
|
|
messageInputRef,
|
|
onTrace,
|
|
onRunTracePath,
|
|
onPathDiscovery,
|
|
onToggleFavorite,
|
|
onDeleteContact,
|
|
onDeleteChannel,
|
|
onSetChannelFloodScopeOverride,
|
|
onSetChannelPathHashModeOverride,
|
|
onSelectConversation,
|
|
onOpenContactInfo,
|
|
onOpenChannelInfo,
|
|
onSenderClick,
|
|
onChannelReferenceClick,
|
|
onLoadOlder,
|
|
onResendChannelMessage,
|
|
onTargetReached,
|
|
onLoadNewer,
|
|
onJumpToBottom,
|
|
onDismissUnreadMarker,
|
|
onSendMessage,
|
|
onToggleNotifications,
|
|
pushSupported,
|
|
pushSubscribed,
|
|
pushEnabledForConversation,
|
|
onTogglePush,
|
|
onOpenPushSettings,
|
|
trackedTelemetryRepeaters,
|
|
onToggleTrackedTelemetry,
|
|
repeaterAutoLoginKey,
|
|
onClearRepeaterAutoLogin,
|
|
}: ConversationPaneProps) {
|
|
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
|
const activeContactIsRepeater = useMemo(() => {
|
|
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
|
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
|
return contact?.type === CONTACT_TYPE_REPEATER;
|
|
}, [activeConversation, contacts]);
|
|
const activeContact = useMemo(() => {
|
|
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
|
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
|
}, [activeConversation, contacts]);
|
|
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
|
|
useEffect(() => {
|
|
setRoomAuthenticated(false);
|
|
}, [activeConversation?.id]);
|
|
const isPrefixOnlyActiveContact = activeContact
|
|
? isPrefixOnlyContact(activeContact.public_key)
|
|
: false;
|
|
const isUnknownFullKeyActiveContact =
|
|
activeContact !== null &&
|
|
!isPrefixOnlyActiveContact &&
|
|
isUnknownFullKeyContact(activeContact.public_key, activeContact.last_advert);
|
|
|
|
if (!activeConversation) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
Select a conversation or start a new one
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (activeConversation.type === 'map') {
|
|
return (
|
|
<>
|
|
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
|
Node Map
|
|
</h2>
|
|
<div className="flex-1 overflow-hidden">
|
|
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
|
<MapView
|
|
contacts={contacts}
|
|
focusedKey={activeConversation.mapFocusKey}
|
|
rawPackets={rawPackets}
|
|
config={config}
|
|
onSelectContact={(contact) =>
|
|
onSelectConversation({
|
|
type: 'contact',
|
|
id: contact.public_key,
|
|
name: getContactDisplayName(
|
|
contact.name,
|
|
contact.public_key,
|
|
contact.last_advert
|
|
),
|
|
})
|
|
}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (activeConversation.type === 'visualizer') {
|
|
return (
|
|
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
|
|
<VisualizerView packets={rawPackets} contacts={contacts} config={config} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
if (activeConversation.type === 'raw') {
|
|
return (
|
|
<RawPacketFeedView
|
|
packets={rawPackets}
|
|
rawPacketStatsSession={rawPacketStatsSession}
|
|
contacts={contacts}
|
|
channels={channels}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (activeConversation.type === 'search') {
|
|
return null;
|
|
}
|
|
|
|
if (activeConversation.type === 'trace') {
|
|
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
|
|
}
|
|
|
|
if (activeContactIsRepeater) {
|
|
return (
|
|
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
|
<RepeaterDashboard
|
|
key={activeConversation.id}
|
|
conversation={activeConversation}
|
|
contacts={contacts}
|
|
notificationsSupported={notificationsSupported}
|
|
notificationsEnabled={notificationsEnabled}
|
|
notificationsPermission={notificationsPermission}
|
|
radioLat={config?.lat ?? null}
|
|
radioLon={config?.lon ?? null}
|
|
radioName={config?.name ?? null}
|
|
onTrace={onTrace}
|
|
onPathDiscovery={onPathDiscovery}
|
|
onToggleNotifications={onToggleNotifications}
|
|
onToggleFavorite={onToggleFavorite}
|
|
onDeleteContact={onDeleteContact}
|
|
onOpenContactInfo={onOpenContactInfo}
|
|
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
|
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
|
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
|
|
onAutoLoginConsumed={onClearRepeaterAutoLogin}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
|
|
|
|
return (
|
|
<>
|
|
<ChatHeader
|
|
conversation={activeConversation}
|
|
contacts={contacts}
|
|
channels={channels}
|
|
config={config}
|
|
notificationsSupported={notificationsSupported}
|
|
notificationsEnabled={notificationsEnabled}
|
|
notificationsPermission={notificationsPermission}
|
|
pushSupported={pushSupported}
|
|
pushSubscribed={pushSubscribed}
|
|
pushEnabledForConversation={pushEnabledForConversation}
|
|
onTogglePush={onTogglePush}
|
|
onOpenPushSettings={onOpenPushSettings}
|
|
onTrace={onTrace}
|
|
onPathDiscovery={onPathDiscovery}
|
|
onToggleNotifications={onToggleNotifications}
|
|
onToggleFavorite={onToggleFavorite}
|
|
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
|
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
|
|
onDeleteChannel={onDeleteChannel}
|
|
onDeleteContact={onDeleteContact}
|
|
onOpenContactInfo={onOpenContactInfo}
|
|
onOpenChannelInfo={onOpenChannelInfo}
|
|
/>
|
|
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact && (
|
|
<ContactResolutionBanner variant="prefix-only" />
|
|
)}
|
|
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
|
<ContactResolutionBanner variant="unknown-full-key" />
|
|
)}
|
|
{activeContactIsRoom && activeContact && (
|
|
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
|
)}
|
|
{showRoomChat && (
|
|
<MessageList
|
|
key={activeConversation.id}
|
|
messages={messages}
|
|
preSorted={preSorted}
|
|
contacts={contacts}
|
|
channels={channels}
|
|
loading={messagesLoading}
|
|
loadingOlder={loadingOlder}
|
|
hasOlderMessages={hasOlderMessages}
|
|
unreadMarkerLastReadAt={
|
|
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
|
}
|
|
onDismissUnreadMarker={
|
|
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
|
}
|
|
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
|
onChannelReferenceClick={onChannelReferenceClick}
|
|
onLoadOlder={onLoadOlder}
|
|
onResendChannelMessage={
|
|
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
|
}
|
|
radioName={config?.name}
|
|
config={config}
|
|
onOpenContactInfo={onOpenContactInfo}
|
|
targetMessageId={targetMessageId}
|
|
onTargetReached={onTargetReached}
|
|
hasNewerMessages={hasNewerMessages}
|
|
loadingNewer={loadingNewer}
|
|
onLoadNewer={onLoadNewer}
|
|
onJumpToBottom={onJumpToBottom}
|
|
/>
|
|
)}
|
|
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
|
|
<MessageInput
|
|
ref={messageInputRef}
|
|
onSend={onSendMessage}
|
|
disabled={!health?.radio_connected}
|
|
conversationType={activeConversation.type}
|
|
senderName={config?.name}
|
|
placeholder={
|
|
!health?.radio_connected
|
|
? 'Radio not connected'
|
|
: `Message ${activeConversation.name}...`
|
|
}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|