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

View File

@@ -12,6 +12,7 @@ don't work reliably.
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from meshcore import EventType
@@ -28,6 +29,20 @@ _message_poll_task: asyncio.Task | None = None
# Message poll interval in seconds
MESSAGE_POLL_INTERVAL = 5
# Flag to pause polling during repeater operations
_polling_paused: bool = False
@asynccontextmanager
async def pause_polling():
"""Context manager to pause message polling during repeater operations."""
global _polling_paused
_polling_paused = True
try:
yield
finally:
_polling_paused = False
# Background task handle
_sync_task: asyncio.Task | None = None
@@ -276,7 +291,7 @@ async def _message_poll_loop():
try:
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
if radio_manager.is_connected:
if radio_manager.is_connected and not _polling_paused:
await poll_for_messages()
except asyncio.CancelledError:

View File

@@ -15,7 +15,7 @@ class ContactRepository:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = excluded.type,
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,

View File

@@ -23,6 +23,7 @@ ACL_PERMISSION_NAMES = {
3: "Admin",
}
from app.radio import radio_manager
from app.radio_sync import pause_polling
from app.repository import ContactRepository
logger = logging.getLogger(__name__)
@@ -382,50 +383,52 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})"
)
# Send the command
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
# Pause message polling to prevent it from stealing our response
async with pause_polling():
# Send the command
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send command: {send_result.payload}"
)
# Wait for response (MESSAGES_WAITING event, then get_msg)
try:
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=10.0)
if wait_result is None:
# Timeout - no response received
logger.warning("No response from repeater %s for command: %s", contact.public_key[:12], request.command)
return CommandResponse(
command=request.command,
response="(no response - command may have been processed)"
if send_result.type == EventType.ERROR:
raise HTTPException(
status_code=500,
detail=f"Failed to send command: {send_result.payload}"
)
response_event = await mc.commands.get_msg()
# Wait for response (MESSAGES_WAITING event, then get_msg)
try:
wait_result = await mc.wait_for_event(EventType.MESSAGES_WAITING, timeout=10.0)
if wait_result is None:
# Timeout - no response received
logger.warning("No response from repeater %s for command: %s", contact.public_key[:12], request.command)
return CommandResponse(
command=request.command,
response="(no response - command may have been processed)"
)
response_event = await mc.commands.get_msg()
if response_event.type == EventType.ERROR:
return CommandResponse(
command=request.command,
response=f"(error: {response_event.payload})"
)
# Extract the response text and timestamp from the payload
response_text = response_event.payload.get("text", str(response_event.payload))
sender_timestamp = response_event.payload.get("timestamp")
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
if response_event.type == EventType.ERROR:
return CommandResponse(
command=request.command,
response=f"(error: {response_event.payload})"
response=response_text,
sender_timestamp=sender_timestamp,
)
except Exception as e:
logger.error("Error waiting for response: %s", e)
return CommandResponse(
command=request.command,
response=f"(error waiting for response: {e})"
)
# Extract the response text and timestamp from the payload
response_text = response_event.payload.get("text", str(response_event.payload))
sender_timestamp = response_event.payload.get("timestamp")
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
return CommandResponse(
command=request.command,
response=response_text,
sender_timestamp=sender_timestamp,
)
except Exception as e:
logger.error("Error waiting for response: %s", e)
return CommandResponse(
command=request.command,
response=f"(error waiting for response: {e})"
)

537
frontend/dist/assets/index-BQNVNq9u.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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>

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}
/>
);

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';

View File

@@ -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,
};
}

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,
};
}

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,
};
}

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' });
});
});

View File

@@ -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');
});
});

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');
});
});

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);
}
}