mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Overhaul frontend organization and pause message polling during repeater operations
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
537
frontend/dist/assets/index-BQNVNq9u.js
vendored
Normal file
File diff suppressed because one or more lines are too long
537
frontend/dist/assets/index-CeWxUYMt.js
vendored
537
frontend/dist/assets/index-CeWxUYMt.js
vendored
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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
frontend/src/hooks/index.ts
Normal file
3
frontend/src/hooks/index.ts
Normal 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';
|
||||
154
frontend/src/hooks/useConversationMessages.ts
Normal file
154
frontend/src/hooks/useConversationMessages.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
226
frontend/src/hooks/useRepeaterMode.ts
Normal file
226
frontend/src/hooks/useRepeaterMode.ts
Normal 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
frontend/src/hooks/useUnreadCounts.ts
Normal file
197
frontend/src/hooks/useUnreadCounts.ts
Normal 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
frontend/src/test/urlHash.test.ts
Normal file
192
frontend/src/test/urlHash.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
112
frontend/src/test/useConversationMessages.test.ts
Normal file
112
frontend/src/test/useConversationMessages.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
239
frontend/src/test/useRepeaterMode.test.ts
Normal file
239
frontend/src/test/useRepeaterMode.test.ts
Normal 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
frontend/src/utils/urlHash.ts
Normal file
46
frontend/src/utils/urlHash.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user