mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 18:53:03 +02:00
Overhaul frontend organization and pause message polling during repeater operations
This commit is contained in:
+16
-1
@@ -12,6 +12,7 @@ don't work reliably.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from meshcore import EventType
|
from meshcore import EventType
|
||||||
|
|
||||||
@@ -28,6 +29,20 @@ _message_poll_task: asyncio.Task | None = None
|
|||||||
# Message poll interval in seconds
|
# Message poll interval in seconds
|
||||||
MESSAGE_POLL_INTERVAL = 5
|
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
|
# Background task handle
|
||||||
_sync_task: asyncio.Task | None = None
|
_sync_task: asyncio.Task | None = None
|
||||||
|
|
||||||
@@ -276,7 +291,7 @@ async def _message_poll_loop():
|
|||||||
try:
|
try:
|
||||||
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
||||||
|
|
||||||
if radio_manager.is_connected:
|
if radio_manager.is_connected and not _polling_paused:
|
||||||
await poll_for_messages()
|
await poll_for_messages()
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ class ContactRepository:
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(public_key) DO UPDATE SET
|
ON CONFLICT(public_key) DO UPDATE SET
|
||||||
name = COALESCE(excluded.name, contacts.name),
|
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,
|
flags = excluded.flags,
|
||||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||||
last_path_len = excluded.last_path_len,
|
last_path_len = excluded.last_path_len,
|
||||||
|
|||||||
+42
-39
@@ -23,6 +23,7 @@ ACL_PERMISSION_NAMES = {
|
|||||||
3: "Admin",
|
3: "Admin",
|
||||||
}
|
}
|
||||||
from app.radio import radio_manager
|
from app.radio import radio_manager
|
||||||
|
from app.radio_sync import pause_polling
|
||||||
from app.repository import ContactRepository
|
from app.repository import ContactRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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})"
|
detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send the command
|
# Pause message polling to prevent it from stealing our response
|
||||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
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:
|
if send_result.type == EventType.ERROR:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to send command: {send_result.payload}"
|
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)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
return CommandResponse(
|
||||||
command=request.command,
|
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
File diff suppressed because one or more lines are too long
-537
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CacA_plj.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+51
-552
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
import { useWebSocket } from './useWebSocket';
|
import { useWebSocket } from './useWebSocket';
|
||||||
|
import { useRepeaterMode, useUnreadCounts, useConversationMessages } from './hooks';
|
||||||
import { StatusBar } from './components/StatusBar';
|
import { StatusBar } from './components/StatusBar';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { MessageList } from './components/MessageList';
|
import { MessageList } from './components/MessageList';
|
||||||
@@ -11,18 +12,11 @@ import { RawPacketList } from './components/RawPacketList';
|
|||||||
import { CrackerPanel } from './components/CrackerPanel';
|
import { CrackerPanel } from './components/CrackerPanel';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
||||||
import { Toaster, toast } from './components/ui/sonner';
|
import { Toaster, toast } from './components/ui/sonner';
|
||||||
import {
|
import { getStateKey } from './utils/conversationState';
|
||||||
getLastMessageTimes,
|
|
||||||
getLastReadTimes,
|
|
||||||
setLastMessageTime,
|
|
||||||
setLastReadTime,
|
|
||||||
getStateKey,
|
|
||||||
type ConversationTimes,
|
|
||||||
} from './utils/conversationState';
|
|
||||||
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
||||||
|
import { parseHashConversation, updateUrlHash } from './utils/urlHash';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
AclEntry,
|
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppSettingsUpdate,
|
AppSettingsUpdate,
|
||||||
Contact,
|
Contact,
|
||||||
@@ -30,157 +24,61 @@ import type {
|
|||||||
Conversation,
|
Conversation,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
Message,
|
Message,
|
||||||
NeighborInfo,
|
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioConfig,
|
RadioConfig,
|
||||||
RadioConfigUpdate,
|
RadioConfigUpdate,
|
||||||
TelemetryResponse,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { CONTACT_TYPE_REPEATER } from './types';
|
|
||||||
|
|
||||||
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
|
const MAX_RAW_PACKETS = 500;
|
||||||
|
|
||||||
// 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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const activeConversationRef = useRef<Conversation | null>(null);
|
const activeConversationRef = useRef<Conversation | null>(null);
|
||||||
const seenMessageContent = useRef<Set<string>>(new Set());
|
|
||||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
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 [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
||||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
||||||
const [showCracker, setShowCracker] = useState(false);
|
const [showCracker, setShowCracker] = useState(false);
|
||||||
const [crackerRunning, setCrackerRunning] = 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
|
// Track previous health status to detect changes
|
||||||
const prevHealthRef = useRef<HealthStatus | null>(null);
|
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
|
// WebSocket handlers - memoized to prevent reconnection loops
|
||||||
const wsHandlers = useMemo(() => ({
|
const wsHandlers = useMemo(() => ({
|
||||||
onHealth: (data: HealthStatus) => {
|
onHealth: (data: HealthStatus) => {
|
||||||
@@ -211,27 +109,6 @@ export function App() {
|
|||||||
onMessage: (msg: Message) => {
|
onMessage: (msg: Message) => {
|
||||||
const activeConv = activeConversationRef.current;
|
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
|
// Check if message belongs to the active conversation
|
||||||
const isForActiveConversation = (() => {
|
const isForActiveConversation = (() => {
|
||||||
if (!activeConv) return false;
|
if (!activeConv) return false;
|
||||||
@@ -239,7 +116,6 @@ export function App() {
|
|||||||
return msg.conversation_key === activeConv.id;
|
return msg.conversation_key === activeConv.id;
|
||||||
}
|
}
|
||||||
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
|
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 msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -247,37 +123,31 @@ export function App() {
|
|||||||
|
|
||||||
// Only add to message list if it's for the active conversation
|
// Only add to message list if it's for the active conversation
|
||||||
if (isForActiveConversation) {
|
if (isForActiveConversation) {
|
||||||
setMessages((prev) => {
|
addMessageIfNew(msg);
|
||||||
if (prev.some((m) => m.id === msg.id)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return [...prev, msg];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track last message time for sorting and unread detection
|
// Track for unread counts and sorting
|
||||||
if (conversationKey) {
|
trackNewMessage(msg);
|
||||||
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
|
|
||||||
const updated = setLastMessageTime(conversationKey, timestamp);
|
|
||||||
setLastMessageTimes(updated);
|
|
||||||
|
|
||||||
// Count unread messages during this session (for non-active, incoming messages)
|
// Count unread for non-active, incoming messages
|
||||||
if (!msg.outgoing && !isForActiveConversation) {
|
if (!msg.outgoing && !isForActiveConversation) {
|
||||||
setUnreadCounts((prev) => ({
|
let stateKey: string | null = null;
|
||||||
...prev,
|
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||||
[conversationKey]: (prev[conversationKey] || 0) + 1,
|
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) => {
|
onContact: (contact: Contact) => {
|
||||||
// Update or add contact, preserving existing non-null values
|
|
||||||
setContacts((prev) => {
|
setContacts((prev) => {
|
||||||
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const updated = [...prev];
|
const updated = [...prev];
|
||||||
const existing = prev[idx];
|
const existing = prev[idx];
|
||||||
// Merge: prefer new non-null values, but keep existing values if new is null
|
|
||||||
updated[idx] = {
|
updated[idx] = {
|
||||||
...existing,
|
...existing,
|
||||||
...contact,
|
...contact,
|
||||||
@@ -293,11 +163,9 @@ export function App() {
|
|||||||
},
|
},
|
||||||
onRawPacket: (packet: RawPacket) => {
|
onRawPacket: (packet: RawPacket) => {
|
||||||
setRawPackets((prev) => {
|
setRawPackets((prev) => {
|
||||||
// Check if packet already exists
|
|
||||||
if (prev.some((p) => p.id === packet.id)) {
|
if (prev.some((p) => p.id === packet.id)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
// Limit to MAX_RAW_PACKETS, removing oldest
|
|
||||||
const updated = [...prev, packet];
|
const updated = [...prev, packet];
|
||||||
if (updated.length > MAX_RAW_PACKETS) {
|
if (updated.length > MAX_RAW_PACKETS) {
|
||||||
return updated.slice(-MAX_RAW_PACKETS);
|
return updated.slice(-MAX_RAW_PACKETS);
|
||||||
@@ -306,18 +174,9 @@ export function App() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onMessageAcked: (messageId: number, ackCount: number) => {
|
onMessageAcked: (messageId: number, ackCount: number) => {
|
||||||
// Update message acked count
|
updateMessageAck(messageId, ackCount);
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
}), []);
|
}), [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck]);
|
||||||
|
|
||||||
// Connect to WebSocket
|
// Connect to WebSocket
|
||||||
useWebSocket(wsHandlers);
|
useWebSocket(wsHandlers);
|
||||||
@@ -352,64 +211,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const MESSAGE_PAGE_SIZE = 200;
|
// Initial fetch for config and settings
|
||||||
|
|
||||||
// Fetch messages for active conversation
|
|
||||||
const fetchMessages = useCallback(async (showLoading = false) => {
|
|
||||||
if (!activeConversation) {
|
|
||||||
setMessages([]);
|
|
||||||
setHasOlderMessages(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
setMessagesLoading(true);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await api.getMessages({
|
|
||||||
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
|
||||||
conversation_key: activeConversation.id,
|
|
||||||
limit: MESSAGE_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
setMessages(data);
|
|
||||||
// If we got a full page, there might be more
|
|
||||||
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch messages:', err);
|
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
setMessagesLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeConversation]);
|
|
||||||
|
|
||||||
// Fetch older messages (pagination)
|
|
||||||
const fetchOlderMessages = useCallback(async () => {
|
|
||||||
if (!activeConversation || loadingOlder || !hasOlderMessages) return;
|
|
||||||
|
|
||||||
setLoadingOlder(true);
|
|
||||||
try {
|
|
||||||
const data = await api.getMessages({
|
|
||||||
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
|
||||||
conversation_key: activeConversation.id,
|
|
||||||
limit: MESSAGE_PAGE_SIZE,
|
|
||||||
offset: messages.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.length > 0) {
|
|
||||||
// Prepend older messages (they come sorted DESC, so older are at the end)
|
|
||||||
setMessages(prev => [...prev, ...data]);
|
|
||||||
}
|
|
||||||
// If we got less than a full page, no more messages
|
|
||||||
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch older messages:', err);
|
|
||||||
} finally {
|
|
||||||
setLoadingOlder(false);
|
|
||||||
}
|
|
||||||
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
|
|
||||||
|
|
||||||
// Initial fetch for config and settings (WebSocket handles health/contacts/channels)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
fetchAppSettings();
|
fetchAppSettings();
|
||||||
@@ -425,7 +227,6 @@ export function App() {
|
|||||||
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||||
}
|
}
|
||||||
if (hashConv.type === 'channel') {
|
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}`);
|
const channel = channels.find(c => c.name === hashConv.name || c.name === `#${hashConv.name}`);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
return { type: 'channel', id: channel.key, name: channel.name };
|
return { type: 'channel', id: channel.key, name: channel.name };
|
||||||
@@ -450,7 +251,6 @@ export function App() {
|
|||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
if (channels.length === 0 && contacts.length === 0) return;
|
if (channels.length === 0 && contacts.length === 0) return;
|
||||||
|
|
||||||
// Try to restore from URL hash first
|
|
||||||
const conv = resolveHashToConversation();
|
const conv = resolveHashToConversation();
|
||||||
if (conv) {
|
if (conv) {
|
||||||
setActiveConversation(conv);
|
setActiveConversation(conv);
|
||||||
@@ -458,7 +258,6 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to Public channel
|
|
||||||
const publicChannel = channels.find(c => c.name === 'Public');
|
const publicChannel = channels.find(c => c.name === 'Public');
|
||||||
if (publicChannel) {
|
if (publicChannel) {
|
||||||
setActiveConversation({
|
setActiveConversation({
|
||||||
@@ -470,137 +269,14 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [channels, contacts, activeConversation, resolveHashToConversation]);
|
}, [channels, contacts, activeConversation, resolveHashToConversation]);
|
||||||
|
|
||||||
// Fetch messages and count unreads for all conversations on load (single bulk request)
|
// Keep ref in sync and update URL hash
|
||||||
const fetchedChannels = useRef<Set<string>>(new Set());
|
|
||||||
const fetchedContacts = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Find channels and contacts we haven't fetched yet
|
|
||||||
const newChannels = channels.filter(c => !fetchedChannels.current.has(c.key));
|
|
||||||
const newContacts = contacts.filter(c => c.public_key && !fetchedContacts.current.has(c.public_key));
|
|
||||||
|
|
||||||
if (newChannels.length === 0 && newContacts.length === 0) return;
|
|
||||||
|
|
||||||
// Mark as fetched before starting (to avoid duplicate fetches if effect re-runs)
|
|
||||||
newChannels.forEach(c => fetchedChannels.current.add(c.key));
|
|
||||||
newContacts.forEach(c => fetchedContacts.current.add(c.public_key));
|
|
||||||
|
|
||||||
const fetchAndCountUnreads = async () => {
|
|
||||||
// Build list of conversations to fetch
|
|
||||||
const conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }> = [
|
|
||||||
...newChannels.map(c => ({ type: 'CHAN' as const, conversation_key: c.key })),
|
|
||||||
...newContacts.map(c => ({ type: 'PRIV' as const, conversation_key: c.public_key })),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (conversations.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Single bulk request for all conversations
|
|
||||||
const bulkMessages = await api.getMessagesBulk(conversations, 100);
|
|
||||||
|
|
||||||
// Read lastReadTimes fresh from localStorage for accurate comparison
|
|
||||||
const currentReadTimes = getLastReadTimes();
|
|
||||||
const newUnreadCounts: Record<string, number> = {};
|
|
||||||
const newLastMessageTimes: Record<string, number> = {};
|
|
||||||
|
|
||||||
// Process channel messages
|
|
||||||
for (const channel of newChannels) {
|
|
||||||
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
|
|
||||||
if (msgs.length > 0) {
|
|
||||||
const key = getStateKey('channel', channel.key);
|
|
||||||
const lastRead = currentReadTimes[key] || 0;
|
|
||||||
|
|
||||||
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
|
|
||||||
if (unreadCount > 0) {
|
|
||||||
newUnreadCounts[key] = unreadCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestTime = Math.max(...msgs.map(m => m.received_at));
|
|
||||||
newLastMessageTimes[key] = latestTime;
|
|
||||||
setLastMessageTime(key, latestTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process contact messages
|
|
||||||
for (const contact of newContacts) {
|
|
||||||
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
|
|
||||||
if (msgs.length > 0) {
|
|
||||||
const key = getStateKey('contact', contact.public_key);
|
|
||||||
const lastRead = currentReadTimes[key] || 0;
|
|
||||||
|
|
||||||
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
|
|
||||||
if (unreadCount > 0) {
|
|
||||||
newUnreadCounts[key] = unreadCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestTime = Math.max(...msgs.map(m => m.received_at));
|
|
||||||
newLastMessageTimes[key] = latestTime;
|
|
||||||
setLastMessageTime(key, latestTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state with all the counts and times
|
|
||||||
if (Object.keys(newUnreadCounts).length > 0) {
|
|
||||||
setUnreadCounts(prev => ({ ...prev, ...newUnreadCounts }));
|
|
||||||
}
|
|
||||||
setLastMessageTimes(getLastMessageTimes());
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch messages bulk:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAndCountUnreads();
|
|
||||||
}, [channels, contacts]);
|
|
||||||
|
|
||||||
// Keep ref in sync with state and mark conversation as read when viewed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeConversationRef.current = activeConversation;
|
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) {
|
if (activeConversation) {
|
||||||
const newHash = getConversationHash(activeConversation);
|
updateUrlHash(activeConversation);
|
||||||
if (newHash !== window.location.hash) {
|
|
||||||
window.history.replaceState(null, '', newHash);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [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
|
// Send message handler
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
@@ -611,159 +287,11 @@ export function App() {
|
|||||||
} else {
|
} else {
|
||||||
await api.sendDirectMessage(activeConversation.id, text);
|
await api.sendDirectMessage(activeConversation.id, text);
|
||||||
}
|
}
|
||||||
// Message will arrive via WebSocket, but fetch to be safe
|
|
||||||
await fetchMessages();
|
await fetchMessages();
|
||||||
},
|
},
|
||||||
[activeConversation, 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
|
// Config save handler
|
||||||
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
|
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
|
||||||
await api.updateRadioConfig(update);
|
await api.updateRadioConfig(update);
|
||||||
@@ -785,12 +313,9 @@ export function App() {
|
|||||||
// Reboot radio handler
|
// Reboot radio handler
|
||||||
const handleReboot = useCallback(async () => {
|
const handleReboot = useCallback(async () => {
|
||||||
await api.rebootRadio();
|
await api.rebootRadio();
|
||||||
// Immediately show disconnected state
|
|
||||||
setHealth((prev) =>
|
setHealth((prev) =>
|
||||||
prev ? { ...prev, radio_connected: false } : prev
|
prev ? { ...prev, radio_connected: false } : prev
|
||||||
);
|
);
|
||||||
// Health updates will come via WebSocket when reconnected
|
|
||||||
// But also poll as backup
|
|
||||||
const pollUntilReconnected = async () => {
|
const pollUntilReconnected = async () => {
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
@@ -829,28 +354,6 @@ export function App() {
|
|||||||
setSidebarOpen(false);
|
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
|
// Delete channel handler
|
||||||
const handleDeleteChannel = useCallback(async (key: string) => {
|
const handleDeleteChannel = useCallback(async (key: string) => {
|
||||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||||
@@ -893,7 +396,6 @@ export function App() {
|
|||||||
};
|
};
|
||||||
setContacts((prev) => [...prev, newContact]);
|
setContacts((prev) => [...prev, newContact]);
|
||||||
|
|
||||||
// Open the new contact
|
|
||||||
setActiveConversation({
|
setActiveConversation({
|
||||||
type: 'contact',
|
type: 'contact',
|
||||||
id: publicKey,
|
id: publicKey,
|
||||||
@@ -911,11 +413,9 @@ export function App() {
|
|||||||
const handleCreateChannel = useCallback(
|
const handleCreateChannel = useCallback(
|
||||||
async (name: string, key: string, tryHistorical: boolean) => {
|
async (name: string, key: string, tryHistorical: boolean) => {
|
||||||
const created = await api.createChannel(name, key);
|
const created = await api.createChannel(name, key);
|
||||||
// Channel will be broadcast via WebSocket, but fetch to be safe
|
|
||||||
const data = await api.getChannels();
|
const data = await api.getChannels();
|
||||||
setChannels(data);
|
setChannels(data);
|
||||||
|
|
||||||
// Open the new channel (use created.key as the id)
|
|
||||||
setActiveConversation({
|
setActiveConversation({
|
||||||
type: 'channel',
|
type: 'channel',
|
||||||
id: created.key,
|
id: created.key,
|
||||||
@@ -942,7 +442,6 @@ export function App() {
|
|||||||
const data = await api.getChannels();
|
const data = await api.getChannels();
|
||||||
setChannels(data);
|
setChannels(data);
|
||||||
|
|
||||||
// Open the new channel (use created.key as the id)
|
|
||||||
setActiveConversation({
|
setActiveConversation({
|
||||||
type: 'channel',
|
type: 'channel',
|
||||||
id: created.key,
|
id: created.key,
|
||||||
@@ -976,7 +475,7 @@ export function App() {
|
|||||||
showCracker={showCracker}
|
showCracker={showCracker}
|
||||||
crackerRunning={crackerRunning}
|
crackerRunning={crackerRunning}
|
||||||
onToggleCracker={() => setShowCracker((prev) => !prev)}
|
onToggleCracker={() => setShowCracker((prev) => !prev)}
|
||||||
onMarkAllRead={handleMarkAllRead}
|
onMarkAllRead={markAllRead}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useRepeaterMode, type UseRepeaterModeResult, formatDuration, formatTelemetry, formatNeighbors, formatAcl } from './useRepeaterMode';
|
||||||
|
export { useUnreadCounts, type UseUnreadCountsResult } from './useUnreadCounts';
|
||||||
|
export { useConversationMessages, type UseConversationMessagesResult, getMessageContentKey } from './useConversationMessages';
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import type { Conversation, Message } from '../types';
|
||||||
|
|
||||||
|
const MESSAGE_PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
// Generate a key for deduplicating messages by content
|
||||||
|
export function getMessageContentKey(msg: Message): string {
|
||||||
|
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConversationMessagesResult {
|
||||||
|
messages: Message[];
|
||||||
|
messagesLoading: boolean;
|
||||||
|
loadingOlder: boolean;
|
||||||
|
hasOlderMessages: boolean;
|
||||||
|
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||||
|
fetchMessages: (showLoading?: boolean) => Promise<void>;
|
||||||
|
fetchOlderMessages: () => Promise<void>;
|
||||||
|
addMessageIfNew: (msg: Message) => boolean;
|
||||||
|
updateMessageAck: (messageId: number, ackCount: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationMessages(
|
||||||
|
activeConversation: Conversation | null
|
||||||
|
): UseConversationMessagesResult {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [hasOlderMessages, setHasOlderMessages] = useState(false);
|
||||||
|
|
||||||
|
// Track seen message content for deduplication
|
||||||
|
const seenMessageContent = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Fetch messages for active conversation
|
||||||
|
const fetchMessages = useCallback(async (showLoading = false) => {
|
||||||
|
if (!activeConversation || activeConversation.type === 'raw') {
|
||||||
|
setMessages([]);
|
||||||
|
setHasOlderMessages(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
setMessagesLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api.getMessages({
|
||||||
|
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||||
|
conversation_key: activeConversation.id,
|
||||||
|
limit: MESSAGE_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
setMessages(data);
|
||||||
|
// Track seen content for new messages
|
||||||
|
seenMessageContent.current.clear();
|
||||||
|
for (const msg of data) {
|
||||||
|
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||||
|
}
|
||||||
|
// If we got a full page, there might be more
|
||||||
|
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch messages:', err);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setMessagesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeConversation]);
|
||||||
|
|
||||||
|
// Fetch older messages (pagination)
|
||||||
|
const fetchOlderMessages = useCallback(async () => {
|
||||||
|
if (!activeConversation || activeConversation.type === 'raw' || loadingOlder || !hasOlderMessages) return;
|
||||||
|
|
||||||
|
setLoadingOlder(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getMessages({
|
||||||
|
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||||
|
conversation_key: activeConversation.id,
|
||||||
|
limit: MESSAGE_PAGE_SIZE,
|
||||||
|
offset: messages.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
// Prepend older messages (they come sorted DESC, so older are at the end)
|
||||||
|
setMessages(prev => [...prev, ...data]);
|
||||||
|
// Track seen content
|
||||||
|
for (const msg of data) {
|
||||||
|
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we got less than a full page, no more messages
|
||||||
|
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch older messages:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingOlder(false);
|
||||||
|
}
|
||||||
|
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
|
||||||
|
|
||||||
|
// Fetch messages when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessages(true);
|
||||||
|
}, [fetchMessages]);
|
||||||
|
|
||||||
|
// Add a message if it's new (deduplication)
|
||||||
|
// Returns true if the message was added, false if it was a duplicate
|
||||||
|
const addMessageIfNew = useCallback((msg: Message): boolean => {
|
||||||
|
const contentKey = getMessageContentKey(msg);
|
||||||
|
if (seenMessageContent.current.has(contentKey)) {
|
||||||
|
console.debug('Duplicate message content ignored:', contentKey.slice(0, 50));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenMessageContent.current.add(contentKey);
|
||||||
|
|
||||||
|
// Limit set size to prevent memory issues (keep last 500)
|
||||||
|
if (seenMessageContent.current.size > 1000) {
|
||||||
|
const entries = Array.from(seenMessageContent.current);
|
||||||
|
seenMessageContent.current = new Set(entries.slice(-500));
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update a message's ack count
|
||||||
|
const updateMessageAck = useCallback((messageId: number, ackCount: number) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const idx = prev.findIndex((m) => m.id === messageId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = { ...prev[idx], acked: ackCount };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
messagesLoading,
|
||||||
|
loadingOlder,
|
||||||
|
hasOlderMessages,
|
||||||
|
setMessages,
|
||||||
|
fetchMessages,
|
||||||
|
fetchOlderMessages,
|
||||||
|
addMessageIfNew,
|
||||||
|
updateMessageAck,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Tests for URL hash utilities.
|
||||||
|
*
|
||||||
|
* These tests verify the URL hash parsing and generation
|
||||||
|
* for deep linking to conversations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { parseHashConversation, getConversationHash } from '../utils/urlHash';
|
||||||
|
import type { Conversation } from '../types';
|
||||||
|
|
||||||
|
describe('parseHashConversation', () => {
|
||||||
|
let originalHash: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalHash = window.location.hash;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.location.hash = originalHash;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty hash', () => {
|
||||||
|
window.location.hash = '';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses #raw as raw type', () => {
|
||||||
|
window.location.hash = '#raw';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'raw', name: 'raw' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses channel hash', () => {
|
||||||
|
window.location.hash = '#channel/Public';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'channel', name: 'Public' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses contact hash', () => {
|
||||||
|
window.location.hash = '#contact/Alice';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'contact', name: 'Alice' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes URL-encoded names', () => {
|
||||||
|
window.location.hash = '#contact/John%20Doe';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'contact', name: 'John Doe' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid type', () => {
|
||||||
|
window.location.hash = '#invalid/Test';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for hash without slash', () => {
|
||||||
|
window.location.hash = '#channelPublic';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for hash with empty name', () => {
|
||||||
|
window.location.hash = '#channel/';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles channel names with special characters', () => {
|
||||||
|
window.location.hash = '#channel/Test%20Channel%21';
|
||||||
|
|
||||||
|
const result = parseHashConversation();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: 'channel', name: 'Test Channel!' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConversationHash', () => {
|
||||||
|
it('returns empty string for null conversation', () => {
|
||||||
|
const result = getConversationHash(null);
|
||||||
|
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns #raw for raw conversation', () => {
|
||||||
|
const conv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||||
|
|
||||||
|
const result = getConversationHash(conv);
|
||||||
|
|
||||||
|
expect(result).toBe('#raw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates channel hash', () => {
|
||||||
|
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Public' };
|
||||||
|
|
||||||
|
const result = getConversationHash(conv);
|
||||||
|
|
||||||
|
expect(result).toBe('#channel/Public');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates contact hash', () => {
|
||||||
|
const conv: Conversation = { type: 'contact', id: 'pubkey123', name: 'Alice' };
|
||||||
|
|
||||||
|
const result = getConversationHash(conv);
|
||||||
|
|
||||||
|
expect(result).toBe('#contact/Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips leading # from channel names', () => {
|
||||||
|
const conv: Conversation = { type: 'channel', id: 'key123', name: '#TestChannel' };
|
||||||
|
|
||||||
|
const result = getConversationHash(conv);
|
||||||
|
|
||||||
|
expect(result).toBe('#channel/TestChannel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes special characters in names', () => {
|
||||||
|
const conv: Conversation = { type: 'contact', id: 'key', name: 'John Doe' };
|
||||||
|
|
||||||
|
const result = getConversationHash(conv);
|
||||||
|
|
||||||
|
expect(result).toBe('#contact/John%20Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip # from contact names', () => {
|
||||||
|
const conv: Conversation = { type: 'contact', id: 'key', name: '#Hashtag' };
|
||||||
|
|
||||||
|
const result = getConversationHash(conv);
|
||||||
|
|
||||||
|
expect(result).toBe('#contact/%23Hashtag');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseHashConversation and getConversationHash roundtrip', () => {
|
||||||
|
let originalHash: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalHash = window.location.hash;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.location.hash = originalHash;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('channel roundtrip preserves data', () => {
|
||||||
|
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Test Channel' };
|
||||||
|
|
||||||
|
const hash = getConversationHash(conv);
|
||||||
|
window.location.hash = hash;
|
||||||
|
const parsed = parseHashConversation();
|
||||||
|
|
||||||
|
expect(parsed).toEqual({ type: 'channel', name: 'Test Channel' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contact roundtrip preserves data', () => {
|
||||||
|
const conv: Conversation = { type: 'contact', id: 'pubkey', name: 'Alice Bob' };
|
||||||
|
|
||||||
|
const hash = getConversationHash(conv);
|
||||||
|
window.location.hash = hash;
|
||||||
|
const parsed = parseHashConversation();
|
||||||
|
|
||||||
|
expect(parsed).toEqual({ type: 'contact', name: 'Alice Bob' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raw roundtrip preserves type', () => {
|
||||||
|
const conv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||||
|
|
||||||
|
const hash = getConversationHash(conv);
|
||||||
|
window.location.hash = hash;
|
||||||
|
const parsed = parseHashConversation();
|
||||||
|
|
||||||
|
expect(parsed).toEqual({ type: 'raw', name: 'raw' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useConversationMessages hook utilities.
|
||||||
|
*
|
||||||
|
* These tests verify the message deduplication key generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getMessageContentKey } from '../hooks/useConversationMessages';
|
||||||
|
import type { Message } from '../types';
|
||||||
|
|
||||||
|
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
type: 'CHAN',
|
||||||
|
conversation_key: 'channel123',
|
||||||
|
text: 'Hello world',
|
||||||
|
sender_timestamp: 1700000000,
|
||||||
|
received_at: 1700000001,
|
||||||
|
path_len: null,
|
||||||
|
txt_type: 0,
|
||||||
|
signature: null,
|
||||||
|
outgoing: false,
|
||||||
|
acked: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getMessageContentKey', () => {
|
||||||
|
it('generates key from type, conversation_key, text, and sender_timestamp', () => {
|
||||||
|
const msg = createMessage({
|
||||||
|
type: 'CHAN',
|
||||||
|
conversation_key: 'abc123',
|
||||||
|
text: 'Hello',
|
||||||
|
sender_timestamp: 1700000000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = getMessageContentKey(msg);
|
||||||
|
|
||||||
|
expect(key).toBe('CHAN-abc123-Hello-1700000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different keys for different message types', () => {
|
||||||
|
const chanMsg = createMessage({ type: 'CHAN' });
|
||||||
|
const privMsg = createMessage({ type: 'PRIV' });
|
||||||
|
|
||||||
|
expect(getMessageContentKey(chanMsg)).not.toBe(getMessageContentKey(privMsg));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different keys for different conversation keys', () => {
|
||||||
|
const msg1 = createMessage({ conversation_key: 'channel1' });
|
||||||
|
const msg2 = createMessage({ conversation_key: 'channel2' });
|
||||||
|
|
||||||
|
expect(getMessageContentKey(msg1)).not.toBe(getMessageContentKey(msg2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different keys for different text', () => {
|
||||||
|
const msg1 = createMessage({ text: 'Hello' });
|
||||||
|
const msg2 = createMessage({ text: 'World' });
|
||||||
|
|
||||||
|
expect(getMessageContentKey(msg1)).not.toBe(getMessageContentKey(msg2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different keys for different timestamps', () => {
|
||||||
|
const msg1 = createMessage({ sender_timestamp: 1700000000 });
|
||||||
|
const msg2 = createMessage({ sender_timestamp: 1700000001 });
|
||||||
|
|
||||||
|
expect(getMessageContentKey(msg1)).not.toBe(getMessageContentKey(msg2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates same key for messages with same content', () => {
|
||||||
|
const msg1 = createMessage({
|
||||||
|
id: 1,
|
||||||
|
type: 'CHAN',
|
||||||
|
conversation_key: 'abc',
|
||||||
|
text: 'Test',
|
||||||
|
sender_timestamp: 1700000000,
|
||||||
|
});
|
||||||
|
const msg2 = createMessage({
|
||||||
|
id: 2, // Different ID
|
||||||
|
type: 'CHAN',
|
||||||
|
conversation_key: 'abc',
|
||||||
|
text: 'Test',
|
||||||
|
sender_timestamp: 1700000000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getMessageContentKey(msg1)).toBe(getMessageContentKey(msg2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null sender_timestamp', () => {
|
||||||
|
const msg = createMessage({ sender_timestamp: null });
|
||||||
|
|
||||||
|
const key = getMessageContentKey(msg);
|
||||||
|
|
||||||
|
expect(key).toBe('CHAN-channel123-Hello world-null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty text', () => {
|
||||||
|
const msg = createMessage({ text: '' });
|
||||||
|
|
||||||
|
const key = getMessageContentKey(msg);
|
||||||
|
|
||||||
|
expect(key).toContain('--'); // Empty text between dashes
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles text with special characters', () => {
|
||||||
|
const msg = createMessage({ text: 'Hello: World! @user #channel' });
|
||||||
|
|
||||||
|
const key = getMessageContentKey(msg);
|
||||||
|
|
||||||
|
expect(key).toContain('Hello: World! @user #channel');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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