Linting and code cleanup for an imitation of order

This commit is contained in:
Jack Kingsman
2026-01-14 20:08:41 -08:00
parent c6d38ce400
commit 076d466fbd
73 changed files with 2799 additions and 1192 deletions
+191 -160
View File
@@ -1,7 +1,12 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { api } from './api';
import { useWebSocket } from './useWebSocket';
import { useRepeaterMode, useUnreadCounts, useConversationMessages, getMessageContentKey } from './hooks';
import {
useRepeaterMode,
useUnreadCounts,
useConversationMessages,
getMessageContentKey,
} from './hooks';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
import { MessageList } from './components/MessageList';
@@ -103,118 +108,121 @@ export function App() {
} = useRepeaterMode(activeConversation, contacts, setMessages);
// WebSocket handlers - memoized to prevent reconnection loops
const wsHandlers = useMemo(() => ({
onHealth: (data: HealthStatus) => {
const prev = prevHealthRef.current;
prevHealthRef.current = data;
setHealth(data);
const wsHandlers = useMemo(
() => ({
onHealth: (data: HealthStatus) => {
const prev = prevHealthRef.current;
prevHealthRef.current = data;
setHealth(data);
// Show toast on connection status change
if (prev !== null && prev.radio_connected !== data.radio_connected) {
if (data.radio_connected) {
toast.success('Radio connected', {
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
});
} else {
toast.error('Radio disconnected', {
description: 'Check radio connection and power',
});
// Show toast on connection status change
if (prev !== null && prev.radio_connected !== data.radio_connected) {
if (data.radio_connected) {
toast.success('Radio connected', {
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
});
} else {
toast.error('Radio disconnected', {
description: 'Check radio connection and power',
});
}
}
}
},
onError: (error: { message: string; details?: string }) => {
toast.error(error.message, {
description: error.details,
});
},
onContacts: (data: Contact[]) => setContacts(data),
onChannels: (data: Channel[]) => setChannels(data),
onMessage: (msg: Message) => {
const activeConv = activeConversationRef.current;
},
onError: (error: { message: string; details?: string }) => {
toast.error(error.message, {
description: error.details,
});
},
onContacts: (data: Contact[]) => setContacts(data),
onChannels: (data: Channel[]) => setChannels(data),
onMessage: (msg: Message) => {
const activeConv = activeConversationRef.current;
// Check if message belongs to the active conversation
const isForActiveConversation = (() => {
if (!activeConv) return false;
if (msg.type === 'CHAN' && activeConv.type === 'channel') {
return msg.conversation_key === activeConv.id;
}
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
return msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
}
return false;
})();
// Check if message belongs to the active conversation
const isForActiveConversation = (() => {
if (!activeConv) return false;
if (msg.type === 'CHAN' && activeConv.type === 'channel') {
return msg.conversation_key === activeConv.id;
}
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
return msg.conversation_key && pubkeysMatch(activeConv.id, msg.conversation_key);
}
return false;
})();
// Only add to message list if it's for the active conversation
if (isForActiveConversation) {
addMessageIfNew(msg);
}
// Track for unread counts and sorting
trackNewMessage(msg);
// Count unread for non-active, incoming messages (with deduplication)
if (!msg.outgoing && !isForActiveConversation) {
// Skip if we've already seen this message content (prevents duplicate increments
// when the same message arrives via multiple mesh paths)
const contentKey = getMessageContentKey(msg);
if (seenMessageContentRef.current.has(contentKey)) {
return;
}
seenMessageContentRef.current.add(contentKey);
// Limit set size to prevent memory issues
if (seenMessageContentRef.current.size > 1000) {
const keys = Array.from(seenMessageContentRef.current);
seenMessageContentRef.current = new Set(keys.slice(-500));
// Only add to message list if it's for the active conversation
if (isForActiveConversation) {
addMessageIfNew(msg);
}
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
// Track for unread counts and sorting
trackNewMessage(msg);
// Count unread for non-active, incoming messages (with deduplication)
if (!msg.outgoing && !isForActiveConversation) {
// Skip if we've already seen this message content (prevents duplicate increments
// when the same message arrives via multiple mesh paths)
const contentKey = getMessageContentKey(msg);
if (seenMessageContentRef.current.has(contentKey)) {
return;
}
seenMessageContentRef.current.add(contentKey);
// Limit set size to prevent memory issues
if (seenMessageContentRef.current.size > 1000) {
const keys = Array.from(seenMessageContentRef.current);
seenMessageContentRef.current = new Set(keys.slice(-500));
}
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (stateKey) {
const hasMention = checkMention(msg.text);
incrementUnread(stateKey, hasMention);
}
}
if (stateKey) {
const hasMention = checkMention(msg.text);
incrementUnread(stateKey, hasMention);
}
}
},
onContact: (contact: Contact) => {
setContacts((prev) => {
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
if (idx >= 0) {
const updated = [...prev];
const existing = prev[idx];
updated[idx] = {
...existing,
...contact,
name: contact.name ?? existing.name,
last_path: contact.last_path ?? existing.last_path,
lat: contact.lat ?? existing.lat,
lon: contact.lon ?? existing.lon,
};
},
onContact: (contact: Contact) => {
setContacts((prev) => {
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
if (idx >= 0) {
const updated = [...prev];
const existing = prev[idx];
updated[idx] = {
...existing,
...contact,
name: contact.name ?? existing.name,
last_path: contact.last_path ?? existing.last_path,
lat: contact.lat ?? existing.lat,
lon: contact.lon ?? existing.lon,
};
return updated;
}
return [...prev, contact as Contact];
});
},
onRawPacket: (packet: RawPacket) => {
setRawPackets((prev) => {
if (prev.some((p) => p.id === packet.id)) {
return prev;
}
const updated = [...prev, packet];
if (updated.length > MAX_RAW_PACKETS) {
return updated.slice(-MAX_RAW_PACKETS);
}
return updated;
}
return [...prev, contact as Contact];
});
},
onRawPacket: (packet: RawPacket) => {
setRawPackets((prev) => {
if (prev.some((p) => p.id === packet.id)) {
return prev;
}
const updated = [...prev, packet];
if (updated.length > MAX_RAW_PACKETS) {
return updated.slice(-MAX_RAW_PACKETS);
}
return updated;
});
},
onMessageAcked: (messageId: number, ackCount: number) => {
updateMessageAck(messageId, ackCount);
},
}), [addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention]);
});
},
onMessageAcked: (messageId: number, ackCount: number) => {
updateMessageAck(messageId, ackCount);
},
}),
[addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention]
);
// Connect to WebSocket
useWebSocket(wsHandlers);
@@ -265,13 +273,17 @@ export function App() {
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
}
if (hashConv.type === 'channel') {
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) {
return { type: 'channel', id: channel.key, name: channel.name };
}
}
if (hashConv.type === 'contact') {
const contact = contacts.find(c => getContactDisplayName(c.name, c.public_key) === hashConv.name);
const contact = contacts.find(
(c) => getContactDisplayName(c.name, c.public_key) === hashConv.name
);
if (contact) {
return {
type: 'contact',
@@ -296,7 +308,7 @@ export function App() {
return;
}
const publicChannel = channels.find(c => c.name === 'Public');
const publicChannel = channels.find((c) => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
type: 'channel',
@@ -331,29 +343,36 @@ export function App() {
);
// Config save handler
const handleSaveConfig = useCallback(async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
await fetchConfig();
}, [fetchConfig]);
const handleSaveConfig = useCallback(
async (update: RadioConfigUpdate) => {
await api.updateRadioConfig(update);
await fetchConfig();
},
[fetchConfig]
);
// App settings save handler
const handleSaveAppSettings = useCallback(async (update: AppSettingsUpdate) => {
await api.updateSettings(update);
await fetchAppSettings();
}, [fetchAppSettings]);
const handleSaveAppSettings = useCallback(
async (update: AppSettingsUpdate) => {
await api.updateSettings(update);
await fetchAppSettings();
},
[fetchAppSettings]
);
// Set private key handler
const handleSetPrivateKey = useCallback(async (key: string) => {
await api.setPrivateKey(key);
await fetchConfig();
}, [fetchConfig]);
const handleSetPrivateKey = useCallback(
async (key: string) => {
await api.setPrivateKey(key);
await fetchConfig();
},
[fetchConfig]
);
// Reboot radio handler
const handleReboot = useCallback(async () => {
await api.rebootRadio();
setHealth((prev) =>
prev ? { ...prev, radio_connected: false } : prev
);
setHealth((prev) => (prev ? { ...prev, radio_connected: false } : prev));
const pollUntilReconnected = async () => {
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000));
@@ -545,9 +564,7 @@ export function App() {
<div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar - hidden on mobile */}
<div className="hidden md:block">
{sidebarContent}
</div>
<div className="hidden md:block">{sidebarContent}</div>
{/* Mobile sidebar - Sheet that slides in */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
@@ -555,9 +572,7 @@ export function App() {
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden">
{sidebarContent}
</div>
<div className="flex-1 overflow-hidden">{sidebarContent}</div>
</SheetContent>
</Sheet>
@@ -565,14 +580,18 @@ export function App() {
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Node Map</div>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
Node Map
</div>
<div className="flex-1 overflow-hidden">
<MapView contacts={contacts} />
</div>
</>
) : activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">Raw Packet Feed</div>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
Raw Packet Feed
</div>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
@@ -582,34 +601,42 @@ export function App() {
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium gap-2">
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2 min-w-0 flex-1">
<span className="truncate">
{activeConversation.type === 'channel' && !activeConversation.name.startsWith('#') ? '#' : ''}
{activeConversation.type === 'channel' &&
!activeConversation.name.startsWith('#')
? '#'
: ''}
{activeConversation.name}
</span>
<span className="font-normal text-xs text-muted-foreground font-mono truncate">
{activeConversation.id}
{activeConversation.type === 'contact' && (() => {
const contact = contacts.find(c => c.public_key === activeConversation.id);
if (!contact) return null;
const parts: string[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push('direct');
} else if (contact.last_path_len > 0) {
parts.push(`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`);
}
return parts.length > 0 ? (
<span className="ml-2 font-sans">
({parts.join(', ')})
</span>
) : null;
})()}
{activeConversation.type === 'contact' &&
(() => {
const contact = contacts.find(
(c) => c.public_key === activeConversation.id
);
if (!contact) return null;
const parts: string[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push('direct');
} else if (contact.last_path_len > 0) {
parts.push(
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
return parts.length > 0 ? (
<span className="ml-2 font-sans">({parts.join(', ')})</span>
) : null;
})()}
</span>
</span>
{!(activeConversation.type === 'channel' && activeConversation.name === 'Public') && (
{!(
activeConversation.type === 'channel' && activeConversation.name === 'Public'
) && (
<button
className="py-1 px-3 bg-destructive/20 border border-destructive/30 text-destructive rounded text-xs cursor-pointer hover:bg-destructive/30 flex-shrink-0"
onClick={() => {
@@ -630,7 +657,9 @@ export function App() {
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
onSenderClick={activeConversation.type === 'channel' ? handleSenderClick : undefined}
onSenderClick={
activeConversation.type === 'channel' ? handleSenderClick : undefined
}
onLoadOlder={fetchOlderMessages}
radioName={config?.name}
/>
@@ -638,7 +667,9 @@ export function App() {
ref={messageInputRef}
onSend={
activeContactIsRepeater
? (repeaterLoggedIn ? handleRepeaterCommand : handleTelemetryRequest)
? repeaterLoggedIn
? handleRepeaterCommand
: handleTelemetryRequest
: handleSendMessage
}
disabled={!health?.radio_connected}
@@ -649,9 +680,9 @@ export function App() {
!health?.radio_connected
? 'Radio not connected'
: activeContactIsRepeater
? (repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`)
? repeaterLoggedIn
? 'Send CLI command (requires admin login)...'
: `Enter password for ${activeConversation.name} (or . for none)...`
: `Message ${activeConversation.name}...`
}
/>
@@ -668,8 +699,8 @@ export function App() {
{/* Global Cracker Panel - always rendered to maintain state */}
<div
className={cn(
"border-t border-border bg-background transition-all duration-200 overflow-hidden",
showCracker ? "h-[275px]" : "h-0"
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'
)}
>
<CrackerPanel
+8 -14
View File
@@ -53,10 +53,9 @@ export const api = {
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: (flood = true) =>
fetchJson<{ status: string; flood: boolean }>(
`/radio/advertise?flood=${flood}`,
{ method: 'POST' }
),
fetchJson<{ status: string; flood: boolean }>(`/radio/advertise?flood=${flood}`, {
method: 'POST',
}),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
@@ -70,8 +69,7 @@ export const api = {
getContacts: (limit = 100, offset = 0) =>
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getContact: (publicKey: string) => fetchJson<Contact>(`/contacts/${publicKey}`),
syncContacts: () =>
fetchJson<{ synced: number }>('/contacts/sync', { method: 'POST' }),
syncContacts: () => fetchJson<{ synced: number }>('/contacts/sync', { method: 'POST' }),
addContactToRadio: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}/add-to-radio`, {
method: 'POST',
@@ -107,8 +105,7 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
syncChannels: () =>
fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
syncChannels: () => fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
markChannelRead: (key: string) =>
@@ -127,8 +124,7 @@ export const api = {
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
if (params?.type) searchParams.set('type', params.type);
if (params?.conversation_key)
searchParams.set('conversation_key', params.conversation_key);
if (params?.conversation_key) searchParams.set('conversation_key', params.conversation_key);
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`);
},
@@ -155,8 +151,7 @@ export const api = {
}),
// Packets
getUndecryptedPacketCount: () =>
fetchJson<{ count: number }>('/packets/undecrypted/count'),
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact';
channel_key?: string;
@@ -171,8 +166,7 @@ export const api = {
method: 'POST',
body: JSON.stringify({ prune_undecrypted_days: pruneUndecryptedDays }),
}),
deduplicatePackets: () =>
fetchJson<DedupResult>('/packets/dedup', { method: 'POST' }),
deduplicatePackets: () => fetchJson<DedupResult>('/packets/dedup', { method: 'POST' }),
// Read State
markAllRead: () =>
+10 -25
View File
@@ -1,12 +1,6 @@
import { useState, useEffect } from 'react';
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
@@ -119,7 +113,9 @@ export function ConfigModal({
};
const handleReboot = async () => {
if (!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')) {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError('');
@@ -143,9 +139,7 @@ export function ConfigModal({
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">
Loading configuration...
</div>
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
@@ -155,11 +149,7 @@ export function ConfigModal({
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
@@ -279,10 +269,7 @@ export function ConfigModal({
placeholder="64-character hex private key"
className="flex-1"
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || !privateKey.trim()}
>
<Button onClick={handleSetPrivateKey} disabled={loading || !privateKey.trim()}>
Set
</Button>
</div>
@@ -294,8 +281,8 @@ export function ConfigModal({
<Label>Reboot Radio</Label>
<Alert variant="warning">
<AlertDescription>
Some configuration changes (like name) require a radio reboot to take effect.
The connection will temporarily drop and automatically reconnect.
Some configuration changes (like name) require a radio reboot to take effect. The
connection will temporarily drop and automatically reconnect.
</AlertDescription>
</Alert>
<Button
@@ -308,9 +295,7 @@ export function ConfigModal({
</Button>
</div>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
)}
+69 -42
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker';
import NoSleep from 'nosleep.js';
import type { RawPacket, Channel } from '../types';
@@ -64,7 +64,13 @@ interface CrackerPanelProps {
visible?: boolean;
}
export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChange, visible = false }: CrackerPanelProps) {
export function CrackerPanel({
packets,
channels,
onChannelCreate,
onRunningChange,
visible = false,
}: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
@@ -130,7 +136,8 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
// Fetch undecrypted packet count
useEffect(() => {
const fetchCount = () => {
api.getUndecryptedPacketCount()
api
.getUndecryptedPacketCount()
.then(({ count }) => setUndecryptedPacketCount(count))
.catch(() => setUndecryptedPacketCount(null));
};
@@ -140,19 +147,23 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
return () => clearInterval(interval);
}, []);
// Get existing channel keys for filtering
const existingChannelKeys = new Set(channels.map(c => c.key.toUpperCase()));
// Get existing channel keys for filtering (memoized to avoid recreating on every render)
const existingChannelKeys = useMemo(
() => new Set(channels.map((c) => c.key.toUpperCase())),
[channels]
);
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
p => p.payload_type === 'GROUP_TEXT' && !p.decrypted
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
);
// Update queue when packets change (deduplicated by payload)
// Note: We intentionally depend on .length only to avoid re-running on every array identity change
useEffect(() => {
let newSkipped = 0;
setQueue(prev => {
setQueue((prev) => {
const newQueue = new Map(prev);
let changed = false;
@@ -189,8 +200,9 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
});
if (newSkipped > 0) {
setSkippedDuplicates(prev => prev + newSkipped);
setSkippedDuplicates((prev) => prev + newSkipped);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [undecryptedGroupText.length]);
// Keep refs in sync with state
@@ -216,7 +228,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
// Keep undecrypted IDs ref in sync - used to skip packets already decrypted by other means
useEffect(() => {
undecryptedIdsRef.current = new Set(undecryptedGroupText.map(p => p.id));
undecryptedIdsRef.current = new Set(undecryptedGroupText.map((p) => p.id));
}, [undecryptedGroupText]);
// Notify parent of running state changes
@@ -225,9 +237,9 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
}, [isRunning, onRunningChange]);
// Stats (cracking count is implicit - if progress is shown, we're cracking one)
const pendingCount = Array.from(queue.values()).filter(q => q.status === 'pending').length;
const crackedCount = Array.from(queue.values()).filter(q => q.status === 'cracked').length;
const failedCount = Array.from(queue.values()).filter(q => q.status === 'failed').length;
const pendingCount = Array.from(queue.values()).filter((q) => q.status === 'pending').length;
const crackedCount = Array.from(queue.values()).filter((q) => q.status === 'cracked').length;
const failedCount = Array.from(queue.values()).filter((q) => q.status === 'failed').length;
// Process next packet in queue
const processNext = useCallback(async () => {
@@ -273,7 +285,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
// by historical decrypt when we cracked another packet from the same channel
if (!undecryptedIdsRef.current.has(nextId)) {
// Already decrypted by other means, remove from queue and continue
setQueue(prev => {
setQueue((prev) => {
const updated = new Map(prev);
updated.delete(nextId);
return updated;
@@ -289,9 +301,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
const currentMaxLength = maxLengthRef.current;
const isRetry = nextItem.lastAttemptLength > 0;
const targetLength = isRetry
? nextItem.lastAttemptLength + 1
: currentMaxLength;
const targetLength = isRetry ? nextItem.lastAttemptLength + 1 : currentMaxLength;
try {
const result = await crackerRef.current.crack(
@@ -318,7 +328,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
if (result.found && result.roomName && result.key) {
// Success!
setQueue(prev => {
setQueue((prev) => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
@@ -339,7 +349,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
message: result.decryptedMessage || '',
crackedAt: Date.now(),
};
setCrackedRooms(prev => [...prev, newRoom]);
setCrackedRooms((prev) => [...prev, newRoom]);
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
@@ -350,18 +360,22 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
// Optionally decrypt any other historical packets with this newly discovered key
// This prevents wasting cracking cycles on packets from the same channel
if (decryptHistoricalRef.current) {
await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: channelName });
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_name: channelName,
});
}
} catch (err) {
console.error('Failed to create channel or decrypt historical:', err);
toast.error('Failed to save cracked channel', {
description: err instanceof Error ? err.message : 'Channel discovered but could not be saved',
description:
err instanceof Error ? err.message : 'Channel discovered but could not be saved',
});
}
}
} else {
// Failed
setQueue(prev => {
setQueue((prev) => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
@@ -377,7 +391,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
}
} catch (err) {
console.error('Cracking error:', err);
setQueue(prev => {
setQueue((prev) => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
@@ -425,25 +439,28 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
noSleepRef.current?.disable();
};
return (
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border">
<p className="text-xs text-muted-foreground leading-relaxed">
This will attempt to dictionary attack, then brute force GroupText packets as they arrive, testing room names up to the specified length.
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and pick up messages it couldn't crack, attempting them at one longer length.
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and may allow accelerated cracking and/or system instability.
This will attempt to dictionary attack, then brute force GroupText packets as they arrive,
testing room names up to the specified length.
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
pick up messages it couldn't crack, attempting them at one longer length.
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
may allow accelerated cracking and/or system instability.
</p>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={isRunning ? handleStop : handleStart}
disabled={!wordlistLoaded || gpuAvailable === false}
className={cn(
"px-4 py-1.5 rounded text-sm font-medium",
'px-4 py-1.5 rounded text-sm font-medium',
isRunning
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed"
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isRunning ? 'Stop' : 'Start Cracking'}
@@ -512,7 +529,8 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
</span>
{skippedDuplicates > 0 && (
<span className="text-muted-foreground">
Skipped (dup): <span className="text-muted-foreground font-medium">{skippedDuplicates}</span>
Skipped (dup):{' '}
<span className="text-muted-foreground font-medium">{skippedDuplicates}</span>
</span>
)}
</div>
@@ -522,15 +540,22 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'}
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}
: {progress.currentPosition}
{progress.phase === 'wordlist'
? 'Dictionary'
: progress.phase === 'bruteforce'
? 'Bruteforce'
: 'Public Key'}
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}:{' '}
{progress.currentPosition}
</span>
<span>
{progress.rateKeysPerSec >= 1e9
? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s`
: `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}
{' '} ETA: {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
: `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}{' '}
ETA:{' '}
{progress.etaSeconds < 60
? `${Math.round(progress.etaSeconds)}s`
: `${Math.round(progress.etaSeconds / 60)}m`}
</span>
</div>
<div className="h-2 bg-muted rounded overflow-hidden">
@@ -549,9 +574,7 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
<div className="text-sm text-muted-foreground">
Loading wordlist...
</div>
<div className="text-sm text-muted-foreground">Loading wordlist...</div>
)}
{/* Cracked rooms list */}
@@ -560,10 +583,14 @@ export function CrackerPanel({ packets, channels, onChannelCreate, onRunningChan
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
<div className="space-y-1">
{crackedRooms.map((room, i) => (
<div key={i} className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1">
<div
key={i}
className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1"
>
<span className="text-green-400 font-medium">#{room.roomName}</span>
<span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)}{room.message.length > 50 ? '...' : ''}"
"{room.message.slice(0, 50)}
{room.message.length > 50 ? '...' : ''}"
</span>
</div>
))}
+8 -18
View File
@@ -1,11 +1,6 @@
import { useState } from 'react';
import type { HealthStatus } from '../types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
@@ -90,7 +85,8 @@ export function MaintenanceModal({
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Current database size: <span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
Current database size:{' '}
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</p>
<div className="space-y-3">
@@ -101,7 +97,9 @@ export function MaintenanceModal({
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">Days to retain</Label>
<Label htmlFor="retention-days" className="text-xs">
Days to retain
</Label>
<Input
id="retention-days"
type="number"
@@ -112,11 +110,7 @@ export function MaintenanceModal({
className="w-20"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
>
<Button variant="outline" onClick={handleCleanup} disabled={cleaning}>
{cleaning ? 'Cleaning...' : 'Cleanup'}
</Button>
</div>
@@ -128,11 +122,7 @@ export function MaintenanceModal({
Remove packets with duplicate payloads (same message received via different paths).
Runs in background and may take a long time.
</p>
<Button
variant="outline"
onClick={handleDedup}
disabled={deduping}
>
<Button variant="outline" onClick={handleDedup} disabled={deduping}>
{deduping ? 'Starting...' : 'Remove Duplicates'}
</Button>
</div>
+11 -11
View File
@@ -17,10 +17,10 @@ function getMarkerColor(lastSeen: number): string {
const hour = 3600;
const day = 86400;
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
if (age < day) return '#4ade80'; // Light green - less than 1 day
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
return '#9ca3af'; // Gray - older (up to 7 days)
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
if (age < day) return '#4ade80'; // Light green - less than 1 day
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
return '#9ca3af'; // Gray - older (up to 7 days)
}
// Component to handle map bounds fitting
@@ -47,7 +47,9 @@ function MapBoundsHandler({ contacts }: { contacts: Contact[] }) {
}
// Multiple contacts - fit bounds
const bounds: LatLngBoundsExpression = contacts.map(c => [c.lat!, c.lon!] as [number, number]);
const bounds: LatLngBoundsExpression = contacts.map(
(c) => [c.lat!, c.lon!] as [number, number]
);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 12 });
setHasInitialized(true);
};
@@ -79,11 +81,8 @@ export function MapView({ contacts }: MapViewProps) {
// Filter to contacts with GPS coordinates, heard within the last 7 days
const mappableContacts = useMemo(() => {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
return contacts.filter(c =>
c.lat != null &&
c.lon != null &&
c.last_seen != null &&
c.last_seen > sevenDaysAgo
return contacts.filter(
(c) => c.lat != null && c.lon != null && c.last_seen != null && c.last_seen > sevenDaysAgo
);
}, [contacts]);
@@ -92,7 +91,8 @@ export function MapView({ contacts }: MapViewProps) {
{/* Info bar */}
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
<span>
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard in the last 7 days
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
in the last 7 days
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
+29 -7
View File
@@ -1,4 +1,13 @@
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, useMemo, type FormEvent, type KeyboardEvent } from 'react';
import {
useState,
useCallback,
useImperativeHandle,
forwardRef,
useRef,
useMemo,
type FormEvent,
type KeyboardEvent,
} from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
@@ -31,8 +40,10 @@ export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName }, ref) {
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
{ onSend, disabled, placeholder, isRepeaterMode, conversationType, senderName },
ref
) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
@@ -162,14 +173,25 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || (isRepeaterMode ? 'Enter password (or . for none)...' : 'Type a message...')}
placeholder={
placeholder ||
(isRepeaterMode ? 'Enter password (or . for none)...' : 'Type a message...')
}
disabled={disabled || sending}
className="flex-1 min-w-0"
/>
<Button type="submit" disabled={disabled || sending || !canSubmit} className="flex-shrink-0">
<Button
type="submit"
disabled={disabled || sending || !canSubmit}
className="flex-shrink-0"
>
{sending
? (isRepeaterMode ? 'Fetching...' : 'Sending...')
: (isRepeaterMode ? 'Fetch' : 'Send')}
? isRepeaterMode
? 'Fetching...'
: 'Sending...'
: isRepeaterMode
? 'Fetch'
: 'Send'}
</Button>
</div>
{showCharCounter && (
+123 -105
View File
@@ -40,10 +40,8 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
<span
key={keyIndex++}
className={cn(
"rounded px-0.5",
isOwnMention
? "bg-primary/30 text-primary font-medium"
: "bg-muted-foreground/20"
'rounded px-0.5',
isOwnMention ? 'bg-primary/30 text-primary font-medium' : 'bg-muted-foreground/20'
)}
>
@[{mentionedName}]
@@ -117,7 +115,13 @@ export function MessageList({
if (messages.length === 0) {
isInitialLoadRef.current = true;
prevMessagesLengthRef.current = 0;
scrollStateRef.current = { scrollTop: 0, scrollHeight: 0, clientHeight: 0, wasNearTop: false, wasNearBottom: true };
scrollStateRef.current = {
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
wasNearTop: false,
wasNearBottom: true,
};
}
}, [messages.length]);
@@ -158,28 +162,34 @@ export function MessageList({
// Look up contact by public key or prefix
const getContact = (conversationKey: string | null): Contact | null => {
if (!conversationKey) return null;
return contacts.find(c => pubkeysMatch(c.public_key, conversationKey)) || null;
return contacts.find((c) => pubkeysMatch(c.public_key, conversationKey)) || null;
};
// Look up contact by name (for channel messages where we parse sender from text)
const getContactByName = (name: string): Contact | null => {
return contacts.find(c => c.name === name) || null;
return contacts.find((c) => c.name === name) || null;
};
if (loading) {
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">Loading messages...</div>;
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
Loading messages...
</div>
);
}
if (messages.length === 0) {
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">No messages yet</div>;
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
No messages yet
</div>
);
}
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = [...messages].sort(
(a, b) => a.received_at - b.received_at
);
const sortedMessages = [...messages].sort((a, b) => a.received_at - b.received_at);
// Helper to get a unique sender key for grouping messages
const getSenderKey = (msg: Message, sender: string | null): string => {
@@ -190,111 +200,119 @@ export function MessageList({
return (
<div className="flex-1 overflow-hidden relative">
<div className="h-full overflow-y-auto p-4 flex flex-col gap-0.5" ref={listRef} onScroll={handleScroll}>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm">
Loading older messages...
</div>
)}
{!loadingOlder && hasOlderMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
Scroll up for older messages
</div>
)}
{sortedMessages.map((msg, index) => {
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
<div
className="h-full overflow-y-auto p-4 flex flex-col gap-0.5"
ref={listRef}
onScroll={handleScroll}
>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm">
Loading older messages...
</div>
)}
{!loadingOlder && hasOlderMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
Scroll up for older messages
</div>
)}
{sortedMessages.map((msg, index) => {
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
// Skip sender parsing for repeater messages (CLI responses often have colons)
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const displaySender = msg.outgoing
? 'You'
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
// Skip sender parsing for repeater messages (CLI responses often have colons)
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
const displaySender = msg.outgoing
? 'You'
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
// Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey(msg, sender);
const prevMsg = sortedMessages[index - 1];
const prevSenderKey = prevMsg ? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender) : null;
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
const isFirstMessage = index === 0;
// Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey(msg, sender);
const prevMsg = sortedMessages[index - 1];
const prevSenderKey = prevMsg
? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender)
: null;
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
const isFirstMessage = index === 0;
// Get avatar info for incoming messages
let avatarName: string | null = null;
let avatarKey: string = '';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) {
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
} else if (sender) {
// Channel message: try to find contact by name, or use sender name as pseudo-key
const senderContact = getContactByName(sender);
avatarName = sender;
avatarKey = senderContact?.public_key || `name:${sender}`;
// Get avatar info for incoming messages
let avatarName: string | null = null;
let avatarKey: string = '';
if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) {
// DM: use conversation_key (sender's public key)
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
} else if (sender) {
// Channel message: try to find contact by name, or use sender name as pseudo-key
const senderContact = getContactByName(sender);
avatarName = sender;
avatarKey = senderContact?.public_key || `name:${sender}`;
}
}
}
return (
<div
key={msg.id}
className={cn(
"flex items-start max-w-[85%]",
msg.outgoing && "flex-row-reverse self-end",
showAvatar && !isFirstMessage && "mt-3"
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
)}
</div>
)}
<div className={cn(
"py-1.5 px-3 rounded-lg min-w-0",
msg.outgoing ? "bg-[#1e3a29]" : "bg-muted"
)}>
{showAvatar && (
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary hover:underline"
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
return (
<div
key={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
showAvatar && !isFirstMessage && 'mt-3'
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
)}
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-[#1e3a29]' : 'bg-muted'
)}
{msg.outgoing && (msg.acked > 0 ? `${msg.acked > 1 ? msg.acked : ''}` : ' ?')}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary hover:underline"
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
)}
{msg.outgoing && (msg.acked > 0 ? `${msg.acked > 1 ? msg.acked : ''}` : ' ?')}
</div>
</div>
</div>
</div>
);
})}
);
})}
</div>
{/* Scroll to bottom button */}
+5 -14
View File
@@ -1,13 +1,7 @@
import { useState, useRef } from 'react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
@@ -135,9 +129,7 @@ export function NewMessageModal({
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
No contacts available
</div>
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
) : (
contacts.map((contact) => (
<div
@@ -226,7 +218,8 @@ export function NewMessageModal({
htmlFor="try-historical"
className="text-sm text-muted-foreground cursor-pointer"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet{undecryptedCount !== 1 ? 's' : ''}
Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''}
</Label>
<Checkbox
id="try-historical"
@@ -242,9 +235,7 @@ export function NewMessageModal({
</div>
)}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
+42 -32
View File
@@ -172,9 +172,8 @@ export function Sidebar({
? sortedChannels.filter((c) => c.name.toLowerCase().includes(query))
: sortedChannels;
const filteredContacts = query
? sortedContacts.filter((c) =>
(c.name?.toLowerCase().includes(query)) ||
c.public_key.toLowerCase().includes(query)
? sortedContacts.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedContacts;
@@ -220,8 +219,8 @@ export function Sidebar({
{!query && (
<div
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('raw', 'raw') && "bg-accent border-l-primary"
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('raw', 'raw') && 'bg-accent border-l-primary'
)}
onClick={() =>
handleSelectConversation({
@@ -240,8 +239,8 @@ export function Sidebar({
{!query && (
<div
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('map', 'map') && "bg-accent border-l-primary"
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('map', 'map') && 'bg-accent border-l-primary'
)}
onClick={() =>
handleSelectConversation({
@@ -260,18 +259,20 @@ export function Sidebar({
{!query && (
<div
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
showCracker && "bg-accent border-l-primary"
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
showCracker && 'bg-accent border-l-primary'
)}
onClick={onToggleCracker}
>
<span className="text-muted-foreground text-xs">🔓</span>
<span className="flex-1 truncate">
{showCracker ? 'Hide' : 'Show'} Cracker
<span className={cn(
"ml-1 text-xs",
crackerRunning ? "text-green-500" : "text-muted-foreground"
)}>
<span
className={cn(
'ml-1 text-xs',
crackerRunning ? 'text-green-500' : 'text-muted-foreground'
)}
>
({crackerRunning ? 'running' : 'stopped'})
</span>
</span>
@@ -309,9 +310,9 @@ export function Sidebar({
<div
key={`chan-${channel.key}`}
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('channel', channel.key) && "bg-accent border-l-primary",
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('channel', channel.key) && 'bg-accent border-l-primary',
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
)}
onClick={() =>
handleSelectConversation({
@@ -324,12 +325,14 @@ export function Sidebar({
<span className="text-muted-foreground text-xs">#</span>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center",
isMention
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
)}>
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{unreadCount}
</span>
)}
@@ -361,9 +364,9 @@ export function Sidebar({
<div
key={contact.public_key}
className={cn(
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
isActive('contact', contact.public_key) && "bg-accent border-l-primary",
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('contact', contact.public_key) && 'bg-accent border-l-primary',
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
)}
onClick={() =>
handleSelectConversation({
@@ -373,17 +376,24 @@ export function Sidebar({
})
}
>
<ContactAvatar name={contact.name} publicKey={contact.public_key} size={24} contactType={contact.type} />
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={24}
contactType={contact.type}
/>
<span className="name flex-1 truncate">
{getContactDisplayName(contact.name, contact.public_key)}
</span>
{unreadCount > 0 && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center",
isMention
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
)}>
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{unreadCount}
</span>
)}
+18 -4
View File
@@ -13,7 +13,14 @@ interface StatusBarProps {
onMenuClick?: () => void;
}
export function StatusBar({ health, config, onConfigClick, onMaintenanceClick, onAdvertise, onMenuClick }: StatusBarProps) {
export function StatusBar({
health,
config,
onConfigClick,
onMaintenanceClick,
onAdvertise,
onMenuClick,
}: StatusBarProps) {
const connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false);
@@ -50,7 +57,9 @@ export function StatusBar({ health, config, onConfigClick, onMaintenanceClick, o
<div className="flex items-center gap-1 text-[#888]">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[#4caf50]' : 'bg-[#666]'}`} />
<span className="hidden lg:inline text-[#e0e0e0]">{connected ? 'Connected' : 'Disconnected'}</span>
<span className="hidden lg:inline text-[#e0e0e0]">
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
{health?.serial_port && (
@@ -94,13 +103,18 @@ export function StatusBar({ health, config, onConfigClick, onMaintenanceClick, o
className="px-2 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
title="Database Maintenance"
>
<span role="img" aria-label="Settings">&#9881;&#65039;</span>
<span role="img" aria-label="Settings">
&#9881;&#65039;
</span>
</button>
<button
onClick={onConfigClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
<span role="img" aria-label="Radio">&#128251;</span> Config
<span role="img" aria-label="Radio">
&#128251;
</span>{' '}
Config
</button>
</div>
);
+26 -37
View File
@@ -1,61 +1,50 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: "bg-background text-foreground",
default: 'bg-background text-foreground',
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-yellow-500/50 bg-yellow-500/10 text-yellow-200 [&>svg]:text-yellow-500",
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning: 'border-yellow-500/50 bg-yellow-500/10 text-yellow-200 [&>svg]:text-yellow-500',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };
+26 -34
View File
@@ -1,56 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
)
Button.displayName = "Button"
);
Button.displayName = 'Button';
export { Button, buttonVariants }
export { Button, buttonVariants };
+10 -12
View File
@@ -1,10 +1,10 @@
"use client"
'use client';
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -13,18 +13,16 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }
export { Checkbox };
+31 -49
View File
@@ -1,18 +1,18 @@
"use client"
'use client';
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -21,13 +21,13 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
@@ -50,36 +50,21 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -87,14 +72,11 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@@ -102,11 +84,11 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@@ -119,4 +101,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};
+8 -8
View File
@@ -1,22 +1,22 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
);
}
)
Input.displayName = "Input"
);
Input.displayName = 'Input';
export { Input }
export { Input };
+12 -17
View File
@@ -1,26 +1,21 @@
"use client"
'use client';
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label }
export { Label };
+19 -24
View File
@@ -1,31 +1,26 @@
"use client"
'use client';
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };
+43 -61
View File
@@ -1,19 +1,19 @@
"use client"
'use client';
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -21,51 +21,48 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: "right",
side: 'right',
},
}
)
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
hideCloseButton?: boolean
hideCloseButton?: boolean;
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, hideCloseButton = false, ...props }, ref) => (
>(({ side = 'right', className, children, hideCloseButton = false, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{!hideCloseButton && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
@@ -75,36 +72,21 @@ const SheetContent = React.forwardRef<
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@@ -112,11 +94,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@@ -124,11 +106,11 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
@@ -141,4 +123,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};
+11 -12
View File
@@ -1,6 +1,6 @@
import { Toaster as Sonner, toast } from "sonner"
import { Toaster as Sonner, toast } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
@@ -10,19 +10,18 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
// Muted error style - dark red-tinted background with readable text
error: "group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]",
error:
'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]',
},
}}
{...props}
/>
)
}
);
};
export { Toaster, toast }
export { Toaster, toast };
+15 -15
View File
@@ -1,11 +1,11 @@
"use client"
'use client';
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@@ -14,13 +14,13 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -29,13 +29,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -44,12 +44,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+13 -2
View File
@@ -1,3 +1,14 @@
export { useRepeaterMode, type UseRepeaterModeResult, formatDuration, formatTelemetry, formatNeighbors, formatAcl } from './useRepeaterMode';
export {
useRepeaterMode,
type UseRepeaterModeResult,
formatDuration,
formatTelemetry,
formatNeighbors,
formatAcl,
} from './useRepeaterMode';
export { useUnreadCounts, type UseUnreadCountsResult } from './useUnreadCounts';
export { useConversationMessages, type UseConversationMessagesResult, getMessageContentKey } from './useConversationMessages';
export {
useConversationMessages,
type UseConversationMessagesResult,
getMessageContentKey,
} from './useConversationMessages';
+45 -36
View File
@@ -34,47 +34,56 @@ export function useConversationMessages(
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;
}
const fetchMessages = useCallback(
async (showLoading = false) => {
if (!activeConversation || activeConversation.type === 'raw') {
setMessages([]);
setHasOlderMessages(false);
return;
}
if (showLoading) {
setMessagesLoading(true);
// Clear messages first so MessageList resets scroll state for new conversation
setMessages([]);
}
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);
toast.error('Failed to load messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
if (showLoading) {
setMessagesLoading(false);
setMessagesLoading(true);
// Clear messages first so MessageList resets scroll state for new conversation
setMessages([]);
}
}
}, [activeConversation]);
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);
toast.error('Failed to load messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
if (showLoading) {
setMessagesLoading(false);
}
}
},
[activeConversation]
);
// Fetch older messages (pagination)
const fetchOlderMessages = useCallback(async () => {
if (!activeConversation || activeConversation.type === 'raw' || loadingOlder || !hasOlderMessages) return;
if (
!activeConversation ||
activeConversation.type === 'raw' ||
loadingOlder ||
!hasOlderMessages
)
return;
setLoadingOlder(true);
try {
@@ -87,7 +96,7 @@ export function useConversationMessages(
if (data.length > 0) {
// Prepend older messages (they come sorted DESC, so older are at the end)
setMessages(prev => [...prev, ...data]);
setMessages((prev) => [...prev, ...data]);
// Track seen content
for (const msg of data) {
seenMessageContent.current.add(getMessageContentKey(msg));
+10 -8
View File
@@ -1,6 +1,13 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { api } from '../api';
import type { Contact, Conversation, Message, TelemetryResponse, NeighborInfo, AclEntry } from '../types';
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)
@@ -121,7 +128,7 @@ export function useRepeaterMode(
// 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);
const contact = contacts.find((c) => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
@@ -181,12 +188,7 @@ export function useRepeaterMode(
if (!activeContactIsRepeater || !repeaterLoggedIn) return;
// Show the command as an outgoing message
const commandMessage = createLocalMessage(
activeConversation.id,
`> ${command}`,
true,
0
);
const commandMessage = createLocalMessage(activeConversation.id, `> ${command}`, true, 0);
setMessages((prev) => [...prev, commandMessage]);
try {
+21 -15
View File
@@ -51,19 +51,21 @@ export function useUnreadCounts(
// Fetch messages and count unreads for new channels/contacts
// Uses server-side last_read_at for consistent read state across devices
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));
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));
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 })),
...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;
@@ -82,16 +84,16 @@ export function useUnreadCounts(
// Use server-side last_read_at, fallback to 0 if never read
const lastRead = channel.last_read_at || 0;
const unreadMsgs = msgs.filter(m => !m.outgoing && m.received_at > lastRead);
const unreadMsgs = msgs.filter((m) => !m.outgoing && m.received_at > lastRead);
if (unreadMsgs.length > 0) {
newUnreadCounts[key] = unreadMsgs.length;
// Check if any unread message mentions the user
if (unreadMsgs.some(m => messageContainsMention(m.text, myNameRef.current))) {
if (unreadMsgs.some((m) => messageContainsMention(m.text, myNameRef.current))) {
newMentions[key] = true;
}
}
const latestTime = Math.max(...msgs.map(m => m.received_at));
const latestTime = Math.max(...msgs.map((m) => m.received_at));
newLastMessageTimes[key] = latestTime;
setLastMessageTime(key, latestTime);
}
@@ -105,26 +107,26 @@ export function useUnreadCounts(
// Use server-side last_read_at, fallback to 0 if never read
const lastRead = contact.last_read_at || 0;
const unreadMsgs = msgs.filter(m => !m.outgoing && m.received_at > lastRead);
const unreadMsgs = msgs.filter((m) => !m.outgoing && m.received_at > lastRead);
if (unreadMsgs.length > 0) {
newUnreadCounts[key] = unreadMsgs.length;
// Check if any unread message mentions the user
if (unreadMsgs.some(m => messageContainsMention(m.text, myNameRef.current))) {
if (unreadMsgs.some((m) => messageContainsMention(m.text, myNameRef.current))) {
newMentions[key] = true;
}
}
const latestTime = Math.max(...msgs.map(m => m.received_at));
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 }));
setUnreadCounts((prev) => ({ ...prev, ...newUnreadCounts }));
}
if (Object.keys(newMentions).length > 0) {
setMentions(prev => ({ ...prev, ...newMentions }));
setMentions((prev) => ({ ...prev, ...newMentions }));
}
setLastMessageTimes(getLastMessageTimes());
} catch (err) {
@@ -138,7 +140,11 @@ export function useUnreadCounts(
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
useEffect(() => {
if (activeConversation && activeConversation.type !== 'raw' && activeConversation.type !== 'map') {
if (
activeConversation &&
activeConversation.type !== 'raw' &&
activeConversation.type !== 'map'
) {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
+3 -3
View File
@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
+3 -1
View File
@@ -6,7 +6,9 @@
padding: 0;
}
html, body, #root {
html,
body,
#root {
height: 100%;
}
+6 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { getAvatarText, getAvatarColor, getContactAvatar, CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import {
getAvatarText,
getAvatarColor,
getContactAvatar,
CONTACT_TYPE_REPEATER,
} from '../utils/contactAvatar';
describe('getAvatarText', () => {
it('returns first emoji when name contains emoji', () => {
+16 -25
View File
@@ -52,8 +52,8 @@ function handleMessageEvent(
let unreadIncremented = false;
// Check if message is for active conversation
const isForActiveConversation = activeConversationKey !== null &&
msg.conversation_key === activeConversationKey;
const isForActiveConversation =
activeConversationKey !== null && msg.conversation_key === activeConversationKey;
// Add to messages if for active conversation (with deduplication)
if (isForActiveConversation) {
@@ -65,9 +65,10 @@ function handleMessageEvent(
}
// Update last message time
const stateKey = msg.type === 'CHAN'
? getStateKey('channel', msg.conversation_key)
: getStateKey('contact', msg.conversation_key);
const stateKey =
msg.type === 'CHAN'
? getStateKey('channel', msg.conversation_key)
: getStateKey('contact', msg.conversation_key);
state.lastMessageTimes[stateKey] = msg.received_at;
@@ -88,7 +89,7 @@ function handleMessageEvent(
* Simulate handling a contact WebSocket event.
*/
function handleContactEvent(state: MockState, contact: Contact): void {
const idx = state.contacts.findIndex(c => c.public_key === contact.public_key);
const idx = state.contacts.findIndex((c) => c.public_key === contact.public_key);
if (idx >= 0) {
// Update existing contact
state.contacts[idx] = { ...state.contacts[idx], ...contact };
@@ -101,12 +102,8 @@ function handleContactEvent(state: MockState, contact: Contact): void {
/**
* Simulate handling a message_acked WebSocket event.
*/
function handleMessageAckedEvent(
state: MockState,
messageId: number,
ackCount: number
): boolean {
const idx = state.messages.findIndex(m => m.id === messageId);
function handleMessageAckedEvent(state: MockState, messageId: number, ackCount: number): boolean {
const idx = state.messages.findIndex((m) => m.id === messageId);
if (idx >= 0) {
state.messages[idx] = { ...state.messages[idx], acked: ackCount };
return true;
@@ -114,7 +111,6 @@ function handleMessageAckedEvent(
return false;
}
describe('Integration: Channel Message Events', () => {
const fixture = fixtures.channel_message;
@@ -170,7 +166,6 @@ describe('Integration: Channel Message Events', () => {
});
});
describe('Integration: Duplicate Message Handling', () => {
// Note: duplicate_channel_message fixture references the same packet data as channel_message
@@ -186,7 +181,7 @@ describe('Integration: Duplicate Message Handling', () => {
const result2 = handleMessageEvent(state, msg2, msg2.conversation_key);
expect(result1.added).toBe(true);
expect(result2.added).toBe(false); // Deduplicated
expect(result2.added).toBe(false); // Deduplicated
expect(state.messages).toHaveLength(1);
});
@@ -201,14 +196,13 @@ describe('Integration: Duplicate Message Handling', () => {
const result2 = handleMessageEvent(state, msg2, 'other_conversation');
expect(result1.unreadIncremented).toBe(true);
expect(result2.unreadIncremented).toBe(false); // Deduplicated
expect(result2.unreadIncremented).toBe(false); // Deduplicated
const stateKey = getStateKey('channel', msg1.conversation_key);
expect(state.unreadCounts[stateKey]).toBe(1); // Only incremented once
expect(state.unreadCounts[stateKey]).toBe(1); // Only incremented once
});
});
describe('Integration: Contact/Advertisement Events', () => {
const fixture = fixtures.advertisement_with_gps;
@@ -221,7 +215,7 @@ describe('Integration: Contact/Advertisement Events', () => {
expect(state.contacts).toHaveLength(1);
expect(state.contacts[0].public_key).toBe(contact.public_key);
expect(state.contacts[0].name).toBe('Can O Mesh 2 🥫');
expect(state.contacts[0].type).toBe(2); // Repeater
expect(state.contacts[0].type).toBe(2); // Repeater
expect(state.contacts[0].lat).toBeCloseTo(49.02056, 4);
expect(state.contacts[0].lon).toBeCloseTo(-123.82935, 4);
});
@@ -243,8 +237,8 @@ describe('Integration: Contact/Advertisement Events', () => {
handleContactEvent(state, contact);
expect(state.contacts).toHaveLength(1);
expect(state.contacts[0].name).toBe('Can O Mesh 2 🥫'); // Updated
expect(state.contacts[0].type).toBe(2); // Updated
expect(state.contacts[0].name).toBe('Can O Mesh 2 🥫'); // Updated
expect(state.contacts[0].type).toBe(2); // Updated
});
it('preserves contact GPS from chat node advertisement', () => {
@@ -256,11 +250,10 @@ describe('Integration: Contact/Advertisement Events', () => {
expect(state.contacts[0].lat).toBeCloseTo(47.786038, 4);
expect(state.contacts[0].lon).toBeCloseTo(-122.344096, 4);
expect(state.contacts[0].type).toBe(1); // Chat node
expect(state.contacts[0].type).toBe(1); // Chat node
});
});
describe('Integration: ACK Events', () => {
const fixture = fixtures.message_acked;
@@ -324,7 +317,6 @@ describe('Integration: ACK Events', () => {
});
});
describe('Integration: Message Content Key Contract', () => {
it('generates consistent keys for deduplication', () => {
const msg = fixtures.channel_message.expected_ws_event.data as unknown as Message;
@@ -348,7 +340,6 @@ describe('Integration: Message Content Key Contract', () => {
});
});
describe('Integration: State Key Contract', () => {
it('generates correct channel state key', () => {
const channelKey = fixtures.channel_message.expected_ws_event.data.conversation_key;
+54 -13
View File
@@ -35,7 +35,10 @@ function shouldIncrementUnread(
// Use 12-char prefix for contact key
const key = `contact-${getPubkeyPrefix(msg.conversation_key)}`;
// Don't count if this contact is active (compare by prefix)
if (activeConversation?.type === 'contact' && pubkeysMatch(activeConversation.id, msg.conversation_key)) {
if (
activeConversation?.type === 'contact' &&
pubkeysMatch(activeConversation.id, msg.conversation_key)
) {
return null;
}
return { key };
@@ -78,8 +81,15 @@ describe('shouldIncrementUnread', () => {
});
it('returns key for incoming channel message when not viewing that channel', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', name: 'other' };
const msg = createMessage({
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3',
});
const activeConversation: Conversation = {
type: 'channel',
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5',
name: 'other',
};
const result = shouldIncrementUnread(msg, activeConversation);
@@ -87,8 +97,15 @@ describe('shouldIncrementUnread', () => {
});
it('returns null for incoming channel message when viewing that channel', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
const activeConversation: Conversation = { type: 'channel', id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', name: '#test' };
const msg = createMessage({
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3',
});
const activeConversation: Conversation = {
type: 'channel',
id: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3',
name: '#test',
};
const result = shouldIncrementUnread(msg, activeConversation);
@@ -96,7 +113,11 @@ describe('shouldIncrementUnread', () => {
});
it('returns null for outgoing messages', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', outgoing: true });
const msg = createMessage({
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3',
outgoing: true,
});
const result = shouldIncrementUnread(msg, null);
@@ -104,8 +125,15 @@ describe('shouldIncrementUnread', () => {
});
it('returns key for incoming direct message when not viewing that contact', () => {
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
const activeConversation: Conversation = { type: 'contact', id: 'xyz999999999012345678901234567890123456789012345678901234567', name: 'other' };
const msg = createMessage({
type: 'PRIV',
conversation_key: 'abc123456789012345678901234567890123456789012345678901234567',
});
const activeConversation: Conversation = {
type: 'contact',
id: 'xyz999999999012345678901234567890123456789012345678901234567',
name: 'other',
};
const result = shouldIncrementUnread(msg, activeConversation);
@@ -113,7 +141,10 @@ describe('shouldIncrementUnread', () => {
});
it('returns null for incoming direct message when viewing that contact', () => {
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
const msg = createMessage({
type: 'PRIV',
conversation_key: 'abc123456789012345678901234567890123456789012345678901234567',
});
const activeConversation: Conversation = {
type: 'contact',
id: 'abc123456789fullkey12345678901234567890123456789012345678',
@@ -126,7 +157,10 @@ describe('shouldIncrementUnread', () => {
});
it('returns key when no conversation is active', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
const msg = createMessage({
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0',
});
const result = shouldIncrementUnread(msg, null);
@@ -134,7 +168,10 @@ describe('shouldIncrementUnread', () => {
});
it('returns key when viewing raw packet feed', () => {
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
const msg = createMessage({
type: 'CHAN',
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1',
});
const activeConversation: Conversation = { type: 'raw', id: 'raw', name: 'Packets' };
const result = shouldIncrementUnread(msg, activeConversation);
@@ -160,7 +197,9 @@ describe('getUnreadCount', () => {
const counts = { 'contact-abc123456789': 5 };
// Full public key lookup should match the prefix
expect(getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)).toBe(5);
expect(
getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)
).toBe(5);
});
it('handles contact key shorter than 12 chars', () => {
@@ -172,7 +211,9 @@ describe('getUnreadCount', () => {
it('returns 0 for contact with no unread', () => {
const counts = { 'contact-abc123456789': 5 };
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
expect(
getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)
).toBe(0);
});
});
+1 -1
View File
@@ -109,7 +109,7 @@ export interface RawPacket {
timestamp: number;
data: string; // hex
payload_type: string;
snr: number | null; // Signal-to-noise ratio in dB
snr: number | null; // Signal-to-noise ratio in dB
rssi: number | null; // Received signal strength in dBm
decrypted: boolean;
decrypted_info: {
+3 -2
View File
@@ -21,14 +21,15 @@ function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
// Regex to match emoji (covers most common emoji ranges)
const emojiRegex = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
const emojiRegex =
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
/**
* Extract display characters from a contact name.
+1 -4
View File
@@ -56,10 +56,7 @@ export function setLastMessageTime(stateKey: string, timestamp: number): Convers
* The 12-char prefix for contacts ensures consistent matching regardless
* of whether we have a full 64-char pubkey or just a prefix.
*/
export function getStateKey(
type: 'channel' | 'contact',
id: string
): string {
export function getStateKey(type: 'channel' | 'contact', id: string): string {
if (type === 'channel') {
return `channel-${id}`;
}
-1
View File
@@ -36,4 +36,3 @@ export function formatTime(timestamp: number): string {
const dateStr = date.toLocaleDateString([], { month: 'short', day: 'numeric' });
return `${dateStr} ${time}`;
}
+2 -3
View File
@@ -36,9 +36,8 @@ export function getConversationHash(conv: Conversation | null): string {
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
// Strip leading # from channel names for cleaner URLs
const name = conv.type === 'channel' && conv.name.startsWith('#')
? conv.name.slice(1)
: conv.name;
const name =
conv.type === 'channel' && conv.name.startsWith('#') ? conv.name.slice(1) : conv.name;
return `#${conv.type}/${encodeURIComponent(name)}`;
}