Overhaul frontend organization and pause message polling during repeater operations

This commit is contained in:
Jack Kingsman
2026-01-10 16:27:15 -08:00
parent e559d6cd47
commit b0ab2bcb32
15 changed files with 1817 additions and 1131 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-CeWxUYMt.js"></script>
<script type="module" crossorigin src="/assets/index-BQNVNq9u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CacA_plj.css">
</head>
<body>
+51 -552
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { api } from './api';
import { useWebSocket } from './useWebSocket';
import { useRepeaterMode, useUnreadCounts, useConversationMessages } from './hooks';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList';
@@ -11,18 +12,11 @@ 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 { getStateKey } from './utils/conversationState';
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
import { parseHashConversation, updateUrlHash } from './utils/urlHash';
import { cn } from '@/lib/utils';
import type {
AclEntry,
AppSettings,
AppSettingsUpdate,
Contact,
@@ -30,157 +24,61 @@ import type {
Conversation,
HealthStatus,
Message,
NeighborInfo,
RawPacket,
RadioConfig,
RadioConfigUpdate,
TelemetryResponse,
} from './types';
import { CONTACT_TYPE_REPEATER } from './types';
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) {
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
// Format telemetry response as human-readable text
function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`Telemetry`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
`RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`,
'',
`Noise Floor: ${telemetry.noise_floor_dbm} dBm`,
`Last RSSI: ${telemetry.last_rssi_dbm} dBm`,
`Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`,
'',
`Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`,
`Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`,
`Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`,
`Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`,
'',
`TX Queue: ${telemetry.tx_queue_len}`,
`Debug Flags: ${telemetry.full_events}`,
];
return lines.join('\n');
}
// Format neighbors list as human-readable text
function formatNeighbors(neighbors: NeighborInfo[]): string {
if (neighbors.length === 0) {
return 'Neighbors\nNo neighbors reported';
}
// Sort by SNR descending (highest first)
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
const lines = [`Neighbors (${sorted.length})`];
for (const n of sorted) {
const name = n.name || n.pubkey_prefix;
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`);
}
return lines.join('\n');
}
// Format ACL list as human-readable text
function formatAcl(acl: AclEntry[]): string {
if (acl.length === 0) {
return 'ACL\nNo ACL entries';
}
const lines = [`ACL (${acl.length})`];
for (const entry of acl) {
const name = entry.name || entry.pubkey_prefix;
lines.push(`${name}: ${entry.permission_name}`);
}
return lines.join('\n');
}
// Generate a key for deduplicating messages by content
function getMessageContentKey(msg: Message): string {
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
}
// Parse URL hash to get conversation (e.g., #channel/Public or #contact/JohnDoe or #raw)
function parseHashConversation(): { type: 'channel' | 'contact' | 'raw'; name: string } | null {
const hash = window.location.hash.slice(1); // Remove leading #
if (!hash) return null;
if (hash === 'raw') {
return { type: 'raw', name: 'raw' };
}
const slashIndex = hash.indexOf('/');
if (slashIndex === -1) return null;
const type = hash.slice(0, slashIndex);
const name = decodeURIComponent(hash.slice(slashIndex + 1));
if ((type === 'channel' || type === 'contact') && name) {
return { type, name };
}
return null;
}
// Generate URL hash from conversation
function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';
if (conv.type === 'raw') return '#raw';
// Strip leading # from channel names for cleaner URLs
const name = conv.type === 'channel' && conv.name.startsWith('#')
? conv.name.slice(1)
: conv.name;
return `#${conv.type}/${encodeURIComponent(name)}`;
}
const MAX_RAW_PACKETS = 500;
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);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
// Track if we've logged into the current repeater (for CLI command mode)
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
// 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);
// Custom hooks for extracted functionality
const {
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
setMessages,
fetchMessages,
fetchOlderMessages,
addMessageIfNew,
updateMessageAck,
} = useConversationMessages(activeConversation);
const {
unreadCounts,
lastMessageTimes,
incrementUnread,
markAllRead,
trackNewMessage,
} = useUnreadCounts(channels, contacts, activeConversation);
const {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
} = useRepeaterMode(activeConversation, contacts, setMessages);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(() => ({
onHealth: (data: HealthStatus) => {
@@ -211,27 +109,6 @@ export function App() {
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;
@@ -239,7 +116,6 @@ export function App() {
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;
@@ -247,37 +123,31 @@ export function App() {
// 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];
});
addMessageIfNew(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);
// Track for unread counts and sorting
trackNewMessage(msg);
// Count unread messages during this session (for non-active, incoming messages)
if (!msg.outgoing && !isForActiveConversation) {
setUnreadCounts((prev) => ({
...prev,
[conversationKey]: (prev[conversationKey] || 0) + 1,
}));
// Count unread for non-active, incoming messages
if (!msg.outgoing && !isForActiveConversation) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (stateKey) {
incrementUnread(stateKey);
}
}
},
onContact: (contact: Contact) => {
// 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,
@@ -293,11 +163,9 @@ export function App() {
},
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);
@@ -306,18 +174,9 @@ export function App() {
});
},
onMessageAcked: (messageId: number, ackCount: number) => {
// Update message acked count
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...prev[idx], acked: ackCount };
return updated;
}
return prev;
});
updateMessageAck(messageId, ackCount);
},
}), []);
}), [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck]);
// Connect to WebSocket
useWebSocket(wsHandlers);
@@ -352,64 +211,7 @@ export function App() {
}
}, []);
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)
// Initial fetch for config and settings
useEffect(() => {
fetchConfig();
fetchAppSettings();
@@ -425,7 +227,6 @@ export function App() {
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
}
if (hashConv.type === 'channel') {
// Match with or without leading # (URL strips it for cleaner URLs)
const channel = channels.find(c => c.name === hashConv.name || c.name === `#${hashConv.name}`);
if (channel) {
return { type: 'channel', id: channel.key, name: channel.name };
@@ -450,7 +251,6 @@ export function App() {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0 && contacts.length === 0) return;
// Try to restore from URL hash first
const conv = resolveHashToConversation();
if (conv) {
setActiveConversation(conv);
@@ -458,7 +258,6 @@ export function App() {
return;
}
// Fall back to Public channel
const publicChannel = channels.find(c => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
@@ -470,137 +269,14 @@ export function App() {
}
}, [channels, contacts, activeConversation, resolveHashToConversation]);
// 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
// Keep ref in sync and update URL hash
useEffect(() => {
activeConversationRef.current = activeConversation;
// Reset repeater login state when conversation changes
setRepeaterLoggedIn(false);
// 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;
});
}
// Update URL hash (replaceState doesn't add to history)
if (activeConversation) {
const newHash = getConversationHash(activeConversation);
if (newHash !== window.location.hash) {
window.history.replaceState(null, '', newHash);
}
updateUrlHash(activeConversation);
}
}, [activeConversation]);
// Fetch messages when conversation changes
useEffect(() => {
fetchMessages(true);
}, [fetchMessages]);
// Check if active conversation is a repeater
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find(c => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// Send message handler
const handleSendMessage = useCallback(
async (text: string) => {
@@ -611,159 +287,11 @@ export function App() {
} else {
await api.sendDirectMessage(activeConversation.id, text);
}
// Message will arrive via WebSocket, but fetch to be safe
await fetchMessages();
},
[activeConversation, fetchMessages]
);
// Request telemetry from a repeater
const handleTelemetryRequest = useCallback(
async (password: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater) return;
try {
const telemetry = await api.requestTelemetry(activeConversation.id, password);
const now = Math.floor(Date.now() / 1000);
// Create a local message to display the telemetry (not persisted to database)
const telemetryMessage: Message = {
id: -Date.now(), // Negative ID to avoid collision with real messages
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatTelemetry(telemetry),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false, // Show as incoming (from the repeater)
acked: 1, // Mark as acked since it's a response
};
// Create a second message for neighbors
const neighborsMessage: Message = {
id: -Date.now() - 1, // Different ID
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatNeighbors(telemetry.neighbors),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 1,
};
// Create a third message for ACL
const aclMessage: Message = {
id: -Date.now() - 2, // Different ID
type: 'PRIV',
conversation_key: activeConversation.id,
text: formatAcl(telemetry.acl),
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 1,
};
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
// Mark as logged in for CLI command mode
setRepeaterLoggedIn(true);
} catch (err) {
// Show error as a local message
const errorMessage: Message = {
id: -Date.now(),
type: 'PRIV',
conversation_key: activeConversation.id,
text: `Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
sender_timestamp: Math.floor(Date.now() / 1000),
received_at: Math.floor(Date.now() / 1000),
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 1,
};
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater]
);
// Send CLI command to a repeater (after logged in)
const handleRepeaterCommand = useCallback(
async (command: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
const now = Math.floor(Date.now() / 1000);
// Show the command as an outgoing message
const commandMessage: Message = {
id: -Date.now(),
type: 'PRIV',
conversation_key: activeConversation.id,
text: `> ${command}`,
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: true,
acked: 1,
};
setMessages((prev) => [...prev, commandMessage]);
try {
const response = await api.sendRepeaterCommand(activeConversation.id, command);
// Use the actual timestamp from the repeater if available
const responseTimestamp = response.sender_timestamp ?? now;
// Show the response
const responseMessage: Message = {
id: -Date.now() - 1,
type: 'PRIV',
conversation_key: activeConversation.id,
text: response.response,
sender_timestamp: responseTimestamp,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 1,
};
setMessages((prev) => [...prev, responseMessage]);
} catch (err) {
const errorMessage: Message = {
id: -Date.now() - 1,
type: 'PRIV',
conversation_key: activeConversation.id,
text: `Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 1,
};
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, repeaterLoggedIn]
);
// Config save handler
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
@@ -785,12 +313,9 @@ export function App() {
// 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));
@@ -829,28 +354,6 @@ export function App() {
setSidebarOpen(false);
}, []);
// Mark all conversations as read
const handleMarkAllRead = useCallback(() => {
const now = Math.floor(Date.now() / 1000);
// Update localStorage for all channels
for (const channel of channels) {
const key = getStateKey('channel', channel.key);
setLastReadTime(key, now);
}
// Update localStorage for all contacts
for (const contact of contacts) {
if (contact.public_key) {
const key = getStateKey('contact', contact.public_key);
setLastReadTime(key, now);
}
}
// Clear all unread counts
setUnreadCounts({});
}, [channels, contacts]);
// Delete channel handler
const handleDeleteChannel = useCallback(async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -893,7 +396,6 @@ export function App() {
};
setContacts((prev) => [...prev, newContact]);
// Open the new contact
setActiveConversation({
type: 'contact',
id: publicKey,
@@ -911,11 +413,9 @@ export function App() {
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,
@@ -942,7 +442,6 @@ export function App() {
const data = await api.getChannels();
setChannels(data);
// Open the new channel (use created.key as the id)
setActiveConversation({
type: 'channel',
id: created.key,
@@ -976,7 +475,7 @@ export function App() {
showCracker={showCracker}
crackerRunning={crackerRunning}
onToggleCracker={() => setShowCracker((prev) => !prev)}
onMarkAllRead={handleMarkAllRead}
onMarkAllRead={markAllRead}
/>
);
+3
View File
@@ -0,0 +1,3 @@
export { useRepeaterMode, type UseRepeaterModeResult, formatDuration, formatTelemetry, formatNeighbors, formatAcl } from './useRepeaterMode';
export { useUnreadCounts, type UseUnreadCountsResult } from './useUnreadCounts';
export { useConversationMessages, type UseConversationMessagesResult, getMessageContentKey } from './useConversationMessages';
@@ -0,0 +1,154 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../api';
import type { Conversation, Message } from '../types';
const MESSAGE_PAGE_SIZE = 200;
// Generate a key for deduplicating messages by content
export function getMessageContentKey(msg: Message): string {
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
}
export interface UseConversationMessagesResult {
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
fetchMessages: (showLoading?: boolean) => Promise<void>;
fetchOlderMessages: () => Promise<void>;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number) => void;
}
export function useConversationMessages(
activeConversation: Conversation | null
): UseConversationMessagesResult {
const [messages, setMessages] = useState<Message[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
// Track seen message content for deduplication
const seenMessageContent = useRef<Set<string>>(new Set());
// Fetch messages for active conversation
const fetchMessages = useCallback(async (showLoading = false) => {
if (!activeConversation || activeConversation.type === 'raw') {
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);
// Track seen content for new messages
seenMessageContent.current.clear();
for (const msg of data) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
// 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 || activeConversation.type === 'raw' || 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]);
// Track seen content
for (const msg of data) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
// 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]);
// Fetch messages when conversation changes
useEffect(() => {
fetchMessages(true);
}, [fetchMessages]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
const addMessageIfNew = useCallback((msg: Message): boolean => {
const contentKey = getMessageContentKey(msg);
if (seenMessageContent.current.has(contentKey)) {
console.debug('Duplicate message content ignored:', contentKey.slice(0, 50));
return false;
}
seenMessageContent.current.add(contentKey);
// Limit set size to prevent memory issues (keep last 500)
if (seenMessageContent.current.size > 1000) {
const entries = Array.from(seenMessageContent.current);
seenMessageContent.current = new Set(entries.slice(-500));
}
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) {
return prev;
}
return [...prev, msg];
});
return true;
}, []);
// Update a message's ack count
const updateMessageAck = useCallback((messageId: number, ackCount: number) => {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...prev[idx], acked: ackCount };
return updated;
}
return prev;
});
}, []);
return {
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
setMessages,
fetchMessages,
fetchOlderMessages,
addMessageIfNew,
updateMessageAck,
};
}
+226
View File
@@ -0,0 +1,226 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { api } from '../api';
import type { Contact, Conversation, Message, TelemetryResponse, NeighborInfo, AclEntry } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
// Format seconds into human-readable duration (e.g., 1d17h2m, 1h5m, 3m)
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
if (hours > 0 && mins > 0) return `${days}d${hours}h${mins}m`;
if (hours > 0) return `${days}d${hours}h`;
if (mins > 0) return `${days}d${mins}m`;
return `${days}d`;
}
if (hours > 0) {
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
return `${mins}m`;
}
// Format telemetry response as human-readable text
export function formatTelemetry(telemetry: TelemetryResponse): string {
const lines = [
`Telemetry`,
`Battery Voltage: ${telemetry.battery_volts.toFixed(3)}V`,
`Uptime: ${formatDuration(telemetry.uptime_seconds)}`,
`TX Airtime: ${formatDuration(telemetry.airtime_seconds)}`,
`RX Airtime: ${formatDuration(telemetry.rx_airtime_seconds)}`,
'',
`Noise Floor: ${telemetry.noise_floor_dbm} dBm`,
`Last RSSI: ${telemetry.last_rssi_dbm} dBm`,
`Last SNR: ${telemetry.last_snr_db.toFixed(1)} dB`,
'',
`Packets: ${telemetry.packets_received.toLocaleString()} rx / ${telemetry.packets_sent.toLocaleString()} tx`,
`Flood: ${telemetry.recv_flood.toLocaleString()} rx / ${telemetry.sent_flood.toLocaleString()} tx`,
`Direct: ${telemetry.recv_direct.toLocaleString()} rx / ${telemetry.sent_direct.toLocaleString()} tx`,
`Duplicates: ${telemetry.flood_dups.toLocaleString()} flood / ${telemetry.direct_dups.toLocaleString()} direct`,
'',
`TX Queue: ${telemetry.tx_queue_len}`,
`Debug Flags: ${telemetry.full_events}`,
];
return lines.join('\n');
}
// Format neighbors list as human-readable text
export function formatNeighbors(neighbors: NeighborInfo[]): string {
if (neighbors.length === 0) {
return 'Neighbors\nNo neighbors reported';
}
// Sort by SNR descending (highest first)
const sorted = [...neighbors].sort((a, b) => b.snr - a.snr);
const lines = [`Neighbors (${sorted.length})`];
for (const n of sorted) {
const name = n.name || n.pubkey_prefix;
const snr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
lines.push(`${name}, ${snr} dB [${formatDuration(n.last_heard_seconds)} ago]`);
}
return lines.join('\n');
}
// Format ACL list as human-readable text
export function formatAcl(acl: AclEntry[]): string {
if (acl.length === 0) {
return 'ACL\nNo ACL entries';
}
const lines = [`ACL (${acl.length})`];
for (const entry of acl) {
const name = entry.name || entry.pubkey_prefix;
lines.push(`${name}: ${entry.permission_name}`);
}
return lines.join('\n');
}
// Create a local message object (not persisted to database)
function createLocalMessage(
conversationKey: string,
text: string,
outgoing: boolean,
idOffset = 0
): Message {
const now = Math.floor(Date.now() / 1000);
return {
id: -Date.now() - idOffset,
type: 'PRIV',
conversation_key: conversationKey,
text,
sender_timestamp: now,
received_at: now,
path_len: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
export interface UseRepeaterModeResult {
repeaterLoggedIn: boolean;
activeContactIsRepeater: boolean;
handleTelemetryRequest: (password: string) => Promise<void>;
handleRepeaterCommand: (command: string) => Promise<void>;
}
export function useRepeaterMode(
activeConversation: Conversation | null,
contacts: Contact[],
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
): UseRepeaterModeResult {
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
// Reset login state when conversation changes
useEffect(() => {
setRepeaterLoggedIn(false);
}, [activeConversation?.id]);
// Check if active conversation is a repeater
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find(c => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
// Request telemetry from a repeater
const handleTelemetryRequest = useCallback(
async (password: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater) return;
try {
const telemetry = await api.requestTelemetry(activeConversation.id, password);
// Create local messages to display the telemetry (not persisted to database)
const telemetryMessage = createLocalMessage(
activeConversation.id,
formatTelemetry(telemetry),
false,
0
);
const neighborsMessage = createLocalMessage(
activeConversation.id,
formatNeighbors(telemetry.neighbors),
false,
1
);
const aclMessage = createLocalMessage(
activeConversation.id,
formatAcl(telemetry.acl),
false,
2
);
// Add all messages to the list
setMessages((prev) => [...prev, telemetryMessage, neighborsMessage, aclMessage]);
// Mark as logged in for CLI command mode
setRepeaterLoggedIn(true);
} catch (err) {
const errorMessage = createLocalMessage(
activeConversation.id,
`Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
0
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, setMessages]
);
// Send CLI command to a repeater (after logged in)
const handleRepeaterCommand = useCallback(
async (command: string) => {
if (!activeConversation || activeConversation.type !== 'contact') return;
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
// Show the command as an outgoing message
const commandMessage = createLocalMessage(
activeConversation.id,
`> ${command}`,
true,
0
);
setMessages((prev) => [...prev, commandMessage]);
try {
const response = await api.sendRepeaterCommand(activeConversation.id, command);
// Use the actual timestamp from the repeater if available
const responseMessage = createLocalMessage(
activeConversation.id,
response.response,
false,
1
);
if (response.sender_timestamp) {
responseMessage.sender_timestamp = response.sender_timestamp;
}
setMessages((prev) => [...prev, responseMessage]);
} catch (err) {
const errorMessage = createLocalMessage(
activeConversation.id,
`Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
1
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, repeaterLoggedIn, setMessages]
);
return {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
};
}
+197
View File
@@ -0,0 +1,197 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../api';
import {
getLastMessageTimes,
getLastReadTimes,
setLastMessageTime,
setLastReadTime,
getStateKey,
type ConversationTimes,
} from '../utils/conversationState';
import type { Channel, Contact, Conversation, Message } from '../types';
export interface UseUnreadCountsResult {
unreadCounts: Record<string, number>;
lastMessageTimes: ConversationTimes;
incrementUnread: (stateKey: string) => void;
markAllRead: () => void;
markConversationRead: (conv: Conversation) => void;
trackNewMessage: (msg: Message) => void;
}
export function useUnreadCounts(
channels: Channel[],
contacts: Contact[],
activeConversation: Conversation | null
): UseUnreadCountsResult {
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Track which channels/contacts we've already fetched unreads for
const fetchedChannels = useRef<Set<string>>(new Set());
const fetchedContacts = useRef<Set<string>>(new Set());
// Fetch messages and count unreads for new channels/contacts
useEffect(() => {
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 () => {
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 {
const bulkMessages = await api.getMessagesBulk(conversations, 100);
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);
}
}
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]);
// Mark conversation as read when user views it
useEffect(() => {
if (activeConversation && activeConversation.type !== 'raw') {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
);
const now = Math.floor(Date.now() / 1000);
setLastReadTime(key, now);
setUnreadCounts((prev) => {
if (prev[key]) {
const next = { ...prev };
delete next[key];
return next;
}
return prev;
});
}
}, [activeConversation]);
// Increment unread count for a conversation
const incrementUnread = useCallback((stateKey: string) => {
setUnreadCounts((prev) => ({
...prev,
[stateKey]: (prev[stateKey] || 0) + 1,
}));
}, []);
// Mark all conversations as read
const markAllRead = useCallback(() => {
const now = Math.floor(Date.now() / 1000);
for (const channel of channels) {
const key = getStateKey('channel', channel.key);
setLastReadTime(key, now);
}
for (const contact of contacts) {
if (contact.public_key) {
const key = getStateKey('contact', contact.public_key);
setLastReadTime(key, now);
}
}
setUnreadCounts({});
}, [channels, contacts]);
// Mark a specific conversation as read
const markConversationRead = useCallback((conv: Conversation) => {
if (conv.type === 'raw') return;
const key = getStateKey(conv.type as 'channel' | 'contact', conv.id);
const now = Math.floor(Date.now() / 1000);
setLastReadTime(key, now);
setUnreadCounts((prev) => {
if (prev[key]) {
const next = { ...prev };
delete next[key];
return next;
}
return prev;
});
}, []);
// Track a new incoming message for unread counts
const trackNewMessage = useCallback((msg: 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);
}
if (conversationKey) {
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(conversationKey, timestamp);
setLastMessageTimes(updated);
}
}, []);
return {
unreadCounts,
lastMessageTimes,
incrementUnread,
markAllRead,
markConversationRead,
trackNewMessage,
};
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Tests for URL hash utilities.
*
* These tests verify the URL hash parsing and generation
* for deep linking to conversations.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseHashConversation, getConversationHash } from '../utils/urlHash';
import type { Conversation } from '../types';
describe('parseHashConversation', () => {
let originalHash: string;
beforeEach(() => {
originalHash = window.location.hash;
});
afterEach(() => {
window.location.hash = originalHash;
});
it('returns null for empty hash', () => {
window.location.hash = '';
const result = parseHashConversation();
expect(result).toBeNull();
});
it('parses #raw as raw type', () => {
window.location.hash = '#raw';
const result = parseHashConversation();
expect(result).toEqual({ type: 'raw', name: 'raw' });
});
it('parses channel hash', () => {
window.location.hash = '#channel/Public';
const result = parseHashConversation();
expect(result).toEqual({ type: 'channel', name: 'Public' });
});
it('parses contact hash', () => {
window.location.hash = '#contact/Alice';
const result = parseHashConversation();
expect(result).toEqual({ type: 'contact', name: 'Alice' });
});
it('decodes URL-encoded names', () => {
window.location.hash = '#contact/John%20Doe';
const result = parseHashConversation();
expect(result).toEqual({ type: 'contact', name: 'John Doe' });
});
it('returns null for invalid type', () => {
window.location.hash = '#invalid/Test';
const result = parseHashConversation();
expect(result).toBeNull();
});
it('returns null for hash without slash', () => {
window.location.hash = '#channelPublic';
const result = parseHashConversation();
expect(result).toBeNull();
});
it('returns null for hash with empty name', () => {
window.location.hash = '#channel/';
const result = parseHashConversation();
expect(result).toBeNull();
});
it('handles channel names with special characters', () => {
window.location.hash = '#channel/Test%20Channel%21';
const result = parseHashConversation();
expect(result).toEqual({ type: 'channel', name: 'Test Channel!' });
});
});
describe('getConversationHash', () => {
it('returns empty string for null conversation', () => {
const result = getConversationHash(null);
expect(result).toBe('');
});
it('returns #raw for raw conversation', () => {
const conv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
const result = getConversationHash(conv);
expect(result).toBe('#raw');
});
it('generates channel hash', () => {
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Public' };
const result = getConversationHash(conv);
expect(result).toBe('#channel/Public');
});
it('generates contact hash', () => {
const conv: Conversation = { type: 'contact', id: 'pubkey123', name: 'Alice' };
const result = getConversationHash(conv);
expect(result).toBe('#contact/Alice');
});
it('strips leading # from channel names', () => {
const conv: Conversation = { type: 'channel', id: 'key123', name: '#TestChannel' };
const result = getConversationHash(conv);
expect(result).toBe('#channel/TestChannel');
});
it('encodes special characters in names', () => {
const conv: Conversation = { type: 'contact', id: 'key', name: 'John Doe' };
const result = getConversationHash(conv);
expect(result).toBe('#contact/John%20Doe');
});
it('does not strip # from contact names', () => {
const conv: Conversation = { type: 'contact', id: 'key', name: '#Hashtag' };
const result = getConversationHash(conv);
expect(result).toBe('#contact/%23Hashtag');
});
});
describe('parseHashConversation and getConversationHash roundtrip', () => {
let originalHash: string;
beforeEach(() => {
originalHash = window.location.hash;
});
afterEach(() => {
window.location.hash = originalHash;
});
it('channel roundtrip preserves data', () => {
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Test Channel' };
const hash = getConversationHash(conv);
window.location.hash = hash;
const parsed = parseHashConversation();
expect(parsed).toEqual({ type: 'channel', name: 'Test Channel' });
});
it('contact roundtrip preserves data', () => {
const conv: Conversation = { type: 'contact', id: 'pubkey', name: 'Alice Bob' };
const hash = getConversationHash(conv);
window.location.hash = hash;
const parsed = parseHashConversation();
expect(parsed).toEqual({ type: 'contact', name: 'Alice Bob' });
});
it('raw roundtrip preserves type', () => {
const conv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
const hash = getConversationHash(conv);
window.location.hash = hash;
const parsed = parseHashConversation();
expect(parsed).toEqual({ type: 'raw', name: 'raw' });
});
});
@@ -0,0 +1,112 @@
/**
* Tests for useConversationMessages hook utilities.
*
* These tests verify the message deduplication key generation.
*/
import { describe, it, expect } from 'vitest';
import { getMessageContentKey } from '../hooks/useConversationMessages';
import type { Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'channel123',
text: 'Hello world',
sender_timestamp: 1700000000,
received_at: 1700000001,
path_len: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 0,
...overrides,
};
}
describe('getMessageContentKey', () => {
it('generates key from type, conversation_key, text, and sender_timestamp', () => {
const msg = createMessage({
type: 'CHAN',
conversation_key: 'abc123',
text: 'Hello',
sender_timestamp: 1700000000,
});
const key = getMessageContentKey(msg);
expect(key).toBe('CHAN-abc123-Hello-1700000000');
});
it('generates different keys for different message types', () => {
const chanMsg = createMessage({ type: 'CHAN' });
const privMsg = createMessage({ type: 'PRIV' });
expect(getMessageContentKey(chanMsg)).not.toBe(getMessageContentKey(privMsg));
});
it('generates different keys for different conversation keys', () => {
const msg1 = createMessage({ conversation_key: 'channel1' });
const msg2 = createMessage({ conversation_key: 'channel2' });
expect(getMessageContentKey(msg1)).not.toBe(getMessageContentKey(msg2));
});
it('generates different keys for different text', () => {
const msg1 = createMessage({ text: 'Hello' });
const msg2 = createMessage({ text: 'World' });
expect(getMessageContentKey(msg1)).not.toBe(getMessageContentKey(msg2));
});
it('generates different keys for different timestamps', () => {
const msg1 = createMessage({ sender_timestamp: 1700000000 });
const msg2 = createMessage({ sender_timestamp: 1700000001 });
expect(getMessageContentKey(msg1)).not.toBe(getMessageContentKey(msg2));
});
it('generates same key for messages with same content', () => {
const msg1 = createMessage({
id: 1,
type: 'CHAN',
conversation_key: 'abc',
text: 'Test',
sender_timestamp: 1700000000,
});
const msg2 = createMessage({
id: 2, // Different ID
type: 'CHAN',
conversation_key: 'abc',
text: 'Test',
sender_timestamp: 1700000000,
});
expect(getMessageContentKey(msg1)).toBe(getMessageContentKey(msg2));
});
it('handles null sender_timestamp', () => {
const msg = createMessage({ sender_timestamp: null });
const key = getMessageContentKey(msg);
expect(key).toBe('CHAN-channel123-Hello world-null');
});
it('handles empty text', () => {
const msg = createMessage({ text: '' });
const key = getMessageContentKey(msg);
expect(key).toContain('--'); // Empty text between dashes
});
it('handles text with special characters', () => {
const msg = createMessage({ text: 'Hello: World! @user #channel' });
const key = getMessageContentKey(msg);
expect(key).toContain('Hello: World! @user #channel');
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* Tests for useRepeaterMode hook utilities.
*
* These tests verify the formatting functions used to display
* telemetry data from repeaters.
*/
import { describe, it, expect } from 'vitest';
import {
formatDuration,
formatTelemetry,
formatNeighbors,
formatAcl,
} from '../hooks/useRepeaterMode';
import type { TelemetryResponse, NeighborInfo, AclEntry } from '../types';
describe('formatDuration', () => {
it('formats seconds under a minute', () => {
expect(formatDuration(0)).toBe('0s');
expect(formatDuration(30)).toBe('30s');
expect(formatDuration(59)).toBe('59s');
});
it('formats minutes only', () => {
expect(formatDuration(60)).toBe('1m');
expect(formatDuration(120)).toBe('2m');
expect(formatDuration(300)).toBe('5m');
expect(formatDuration(3599)).toBe('59m');
});
it('formats hours and minutes', () => {
expect(formatDuration(3600)).toBe('1h');
expect(formatDuration(3660)).toBe('1h1m');
expect(formatDuration(7200)).toBe('2h');
expect(formatDuration(7380)).toBe('2h3m');
});
it('formats days only', () => {
expect(formatDuration(86400)).toBe('1d');
expect(formatDuration(172800)).toBe('2d');
});
it('formats days and hours', () => {
expect(formatDuration(90000)).toBe('1d1h');
expect(formatDuration(97200)).toBe('1d3h');
});
it('formats days and minutes (no hours)', () => {
expect(formatDuration(86700)).toBe('1d5m');
});
it('formats days, hours, and minutes', () => {
expect(formatDuration(90060)).toBe('1d1h1m');
expect(formatDuration(148920)).toBe('1d17h22m');
});
});
describe('formatTelemetry', () => {
it('formats telemetry response with all fields', () => {
const telemetry: TelemetryResponse = {
pubkey_prefix: 'abc123',
battery_volts: 4.123,
uptime_seconds: 90060, // 1d1h1m
airtime_seconds: 3600, // 1h
rx_airtime_seconds: 7200, // 2h
noise_floor_dbm: -120,
last_rssi_dbm: -90,
last_snr_db: 8.5,
packets_received: 1000,
packets_sent: 500,
recv_flood: 800,
sent_flood: 400,
recv_direct: 200,
sent_direct: 100,
flood_dups: 50,
direct_dups: 10,
tx_queue_len: 2,
full_events: 0,
neighbors: [],
acl: [],
};
const result = formatTelemetry(telemetry);
expect(result).toContain('Telemetry');
expect(result).toContain('Battery Voltage: 4.123V');
expect(result).toContain('Uptime: 1d1h1m');
expect(result).toContain('TX Airtime: 1h');
expect(result).toContain('RX Airtime: 2h');
expect(result).toContain('Noise Floor: -120 dBm');
expect(result).toContain('Last RSSI: -90 dBm');
expect(result).toContain('Last SNR: 8.5 dB');
expect(result).toContain('Packets: 1,000 rx / 500 tx');
expect(result).toContain('Flood: 800 rx / 400 tx');
expect(result).toContain('Direct: 200 rx / 100 tx');
expect(result).toContain('Duplicates: 50 flood / 10 direct');
expect(result).toContain('TX Queue: 2');
});
it('formats battery voltage with 3 decimal places', () => {
const telemetry: TelemetryResponse = {
pubkey_prefix: 'abc123',
battery_volts: 3.7,
uptime_seconds: 0,
airtime_seconds: 0,
rx_airtime_seconds: 0,
noise_floor_dbm: 0,
last_rssi_dbm: 0,
last_snr_db: 0,
packets_received: 0,
packets_sent: 0,
recv_flood: 0,
sent_flood: 0,
recv_direct: 0,
sent_direct: 0,
flood_dups: 0,
direct_dups: 0,
tx_queue_len: 0,
full_events: 0,
neighbors: [],
acl: [],
};
const result = formatTelemetry(telemetry);
expect(result).toContain('Battery Voltage: 3.700V');
});
});
describe('formatNeighbors', () => {
it('returns "No neighbors" message for empty list', () => {
const result = formatNeighbors([]);
expect(result).toBe('Neighbors\nNo neighbors reported');
});
it('formats single neighbor', () => {
const neighbors: NeighborInfo[] = [
{ pubkey_prefix: 'abc123', name: 'Alice', snr: 8.5, last_heard_seconds: 60 },
];
const result = formatNeighbors(neighbors);
expect(result).toContain('Neighbors (1)');
expect(result).toContain('Alice, +8.5 dB [1m ago]');
});
it('sorts neighbors by SNR descending', () => {
const neighbors: NeighborInfo[] = [
{ pubkey_prefix: 'aaa', name: 'Low', snr: -5, last_heard_seconds: 10 },
{ pubkey_prefix: 'bbb', name: 'High', snr: 10, last_heard_seconds: 20 },
{ pubkey_prefix: 'ccc', name: 'Mid', snr: 5, last_heard_seconds: 30 },
];
const result = formatNeighbors(neighbors);
const lines = result.split('\n');
expect(lines[1]).toContain('High');
expect(lines[2]).toContain('Mid');
expect(lines[3]).toContain('Low');
});
it('uses pubkey_prefix when name is null', () => {
const neighbors: NeighborInfo[] = [
{ pubkey_prefix: 'abc123def456', name: null, snr: 5, last_heard_seconds: 120 },
];
const result = formatNeighbors(neighbors);
expect(result).toContain('abc123def456, +5.0 dB [2m ago]');
});
it('formats negative SNR without plus sign', () => {
const neighbors: NeighborInfo[] = [
{ pubkey_prefix: 'abc', name: 'Test', snr: -3.5, last_heard_seconds: 60 },
];
const result = formatNeighbors(neighbors);
expect(result).toContain('Test, -3.5 dB');
});
it('formats last heard in various durations', () => {
const neighbors: NeighborInfo[] = [
{ pubkey_prefix: 'a', name: 'Seconds', snr: 0, last_heard_seconds: 45 },
{ pubkey_prefix: 'b', name: 'Minutes', snr: 0, last_heard_seconds: 300 },
{ pubkey_prefix: 'c', name: 'Hours', snr: 0, last_heard_seconds: 7200 },
];
const result = formatNeighbors(neighbors);
expect(result).toContain('Seconds, +0.0 dB [45s ago]');
expect(result).toContain('Minutes, +0.0 dB [5m ago]');
expect(result).toContain('Hours, +0.0 dB [2h ago]');
});
});
describe('formatAcl', () => {
it('returns "No ACL entries" message for empty list', () => {
const result = formatAcl([]);
expect(result).toBe('ACL\nNo ACL entries');
});
it('formats single ACL entry', () => {
const acl: AclEntry[] = [
{ pubkey_prefix: 'abc123', name: 'Alice', permission: 3, permission_name: 'Admin' },
];
const result = formatAcl(acl);
expect(result).toContain('ACL (1)');
expect(result).toContain('Alice: Admin');
});
it('formats multiple ACL entries', () => {
const acl: AclEntry[] = [
{ pubkey_prefix: 'aaa', name: 'Admin User', permission: 3, permission_name: 'Admin' },
{ pubkey_prefix: 'bbb', name: 'Read Only', permission: 1, permission_name: 'Read-only' },
{ pubkey_prefix: 'ccc', name: null, permission: 0, permission_name: 'Guest' },
];
const result = formatAcl(acl);
expect(result).toContain('ACL (3)');
expect(result).toContain('Admin User: Admin');
expect(result).toContain('Read Only: Read-only');
expect(result).toContain('ccc: Guest');
});
it('uses pubkey_prefix when name is null', () => {
const acl: AclEntry[] = [
{ pubkey_prefix: 'xyz789', name: null, permission: 2, permission_name: 'Read-write' },
];
const result = formatAcl(acl);
expect(result).toContain('xyz789: Read-write');
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { Conversation } from '../types';
export interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw';
name: string;
}
// Parse URL hash to get conversation (e.g., #channel/Public or #contact/JohnDoe or #raw)
export function parseHashConversation(): ParsedHashConversation | null {
const hash = window.location.hash.slice(1); // Remove leading #
if (!hash) return null;
if (hash === 'raw') {
return { type: 'raw', name: 'raw' };
}
const slashIndex = hash.indexOf('/');
if (slashIndex === -1) return null;
const type = hash.slice(0, slashIndex);
const name = decodeURIComponent(hash.slice(slashIndex + 1));
if ((type === 'channel' || type === 'contact') && name) {
return { type, name };
}
return null;
}
// Generate URL hash from conversation
export function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';
if (conv.type === 'raw') return '#raw';
// Strip leading # from channel names for cleaner URLs
const name = conv.type === 'channel' && conv.name.startsWith('#')
? conv.name.slice(1)
: conv.name;
return `#${conv.type}/${encodeURIComponent(name)}`;
}
// Update URL hash without adding to history
export function updateUrlHash(conv: Conversation | null): void {
const newHash = getConversationHash(conv);
if (newHash !== window.location.hash) {
window.history.replaceState(null, '', newHash || window.location.pathname);
}
}