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
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-DPT3GFYS.js"></script>
<script type="module" crossorigin src="/assets/index-w8xAO9M9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZ67iE5i.css">
</head>
<body>
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
// Allow unused vars prefixed with _
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
// Allow any in specific cases (can tighten later)
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.ts'],
}
);
+1247 -2
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -8,7 +8,11 @@
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
@@ -31,6 +35,7 @@
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/leaflet": "^1.9.21",
@@ -39,10 +44,14 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.23",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"jsdom": "^25.0.0",
"postcss": "^8.5.6",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.19",
"typescript": "^5.6.3",
"typescript-eslint": "^8.19.0",
"vite": "^6.0.3",
"vitest": "^2.1.0"
}
+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)}`;
}