mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-05 13:02:58 +02:00
689 lines
23 KiB
TypeScript
689 lines
23 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { api } from './api';
|
|
import { useWebSocket } from './useWebSocket';
|
|
import { useRepeaterMode, useUnreadCounts, useConversationMessages } from './hooks';
|
|
import { StatusBar } from './components/StatusBar';
|
|
import { Sidebar } from './components/Sidebar';
|
|
import { MessageList } from './components/MessageList';
|
|
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
|
|
import { NewMessageModal } from './components/NewMessageModal';
|
|
import { ConfigModal } from './components/ConfigModal';
|
|
import { MaintenanceModal } from './components/MaintenanceModal';
|
|
import { RawPacketList } from './components/RawPacketList';
|
|
import { CrackerPanel } from './components/CrackerPanel';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
|
import { Toaster, toast } from './components/ui/sonner';
|
|
import { getStateKey } from './utils/conversationState';
|
|
import { formatTime } from './utils/messageParser';
|
|
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
|
import { parseHashConversation, updateUrlHash } from './utils/urlHash';
|
|
import { cn } from '@/lib/utils';
|
|
import type {
|
|
AppSettings,
|
|
AppSettingsUpdate,
|
|
Contact,
|
|
Channel,
|
|
Conversation,
|
|
HealthStatus,
|
|
Message,
|
|
RawPacket,
|
|
RadioConfig,
|
|
RadioConfigUpdate,
|
|
} from './types';
|
|
|
|
const MAX_RAW_PACKETS = 500;
|
|
|
|
export function App() {
|
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
|
const activeConversationRef = useRef<Conversation | null>(null);
|
|
// Track seen message IDs to prevent duplicate unread increments
|
|
const seenMessageIdsRef = useRef<Set<number>>(new Set());
|
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
|
const [config, setConfig] = useState<RadioConfig | null>(null);
|
|
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
const [channels, setChannels] = useState<Channel[]>([]);
|
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
|
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
|
const [showNewMessage, setShowNewMessage] = useState(false);
|
|
const [showConfig, setShowConfig] = useState(false);
|
|
const [showMaintenance, setShowMaintenance] = useState(false);
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
|
const [showCracker, setShowCracker] = useState(false);
|
|
const [crackerRunning, setCrackerRunning] = useState(false);
|
|
|
|
// Track previous health status to detect changes
|
|
const prevHealthRef = useRef<HealthStatus | null>(null);
|
|
|
|
// Custom hooks for extracted functionality
|
|
const {
|
|
messages,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
setMessages,
|
|
fetchMessages,
|
|
fetchOlderMessages,
|
|
addMessageIfNew,
|
|
updateMessageAck,
|
|
} = useConversationMessages(activeConversation);
|
|
|
|
const {
|
|
unreadCounts,
|
|
lastMessageTimes,
|
|
incrementUnread,
|
|
markAllRead,
|
|
trackNewMessage,
|
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
|
|
|
const {
|
|
repeaterLoggedIn,
|
|
activeContactIsRepeater,
|
|
handleTelemetryRequest,
|
|
handleRepeaterCommand,
|
|
} = useRepeaterMode(activeConversation, contacts, setMessages);
|
|
|
|
// WebSocket handlers - memoized to prevent reconnection loops
|
|
const wsHandlers = useMemo(() => ({
|
|
onHealth: (data: HealthStatus) => {
|
|
const prev = prevHealthRef.current;
|
|
prevHealthRef.current = data;
|
|
setHealth(data);
|
|
|
|
// Show toast on connection status change
|
|
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
|
if (data.radio_connected) {
|
|
toast.success('Radio connected', {
|
|
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
|
|
});
|
|
} else {
|
|
toast.error('Radio disconnected', {
|
|
description: 'Check radio connection and power',
|
|
});
|
|
}
|
|
}
|
|
},
|
|
onError: (error: { message: string; details?: string }) => {
|
|
toast.error(error.message, {
|
|
description: error.details,
|
|
});
|
|
},
|
|
onContacts: (data: Contact[]) => setContacts(data),
|
|
onChannels: (data: Channel[]) => setChannels(data),
|
|
onMessage: (msg: Message) => {
|
|
const activeConv = activeConversationRef.current;
|
|
|
|
// Check if message belongs to the active conversation
|
|
const isForActiveConversation = (() => {
|
|
if (!activeConv) return false;
|
|
if (msg.type === 'CHAN' && activeConv.type === 'channel') {
|
|
return msg.conversation_key === activeConv.id;
|
|
}
|
|
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
|
|
return msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
|
|
}
|
|
return false;
|
|
})();
|
|
|
|
// Only add to message list if it's for the active conversation
|
|
if (isForActiveConversation) {
|
|
addMessageIfNew(msg);
|
|
}
|
|
|
|
// Track for unread counts and sorting
|
|
trackNewMessage(msg);
|
|
|
|
// Count unread for non-active, incoming messages (with deduplication)
|
|
if (!msg.outgoing && !isForActiveConversation) {
|
|
// Skip if we've already seen this message ID (prevents duplicate increments)
|
|
if (seenMessageIdsRef.current.has(msg.id)) {
|
|
return;
|
|
}
|
|
seenMessageIdsRef.current.add(msg.id);
|
|
|
|
// Limit set size to prevent memory issues
|
|
if (seenMessageIdsRef.current.size > 1000) {
|
|
const ids = Array.from(seenMessageIdsRef.current);
|
|
seenMessageIdsRef.current = new Set(ids.slice(-500));
|
|
}
|
|
|
|
let stateKey: string | null = null;
|
|
if (msg.type === 'CHAN' && msg.conversation_key) {
|
|
stateKey = getStateKey('channel', msg.conversation_key);
|
|
} else if (msg.type === 'PRIV' && msg.conversation_key) {
|
|
stateKey = getStateKey('contact', msg.conversation_key);
|
|
}
|
|
if (stateKey) {
|
|
incrementUnread(stateKey);
|
|
}
|
|
}
|
|
},
|
|
onContact: (contact: Contact) => {
|
|
setContacts((prev) => {
|
|
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
|
if (idx >= 0) {
|
|
const updated = [...prev];
|
|
const existing = prev[idx];
|
|
updated[idx] = {
|
|
...existing,
|
|
...contact,
|
|
name: contact.name ?? existing.name,
|
|
last_path: contact.last_path ?? existing.last_path,
|
|
lat: contact.lat ?? existing.lat,
|
|
lon: contact.lon ?? existing.lon,
|
|
};
|
|
return updated;
|
|
}
|
|
return [...prev, contact as Contact];
|
|
});
|
|
},
|
|
onRawPacket: (packet: RawPacket) => {
|
|
setRawPackets((prev) => {
|
|
if (prev.some((p) => p.id === packet.id)) {
|
|
return prev;
|
|
}
|
|
const updated = [...prev, packet];
|
|
if (updated.length > MAX_RAW_PACKETS) {
|
|
return updated.slice(-MAX_RAW_PACKETS);
|
|
}
|
|
return updated;
|
|
});
|
|
},
|
|
onMessageAcked: (messageId: number, ackCount: number) => {
|
|
updateMessageAck(messageId, ackCount);
|
|
},
|
|
}), [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck]);
|
|
|
|
// Connect to WebSocket
|
|
useWebSocket(wsHandlers);
|
|
|
|
// Fetch radio config (not sent via WebSocket)
|
|
const fetchConfig = useCallback(async () => {
|
|
try {
|
|
const data = await api.getRadioConfig();
|
|
setConfig(data);
|
|
} catch (err) {
|
|
console.error('Failed to fetch config:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch app settings
|
|
const fetchAppSettings = useCallback(async () => {
|
|
try {
|
|
const data = await api.getSettings();
|
|
setAppSettings(data);
|
|
} catch (err) {
|
|
console.error('Failed to fetch app settings:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch undecrypted packet count
|
|
const fetchUndecryptedCount = useCallback(async () => {
|
|
try {
|
|
const data = await api.getUndecryptedPacketCount();
|
|
setUndecryptedCount(data.count);
|
|
} catch (err) {
|
|
console.error('Failed to fetch undecrypted count:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Initial fetch for config and settings
|
|
useEffect(() => {
|
|
fetchConfig();
|
|
fetchAppSettings();
|
|
fetchUndecryptedCount();
|
|
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
|
|
|
|
// Resolve URL hash to a conversation
|
|
const resolveHashToConversation = useCallback((): Conversation | null => {
|
|
const hashConv = parseHashConversation();
|
|
if (!hashConv) return null;
|
|
|
|
if (hashConv.type === 'raw') {
|
|
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
|
}
|
|
if (hashConv.type === 'channel') {
|
|
const channel = channels.find(c => c.name === hashConv.name || c.name === `#${hashConv.name}`);
|
|
if (channel) {
|
|
return { type: 'channel', id: channel.key, name: channel.name };
|
|
}
|
|
}
|
|
if (hashConv.type === 'contact') {
|
|
const contact = contacts.find(c => getContactDisplayName(c.name, c.public_key) === hashConv.name);
|
|
if (contact) {
|
|
return {
|
|
type: 'contact',
|
|
id: contact.public_key,
|
|
name: getContactDisplayName(contact.name, contact.public_key),
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}, [channels, contacts]);
|
|
|
|
// Set initial conversation from URL hash or default to Public channel
|
|
const hasSetDefaultConversation = useRef(false);
|
|
useEffect(() => {
|
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
|
if (channels.length === 0 && contacts.length === 0) return;
|
|
|
|
const conv = resolveHashToConversation();
|
|
if (conv) {
|
|
setActiveConversation(conv);
|
|
hasSetDefaultConversation.current = true;
|
|
return;
|
|
}
|
|
|
|
const publicChannel = channels.find(c => c.name === 'Public');
|
|
if (publicChannel) {
|
|
setActiveConversation({
|
|
type: 'channel',
|
|
id: publicChannel.key,
|
|
name: publicChannel.name,
|
|
});
|
|
hasSetDefaultConversation.current = true;
|
|
}
|
|
}, [channels, contacts, activeConversation, resolveHashToConversation]);
|
|
|
|
// Keep ref in sync and update URL hash
|
|
useEffect(() => {
|
|
activeConversationRef.current = activeConversation;
|
|
if (activeConversation) {
|
|
updateUrlHash(activeConversation);
|
|
}
|
|
}, [activeConversation]);
|
|
|
|
// Send message handler
|
|
const handleSendMessage = useCallback(
|
|
async (text: string) => {
|
|
if (!activeConversation) return;
|
|
|
|
if (activeConversation.type === 'channel') {
|
|
await api.sendChannelMessage(activeConversation.id, text);
|
|
} else {
|
|
await api.sendDirectMessage(activeConversation.id, text);
|
|
}
|
|
await fetchMessages();
|
|
},
|
|
[activeConversation, fetchMessages]
|
|
);
|
|
|
|
// Config save handler
|
|
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
|
|
await api.updateRadioConfig(update);
|
|
await fetchConfig();
|
|
}, [fetchConfig]);
|
|
|
|
// App settings save handler
|
|
const handleSaveAppSettings = useCallback(async (update: AppSettingsUpdate) => {
|
|
await api.updateSettings(update);
|
|
await fetchAppSettings();
|
|
}, [fetchAppSettings]);
|
|
|
|
// Set private key handler
|
|
const handleSetPrivateKey = useCallback(async (key: string) => {
|
|
await api.setPrivateKey(key);
|
|
await fetchConfig();
|
|
}, [fetchConfig]);
|
|
|
|
// Reboot radio handler
|
|
const handleReboot = useCallback(async () => {
|
|
await api.rebootRadio();
|
|
setHealth((prev) =>
|
|
prev ? { ...prev, radio_connected: false } : prev
|
|
);
|
|
const pollUntilReconnected = async () => {
|
|
for (let i = 0; i < 30; i++) {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
try {
|
|
const data = await api.getHealth();
|
|
setHealth(data);
|
|
if (data.radio_connected) {
|
|
fetchConfig();
|
|
return;
|
|
}
|
|
} catch {
|
|
// Keep polling
|
|
}
|
|
}
|
|
};
|
|
pollUntilReconnected();
|
|
}, [fetchConfig]);
|
|
|
|
// Send flood advertisement handler
|
|
const handleAdvertise = useCallback(async () => {
|
|
try {
|
|
await api.sendAdvertisement(true);
|
|
toast.success('Advertisement sent');
|
|
} catch (err) {
|
|
console.error('Failed to send advertisement:', err);
|
|
toast.error('Failed to send advertisement', {
|
|
description: err instanceof Error ? err.message : 'Check radio connection',
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Handle sender click to add mention
|
|
const handleSenderClick = useCallback((sender: string) => {
|
|
messageInputRef.current?.appendText(`@[${sender}] `);
|
|
}, []);
|
|
|
|
// Handle conversation selection (closes sidebar on mobile)
|
|
const handleSelectConversation = useCallback((conv: Conversation) => {
|
|
setActiveConversation(conv);
|
|
setSidebarOpen(false);
|
|
}, []);
|
|
|
|
// Delete channel handler
|
|
const handleDeleteChannel = useCallback(async (key: string) => {
|
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
|
try {
|
|
await api.deleteChannel(key);
|
|
setChannels((prev) => prev.filter((c) => c.key !== key));
|
|
setActiveConversation(null);
|
|
toast.success('Channel deleted');
|
|
} catch (err) {
|
|
console.error('Failed to delete channel:', err);
|
|
toast.error('Failed to delete channel', {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Delete contact handler
|
|
const handleDeleteContact = useCallback(async (publicKey: string) => {
|
|
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
|
try {
|
|
await api.deleteContact(publicKey);
|
|
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
|
setActiveConversation(null);
|
|
toast.success('Contact deleted');
|
|
} catch (err) {
|
|
console.error('Failed to delete contact:', err);
|
|
toast.error('Failed to delete contact', {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
// Create contact handler
|
|
const handleCreateContact = useCallback(
|
|
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
|
const newContact: Contact = {
|
|
public_key: publicKey,
|
|
name,
|
|
type: 0,
|
|
flags: 0,
|
|
last_path: null,
|
|
last_path_len: -1,
|
|
last_advert: null,
|
|
lat: null,
|
|
lon: null,
|
|
last_seen: null,
|
|
on_radio: false,
|
|
last_read_at: null,
|
|
};
|
|
setContacts((prev) => [...prev, newContact]);
|
|
|
|
setActiveConversation({
|
|
type: 'contact',
|
|
id: publicKey,
|
|
name: getContactDisplayName(name, publicKey),
|
|
});
|
|
|
|
if (tryHistorical) {
|
|
console.log('Contact historical decryption not yet supported');
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Create channel handler
|
|
const handleCreateChannel = useCallback(
|
|
async (name: string, key: string, tryHistorical: boolean) => {
|
|
const created = await api.createChannel(name, key);
|
|
const data = await api.getChannels();
|
|
setChannels(data);
|
|
|
|
setActiveConversation({
|
|
type: 'channel',
|
|
id: created.key,
|
|
name,
|
|
});
|
|
|
|
if (tryHistorical) {
|
|
await api.decryptHistoricalPackets({
|
|
key_type: 'channel',
|
|
channel_key: created.key,
|
|
});
|
|
fetchUndecryptedCount();
|
|
}
|
|
},
|
|
[fetchUndecryptedCount]
|
|
);
|
|
|
|
// Create hashtag channel handler
|
|
const handleCreateHashtagChannel = useCallback(
|
|
async (name: string, tryHistorical: boolean) => {
|
|
const channelName = name.startsWith('#') ? name : `#${name}`;
|
|
|
|
const created = await api.createChannel(channelName);
|
|
const data = await api.getChannels();
|
|
setChannels(data);
|
|
|
|
setActiveConversation({
|
|
type: 'channel',
|
|
id: created.key,
|
|
name: channelName,
|
|
});
|
|
|
|
if (tryHistorical) {
|
|
await api.decryptHistoricalPackets({
|
|
key_type: 'channel',
|
|
channel_name: channelName,
|
|
});
|
|
fetchUndecryptedCount();
|
|
}
|
|
},
|
|
[fetchUndecryptedCount]
|
|
);
|
|
|
|
// Sidebar content (shared between desktop and mobile)
|
|
const sidebarContent = (
|
|
<Sidebar
|
|
contacts={contacts}
|
|
channels={channels}
|
|
activeConversation={activeConversation}
|
|
onSelectConversation={handleSelectConversation}
|
|
onNewMessage={() => {
|
|
setShowNewMessage(true);
|
|
setSidebarOpen(false);
|
|
}}
|
|
lastMessageTimes={lastMessageTimes}
|
|
unreadCounts={unreadCounts}
|
|
showCracker={showCracker}
|
|
crackerRunning={crackerRunning}
|
|
onToggleCracker={() => setShowCracker((prev) => !prev)}
|
|
onMarkAllRead={markAllRead}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col h-dvh">
|
|
<StatusBar
|
|
health={health}
|
|
config={config}
|
|
onConfigClick={() => setShowConfig(true)}
|
|
onMaintenanceClick={() => setShowMaintenance(true)}
|
|
onAdvertise={handleAdvertise}
|
|
onMenuClick={() => setSidebarOpen(true)}
|
|
/>
|
|
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Desktop sidebar - hidden on mobile */}
|
|
<div className="hidden md:block">
|
|
{sidebarContent}
|
|
</div>
|
|
|
|
{/* Mobile sidebar - Sheet that slides in */}
|
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
|
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
|
|
<SheetHeader className="sr-only">
|
|
<SheetTitle>Navigation</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="flex-1 overflow-hidden">
|
|
{sidebarContent}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<div className="flex-1 flex flex-col bg-background min-w-0">
|
|
{activeConversation ? (
|
|
activeConversation.type === 'raw' ? (
|
|
<>
|
|
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Raw Packet Feed</div>
|
|
<div className="flex-1 overflow-hidden">
|
|
<RawPacketList packets={rawPackets} />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium gap-2">
|
|
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2 min-w-0 flex-1">
|
|
<span className="truncate">
|
|
{activeConversation.type === 'channel' && !activeConversation.name.startsWith('#') ? '#' : ''}
|
|
{activeConversation.name}
|
|
</span>
|
|
<span className="font-normal text-xs text-muted-foreground font-mono truncate">
|
|
{activeConversation.id}
|
|
{activeConversation.type === 'contact' && (() => {
|
|
const contact = contacts.find(c => c.public_key === activeConversation.id);
|
|
return contact?.last_seen ? (
|
|
<span className="ml-2 font-sans">
|
|
(Last heard: {formatTime(contact.last_seen)})
|
|
</span>
|
|
) : null;
|
|
})()}
|
|
</span>
|
|
</span>
|
|
{!(activeConversation.type === 'channel' && activeConversation.name === 'Public') && (
|
|
<button
|
|
className="py-1 px-3 bg-destructive/20 border border-destructive/30 text-destructive rounded text-xs cursor-pointer hover:bg-destructive/30 flex-shrink-0"
|
|
onClick={() => {
|
|
if (activeConversation.type === 'channel') {
|
|
handleDeleteChannel(activeConversation.id);
|
|
} else {
|
|
handleDeleteContact(activeConversation.id);
|
|
}
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
<MessageList
|
|
messages={messages}
|
|
contacts={contacts}
|
|
loading={messagesLoading}
|
|
loadingOlder={loadingOlder}
|
|
hasOlderMessages={hasOlderMessages}
|
|
onSenderClick={activeConversation.type === 'channel' ? handleSenderClick : undefined}
|
|
onLoadOlder={fetchOlderMessages}
|
|
radioName={config?.name}
|
|
/>
|
|
<MessageInput
|
|
ref={messageInputRef}
|
|
onSend={
|
|
activeContactIsRepeater
|
|
? (repeaterLoggedIn ? handleRepeaterCommand : handleTelemetryRequest)
|
|
: handleSendMessage
|
|
}
|
|
disabled={!health?.radio_connected}
|
|
isRepeaterMode={activeContactIsRepeater && !repeaterLoggedIn}
|
|
conversationType={activeConversation.type}
|
|
senderName={config?.name}
|
|
placeholder={
|
|
!health?.radio_connected
|
|
? 'Radio not connected'
|
|
: activeContactIsRepeater
|
|
? (repeaterLoggedIn
|
|
? 'Send CLI command (requires admin login)...'
|
|
: `Enter password for ${activeConversation.name} (or . for none)...`)
|
|
: `Message ${activeConversation.name}...`
|
|
}
|
|
/>
|
|
</>
|
|
)
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
Select a conversation or start a new one
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Global Cracker Panel - always rendered to maintain state */}
|
|
<div
|
|
className={cn(
|
|
"border-t border-border bg-background transition-all duration-200 overflow-hidden",
|
|
showCracker ? "h-[275px]" : "h-0"
|
|
)}
|
|
>
|
|
<CrackerPanel
|
|
packets={rawPackets}
|
|
channels={channels}
|
|
onChannelCreate={async (name, key) => {
|
|
const created = await api.createChannel(name, key);
|
|
const data = await api.getChannels();
|
|
setChannels(data);
|
|
await api.decryptHistoricalPackets({
|
|
key_type: 'channel',
|
|
channel_key: created.key,
|
|
});
|
|
fetchUndecryptedCount();
|
|
}}
|
|
onRunningChange={setCrackerRunning}
|
|
/>
|
|
</div>
|
|
|
|
<NewMessageModal
|
|
open={showNewMessage}
|
|
contacts={contacts}
|
|
undecryptedCount={undecryptedCount}
|
|
onClose={() => setShowNewMessage(false)}
|
|
onSelectConversation={(conv) => {
|
|
setActiveConversation(conv);
|
|
setShowNewMessage(false);
|
|
}}
|
|
onCreateContact={handleCreateContact}
|
|
onCreateChannel={handleCreateChannel}
|
|
onCreateHashtagChannel={handleCreateHashtagChannel}
|
|
/>
|
|
|
|
<ConfigModal
|
|
open={showConfig}
|
|
config={config}
|
|
appSettings={appSettings}
|
|
onClose={() => setShowConfig(false)}
|
|
onSave={handleSaveConfig}
|
|
onSaveAppSettings={handleSaveAppSettings}
|
|
onSetPrivateKey={handleSetPrivateKey}
|
|
onReboot={handleReboot}
|
|
/>
|
|
|
|
<MaintenanceModal
|
|
open={showMaintenance}
|
|
health={health}
|
|
onClose={() => setShowMaintenance(false)}
|
|
onHealthRefresh={async () => {
|
|
const data = await api.getHealth();
|
|
setHealth(data);
|
|
}}
|
|
/>
|
|
|
|
<Toaster position="top-right" />
|
|
</div>
|
|
);
|
|
}
|