Repeater UI overhaul

This commit is contained in:
Jack Kingsman
2026-02-25 21:02:38 -08:00
parent f4a383082e
commit 26fbfcd015
26 changed files with 3622 additions and 1305 deletions
-1
View File
@@ -1,4 +1,3 @@
export { useRepeaterMode } from './useRepeaterMode';
export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';
-281
View File
@@ -1,281 +0,0 @@
/**
* Airtime/duty cycle tracking for repeaters.
*
* When "dutycycle_start" command is issued, this captures baseline telemetry
* and polls every 5 minutes to display rolling airtime/duty cycle statistics.
*/
import { useRef, useCallback, useEffect } from 'react';
import { api } from '../api';
import type { Message, TelemetryResponse } from '../types';
// Baseline telemetry snapshot for airtime tracking
interface AirtimeBaseline {
startTime: number; // epoch seconds
uptime: number;
txAirtime: number;
rxAirtime: number;
sentFlood: number;
sentDirect: number;
recvFlood: number;
recvDirect: number;
conversationId: string;
}
// Polling interval: 5 minutes
const AIRTIME_POLL_INTERVAL_MS = 5 * 60 * 1000;
// Format duration in XhXmXs format
function formatAirtimeDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours}h${mins}m${secs}s`;
}
// Get emoji indicator for TX duty cycle percentage
function getTxDutyCycleEmoji(pct: number): string {
if (pct <= 5) return '✅';
if (pct <= 10) return '🟢';
if (pct <= 25) return '🟡';
if (pct <= 50) return '🔴';
return '🚨';
}
// Format airtime statistics comparing current telemetry to baseline
function formatAirtimeStats(baseline: AirtimeBaseline, current: TelemetryResponse): string {
const now = Math.floor(Date.now() / 1000);
const wallDuration = now - baseline.startTime;
// Compute deltas
const deltaUptime = current.uptime_seconds - baseline.uptime;
const deltaTxAirtime = current.airtime_seconds - baseline.txAirtime;
const deltaRxAirtime = current.rx_airtime_seconds - baseline.rxAirtime;
const deltaSentFlood = current.sent_flood - baseline.sentFlood;
const deltaSentDirect = current.sent_direct - baseline.sentDirect;
const deltaRecvFlood = current.recv_flood - baseline.recvFlood;
const deltaRecvDirect = current.recv_direct - baseline.recvDirect;
// Calculate airtime percentages
const txPct = deltaUptime > 0 ? (deltaTxAirtime / deltaUptime) * 100 : 0;
const rxPct = deltaUptime > 0 ? (deltaRxAirtime / deltaUptime) * 100 : 0;
// Estimate flood/direct airtime breakdown based on packet proportions
const totalSent = deltaSentFlood + deltaSentDirect;
const totalRecv = deltaRecvFlood + deltaRecvDirect;
const txFloodPct = totalSent > 0 ? txPct * (deltaSentFlood / totalSent) : 0;
const txDirectPct = totalSent > 0 ? txPct * (deltaSentDirect / totalSent) : 0;
const rxFloodPct = totalRecv > 0 ? rxPct * (deltaRecvFlood / totalRecv) : 0;
const rxDirectPct = totalRecv > 0 ? rxPct * (deltaRecvDirect / totalRecv) : 0;
const txEmoji = getTxDutyCycleEmoji(txPct);
const idlePct = Math.max(0, 100 - txPct - rxPct);
const lines = [
`Airtime/Duty Cycle Statistics`,
`Duration: ${formatAirtimeDuration(wallDuration)} (uptime delta: ${formatAirtimeDuration(deltaUptime)})`,
``,
`${txEmoji} TX Airtime: ${txPct.toFixed(3)}% (${totalSent.toLocaleString()} pkts)`,
` Flood: ${txFloodPct.toFixed(3)}% (${deltaSentFlood.toLocaleString()} pkts)`,
` Direct: ${txDirectPct.toFixed(3)}% (${deltaSentDirect.toLocaleString()} pkts)`,
``,
`RX Airtime: ${rxPct.toFixed(3)}% (${totalRecv.toLocaleString()} pkts)`,
` Flood: ${rxFloodPct.toFixed(3)}% (${deltaRecvFlood.toLocaleString()} pkts)`,
` Direct: ${rxDirectPct.toFixed(3)}% (${deltaRecvDirect.toLocaleString()} pkts)`,
``,
`Idle: ${idlePct.toFixed(3)}%`,
];
return lines.join('\n');
}
// Create a local message object (not persisted to database)
function createLocalMessage(conversationKey: string, text: string, outgoing: boolean): Message {
const now = Math.floor(Date.now() / 1000);
return {
id: -Date.now(),
type: 'PRIV',
conversation_key: conversationKey,
text,
sender_timestamp: now,
received_at: now,
paths: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
interface UseAirtimeTrackingResult {
/** Returns true if this was an airtime command that was handled */
handleAirtimeCommand: (command: string, conversationId: string) => Promise<boolean>;
/** Stop any active airtime tracking */
stopTracking: () => void;
}
export function useAirtimeTracking(
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
): UseAirtimeTrackingResult {
const baselineRef = useRef<AirtimeBaseline | null>(null);
const intervalRef = useRef<number | null>(null);
// Stop tracking and clear interval
const stopTracking = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
baselineRef.current = null;
}, []);
// Poll for airtime stats with one retry on failure
const pollAirtimeStats = useCallback(async () => {
const baseline = baselineRef.current;
if (!baseline) return;
let telemetry: TelemetryResponse | null = null;
let lastError: Error | null = null;
// Try up to 2 times (initial + 1 retry)
for (let attempt = 0; attempt < 2; attempt++) {
try {
telemetry = await api.requestTelemetry(baseline.conversationId, '');
break; // Success, exit loop
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
// Wait a moment before retry
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
// If tracking was stopped while the request was in-flight (e.g. conversation
// switch called stopTracking), discard the stale response.
if (!baselineRef.current) return;
if (telemetry) {
const statsMessage = createLocalMessage(
baseline.conversationId,
formatAirtimeStats(baseline, telemetry),
false
);
setMessages((prev) => [...prev, statsMessage]);
} else {
const errorMessage = createLocalMessage(
baseline.conversationId,
`Duty cycle poll failed after retry: ${lastError?.message ?? 'Unknown error'}`,
false
);
setMessages((prev) => [...prev, errorMessage]);
}
}, [setMessages]);
// Handle airtime commands
const handleAirtimeCommand = useCallback(
async (command: string, conversationId: string): Promise<boolean> => {
const cmd = command.trim().toLowerCase();
if (cmd === 'dutycycle_start') {
// Stop any existing tracking
stopTracking();
// Fetch initial telemetry with one retry
let telemetry: TelemetryResponse | null = null;
let lastError: Error | null = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
telemetry = await api.requestTelemetry(conversationId, '');
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error('Unknown error');
if (attempt === 0) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
}
if (!telemetry) {
const errorMessage = createLocalMessage(
conversationId,
`Failed to start duty cycle tracking after retry: ${lastError?.message ?? 'Unknown error'}`,
false
);
setMessages((prev) => [...prev, errorMessage]);
return true;
}
// Store baseline
const now = Math.floor(Date.now() / 1000);
baselineRef.current = {
startTime: now,
uptime: telemetry.uptime_seconds,
txAirtime: telemetry.airtime_seconds,
rxAirtime: telemetry.rx_airtime_seconds,
sentFlood: telemetry.sent_flood,
sentDirect: telemetry.sent_direct,
recvFlood: telemetry.recv_flood,
recvDirect: telemetry.recv_direct,
conversationId,
};
// Add start message
const startMessage = createLocalMessage(
conversationId,
`Airtime/duty cycle statistics gathering begins at ${now}. Logs will follow every 5 minutes. To stop, run dutycycle_stop or navigate away from this conversation.`,
false
);
setMessages((prev) => [...prev, startMessage]);
// Start polling interval
intervalRef.current = window.setInterval(pollAirtimeStats, AIRTIME_POLL_INTERVAL_MS);
return true;
}
if (cmd === 'dutycycle_stop') {
if (baselineRef.current && baselineRef.current.conversationId === conversationId) {
// Do one final poll before stopping
await pollAirtimeStats();
stopTracking();
const stopMessage = createLocalMessage(
conversationId,
'Airtime/duty cycle statistics gathering stopped.',
false
);
setMessages((prev) => [...prev, stopMessage]);
} else {
const notRunningMessage = createLocalMessage(
conversationId,
'Duty cycle tracking is not active.',
false
);
setMessages((prev) => [...prev, notRunningMessage]);
}
return true;
}
return false; // Not an airtime command
},
[setMessages, stopTracking, pollAirtimeStats]
);
// Cleanup on unmount
useEffect(() => {
return () => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
}
};
}, []);
return {
handleAirtimeCommand,
stopTracking,
};
}
+298
View File
@@ -0,0 +1,298 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { api } from '../api';
import { toast } from '../components/ui/sonner';
import type {
Conversation,
PaneName,
PaneState,
RepeaterStatusResponse,
RepeaterNeighborsResponse,
RepeaterAclResponse,
RepeaterRadioSettingsResponse,
RepeaterAdvertIntervalsResponse,
RepeaterOwnerInfoResponse,
RepeaterLppTelemetryResponse,
CommandResponse,
} from '../types';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
interface ConsoleEntry {
command: string;
response: string;
timestamp: number;
outgoing: boolean;
}
interface PaneData {
status: RepeaterStatusResponse | null;
neighbors: RepeaterNeighborsResponse | null;
acl: RepeaterAclResponse | null;
radioSettings: RepeaterRadioSettingsResponse | null;
advertIntervals: RepeaterAdvertIntervalsResponse | null;
ownerInfo: RepeaterOwnerInfoResponse | null;
lppTelemetry: RepeaterLppTelemetryResponse | null;
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
status: { ...INITIAL_PANE_STATE },
neighbors: { ...INITIAL_PANE_STATE },
acl: { ...INITIAL_PANE_STATE },
radioSettings: { ...INITIAL_PANE_STATE },
advertIntervals: { ...INITIAL_PANE_STATE },
ownerInfo: { ...INITIAL_PANE_STATE },
lppTelemetry: { ...INITIAL_PANE_STATE },
};
}
function createInitialPaneData(): PaneData {
return {
status: null,
neighbors: null,
acl: null,
radioSettings: null,
advertIntervals: null,
ownerInfo: null,
lppTelemetry: null,
};
}
// Maps pane name to the API call
function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
case 'status':
return api.repeaterStatus(publicKey);
case 'neighbors':
return api.repeaterNeighbors(publicKey);
case 'acl':
return api.repeaterAcl(publicKey);
case 'radioSettings':
return api.repeaterRadioSettings(publicKey);
case 'advertIntervals':
return api.repeaterAdvertIntervals(publicKey);
case 'ownerInfo':
return api.repeaterOwnerInfo(publicKey);
case 'lppTelemetry':
return api.repeaterLppTelemetry(publicKey);
}
}
export interface UseRepeaterDashboardResult {
loggedIn: boolean;
loginLoading: boolean;
loginError: string | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
consoleLoading: boolean;
login: (password: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
refreshPane: (pane: PaneName) => Promise<void>;
loadAll: () => Promise<void>;
sendConsoleCommand: (command: string) => Promise<void>;
sendAdvert: () => Promise<void>;
rebootRepeater: () => Promise<void>;
syncClock: () => Promise<void>;
}
export function useRepeaterDashboard(
activeConversation: Conversation | null
): UseRepeaterDashboardResult {
const [loggedIn, setLoggedIn] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
const [paneStates, setPaneStates] =
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleLoading, setConsoleLoading] = useState(false);
// Track which conversation we're operating on to avoid stale updates after
// unmount. Initialised from activeConversation because the parent renders
// <RepeaterDashboard key={id}>, so this hook only ever sees one conversation.
const activeIdRef = useRef(activeConversation?.id ?? null);
// Guard against setting state after unmount (retry timers firing late)
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
}, [activeConversation]);
const login = useCallback(
async (password: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
setLoginLoading(true);
setLoginError(null);
try {
await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLoggedIn(true);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLoginError(msg);
} finally {
if (activeIdRef.current === conversationId) {
setLoginLoading(false);
}
}
},
[getPublicKey]
);
const loginAsGuest = useCallback(async () => {
await login('');
}, [login]);
const refreshPane = useCallback(
async (pane: PaneName) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneStates((prev) => ({
...prev,
[pane]: { loading: true, attempt, error: null },
}));
try {
const data = await fetchPaneData(publicKey, pane);
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null },
}));
return; // Success
} catch (err) {
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Request failed';
if (attempt === MAX_RETRIES) {
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: msg },
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {
// Wait before retrying
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
}
},
[getPublicKey]
);
const loadAll = useCallback(async () => {
const panes: PaneName[] = [
'status',
'neighbors',
'acl',
'radioSettings',
'advertIntervals',
'ownerInfo',
'lppTelemetry',
];
// Serial execution — parallel calls just queue behind the radio lock anyway
for (const pane of panes) {
await refreshPane(pane);
}
}, [refreshPane]);
const sendConsoleCommand = useCallback(
async (command: string) => {
const publicKey = getPublicKey();
if (!publicKey) return;
const conversationId = publicKey;
const now = Math.floor(Date.now() / 1000);
// Add outgoing command entry
setConsoleHistory((prev) => [
...prev,
{ command, response: '', timestamp: now, outgoing: true },
]);
setConsoleLoading(true);
try {
const result: CommandResponse = await api.sendRepeaterCommand(publicKey, command);
if (activeIdRef.current !== conversationId) return;
setConsoleHistory((prev) => [
...prev,
{
command,
response: result.response,
timestamp: result.sender_timestamp ?? now,
outgoing: false,
},
]);
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Command failed';
setConsoleHistory((prev) => [
...prev,
{ command, response: `Error: ${msg}`, timestamp: now, outgoing: false },
]);
} finally {
if (activeIdRef.current === conversationId) {
setConsoleLoading(false);
}
}
},
[getPublicKey]
);
const sendAdvert = useCallback(async () => {
await sendConsoleCommand('advert');
}, [sendConsoleCommand]);
const rebootRepeater = useCallback(async () => {
await sendConsoleCommand('reboot');
}, [sendConsoleCommand]);
const syncClock = useCallback(async () => {
const epoch = Math.floor(Date.now() / 1000);
await sendConsoleCommand(`clock ${epoch}`);
}, [sendConsoleCommand]);
return {
loggedIn,
loginLoading,
loginError,
paneData,
paneStates,
consoleHistory,
consoleLoading,
login,
loginAsGuest,
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
rebootRepeater,
syncClock,
};
}
-246
View File
@@ -1,246 +0,0 @@
import { useState, useCallback, useMemo, useEffect, type RefObject } from 'react';
import { api } from '../api';
import type {
Contact,
Conversation,
Message,
TelemetryResponse,
NeighborInfo,
AclEntry,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { useAirtimeTracking } from './useAirtimeTracking';
// 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)}`,
...(telemetry.clock_output ? [`Clock: ${telemetry.clock_output}`] : []),
`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');
}
// 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,
paths: null,
txt_type: 0,
signature: null,
outgoing,
acked: 1,
};
}
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[]>>,
activeConversationRef: RefObject<Conversation | null>
): UseRepeaterModeResult {
const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false);
const { handleAirtimeCommand, stopTracking } = useAirtimeTracking(setMessages);
// Reset login state and stop airtime tracking when conversation changes
useEffect(() => {
setRepeaterLoggedIn(false);
stopTracking();
}, [activeConversation?.id, stopTracking]);
// 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;
const conversationId = activeConversation.id;
try {
const telemetry = await api.requestTelemetry(conversationId, password);
// User may have switched conversations during the await
if (activeConversationRef.current?.id !== conversationId) return;
// Create local messages to display the telemetry (not persisted to database)
const telemetryMessage = createLocalMessage(
conversationId,
formatTelemetry(telemetry),
false,
0
);
const neighborsMessage = createLocalMessage(
conversationId,
formatNeighbors(telemetry.neighbors),
false,
1
);
const aclMessage = createLocalMessage(conversationId, 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) {
if (activeConversationRef.current?.id !== conversationId) return;
const errorMessage = createLocalMessage(
conversationId,
`Telemetry request failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
0
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[activeConversation, activeContactIsRepeater, setMessages, activeConversationRef]
);
// 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 conversationId = activeConversation.id;
// Check for special airtime commands first (handled locally)
const handled = await handleAirtimeCommand(command, conversationId);
if (handled) return;
// Show the command as an outgoing message
const commandMessage = createLocalMessage(conversationId, `> ${command}`, true, 0);
setMessages((prev) => [...prev, commandMessage]);
try {
const response = await api.sendRepeaterCommand(conversationId, command);
// User may have switched conversations during the await
if (activeConversationRef.current?.id !== conversationId) return;
// Use the actual timestamp from the repeater if available
const responseMessage = createLocalMessage(conversationId, response.response, false, 1);
if (response.sender_timestamp) {
responseMessage.sender_timestamp = response.sender_timestamp;
}
setMessages((prev) => [...prev, responseMessage]);
} catch (err) {
if (activeConversationRef.current?.id !== conversationId) return;
const errorMessage = createLocalMessage(
conversationId,
`Command failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
false,
1
);
setMessages((prev) => [...prev, errorMessage]);
}
},
[
activeConversation,
activeContactIsRepeater,
repeaterLoggedIn,
setMessages,
handleAirtimeCommand,
activeConversationRef,
]
);
return {
repeaterLoggedIn,
activeContactIsRepeater,
handleTelemetryRequest,
handleRepeaterCommand,
};
}