Initial commit

This commit is contained in:
Jack Kingsman
2026-01-06 19:59:51 -08:00
commit 557cb12879
82 changed files with 387739 additions and 0 deletions
+781
View File
@@ -0,0 +1,781 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { api } from './api';
import { useWebSocket } from './useWebSocket';
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 { 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 {
getLastMessageTimes,
getLastReadTimes,
setLastMessageTime,
setLastReadTime,
getStateKey,
type ConversationTimes,
} from './utils/conversationState';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
Channel,
Conversation,
HealthStatus,
Message,
RawPacket,
RadioConfig,
RadioConfigUpdate,
} from './types';
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
// Generate a key for deduplicating messages by content
function getMessageContentKey(msg: Message): string {
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
}
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const seenMessageContent = useRef<Set<string>>(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 [messages, setMessages] = useState<Message[]>([]);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [messagesLoading, setMessagesLoading] = useState(false);
const [showNewMessage, setShowNewMessage] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [undecryptedCount, setUndecryptedCount] = useState(0);
// Track last message times (persisted in localStorage, used for sorting)
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Track unread counts (calculated on load and incremented during session)
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
// Track previous health status to detect changes
const prevHealthRef = useRef<HealthStatus | null>(null);
// 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;
// Skip duplicate messages (same content + timestamp)
const contentKey = getMessageContentKey(msg);
if (seenMessageContent.current.has(contentKey)) {
console.debug('Duplicate message content ignored:', contentKey.slice(0, 50));
return;
}
seenMessageContent.current.add(contentKey);
// Limit set size to prevent memory issues (keep last 1000)
if (seenMessageContent.current.size > 1000) {
const entries = Array.from(seenMessageContent.current);
seenMessageContent.current = new Set(entries.slice(-500));
}
// Determine conversation key for this message
let conversationKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
conversationKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
conversationKey = getStateKey('contact', msg.conversation_key);
}
// 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') {
// Match by public key or prefix (either could be full key or prefix)
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) {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) {
return prev;
}
return [...prev, msg];
});
}
// Track last message time for sorting and unread detection
if (conversationKey) {
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(conversationKey, timestamp);
setLastMessageTimes(updated);
// Count unread messages during this session (for non-active, incoming messages)
if (!msg.outgoing && !isForActiveConversation) {
setUnreadCounts((prev) => ({
...prev,
[conversationKey]: (prev[conversationKey] || 0) + 1,
}));
}
}
},
onContact: (contact: Contact) => {
// Update or add contact, preserving existing non-null values
setContacts((prev) => {
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
if (idx >= 0) {
const updated = [...prev];
const existing = prev[idx];
// Merge: prefer new non-null values, but keep existing values if new is null
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) => {
// Check if packet already exists
if (prev.some((p) => p.id === packet.id)) {
return prev;
}
// Limit to MAX_RAW_PACKETS, removing oldest
const updated = [...prev, packet];
if (updated.length > MAX_RAW_PACKETS) {
return updated.slice(-MAX_RAW_PACKETS);
}
return updated;
});
},
onMessageAcked: (messageId: number) => {
// Update message acked status
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...prev[idx], acked: true };
return updated;
}
return prev;
});
},
}), []);
// 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);
}
}, []);
const MESSAGE_PAGE_SIZE = 200;
// Fetch messages for active conversation
const fetchMessages = useCallback(async (showLoading = false) => {
if (!activeConversation) {
setMessages([]);
setHasOlderMessages(false);
return;
}
if (showLoading) {
setMessagesLoading(true);
}
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
});
setMessages(data);
// If we got a full page, there might be more
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch messages:', err);
} finally {
if (showLoading) {
setMessagesLoading(false);
}
}
}, [activeConversation]);
// Fetch older messages (pagination)
const fetchOlderMessages = useCallback(async () => {
if (!activeConversation || loadingOlder || !hasOlderMessages) return;
setLoadingOlder(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
offset: messages.length,
});
if (data.length > 0) {
// Prepend older messages (they come sorted DESC, so older are at the end)
setMessages(prev => [...prev, ...data]);
}
// If we got less than a full page, no more messages
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch older messages:', err);
} finally {
setLoadingOlder(false);
}
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
// Initial fetch for config and settings (WebSocket handles health/contacts/channels)
useEffect(() => {
fetchConfig();
fetchAppSettings();
fetchUndecryptedCount();
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
// Select Public channel by default when channels first load
const hasSetDefaultConversation = useRef(false);
useEffect(() => {
if (hasSetDefaultConversation.current || channels.length === 0 || activeConversation) return;
const publicChannel = channels.find(c => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
});
hasSetDefaultConversation.current = true;
}
}, [channels, activeConversation]);
// Fetch messages and count unreads for all conversations on load (single bulk request)
const fetchedChannels = useRef<Set<string>>(new Set());
const fetchedContacts = useRef<Set<string>>(new Set());
useEffect(() => {
// Find channels and contacts we haven't fetched yet
const newChannels = channels.filter(c => !fetchedChannels.current.has(c.key));
const newContacts = contacts.filter(c => c.public_key && !fetchedContacts.current.has(c.public_key));
if (newChannels.length === 0 && newContacts.length === 0) return;
// Mark as fetched before starting (to avoid duplicate fetches if effect re-runs)
newChannels.forEach(c => fetchedChannels.current.add(c.key));
newContacts.forEach(c => fetchedContacts.current.add(c.public_key));
const fetchAndCountUnreads = async () => {
// Build list of conversations to fetch
const conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }> = [
...newChannels.map(c => ({ type: 'CHAN' as const, conversation_key: c.key })),
...newContacts.map(c => ({ type: 'PRIV' as const, conversation_key: c.public_key })),
];
if (conversations.length === 0) return;
try {
// Single bulk request for all conversations
const bulkMessages = await api.getMessagesBulk(conversations, 100);
// Read lastReadTimes fresh from localStorage for accurate comparison
const currentReadTimes = getLastReadTimes();
const newUnreadCounts: Record<string, number> = {};
const newLastMessageTimes: Record<string, number> = {};
// Process channel messages
for (const channel of newChannels) {
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
if (msgs.length > 0) {
const key = getStateKey('channel', channel.key);
const lastRead = currentReadTimes[key] || 0;
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
if (unreadCount > 0) {
newUnreadCounts[key] = unreadCount;
}
const latestTime = Math.max(...msgs.map(m => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
}
// Process contact messages
for (const contact of newContacts) {
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
if (msgs.length > 0) {
const key = getStateKey('contact', contact.public_key);
const lastRead = currentReadTimes[key] || 0;
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
if (unreadCount > 0) {
newUnreadCounts[key] = unreadCount;
}
const latestTime = Math.max(...msgs.map(m => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
}
// Update state with all the counts and times
if (Object.keys(newUnreadCounts).length > 0) {
setUnreadCounts(prev => ({ ...prev, ...newUnreadCounts }));
}
setLastMessageTimes(getLastMessageTimes());
} catch (err) {
console.error('Failed to fetch messages bulk:', err);
}
};
fetchAndCountUnreads();
}, [channels, contacts]);
// Keep ref in sync with state and mark conversation as read when viewed
useEffect(() => {
activeConversationRef.current = activeConversation;
// Mark conversation as read when user views it
if (activeConversation && activeConversation.type !== 'raw') {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
);
// Update localStorage-based read time
const now = Math.floor(Date.now() / 1000);
setLastReadTime(key, now);
// Clear unread count for this conversation
setUnreadCounts((prev) => {
if (prev[key]) {
const next = { ...prev };
delete next[key];
return next;
}
return prev;
});
}
}, [activeConversation]);
// Fetch messages when conversation changes
useEffect(() => {
fetchMessages(true);
}, [fetchMessages]);
// 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);
}
// Message will arrive via WebSocket, but fetch to be safe
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();
// Immediately show disconnected state
setHealth((prev) =>
prev ? { ...prev, radio_connected: false } : prev
);
// Health updates will come via WebSocket when reconnected
// But also poll as backup
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);
} catch (err) {
console.error('Failed to send advertisement:', err);
}
}, []);
// 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);
} catch (err) {
console.error('Failed to delete channel:', err);
}
}, []);
// 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);
} catch (err) {
console.error('Failed to delete contact:', err);
}
}, []);
// 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,
};
setContacts((prev) => [...prev, newContact]);
// Open the new contact
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);
// Channel will be broadcast via WebSocket, but fetch to be safe
const data = await api.getChannels();
setChannels(data);
// Open the new channel (use created.key as the id)
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);
// Open the new channel (use created.key as the id)
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}
/>
);
return (
<div className="flex flex-col h-screen">
<StatusBar
health={health}
config={config}
onConfigClick={() => setShowConfig(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">
{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 flex flex-col overflow-hidden">
<div className="flex-1 overflow-hidden min-h-0">
<RawPacketList packets={rawPackets} />
</div>
<div className="h-[280px] flex-shrink-0 border-t border-border overflow-hidden">
<CrackerPanel
packets={rawPackets}
channels={channels}
onChannelCreate={async (name, key) => {
// Create channel without navigating to it
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
// Try to decrypt historical packets with this key
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
}}
/>
</div>
</div>
</>
) : (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
<span>
{activeConversation.type === 'channel' && !activeConversation.name.startsWith('#') ? '#' : ''}
{activeConversation.name}
</span>
<span className="font-normal text-xs text-muted-foreground font-mono">
{activeConversation.id}
</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"
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}
/>
<MessageInput
ref={messageInputRef}
onSend={handleSendMessage}
disabled={!health?.radio_connected}
placeholder={
health?.radio_connected
? `Message ${activeConversation.name}...`
: 'Radio not connected'
}
/>
</>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div>
)}
</div>
</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}
/>
<Toaster position="top-right" />
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import type {
AppSettings,
AppSettingsUpdate,
Channel,
Contact,
HealthStatus,
Message,
RadioConfig,
RadioConfigUpdate,
} from './types';
const API_BASE = '/api';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
const error = await res.text();
throw new Error(error || res.statusText);
}
return res.json();
}
interface DecryptResult {
started: boolean;
total_packets: number;
message: string;
}
export const api = {
// Health
getHealth: () => fetchJson<HealthStatus>('/health'),
// Radio config
getRadioConfig: () => fetchJson<RadioConfig>('/radio/config'),
updateRadioConfig: (config: RadioConfigUpdate) =>
fetchJson<RadioConfig>('/radio/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
setPrivateKey: (privateKey: string) =>
fetchJson<{ status: string }>('/radio/private-key', {
method: 'PUT',
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: (flood = true) =>
fetchJson<{ status: string; flood: boolean }>(
`/radio/advertise?flood=${flood}`,
{ method: 'POST' }
),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
}),
reconnectRadio: () =>
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
method: 'POST',
}),
// Contacts
getContacts: (limit = 100, offset = 0) =>
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getContact: (publicKey: string) => fetchJson<Contact>(`/contacts/${publicKey}`),
syncContacts: () =>
fetchJson<{ synced: number }>('/contacts/sync', { method: 'POST' }),
addContactToRadio: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}/add-to-radio`, {
method: 'POST',
}),
removeContactFromRadio: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}/remove-from-radio`, {
method: 'POST',
}),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),
getChannel: (key: string) => fetchJson<Channel>(`/channels/${key}`),
createChannel: (name: string, key?: string) =>
fetchJson<Channel>('/channels', {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
syncChannels: () =>
fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
// Messages
getMessages: (params?: {
limit?: number;
offset?: number;
type?: 'PRIV' | 'CHAN';
conversation_key?: string;
}) => {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
if (params?.type) searchParams.set('type', params.type);
if (params?.conversation_key)
searchParams.set('conversation_key', params.conversation_key);
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`);
},
getMessagesBulk: (
conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }>,
limitPerConversation: number = 100
) =>
fetchJson<Record<string, Message[]>>(
`/messages/bulk?limit_per_conversation=${limitPerConversation}`,
{
method: 'POST',
body: JSON.stringify(conversations),
}
),
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',
body: JSON.stringify({ destination, text }),
}),
sendChannelMessage: (channelKey: string, text: string) =>
fetchJson<Message>('/messages/channel', {
method: 'POST',
body: JSON.stringify({ channel_key: channelKey, text }),
}),
// Packets
getUndecryptedPacketCount: () =>
fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact';
channel_key?: string;
channel_name?: string;
}) =>
fetchJson<DecryptResult>('/packets/decrypt/historical', {
method: 'POST',
body: JSON.stringify(params),
}),
// App Settings
getSettings: () => fetchJson<AppSettings>('/settings'),
updateSettings: (settings: AppSettingsUpdate) =>
fetchJson<AppSettings>('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
}),
};
+328
View File
@@ -0,0 +1,328 @@
import { useState, useEffect } from 'react';
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
interface ConfigModalProps {
open: boolean;
config: RadioConfig | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
}
export function ConfigModal({
open,
config,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
}: ConfigModalProps) {
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
const handleSave = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
name,
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio Configuration</DialogTitle>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">
Loading configuration...
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat">Latitude</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon">Longitude</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="1"
max="4"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<div className="flex gap-2">
<Input
id="private-key"
type="password"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
className="flex-1"
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || !privateKey.trim()}
>
Set
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-3">
<Label>Reboot Radio</Label>
<Alert variant="warning">
<AlertDescription>
Some configuration changes (like name) require a radio reboot to take effect.
The connection will temporarily drop and automatically reconnect.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
</div>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || !config}>
{loading ? 'Saving...' : 'Save Config'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { getContactAvatar } from '../utils/contactAvatar';
interface ContactAvatarProps {
name: string | null;
publicKey: string;
size?: number;
contactType?: number;
}
export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) {
const avatar = getContactAvatar(name, publicKey, contactType);
return (
<div
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
style={{
backgroundColor: avatar.background,
color: avatar.textColor,
width: size,
height: size,
fontSize: size * 0.45,
}}
>
{avatar.text}
</div>
);
}
+387
View File
@@ -0,0 +1,387 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-cracker';
import type { RawPacket, Channel } from '../types';
import { cn } from '@/lib/utils';
interface CrackedRoom {
roomName: string;
key: string;
packetId: number;
message: string;
crackedAt: number;
}
interface QueueItem {
packet: RawPacket;
attempts: number;
lastAttemptLength: number;
status: 'pending' | 'cracking' | 'cracked' | 'failed';
}
interface CrackerPanelProps {
packets: RawPacket[];
channels: Channel[];
onChannelCreate: (name: string, key: string) => Promise<void>;
}
export function CrackerPanel({ packets, channels, onChannelCreate }: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
const [progress, setProgress] = useState<ProgressReport | null>(null);
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
const [wordlistLoaded, setWordlistLoaded] = useState(false);
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
const crackerRef = useRef<GroupTextCracker | null>(null);
const isRunningRef = useRef(false);
const abortedRef = useRef(false);
const isProcessingRef = useRef(false);
const queueRef = useRef<Map<number, QueueItem>>(new Map());
const retryFailedRef = useRef(false);
const maxLengthRef = useRef(6);
// Initialize cracker
useEffect(() => {
const cracker = new GroupTextCracker();
crackerRef.current = cracker;
setGpuAvailable(cracker.isGpuAvailable());
// Load wordlist
cracker.loadWordlist('/words_alpha.txt')
.then(() => setWordlistLoaded(true))
.catch((err) => console.error('Failed to load wordlist:', err));
return () => {
cracker.destroy();
crackerRef.current = null;
};
}, []);
// Get existing channel keys for filtering
const existingChannelKeys = new Set(channels.map(c => c.key.toUpperCase()));
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
p => p.payload_type === 'GROUP_TEXT' && !p.decrypted
);
// Update queue when packets change
useEffect(() => {
setQueue(prev => {
const newQueue = new Map(prev);
let changed = false;
for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) {
newQueue.set(packet.id, {
packet,
attempts: 0,
lastAttemptLength: 0,
status: 'pending',
});
changed = true;
}
}
if (changed) {
queueRef.current = newQueue;
return newQueue;
}
return prev;
});
}, [undecryptedGroupText.length]);
// Keep refs in sync with state
useEffect(() => {
queueRef.current = queue;
}, [queue]);
useEffect(() => {
retryFailedRef.current = retryFailedAtNextLength;
}, [retryFailedAtNextLength]);
useEffect(() => {
maxLengthRef.current = maxLength;
}, [maxLength]);
// Stats (cracking count is implicit - if progress is shown, we're cracking one)
const pendingCount = Array.from(queue.values()).filter(q => q.status === 'pending').length;
const crackedCount = Array.from(queue.values()).filter(q => q.status === 'cracked').length;
const failedCount = Array.from(queue.values()).filter(q => q.status === 'failed').length;
// Process next packet in queue
const processNext = useCallback(async () => {
// Prevent concurrent processing
if (isProcessingRef.current) return;
if (!crackerRef.current || !isRunningRef.current) return;
const currentQueue = queueRef.current;
// Find next pending packet
let nextItem: QueueItem | null = null;
let nextId: number | null = null;
for (const [id, item] of currentQueue.entries()) {
if (item.status === 'pending') {
nextItem = item;
nextId = id;
break;
}
}
// If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength
if (!nextItem && retryFailedRef.current) {
const failedItems = Array.from(currentQueue.entries()).filter(
([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10
);
if (failedItems.length > 0) {
// Sort by lastAttemptLength ascending and pick the first (lowest)
failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength);
[nextId, nextItem] = failedItems[0];
}
}
if (!nextItem || nextId === null) {
// Nothing to process right now, but keep running and check again later
if (isRunningRef.current) {
setTimeout(() => processNext(), 1000);
}
return;
}
// Lock processing
isProcessingRef.current = true;
const currentMaxLength = maxLengthRef.current;
const targetLength = nextItem.lastAttemptLength > 0
? nextItem.lastAttemptLength + 1
: currentMaxLength;
try {
const result = await crackerRef.current.crack(
nextItem.packet.data,
{
maxLength: targetLength,
useTimestampFilter: true,
useUtf8Filter: true,
},
(prog) => {
setProgress(prog);
}
);
if (abortedRef.current) {
abortedRef.current = false;
isProcessingRef.current = false;
setProgress(null);
return;
}
if (result.found && result.roomName && result.key) {
// Success!
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'cracked',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
const newRoom: CrackedRoom = {
roomName: result.roomName,
key: result.key,
packetId: nextId!,
message: result.decryptedMessage || '',
crackedAt: Date.now(),
};
setCrackedRooms(prev => [...prev, newRoom]);
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
if (!existingChannelKeys.has(keyUpper)) {
try {
await onChannelCreate('#' + result.roomName, result.key);
} catch (err) {
console.error('Failed to create channel:', err);
}
}
} else {
// Failed
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'failed',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
}
} catch (err) {
console.error('Cracking error:', err);
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'failed',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
}
// Unlock processing
isProcessingRef.current = false;
setProgress(null);
// Continue processing if still running
if (isRunningRef.current) {
setTimeout(() => processNext(), 100);
}
}, [existingChannelKeys, onChannelCreate]);
// Start/stop handlers
const handleStart = () => {
if (!gpuAvailable) {
alert('WebGPU is not available in your browser. Please use Chrome 113+ or Edge 113+.');
return;
}
setIsRunning(true);
isRunningRef.current = true;
abortedRef.current = false;
processNext();
};
const handleStop = () => {
setIsRunning(false);
isRunningRef.current = false;
abortedRef.current = true;
crackerRef.current?.abort();
};
return (
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border">
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={isRunning ? handleStop : handleStart}
disabled={!wordlistLoaded || gpuAvailable === false}
className={cn(
"px-4 py-1.5 rounded text-sm font-medium",
isRunning
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
{isRunning ? 'Stop' : 'Start Cracking'}
</button>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Max Length:</label>
<input
type="number"
min={1}
max={10}
value={maxLength}
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={retryFailedAtNextLength}
onChange={(e) => setRetryFailedAtNextLength(e.target.checked)}
className="rounded"
/>
Retry failed at n+1
</label>
</div>
{/* Status */}
<div className="flex gap-4 text-sm">
<span className="text-muted-foreground">
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
</span>
</div>
{/* Progress */}
{progress && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'}
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}
: {progress.currentPosition}
</span>
<span>
{progress.rateKeysPerSec >= 1e9
? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s`
: `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}
{' '} ETA: {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
</span>
</div>
<div className="h-2 bg-muted rounded overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
<div className="text-sm text-muted-foreground">
Loading wordlist...
</div>
)}
{/* Cracked rooms list */}
{crackedRooms.length > 0 && (
<div className="flex-1 overflow-y-auto min-h-0">
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
<div className="space-y-1">
{crackedRooms.map((room, i) => (
<div key={i} className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1">
<span className="text-green-400 font-medium">#{room.roomName}</span>
<span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)}{room.message.length > 50 ? '...' : ''}"
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, type FormEvent, type KeyboardEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
}
export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder }, ref) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
appendText: (appendedText: string) => {
setText((prev) => prev + appendedText);
// Focus the input after appending
inputRef.current?.focus();
},
}));
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
},
[text, sending, disabled, onSend]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as FormEvent);
}
},
[handleSubmit]
);
return (
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
<Input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}
className="flex-1"
/>
<Button type="submit" disabled={disabled || sending || !text.trim()}>
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
);
});
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import type { Contact, Message } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { cn } from '@/lib/utils';
interface MessageListProps {
messages: Message[];
contacts: Contact[];
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
}
export function MessageList({
messages,
contacts,
loading,
loadingOlder = false,
hasOlderMessages = false,
onSenderClick,
onLoadOlder,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
scrollTop: 0,
scrollHeight: 0,
wasNearTop: false,
});
// Handle scroll position AFTER render
useLayoutEffect(() => {
if (!listRef.current) return;
const list = listRef.current;
const messagesAdded = messages.length - prevMessagesLengthRef.current;
if (isInitialLoadRef.current && messages.length > 0) {
// Initial load - scroll to bottom
list.scrollTop = list.scrollHeight;
isInitialLoadRef.current = false;
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
// Messages were added - use scroll state captured before the update
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
// User was near top (loading older) - preserve position by adding the height diff
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
} else if (!scrollStateRef.current.wasNearTop) {
// User was at bottom - scroll to bottom for new messages
list.scrollTop = list.scrollHeight;
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages]);
// Reset initial load flag when conversation changes (messages becomes empty then filled)
useEffect(() => {
if (messages.length === 0) {
isInitialLoadRef.current = true;
prevMessagesLengthRef.current = 0;
scrollStateRef.current = { scrollTop: 0, scrollHeight: 0, wasNearTop: false };
}
}, [messages.length]);
// Handle scroll - capture state and detect when user is near top
const handleScroll = useCallback(() => {
if (!listRef.current) return;
const { scrollTop, scrollHeight } = listRef.current;
// Always capture current scroll state (needed for scroll preservation)
scrollStateRef.current = {
scrollTop,
scrollHeight,
wasNearTop: scrollTop < 150,
};
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
// Trigger load when within 100px of top
if (scrollTop < 100) {
onLoadOlder();
}
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
// Look up contact by public key or prefix
const getContact = (conversationKey: string | null): Contact | null => {
if (!conversationKey) return null;
return contacts.find(c => pubkeysMatch(c.public_key, conversationKey)) || null;
};
// Look up contact by name (for channel messages where we parse sender from text)
const getContactByName = (name: string): Contact | null => {
return contacts.find(c => c.name === name) || null;
};
if (loading) {
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">Loading messages...</div>;
}
if (messages.length === 0) {
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">No messages yet</div>;
}
// Deduplicate messages by content + timestamp (same message via different paths)
const deduplicatedMessages = messages.reduce<Message[]>((acc, msg) => {
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
const existing = acc.find(m =>
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
);
if (!existing) {
acc.push(msg);
}
return acc;
}, []);
// Sort messages by received_at ascending (oldest first)
const sortedMessages = [...deduplicatedMessages].sort(
(a, b) => a.received_at - b.received_at
);
// Helper to get a unique sender key for grouping messages
const getSenderKey = (msg: Message, sender: string | null): string => {
if (msg.outgoing) return '__outgoing__';
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
return sender || '__unknown__';
};
return (
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-0.5" ref={listRef} onScroll={handleScroll}>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm">
Loading older messages...
</div>
)}
{!loadingOlder && hasOlderMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
Scroll up for older messages
</div>
)}
{sortedMessages.map((msg, index) => {
const { sender, content } = parseSenderFromText(msg.text);
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const displaySender = msg.outgoing
? 'You'
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
// Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey(msg, sender);
const prevMsg = sortedMessages[index - 1];
const prevSenderKey = prevMsg ? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender) : null;
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
const isFirstMessage = index === 0;
// Get avatar info for incoming messages
let avatarName: string | null = null;
let avatarKey: string = '';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) {
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
} else if (sender) {
// Channel message: try to find contact by name, or use sender name as pseudo-key
const senderContact = getContactByName(sender);
avatarName = sender;
avatarKey = senderContact?.public_key || `name:${sender}`;
}
}
return (
<div
key={msg.id}
className={cn(
"flex items-start max-w-[85%]",
msg.outgoing && "flex-row-reverse self-end",
showAvatar && !isFirstMessage && "mt-3"
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
)}
</div>
)}
<div className={cn(
"py-1.5 px-3 rounded-lg min-w-0",
msg.outgoing ? "bg-[#1e3a29]" : "bg-muted"
)}>
{showAvatar && (
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary hover:underline"
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{line}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
)}
{msg.outgoing && (msg.acked ? ' ✓' : ' ?')}
</div>
</div>
</div>
);
})}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import { useState, useRef } from 'react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
contacts: Contact[];
undecryptedCount: number;
onClose: () => void;
onSelectConversation: (conversation: Conversation) => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
}
export function NewMessageModal({
open,
contacts,
undecryptedCount,
onClose,
onSelectConversation,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('existing');
const [name, setName] = useState('');
const [key, setKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const handleCreate = async () => {
setError('');
setLoading(true);
try {
if (tab === 'new-contact') {
if (!name.trim() || !key.trim()) {
setError('Name and public key are required');
return;
}
await onCreateContact(name.trim(), key.trim(), tryHistorical);
onSelectConversation({
type: 'contact',
id: key.trim(),
name: name.trim(),
});
} else if (tab === 'new-room') {
if (!name.trim() || !key.trim()) {
setError('Room name and key are required');
return;
}
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
if (validationError) {
setError(validationError);
return;
}
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
try {
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
setName('');
hashtagInputRef.current?.focus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
}
};
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="existing">Existing</TabsTrigger>
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-room">Room</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
No contacts available
</div>
) : (
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent"
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key)}
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="new-contact" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="contact-name">Name</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contact name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-key">Public Key</Label>
<Input
id="contact-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="64-character hex public key"
/>
</div>
</TabsContent>
<TabsContent value="new-room" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label>
<Input
id="room-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Room name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Input
id="room-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Pre-shared key (hex)"
/>
</div>
</TabsContent>
<TabsContent value="hashtag" className="mt-4">
<div className="space-y-2">
<Label htmlFor="hashtag-name">Hashtag Channel</Label>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">#</span>
<Input
ref={hashtagInputRef}
id="hashtag-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="channel-name"
className="flex-1"
/>
</div>
</div>
</TabsContent>
</Tabs>
{showHistoricalOption && (
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="text-sm text-muted-foreground cursor-pointer"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet{undecryptedCount !== 1 ? 's' : ''}
</Label>
<Checkbox
id="try-historical"
checked={tryHistorical}
onCheckedChange={(checked) => setTryHistorical(checked === true)}
/>
</div>
)}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
{tab === 'hashtag' && (
<Button variant="secondary" onClick={handleCreateAndAddAnother} disabled={loading}>
{loading ? 'Creating...' : 'Create & Add Another'}
</Button>
)}
{tab !== 'existing' && (
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react';
import type { RawPacket } from '../types';
interface RawPacketListProps {
packets: RawPacket[];
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatPayloadType(type: string): string {
// Convert SNAKE_CASE to Title Case
return type
.split('_')
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
function getDecryptedLabel(packet: RawPacket): string {
if (!packet.decrypted || !packet.decrypted_info) {
return formatPayloadType(packet.payload_type);
}
const info = packet.decrypted_info;
if (packet.payload_type === 'GROUP_TEXT' && info.channel_name) {
return `GroupText to ${info.channel_name}`;
}
if (packet.payload_type === 'TEXT_MESSAGE' && info.sender) {
return `TextMessage from ${info.sender}`;
}
return formatPayloadType(packet.payload_type);
}
function formatSignalInfo(packet: RawPacket): string {
const parts: string[] = [];
if (packet.snr !== null && packet.snr !== undefined) {
parts.push(`SNR: ${packet.snr.toFixed(1)} dB`);
}
if (packet.rssi !== null && packet.rssi !== undefined) {
parts.push(`RSSI: ${packet.rssi} dBm`);
}
return parts.join(' | ');
}
export function RawPacketList({ packets }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [packets]);
if (packets.length === 0) {
return (
<div className="h-full overflow-y-auto p-5 text-center text-muted-foreground">
No packets received yet. Packets will appear here in real-time.
</div>
);
}
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = [...packets].sort((a, b) => a.timestamp - b.timestamp);
return (
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
{sortedPackets.map((packet) => (
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
<div className={packet.decrypted ? 'text-primary' : 'text-destructive'}>
{!packet.decrypted && <span className="mr-1">🔒</span>}
{getDecryptedLabel(packet)}
{' • '}
{formatTime(packet.timestamp)}
</div>
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatSignalInfo(packet)}
</div>
)}
<div className="font-mono text-[11px] break-all text-muted-foreground/70 mt-1">
{packet.data.toUpperCase()}
</div>
</div>
))}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useState } from 'react';
import type { Contact, Channel, Conversation } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
type SortOrder = 'alpha' | 'recent';
interface SidebarProps {
contacts: Contact[];
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
}
// Load sort preference from localStorage
function loadSortOrder(): SortOrder {
try {
const stored = localStorage.getItem('remoteterm-sortOrder');
return stored === 'recent' ? 'recent' : 'alpha';
} catch {
return 'alpha';
}
}
// Save sort preference to localStorage
function saveSortOrder(order: SortOrder): void {
try {
localStorage.setItem('remoteterm-sortOrder', order);
} catch {
// localStorage might be full or disabled
}
}
export function Sidebar({
contacts,
channels,
activeConversation,
onSelectConversation,
onNewMessage,
lastMessageTimes,
unreadCounts,
}: SidebarProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
const [searchQuery, setSearchQuery] = useState('');
const handleSortToggle = () => {
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
setSortOrder(newOrder);
saveSortOrder(newOrder);
};
const handleSelectConversation = (conversation: Conversation) => {
setSearchQuery('');
onSelectConversation(conversation);
};
const isActive = (type: 'contact' | 'channel' | 'raw', id: string) =>
activeConversation?.type === type && activeConversation?.id === id;
// Get unread count for a conversation
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
const key = getStateKey(type, id);
return unreadCounts[key] || 0;
};
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
const key = getStateKey(type, id);
return lastMessageTimes[key] || 0;
};
// Deduplicate channels by name, keeping the first (lowest index)
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
if (!acc.some((c) => c.name === channel.name)) {
acc.push(channel);
}
return acc;
}, []);
// Deduplicate contacts by 12-char prefix, preferring ones with names
// Also filter out any contacts with empty public keys
const uniqueContacts = contacts
.filter((c) => c.public_key && c.public_key.length > 0)
.sort((a, b) => {
// Sort contacts with names first
if (a.name && !b.name) return -1;
if (!a.name && b.name) return 1;
return (a.name || '').localeCompare(b.name || '');
})
.reduce<Contact[]>((acc, contact) => {
const prefix = getPubkeyPrefix(contact.public_key);
if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) {
acc.push(contact);
}
return acc;
}, []);
// Sort channels based on sort order, with Public always first
const sortedChannels = [...uniqueChannels].sort((a, b) => {
// Public channel always sorts to the top
if (a.name === 'Public') return -1;
if (b.name === 'Public') return 1;
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return a.name.localeCompare(b.name);
});
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
const sortedContacts = [...uniqueContacts].sort((a, b) => {
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
// Repeaters always go to the bottom
if (aIsRepeater && !bIsRepeater) return 1;
if (!aIsRepeater && bIsRepeater) return -1;
// Both repeaters: always sort alphabetically
if (aIsRepeater && bIsRepeater) {
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}
// Both non-repeaters: use selected sort order
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
});
// Filter by search query
const query = searchQuery.toLowerCase().trim();
const filteredChannels = query
? sortedChannels.filter((c) => c.name.toLowerCase().includes(query))
: sortedChannels;
const filteredContacts = query
? sortedContacts.filter((c) =>
(c.name?.toLowerCase().includes(query)) ||
c.public_key.toLowerCase().includes(query)
)
: sortedContacts;
return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
{/* Header */}
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
<h2 className="text-xs uppercase text-muted-foreground font-medium">Conversations</h2>
<Button
variant="ghost"
size="sm"
onClick={onNewMessage}
title="New Message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
>
+
</Button>
</div>
{/* Search */}
<div className="relative px-3 py-2 border-b border-border">
<Input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 text-sm pr-8"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none"
onClick={() => setSearchQuery('')}
title="Clear search"
>
×
</button>
)}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{/* Raw Packet Feed */}
{!query && (
<div
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('raw', 'raw') && "bg-accent border-l-primary"
)}
onClick={() =>
handleSelectConversation({
type: 'raw',
id: 'raw',
name: 'Raw Packet Feed',
})
}
>
<span className="text-muted-foreground text-xs">📡</span>
<span className="flex-1 truncate">Packet Feed</span>
</div>
)}
{/* Channels */}
{filteredChannels.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
</div>
{filteredChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key);
return (
<div
key={`chan-${channel.key}`}
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('channel', channel.key) && "bg-accent border-l-primary",
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
)}
onClick={() =>
handleSelectConversation({
type: 'channel',
id: channel.key,
name: channel.name,
})
}
>
<span className="text-muted-foreground text-xs">#</span>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{unreadCount}
</span>
)}
</div>
);
})}
</>
)}
{/* Contacts */}
{filteredContacts.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
{filteredChannels.length === 0 && (
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
</div>
{filteredContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key);
return (
<div
key={contact.public_key}
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('contact', contact.public_key) && "bg-accent border-l-primary",
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
)}
onClick={() =>
handleSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
})
}
>
<ContactAvatar name={contact.name} publicKey={contact.public_key} size={24} contactType={contact.type} />
<span className="name flex-1 truncate">
{getContactDisplayName(contact.name, contact.public_key)}
</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{unreadCount}
</span>
)}
</div>
);
})}
</>
)}
{/* Empty state */}
{filteredContacts.length === 0 && filteredChannels.length === 0 && (
<div className="p-5 text-center text-muted-foreground">
{query ? 'No matches found' : 'No conversations yet'}
</div>
)}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
interface StatusBarProps {
health: HealthStatus | null;
config: RadioConfig | null;
onConfigClick: () => void;
onAdvertise: () => void;
onMenuClick?: () => void;
}
export function StatusBar({ health, config, onConfigClick, onAdvertise, onMenuClick }: StatusBarProps) {
const connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false);
const handleReconnect = async () => {
setReconnecting(true);
try {
const result = await api.reconnectRadio();
if (result.connected) {
toast.success('Reconnected', { description: result.message });
}
} catch (err) {
toast.error('Reconnection failed', {
description: err instanceof Error ? err.message : 'Check radio connection and power',
});
} finally {
setReconnecting(false);
}
};
return (
<div className="flex items-center gap-4 px-4 py-2 bg-[#252525] border-b border-[#333] text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
onClick={onMenuClick}
className="md:hidden p-1 bg-transparent border-none text-[#e0e0e0] cursor-pointer"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
)}
<h1 className="hidden lg:block text-base font-semibold mr-auto">RemoteTerm</h1>
<div className="flex items-center gap-1 text-[#888]">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[#4caf50]' : 'bg-[#666]'}`} />
<span className="hidden lg:inline text-[#e0e0e0]">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
{health?.serial_port && (
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
</div>
)}
{config && (
<>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
Name: <span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Freq: <span className="text-[#e0e0e0]">{config.radio.freq} MHz</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
SF{config.radio.sf}/CR{config.radio.cr}
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
TX: <span className="text-[#e0e0e0]">{config.tx_power} dBm</span>
</div>
</>
)}
{/* Spacer to push buttons right on mobile */}
<div className="flex-1 lg:hidden" />
{!connected && (
<button
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1 bg-[#4a3000] border border-[#6b4500] text-[#ffa500] rounded text-xs cursor-pointer hover:bg-[#5a3a00] disabled:opacity-50 disabled:cursor-not-allowed"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
</button>
)}
<button
onClick={onAdvertise}
disabled={!connected}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
>
Advertise
</button>
<button
onClick={onConfigClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
Config
</button>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-yellow-500/50 bg-yellow-500/10 text-yellow-200 [&>svg]:text-yellow-500",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+122
View File
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+144
View File
@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
hideCloseButton?: boolean
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, hideCloseButton = false, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{!hideCloseButton && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+28
View File
@@ -0,0 +1,28 @@
import { Toaster as Sonner, toast } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
// Muted error style - dark red-tinted background with readable text
error: "group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]",
},
}}
{...props}
/>
)
}
export { Toaster, toast }
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+39
View File
@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 10%;
--foreground: 0 0% 88%;
--card: 0 0% 14%;
--card-foreground: 0 0% 88%;
--popover: 0 0% 14%;
--popover-foreground: 0 0% 88%;
--primary: 122 39% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 20%;
--secondary-foreground: 0 0% 88%;
--muted: 0 0% 20%;
--muted-foreground: 0 0% 53%;
--accent: 0 0% 20%;
--accent-foreground: 0 0% 88%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 20%;
--input: 0 0% 20%;
--ring: 122 39% 49%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+17
View File
@@ -0,0 +1,17 @@
/* Base styles - minimal CSS that Tailwind/shadcn can't handle */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Mobile sidebar override - ensures sidebar fills Sheet container */
[data-state] .sidebar {
width: 100%;
border-right: none;
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { getAvatarText, getAvatarColor, getContactAvatar, CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
describe('getAvatarText', () => {
it('returns first emoji when name contains emoji', () => {
expect(getAvatarText('John 🚀 Doe', 'abc123')).toBe('🚀');
expect(getAvatarText('🎉 Party', 'abc123')).toBe('🎉');
expect(getAvatarText('Test 😀 More 🎯', 'abc123')).toBe('😀');
});
it('returns initials when name has space', () => {
expect(getAvatarText('John Doe', 'abc123')).toBe('JD');
expect(getAvatarText('Alice Bob Charlie', 'abc123')).toBe('AB');
expect(getAvatarText('jane smith', 'abc123')).toBe('JS');
});
it('returns single letter when no space', () => {
expect(getAvatarText('John', 'abc123')).toBe('J');
expect(getAvatarText('alice', 'abc123')).toBe('A');
});
it('falls back to public key when name is null', () => {
expect(getAvatarText(null, 'abc123def456')).toBe('AB');
});
it('falls back to public key when name has no letters', () => {
expect(getAvatarText('123 456', 'xyz789')).toBe('XY');
expect(getAvatarText('---', 'def456')).toBe('DE');
});
it('handles space but no letter after', () => {
expect(getAvatarText('John ', 'abc123')).toBe('J');
expect(getAvatarText('A 123', 'abc123')).toBe('A');
});
it('emoji takes priority over initials', () => {
expect(getAvatarText('John 🎯 Doe', 'abc123')).toBe('🎯');
});
});
describe('getAvatarColor', () => {
it('returns consistent colors for same public key', () => {
const color1 = getAvatarColor('abc123def456');
const color2 = getAvatarColor('abc123def456');
expect(color1).toEqual(color2);
});
it('returns different colors for different public keys', () => {
const color1 = getAvatarColor('abc123def456');
const color2 = getAvatarColor('xyz789uvw012');
expect(color1.background).not.toBe(color2.background);
});
it('returns valid HSL background color', () => {
const color = getAvatarColor('test123');
expect(color.background).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
});
it('returns white or black text color', () => {
const color = getAvatarColor('test123');
expect(['#ffffff', '#000000']).toContain(color.text);
});
});
describe('getContactAvatar', () => {
it('returns complete avatar info', () => {
const avatar = getContactAvatar('John Doe', 'abc123def456');
expect(avatar.text).toBe('JD');
expect(avatar.background).toMatch(/^hsl\(/);
expect(['#ffffff', '#000000']).toContain(avatar.textColor);
});
it('handles null name', () => {
const avatar = getContactAvatar(null, 'abc123def456');
expect(avatar.text).toBe('AB');
});
it('returns repeater avatar for type=2', () => {
const avatar = getContactAvatar('Some Repeater', 'abc123def456', CONTACT_TYPE_REPEATER);
expect(avatar.text).toBe('🛜');
expect(avatar.background).toBe('#444444');
expect(avatar.textColor).toBe('#ffffff');
});
it('repeater avatar ignores name', () => {
const avatar1 = getContactAvatar('🚀 Rocket', 'abc123', CONTACT_TYPE_REPEATER);
const avatar2 = getContactAvatar(null, 'xyz789', CONTACT_TYPE_REPEATER);
expect(avatar1.text).toBe('🛜');
expect(avatar2.text).toBe('🛜');
expect(avatar1.background).toBe(avatar2.background);
});
it('non-repeater types use normal avatar', () => {
const avatar0 = getContactAvatar('John', 'abc123', 0);
const avatar1 = getContactAvatar('John', 'abc123', 1);
expect(avatar0.text).toBe('J');
expect(avatar1.text).toBe('J');
});
});
@@ -0,0 +1,139 @@
/**
* Tests for message deduplication in MessageList.
*
* Messages arriving via different packet paths should be deduplicated
* based on (type, conversation_key, text, sender_timestamp).
*/
import { describe, it, expect } from 'vitest';
import type { Message } from '../types';
/**
* Deduplication logic extracted from MessageList for testing.
* Same message via different paths = same (type, conversation_key, text, timestamp)
*/
function deduplicateMessages(messages: Message[]): Message[] {
return messages.reduce<Message[]>((acc, msg) => {
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
const existing = acc.find(m =>
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
);
if (!existing) {
acc.push(msg);
}
return acc;
}, []);
}
function createMessage(overrides: Partial<Message>): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
text: 'Test message',
sender_timestamp: 1700000000,
received_at: 1700000001,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: false,
...overrides,
};
}
describe('Message Deduplication', () => {
it('keeps unique messages', () => {
const messages = [
createMessage({ id: 1, text: 'Message 1', sender_timestamp: 1000 }),
createMessage({ id: 2, text: 'Message 2', sender_timestamp: 2000 }),
createMessage({ id: 3, text: 'Message 3', sender_timestamp: 3000 }),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(3);
});
it('deduplicates same channel message via different paths', () => {
const messages = [
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
createMessage({ id: 3, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1); // keeps first occurrence
});
it('keeps messages with same text but different timestamps', () => {
const messages = [
createMessage({ id: 1, text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, text: 'Hello', sender_timestamp: 2000 }), // different timestamp
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('keeps messages with same text but different channels', () => {
const messages = [
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, conversation_key: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', text: 'Hello', sender_timestamp: 1000 }), // different channel
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('deduplicates same DM via different paths', () => {
const messages = [
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }), // duplicate
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
});
it('keeps DMs from different senders with same text', () => {
const messages = [
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
createMessage({ id: 2, type: 'PRIV', conversation_key: 'def456789012345678901234567890123456789012345678901234567890', text: 'Hi', sender_timestamp: 1000 }),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('keeps channel message and DM with same text', () => {
const messages = [
createMessage({ id: 1, type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hello', sender_timestamp: 1000 }),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(2);
});
it('handles empty array', () => {
const result = deduplicateMessages([]);
expect(result).toHaveLength(0);
});
it('handles single message', () => {
const messages = [createMessage({ id: 1 })];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
});
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Tests for message parsing utilities.
*
* These tests verify the sender extraction logic used to parse
* channel messages in "sender: message" format.
*/
import { describe, it, expect } from 'vitest';
import { parseSenderFromText, formatTime } from '../utils/messageParser';
import { getStateKey } from '../utils/conversationState';
describe('parseSenderFromText', () => {
it('extracts sender and content from "sender: message" format', () => {
const result = parseSenderFromText('Alice: Hello everyone!');
expect(result.sender).toBe('Alice');
expect(result.content).toBe('Hello everyone!');
});
it('handles sender names with spaces', () => {
const result = parseSenderFromText('Bob Smith: How are you?');
expect(result.sender).toBe('Bob Smith');
expect(result.content).toBe('How are you?');
});
it('returns null sender for plain messages without colon-space', () => {
const result = parseSenderFromText('Just a plain message');
expect(result.sender).toBeNull();
expect(result.content).toBe('Just a plain message');
});
it('returns null sender when colon has no space after', () => {
const result = parseSenderFromText('Note:this is not a sender');
expect(result.sender).toBeNull();
expect(result.content).toBe('Note:this is not a sender');
});
it('rejects sender containing square brackets', () => {
const result = parseSenderFromText('[System]: Alert message');
expect(result.sender).toBeNull();
expect(result.content).toBe('[System]: Alert message');
});
it('rejects sender containing colon', () => {
const result = parseSenderFromText('12:30: Time announcement');
expect(result.sender).toBeNull();
expect(result.content).toBe('12:30: Time announcement');
});
it('rejects sender names longer than 50 characters', () => {
const longName = 'A'.repeat(60);
const result = parseSenderFromText(`${longName}: message`);
expect(result.sender).toBeNull();
});
it('handles empty string', () => {
const result = parseSenderFromText('');
expect(result.sender).toBeNull();
expect(result.content).toBe('');
});
it('handles message with multiple colons', () => {
const result = parseSenderFromText('User: Check this URL: https://example.com');
expect(result.sender).toBe('User');
expect(result.content).toBe('Check this URL: https://example.com');
});
it('handles colon at start of message', () => {
const result = parseSenderFromText(': no sender here');
expect(result.sender).toBeNull();
expect(result.content).toBe(': no sender here');
});
});
describe('formatTime', () => {
it('formats today timestamp as time only', () => {
// Use current time to ensure it's "today"
const now = Math.floor(Date.now() / 1000);
const result = formatTime(now);
// Should be just time (HH:MM format)
expect(result).toMatch(/^\d{1,2}:\d{2}( [AP]M)?$/);
});
it('formats older timestamp with date and time', () => {
// Use a timestamp from 2023 (definitely not today)
const timestamp = 1700000000; // 2023-11-14
const result = formatTime(timestamp);
// Should contain month, day, and time
expect(result).toMatch(/\w+ \d{1,2}/); // e.g., "Nov 14"
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
});
});
describe('getStateKey', () => {
it('creates channel state key with full id', () => {
const key = getStateKey('channel', '5');
expect(key).toBe('channel-5');
});
it('creates contact state key with 12-char prefix', () => {
const fullKey = 'abcdef123456789012345678901234567890';
const key = getStateKey('contact', fullKey);
expect(key).toBe('contact-abcdef123456');
});
it('handles contact key shorter than 12 chars', () => {
const shortKey = 'abc123';
const key = getStateKey('contact', shortKey);
expect(key).toBe('contact-abc123');
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+177
View File
@@ -0,0 +1,177 @@
/**
* Tests for unread count tracking logic.
*
* These tests verify the unread message counting behavior
* without involving React component rendering.
*/
import { describe, it, expect } from 'vitest';
import type { Message, Conversation } from '../types';
import { getPubkeyPrefix, pubkeysMatch } from '../utils/pubkey';
/**
* Determine if a message should increment unread count.
* Extracted logic from App.tsx for testing.
*/
function shouldIncrementUnread(
msg: Message,
activeConversation: Conversation | null
): { key: string } | null {
// Only count incoming messages
if (msg.outgoing) {
return null;
}
if (msg.type === 'CHAN' && msg.conversation_key) {
const key = `channel-${msg.conversation_key}`;
// Don't count if this channel is active
if (activeConversation?.type === 'channel' && activeConversation?.id === msg.conversation_key) {
return null;
}
return { key };
}
if (msg.type === 'PRIV' && msg.conversation_key) {
// Use 12-char prefix for contact key
const key = `contact-${getPubkeyPrefix(msg.conversation_key)}`;
// Don't count if this contact is active (compare by prefix)
if (activeConversation?.type === 'contact' && pubkeysMatch(activeConversation.id, msg.conversation_key)) {
return null;
}
return { key };
}
return null;
}
/**
* Get unread count for a conversation from the counts map.
* Extracted logic from Sidebar.tsx for testing.
*/
function getUnreadCount(
type: 'channel' | 'contact',
id: string,
unreadCounts: Record<string, number>
): number {
if (type === 'channel') {
return unreadCounts[`channel-${id}`] || 0;
}
// For contacts, use prefix
const prefix = `contact-${getPubkeyPrefix(id)}`;
return unreadCounts[prefix] || 0;
}
describe('shouldIncrementUnread', () => {
const createMessage = (overrides: Partial<Message>): Message => ({
id: 1,
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
text: 'Test',
sender_timestamp: null,
received_at: Date.now(),
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: false,
...overrides,
});
it('returns key for incoming channel message when not viewing that channel', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', name: 'other' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
});
it('returns null for incoming channel message when viewing that channel', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', name: '#test' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toBeNull();
});
it('returns null for outgoing messages', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', outgoing: true });
const result = shouldIncrementUnread(msg, null);
expect(result).toBeNull();
});
it('returns key for incoming direct message when not viewing that contact', () => {
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
const activeConversation: Conversation = { type: 'contact', id: 'xyz999999999012345678901234567890123456789012345678901234567', name: 'other' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'contact-abc123456789' });
});
it('returns null for incoming direct message when viewing that contact', () => {
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
const activeConversation: Conversation = {
type: 'contact',
id: 'abc123456789fullkey12345678901234567890123456789012345678',
name: 'Alice',
};
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toBeNull();
});
it('returns key when no conversation is active', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
const result = shouldIncrementUnread(msg, null);
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
});
it('returns key when viewing raw packet feed', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
const activeConversation: Conversation = { type: 'raw', id: 'raw', name: 'Packets' };
const result = shouldIncrementUnread(msg, activeConversation);
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
});
});
describe('getUnreadCount', () => {
it('returns count for channel by exact key match', () => {
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
expect(getUnreadCount('channel', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', counts)).toBe(3);
});
it('returns 0 for channel with no unread', () => {
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
expect(getUnreadCount('channel', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9', counts)).toBe(0);
});
it('returns count for contact using 12-char prefix', () => {
const counts = { 'contact-abc123456789': 5 };
// Full public key lookup should match the prefix
expect(getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)).toBe(5);
});
it('handles contact key shorter than 12 chars', () => {
const counts = { 'contact-short': 2 };
expect(getUnreadCount('contact', 'short', counts)).toBe(2);
});
it('returns 0 for contact with no unread', () => {
const counts = { 'contact-abc123456789': 5 };
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
});
});
+171
View File
@@ -0,0 +1,171 @@
/**
* Tests for WebSocket message parsing.
*
* These tests verify that WebSocket messages are correctly parsed
* and routed to the appropriate handlers.
*/
import { describe, it, expect, vi } from 'vitest';
import type { HealthStatus, Contact, Channel, Message, RawPacket } from '../types';
/**
* Parse and route a WebSocket message.
* Extracted logic from useWebSocket.ts for testing.
*/
function parseWebSocketMessage(
data: string,
handlers: {
onHealth?: (health: HealthStatus) => void;
onContacts?: (contacts: Contact[]) => void;
onChannels?: (channels: Channel[]) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number) => void;
}
): { type: string; handled: boolean } {
try {
const msg = JSON.parse(data);
switch (msg.type) {
case 'health':
handlers.onHealth?.(msg.data as HealthStatus);
return { type: msg.type, handled: !!handlers.onHealth };
case 'contacts':
handlers.onContacts?.(msg.data as Contact[]);
return { type: msg.type, handled: !!handlers.onContacts };
case 'channels':
handlers.onChannels?.(msg.data as Channel[]);
return { type: msg.type, handled: !!handlers.onChannels };
case 'message':
handlers.onMessage?.(msg.data as Message);
return { type: msg.type, handled: !!handlers.onMessage };
case 'contact':
handlers.onContact?.(msg.data as Contact);
return { type: msg.type, handled: !!handlers.onContact };
case 'raw_packet':
handlers.onRawPacket?.(msg.data as RawPacket);
return { type: msg.type, handled: !!handlers.onRawPacket };
case 'message_acked':
handlers.onMessageAcked?.((msg.data as { message_id: number }).message_id);
return { type: msg.type, handled: !!handlers.onMessageAcked };
case 'pong':
return { type: msg.type, handled: true };
default:
return { type: msg.type, handled: false };
}
} catch {
return { type: 'error', handled: false };
}
}
describe('parseWebSocketMessage', () => {
it('routes health message to onHealth handler', () => {
const onHealth = vi.fn();
const data = JSON.stringify({
type: 'health',
data: { radio_connected: true, serial_port: '/dev/ttyUSB0' },
});
const result = parseWebSocketMessage(data, { onHealth });
expect(result.type).toBe('health');
expect(result.handled).toBe(true);
expect(onHealth).toHaveBeenCalledWith({
radio_connected: true,
serial_port: '/dev/ttyUSB0',
});
});
it('routes message_acked to onMessageAcked with message ID', () => {
const onMessageAcked = vi.fn();
const data = JSON.stringify({
type: 'message_acked',
data: { message_id: 42 },
});
const result = parseWebSocketMessage(data, { onMessageAcked });
expect(result.type).toBe('message_acked');
expect(result.handled).toBe(true);
expect(onMessageAcked).toHaveBeenCalledWith(42);
});
it('routes new message to onMessage handler', () => {
const onMessage = vi.fn();
const messageData = {
id: 123,
type: 'CHAN',
channel_idx: 0,
text: 'Hello',
received_at: 1700000000,
outgoing: false,
acked: false,
};
const data = JSON.stringify({ type: 'message', data: messageData });
const result = parseWebSocketMessage(data, { onMessage });
expect(result.type).toBe('message');
expect(result.handled).toBe(true);
expect(onMessage).toHaveBeenCalledWith(messageData);
});
it('handles pong messages silently', () => {
const data = JSON.stringify({ type: 'pong' });
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('pong');
expect(result.handled).toBe(true);
});
it('returns unhandled for unknown message types', () => {
const data = JSON.stringify({ type: 'unknown_type', data: {} });
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('unknown_type');
expect(result.handled).toBe(false);
});
it('handles invalid JSON gracefully', () => {
const data = 'not valid json {';
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('error');
expect(result.handled).toBe(false);
});
it('does not call handler when not provided', () => {
const data = JSON.stringify({
type: 'health',
data: { radio_connected: true },
});
const result = parseWebSocketMessage(data, {});
expect(result.type).toBe('health');
expect(result.handled).toBe(false);
});
it('routes raw_packet to onRawPacket handler', () => {
const onRawPacket = vi.fn();
const packetData = {
id: 1,
timestamp: 1700000000,
data: 'deadbeef',
payload_type: 'GROUP_TEXT',
decrypted: true,
decrypted_info: { channel_name: '#test', sender: 'Alice' },
};
const data = JSON.stringify({ type: 'raw_packet', data: packetData });
const result = parseWebSocketMessage(data, { onRawPacket });
expect(result.type).toBe('raw_packet');
expect(result.handled).toBe(true);
expect(onRawPacket).toHaveBeenCalledWith(packetData);
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* Type aliases for key types used throughout the application.
* These are all hex strings but serve different purposes.
*/
/** 64-character hex string identifying a contact/node */
export type PublicKey = string;
/** 12-character hex prefix of a public key (used in message routing) */
export type PubkeyPrefix = string;
/** 32-character hex string identifying a channel */
export type ChannelKey = string;
export interface RadioSettings {
freq: number;
bw: number;
sf: number;
cr: number;
}
export interface RadioConfig {
public_key: string;
name: string;
lat: number;
lon: number;
tx_power: number;
max_tx_power: number;
radio: RadioSettings;
}
export interface RadioConfigUpdate {
name?: string;
lat?: number;
lon?: number;
tx_power?: number;
radio?: RadioSettings;
}
export interface HealthStatus {
status: string;
radio_connected: boolean;
serial_port: string | null;
}
export interface Contact {
public_key: PublicKey;
name: string | null;
type: number;
flags: number;
last_path: string | null;
last_path_len: number;
last_advert: number | null;
lat: number | null;
lon: number | null;
last_seen: number | null;
on_radio: boolean;
}
export interface Channel {
key: ChannelKey;
name: string;
is_hashtag: boolean;
on_radio: boolean;
}
export interface Message {
id: number;
type: 'PRIV' | 'CHAN';
/** For PRIV: sender's PublicKey (or prefix). For CHAN: ChannelKey */
conversation_key: string;
text: string;
sender_timestamp: number | null;
received_at: number;
path_len: number | null;
txt_type: number;
signature: string | null;
outgoing: boolean;
acked: boolean;
}
export type ConversationType = 'contact' | 'channel' | 'raw';
export interface Conversation {
type: ConversationType;
/** PublicKey for contacts, ChannelKey for channels, 'raw' for raw feed */
id: string;
name: string;
}
export interface RawPacket {
id: number;
timestamp: number;
data: string; // hex
payload_type: string;
snr: number | null; // Signal-to-noise ratio in dB
rssi: number | null; // Received signal strength in dBm
decrypted: boolean;
decrypted_info: {
channel_name: string | null;
sender: string | null;
} | null;
}
export interface AppSettings {
max_radio_contacts: number;
}
export interface AppSettingsUpdate {
max_radio_contacts?: number;
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { HealthStatus, Contact, Channel, Message, RawPacket } from './types';
interface WebSocketMessage {
type: string;
data: unknown;
}
interface ErrorEvent {
message: string;
details?: string;
}
interface UseWebSocketOptions {
onHealth?: (health: HealthStatus) => void;
onContacts?: (contacts: Contact[]) => void;
onChannels?: (channels: Channel[]) => void;
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number) => void;
onError?: (error: ErrorEvent) => void;
}
export function useWebSocket(options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const [connected, setConnected] = useState(false);
const connect = useCallback(() => {
// Determine WebSocket URL based on current location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// In development, connect directly to backend; in production, use same host
const isDev = window.location.port === '5173';
const wsUrl = isDev
? `ws://localhost:8000/api/ws`
: `${protocol}//${window.location.host}/api/ws`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
setConnected(true);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setConnected(false);
wsRef.current = null;
// Reconnect after 3 seconds
reconnectTimeoutRef.current = window.setTimeout(() => {
console.log('Attempting WebSocket reconnect...');
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onmessage = (event) => {
try {
const msg: WebSocketMessage = JSON.parse(event.data);
switch (msg.type) {
case 'health':
options.onHealth?.(msg.data as HealthStatus);
break;
case 'contacts':
options.onContacts?.(msg.data as Contact[]);
break;
case 'channels':
options.onChannels?.(msg.data as Channel[]);
break;
case 'message':
options.onMessage?.(msg.data as Message);
break;
case 'contact':
options.onContact?.(msg.data as Contact);
break;
case 'raw_packet':
options.onRawPacket?.(msg.data as RawPacket);
break;
case 'message_acked':
options.onMessageAcked?.((msg.data as { message_id: number }).message_id);
break;
case 'error':
options.onError?.(msg.data as ErrorEvent);
break;
case 'pong':
// Heartbeat response, ignore
break;
default:
console.log('Unknown WebSocket message type:', msg.type);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
wsRef.current = ws;
}, [options]);
useEffect(() => {
connect();
// Ping every 30 seconds to keep connection alive
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send('ping');
}
}, 30000);
return () => {
clearInterval(pingInterval);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
return { connected };
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Generate consistent profile "images" for contacts.
*
* Uses the contact's public key to generate a consistent background color,
* and extracts initials or emoji from the name for display.
* Repeaters (type=2) always show 🛜 with a gray background.
*/
// Contact type constants (matches backend)
export const CONTACT_TYPE_REPEATER = 2;
// Repeater avatar styling
const REPEATER_AVATAR = {
text: '🛜',
background: '#444444',
textColor: '#ffffff',
};
// Simple hash function for strings
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
// Regex to match emoji (covers most common emoji ranges)
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
/**
* Extract display characters from a contact name.
* Priority:
* 1. First emoji in the name
* 2. First letter + first letter after first space (initials)
* 3. First letter only
*/
export function getAvatarText(name: string | null, publicKey: string): string {
if (!name) {
// Use first 2 chars of public key as fallback
return publicKey.slice(0, 2).toUpperCase();
}
// Check for emoji first
const emojiMatch = name.match(emojiRegex);
if (emojiMatch) {
return emojiMatch[0];
}
// Find first letter
const letters = name.match(/[a-zA-Z]/g);
if (!letters || letters.length === 0) {
// No letters, use first 2 chars of public key
return publicKey.slice(0, 2).toUpperCase();
}
// Check for space - get initials
const spaceIndex = name.indexOf(' ');
if (spaceIndex !== -1) {
const firstLetter = letters[0];
// Find first letter after the space
const afterSpace = name.slice(spaceIndex + 1).match(/[a-zA-Z]/);
if (afterSpace) {
return (firstLetter + afterSpace[0]).toUpperCase();
}
}
// Single letter
return letters[0].toUpperCase();
}
/**
* Generate a consistent HSL color from a public key.
* Uses saturation and lightness ranges that work well for backgrounds.
*/
export function getAvatarColor(publicKey: string): {
background: string;
text: string;
} {
const hash = hashString(publicKey);
// Use hash to generate hue (0-360)
const hue = hash % 360;
// Use different bits of hash for saturation variation (50-80%)
const saturation = 50 + ((hash >> 8) % 30);
// Lightness in a range that allows readable text (35-55%)
const lightness = 35 + ((hash >> 16) % 20);
const background = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
// Calculate perceived luminance to determine text color
// For HSL, we can approximate: if lightness < 50%, use white text
// We'll use a slightly lower threshold since saturated colors appear darker
const textColor = lightness < 45 ? '#ffffff' : '#000000';
return { background, text: textColor };
}
/**
* Get all avatar properties for a contact.
* Repeaters (type=2) always get a special gray avatar with 🛜.
*/
export function getContactAvatar(
name: string | null,
publicKey: string,
contactType?: number
): {
text: string;
background: string;
textColor: string;
} {
// Repeaters always get the repeater avatar
if (contactType === CONTACT_TYPE_REPEATER) {
return REPEATER_AVATAR;
}
const text = getAvatarText(name, publicKey);
const colors = getAvatarColor(publicKey);
return {
text,
background: colors.background,
textColor: colors.text,
};
}
+80
View File
@@ -0,0 +1,80 @@
/**
* localStorage utilities for tracking conversation read/message state.
*
* Stores two maps:
* - lastMessageTime: when each conversation last received a message
* - lastReadTime: when the user last viewed each conversation
*
* A conversation has unread messages if lastMessageTime > lastReadTime.
*/
import { getPubkeyPrefix } from './pubkey';
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
const LAST_READ_KEY = 'remoteterm-lastReadTime';
export type ConversationTimes = Record<string, number>;
function loadTimes(key: string): ConversationTimes {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
function saveTimes(key: string, times: ConversationTimes): void {
try {
localStorage.setItem(key, JSON.stringify(times));
} catch {
// localStorage might be full or disabled
}
}
export function getLastMessageTimes(): ConversationTimes {
return loadTimes(LAST_MESSAGE_KEY);
}
export function getLastReadTimes(): ConversationTimes {
return loadTimes(LAST_READ_KEY);
}
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_MESSAGE_KEY);
// Only update if this is a newer message
if (!times[stateKey] || timestamp > times[stateKey]) {
times[stateKey] = timestamp;
saveTimes(LAST_MESSAGE_KEY, times);
}
return times;
}
export function setLastReadTime(stateKey: string, timestamp: number): ConversationTimes {
const times = loadTimes(LAST_READ_KEY);
times[stateKey] = timestamp;
saveTimes(LAST_READ_KEY, times);
return times;
}
/**
* Generate a state tracking key for unread counts and message times.
*
* This is NOT the same as Message.conversation_key (the database field).
* This creates prefixed keys for localStorage/state tracking:
* - Channels: "channel-{channelKey}"
* - Contacts: "contact-{12-char-pubkey-prefix}"
*
* The 12-char prefix for contacts ensures consistent matching regardless
* of whether we have a full 64-char pubkey or just a prefix.
*/
export function getStateKey(
type: 'channel' | 'contact',
id: string
): string {
if (type === 'channel') {
return `channel-${id}`;
}
// For contacts, use 12-char prefix for consistent matching
return `contact-${getPubkeyPrefix(id)}`;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Parse sender from channel message text.
* Channel messages have format "sender: message".
*/
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
if (colonIndex > 0 && colonIndex < 50) {
const potentialSender = text.substring(0, colonIndex);
// Check for invalid characters that would indicate it's not a sender
if (!/[:\[\]]/.test(potentialSender)) {
return {
sender: potentialSender,
content: text.substring(colonIndex + 2),
};
}
}
return { sender: null, content: text };
}
/**
* Format a Unix timestamp to a time string.
* Shows date for messages not from today.
*/
export function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) {
return time;
}
// Show short date for older messages
const dateStr = date.toLocaleDateString([], { month: 'short', day: 'numeric' });
return `${dateStr} ${time}`;
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Public key utilities for consistent handling of 64-char full keys
* and 12-char prefixes throughout the application.
*
* MeshCore uses 64-character hex strings for public keys, but messages
* and some radio operations only provide 12-character prefixes. This
* module provides utilities for working with both formats consistently.
*/
/** Length of a full public key in hex characters */
export const PUBKEY_FULL_LENGTH = 64;
/** Length of a public key prefix in hex characters */
export const PUBKEY_PREFIX_LENGTH = 12;
/**
* Extract the 12-character prefix from a public key.
* Works with both full keys and existing prefixes.
*/
export function getPubkeyPrefix(key: string): string {
return key.slice(0, PUBKEY_PREFIX_LENGTH);
}
/**
* Check if two public keys match by comparing their prefixes.
* This handles the case where one key is full (64 chars) and
* the other is a prefix (12 chars).
*/
export function pubkeysMatch(a: string, b: string): boolean {
if (!a || !b) return false;
return getPubkeyPrefix(a) === getPubkeyPrefix(b);
}
/**
* Check if a public key starts with the given prefix.
* More explicit than using .startsWith() directly.
*/
export function pubkeyMatchesPrefix(fullKey: string, prefix: string): boolean {
if (!fullKey || !prefix) return false;
return fullKey.startsWith(prefix);
}
/**
* Get a display name for a contact, falling back to pubkey prefix.
*/
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
return name || getPubkeyPrefix(pubkey);
}
/**
* Check if a key is a full 64-character public key.
*/
export function isFullPubkey(key: string): boolean {
return key.length === PUBKEY_FULL_LENGTH;
}
/**
* Check if a key is a 12-character prefix.
*/
export function isPubkeyPrefix(key: string): boolean {
return key.length === PUBKEY_PREFIX_LENGTH;
}