Initial commit

This commit is contained in:
Jack Kingsman
2026-01-06 19:59:51 -08:00
commit 557cb12879
82 changed files with 387739 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
import { useState, useEffect } from 'react';
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { Alert, AlertDescription } from './ui/alert';
interface ConfigModalProps {
open: boolean;
config: RadioConfig | null;
appSettings: AppSettings | null;
onClose: () => void;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
}
export function ConfigModal({
open,
config,
appSettings,
onClose,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
}: ConfigModalProps) {
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [loading, setLoading] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (config) {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}
}, [config]);
useEffect(() => {
if (appSettings) {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}
}, [appSettings]);
const handleSave = async () => {
setError('');
setLoading(true);
try {
const update: RadioConfigUpdate = {
name,
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setLoading(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError('');
setLoading(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setLoading(false);
}
};
const handleReboot = async () => {
if (!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')) {
return;
}
setError('');
setRebooting(true);
try {
await onReboot();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Radio Configuration</DialogTitle>
</DialogHeader>
{!config ? (
<div className="py-8 text-center text-muted-foreground">
Loading configuration...
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lat">Latitude</Label>
<Input
id="lat"
type="number"
step="any"
value={lat}
onChange={(e) => setLat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lon">Longitude</Label>
<Input
id="lon"
type="number"
step="any"
value={lon}
onChange={(e) => setLon(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="freq">Frequency (MHz)</Label>
<Input
id="freq"
type="number"
step="any"
value={freq}
onChange={(e) => setFreq(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bw">Bandwidth (kHz)</Label>
<Input
id="bw"
type="number"
step="any"
value={bw}
onChange={(e) => setBw(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sf">Spreading Factor</Label>
<Input
id="sf"
type="number"
min="7"
max="12"
value={sf}
onChange={(e) => setSf(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cr">Coding Rate</Label>
<Input
id="cr"
type="number"
min="1"
max="4"
value={cr}
onChange={(e) => setCr(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tx-power">TX Power (dBm)</Label>
<Input
id="tx-power"
type="number"
value={txPower}
onChange={(e) => setTxPower(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="max-tx">Max TX Power</Label>
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
</div>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
</p>
</div>
<Separator className="my-4" />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<div className="flex gap-2">
<Input
id="private-key"
type="password"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
className="flex-1"
/>
<Button
onClick={handleSetPrivateKey}
disabled={loading || !privateKey.trim()}
>
Set
</Button>
</div>
</div>
<Separator className="my-4" />
<div className="space-y-3">
<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.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || loading}
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
</div>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || !config}>
{loading ? 'Saving...' : 'Save Config'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { getContactAvatar } from '../utils/contactAvatar';
interface ContactAvatarProps {
name: string | null;
publicKey: string;
size?: number;
contactType?: number;
}
export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) {
const avatar = getContactAvatar(name, publicKey, contactType);
return (
<div
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
style={{
backgroundColor: avatar.background,
color: avatar.textColor,
width: size,
height: size,
fontSize: size * 0.45,
}}
>
{avatar.text}
</div>
);
}
+387
View File
@@ -0,0 +1,387 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-cracker';
import type { RawPacket, Channel } from '../types';
import { cn } from '@/lib/utils';
interface CrackedRoom {
roomName: string;
key: string;
packetId: number;
message: string;
crackedAt: number;
}
interface QueueItem {
packet: RawPacket;
attempts: number;
lastAttemptLength: number;
status: 'pending' | 'cracking' | 'cracked' | 'failed';
}
interface CrackerPanelProps {
packets: RawPacket[];
channels: Channel[];
onChannelCreate: (name: string, key: string) => Promise<void>;
}
export function CrackerPanel({ packets, channels, onChannelCreate }: CrackerPanelProps) {
const [isRunning, setIsRunning] = useState(false);
const [maxLength, setMaxLength] = useState(6);
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
const [progress, setProgress] = useState<ProgressReport | null>(null);
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
const [wordlistLoaded, setWordlistLoaded] = useState(false);
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
const crackerRef = useRef<GroupTextCracker | null>(null);
const isRunningRef = useRef(false);
const abortedRef = useRef(false);
const isProcessingRef = useRef(false);
const queueRef = useRef<Map<number, QueueItem>>(new Map());
const retryFailedRef = useRef(false);
const maxLengthRef = useRef(6);
// Initialize cracker
useEffect(() => {
const cracker = new GroupTextCracker();
crackerRef.current = cracker;
setGpuAvailable(cracker.isGpuAvailable());
// Load wordlist
cracker.loadWordlist('/words_alpha.txt')
.then(() => setWordlistLoaded(true))
.catch((err) => console.error('Failed to load wordlist:', err));
return () => {
cracker.destroy();
crackerRef.current = null;
};
}, []);
// Get existing channel keys for filtering
const existingChannelKeys = new Set(channels.map(c => c.key.toUpperCase()));
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
p => p.payload_type === 'GROUP_TEXT' && !p.decrypted
);
// Update queue when packets change
useEffect(() => {
setQueue(prev => {
const newQueue = new Map(prev);
let changed = false;
for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) {
newQueue.set(packet.id, {
packet,
attempts: 0,
lastAttemptLength: 0,
status: 'pending',
});
changed = true;
}
}
if (changed) {
queueRef.current = newQueue;
return newQueue;
}
return prev;
});
}, [undecryptedGroupText.length]);
// Keep refs in sync with state
useEffect(() => {
queueRef.current = queue;
}, [queue]);
useEffect(() => {
retryFailedRef.current = retryFailedAtNextLength;
}, [retryFailedAtNextLength]);
useEffect(() => {
maxLengthRef.current = maxLength;
}, [maxLength]);
// 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;
// Process next packet in queue
const processNext = useCallback(async () => {
// Prevent concurrent processing
if (isProcessingRef.current) return;
if (!crackerRef.current || !isRunningRef.current) return;
const currentQueue = queueRef.current;
// Find next pending packet
let nextItem: QueueItem | null = null;
let nextId: number | null = null;
for (const [id, item] of currentQueue.entries()) {
if (item.status === 'pending') {
nextItem = item;
nextId = id;
break;
}
}
// If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength
if (!nextItem && retryFailedRef.current) {
const failedItems = Array.from(currentQueue.entries()).filter(
([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10
);
if (failedItems.length > 0) {
// Sort by lastAttemptLength ascending and pick the first (lowest)
failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength);
[nextId, nextItem] = failedItems[0];
}
}
if (!nextItem || nextId === null) {
// Nothing to process right now, but keep running and check again later
if (isRunningRef.current) {
setTimeout(() => processNext(), 1000);
}
return;
}
// Lock processing
isProcessingRef.current = true;
const currentMaxLength = maxLengthRef.current;
const targetLength = nextItem.lastAttemptLength > 0
? nextItem.lastAttemptLength + 1
: currentMaxLength;
try {
const result = await crackerRef.current.crack(
nextItem.packet.data,
{
maxLength: targetLength,
useTimestampFilter: true,
useUtf8Filter: true,
},
(prog) => {
setProgress(prog);
}
);
if (abortedRef.current) {
abortedRef.current = false;
isProcessingRef.current = false;
setProgress(null);
return;
}
if (result.found && result.roomName && result.key) {
// Success!
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'cracked',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
const newRoom: CrackedRoom = {
roomName: result.roomName,
key: result.key,
packetId: nextId!,
message: result.decryptedMessage || '',
crackedAt: Date.now(),
};
setCrackedRooms(prev => [...prev, newRoom]);
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
if (!existingChannelKeys.has(keyUpper)) {
try {
await onChannelCreate('#' + result.roomName, result.key);
} catch (err) {
console.error('Failed to create channel:', err);
}
}
} else {
// Failed
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'failed',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
}
} catch (err) {
console.error('Cracking error:', err);
setQueue(prev => {
const updated = new Map(prev);
const item = updated.get(nextId!);
if (item) {
updated.set(nextId!, {
...item,
status: 'failed',
attempts: item.attempts + 1,
lastAttemptLength: targetLength,
});
}
return updated;
});
}
// Unlock processing
isProcessingRef.current = false;
setProgress(null);
// Continue processing if still running
if (isRunningRef.current) {
setTimeout(() => processNext(), 100);
}
}, [existingChannelKeys, onChannelCreate]);
// Start/stop handlers
const handleStart = () => {
if (!gpuAvailable) {
alert('WebGPU is not available in your browser. Please use Chrome 113+ or Edge 113+.');
return;
}
setIsRunning(true);
isRunningRef.current = true;
abortedRef.current = false;
processNext();
};
const handleStop = () => {
setIsRunning(false);
isRunningRef.current = false;
abortedRef.current = true;
crackerRef.current?.abort();
};
return (
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border">
<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",
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"
)}
>
{isRunning ? 'Stop' : 'Start Cracking'}
</button>
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground">Max Length:</label>
<input
type="number"
min={1}
max={10}
value={maxLength}
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={retryFailedAtNextLength}
onChange={(e) => setRetryFailedAtNextLength(e.target.checked)}
className="rounded"
/>
Retry failed at n+1
</label>
</div>
{/* Status */}
<div className="flex gap-4 text-sm">
<span className="text-muted-foreground">
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
</span>
</div>
{/* Progress */}
{progress && (
<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}
</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`}
</span>
</div>
<div className="h-2 bg-muted rounded overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
<div className="text-sm text-muted-foreground">
Loading wordlist...
</div>
)}
{/* Cracked rooms list */}
{crackedRooms.length > 0 && (
<div className="flex-1 overflow-y-auto min-h-0">
<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">
<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 ? '...' : ''}"
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, type FormEvent, type KeyboardEvent } from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
}
export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
function MessageInput({ onSend, disabled, placeholder }, ref) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
appendText: (appendedText: string) => {
setText((prev) => prev + appendedText);
// Focus the input after appending
inputRef.current?.focus();
},
}));
const handleSubmit = useCallback(
async (e: FormEvent) => {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed || sending || disabled) return;
setSending(true);
try {
await onSend(trimmed);
setText('');
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
},
[text, sending, disabled, onSend]
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as FormEvent);
}
},
[handleSubmit]
);
return (
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
<Input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || 'Type a message...'}
disabled={disabled || sending}
className="flex-1"
/>
<Button type="submit" disabled={disabled || sending || !text.trim()}>
{sending ? 'Sending...' : 'Send'}
</Button>
</form>
);
});
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import type { Contact, Message } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { cn } from '@/lib/utils';
interface MessageListProps {
messages: Message[];
contacts: Contact[];
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
}
export function MessageList({
messages,
contacts,
loading,
loadingOlder = false,
hasOlderMessages = false,
onSenderClick,
onLoadOlder,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
scrollTop: 0,
scrollHeight: 0,
wasNearTop: false,
});
// Handle scroll position AFTER render
useLayoutEffect(() => {
if (!listRef.current) return;
const list = listRef.current;
const messagesAdded = messages.length - prevMessagesLengthRef.current;
if (isInitialLoadRef.current && messages.length > 0) {
// Initial load - scroll to bottom
list.scrollTop = list.scrollHeight;
isInitialLoadRef.current = false;
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
// Messages were added - use scroll state captured before the update
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
// User was near top (loading older) - preserve position by adding the height diff
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
} else if (!scrollStateRef.current.wasNearTop) {
// User was at bottom - scroll to bottom for new messages
list.scrollTop = list.scrollHeight;
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages]);
// Reset initial load flag when conversation changes (messages becomes empty then filled)
useEffect(() => {
if (messages.length === 0) {
isInitialLoadRef.current = true;
prevMessagesLengthRef.current = 0;
scrollStateRef.current = { scrollTop: 0, scrollHeight: 0, wasNearTop: false };
}
}, [messages.length]);
// Handle scroll - capture state and detect when user is near top
const handleScroll = useCallback(() => {
if (!listRef.current) return;
const { scrollTop, scrollHeight } = listRef.current;
// Always capture current scroll state (needed for scroll preservation)
scrollStateRef.current = {
scrollTop,
scrollHeight,
wasNearTop: scrollTop < 150,
};
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
// Trigger load when within 100px of top
if (scrollTop < 100) {
onLoadOlder();
}
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
// 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;
};
// 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;
};
if (loading) {
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>;
}
// Deduplicate messages by content + timestamp (same message via different paths)
const deduplicatedMessages = messages.reduce<Message[]>((acc, msg) => {
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
const existing = acc.find(m =>
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
);
if (!existing) {
acc.push(msg);
}
return acc;
}, []);
// Sort messages by received_at ascending (oldest first)
const sortedMessages = [...deduplicatedMessages].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 => {
if (msg.outgoing) return '__outgoing__';
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
return sender || '__unknown__';
};
return (
<div className="flex-1 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) => {
const { sender, content } = parseSenderFromText(msg.text);
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const displaySender = msg.outgoing
? 'You'
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || '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;
// 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
)}
<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}>
{line}
{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 ? ' ✓' : ' ?')}
</div>
</div>
</div>
);
})}
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
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 { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
contacts: Contact[];
undecryptedCount: number;
onClose: () => void;
onSelectConversation: (conversation: Conversation) => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
}
export function NewMessageModal({
open,
contacts,
undecryptedCount,
onClose,
onSelectConversation,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('existing');
const [name, setName] = useState('');
const [key, setKey] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const handleCreate = async () => {
setError('');
setLoading(true);
try {
if (tab === 'new-contact') {
if (!name.trim() || !key.trim()) {
setError('Name and public key are required');
return;
}
await onCreateContact(name.trim(), key.trim(), tryHistorical);
onSelectConversation({
type: 'contact',
id: key.trim(),
name: name.trim(),
});
} else if (tab === 'new-room') {
if (!name.trim() || !key.trim()) {
setError('Room name and key are required');
return;
}
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
} else if (tab === 'hashtag') {
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
if (validationError) {
setError(validationError);
return;
}
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
}
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
const validationError = validateHashtagName(channelName);
if (validationError) {
setError(validationError);
return;
}
setLoading(true);
try {
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
setName('');
hashtagInputRef.current?.focus();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
}
};
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="existing">Existing</TabsTrigger>
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-room">Room</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
</TabsList>
<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>
) : (
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent"
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key)}
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="new-contact" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="contact-name">Name</Label>
<Input
id="contact-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contact name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contact-key">Public Key</Label>
<Input
id="contact-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="64-character hex public key"
/>
</div>
</TabsContent>
<TabsContent value="new-room" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label>
<Input
id="room-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Room name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room-key">Room Key</Label>
<Input
id="room-key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Pre-shared key (hex)"
/>
</div>
</TabsContent>
<TabsContent value="hashtag" className="mt-4">
<div className="space-y-2">
<Label htmlFor="hashtag-name">Hashtag Channel</Label>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">#</span>
<Input
ref={hashtagInputRef}
id="hashtag-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="channel-name"
className="flex-1"
/>
</div>
</div>
</TabsContent>
</Tabs>
{showHistoricalOption && (
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="text-sm text-muted-foreground cursor-pointer"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet{undecryptedCount !== 1 ? 's' : ''}
</Label>
<Checkbox
id="try-historical"
checked={tryHistorical}
onCheckedChange={(checked) => setTryHistorical(checked === true)}
/>
</div>
)}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
{tab === 'hashtag' && (
<Button variant="secondary" onClick={handleCreateAndAddAnother} disabled={loading}>
{loading ? 'Creating...' : 'Create & Add Another'}
</Button>
)}
{tab !== 'existing' && (
<Button onClick={handleCreate} disabled={loading}>
{loading ? 'Creating...' : 'Create'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react';
import type { RawPacket } from '../types';
interface RawPacketListProps {
packets: RawPacket[];
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatPayloadType(type: string): string {
// Convert SNAKE_CASE to Title Case
return type
.split('_')
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(' ');
}
function getDecryptedLabel(packet: RawPacket): string {
if (!packet.decrypted || !packet.decrypted_info) {
return formatPayloadType(packet.payload_type);
}
const info = packet.decrypted_info;
if (packet.payload_type === 'GROUP_TEXT' && info.channel_name) {
return `GroupText to ${info.channel_name}`;
}
if (packet.payload_type === 'TEXT_MESSAGE' && info.sender) {
return `TextMessage from ${info.sender}`;
}
return formatPayloadType(packet.payload_type);
}
function formatSignalInfo(packet: RawPacket): string {
const parts: string[] = [];
if (packet.snr !== null && packet.snr !== undefined) {
parts.push(`SNR: ${packet.snr.toFixed(1)} dB`);
}
if (packet.rssi !== null && packet.rssi !== undefined) {
parts.push(`RSSI: ${packet.rssi} dBm`);
}
return parts.join(' | ');
}
export function RawPacketList({ packets }: RawPacketListProps) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [packets]);
if (packets.length === 0) {
return (
<div className="h-full overflow-y-auto p-5 text-center text-muted-foreground">
No packets received yet. Packets will appear here in real-time.
</div>
);
}
// Sort packets by timestamp ascending (oldest first)
const sortedPackets = [...packets].sort((a, b) => a.timestamp - b.timestamp);
return (
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
{sortedPackets.map((packet) => (
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
<div className={packet.decrypted ? 'text-primary' : 'text-destructive'}>
{!packet.decrypted && <span className="mr-1">🔒</span>}
{getDecryptedLabel(packet)}
{' • '}
{formatTime(packet.timestamp)}
</div>
{(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[11px] text-muted-foreground mt-0.5">
{formatSignalInfo(packet)}
</div>
)}
<div className="font-mono text-[11px] break-all text-muted-foreground/70 mt-1">
{packet.data.toUpperCase()}
</div>
</div>
))}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import { useState } from 'react';
import type { Contact, Channel, Conversation } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
type SortOrder = 'alpha' | 'recent';
interface SidebarProps {
contacts: Contact[];
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
}
// Load sort preference from localStorage
function loadSortOrder(): SortOrder {
try {
const stored = localStorage.getItem('remoteterm-sortOrder');
return stored === 'recent' ? 'recent' : 'alpha';
} catch {
return 'alpha';
}
}
// Save sort preference to localStorage
function saveSortOrder(order: SortOrder): void {
try {
localStorage.setItem('remoteterm-sortOrder', order);
} catch {
// localStorage might be full or disabled
}
}
export function Sidebar({
contacts,
channels,
activeConversation,
onSelectConversation,
onNewMessage,
lastMessageTimes,
unreadCounts,
}: SidebarProps) {
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
const [searchQuery, setSearchQuery] = useState('');
const handleSortToggle = () => {
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
setSortOrder(newOrder);
saveSortOrder(newOrder);
};
const handleSelectConversation = (conversation: Conversation) => {
setSearchQuery('');
onSelectConversation(conversation);
};
const isActive = (type: 'contact' | 'channel' | 'raw', id: string) =>
activeConversation?.type === type && activeConversation?.id === id;
// Get unread count for a conversation
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
const key = getStateKey(type, id);
return unreadCounts[key] || 0;
};
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
const key = getStateKey(type, id);
return lastMessageTimes[key] || 0;
};
// Deduplicate channels by name, keeping the first (lowest index)
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
if (!acc.some((c) => c.name === channel.name)) {
acc.push(channel);
}
return acc;
}, []);
// Deduplicate contacts by 12-char prefix, preferring ones with names
// Also filter out any contacts with empty public keys
const uniqueContacts = contacts
.filter((c) => c.public_key && c.public_key.length > 0)
.sort((a, b) => {
// Sort contacts with names first
if (a.name && !b.name) return -1;
if (!a.name && b.name) return 1;
return (a.name || '').localeCompare(b.name || '');
})
.reduce<Contact[]>((acc, contact) => {
const prefix = getPubkeyPrefix(contact.public_key);
if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) {
acc.push(contact);
}
return acc;
}, []);
// Sort channels based on sort order, with Public always first
const sortedChannels = [...uniqueChannels].sort((a, b) => {
// Public channel always sorts to the top
if (a.name === 'Public') return -1;
if (b.name === 'Public') return 1;
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return a.name.localeCompare(b.name);
});
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
const sortedContacts = [...uniqueContacts].sort((a, b) => {
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
// Repeaters always go to the bottom
if (aIsRepeater && !bIsRepeater) return 1;
if (!aIsRepeater && bIsRepeater) return -1;
// Both repeaters: always sort alphabetically
if (aIsRepeater && bIsRepeater) {
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}
// Both non-repeaters: use selected sort order
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
// If both have messages, sort by most recent first
if (timeA && timeB) return timeB - timeA;
// Items with messages come before items without
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to alpha for items without messages
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
});
// Filter by search query
const query = searchQuery.toLowerCase().trim();
const filteredChannels = query
? 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;
return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
{/* Header */}
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
<h2 className="text-xs uppercase text-muted-foreground font-medium">Conversations</h2>
<Button
variant="ghost"
size="sm"
onClick={onNewMessage}
title="New Message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
>
+
</Button>
</div>
{/* Search */}
<div className="relative px-3 py-2 border-b border-border">
<Input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 text-sm pr-8"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none"
onClick={() => setSearchQuery('')}
title="Clear search"
>
×
</button>
)}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{/* Raw Packet Feed */}
{!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"
)}
onClick={() =>
handleSelectConversation({
type: 'raw',
id: 'raw',
name: 'Raw Packet Feed',
})
}
>
<span className="text-muted-foreground text-xs">📡</span>
<span className="flex-1 truncate">Packet Feed</span>
</div>
)}
{/* Channels */}
{filteredChannels.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
</div>
{filteredChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key);
return (
<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"
)}
onClick={() =>
handleSelectConversation({
type: 'channel',
id: channel.key,
name: channel.name,
})
}
>
<span className="text-muted-foreground text-xs">#</span>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{unreadCount}
</span>
)}
</div>
);
})}
</>
)}
{/* Contacts */}
{filteredContacts.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
{filteredChannels.length === 0 && (
<button
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
</div>
{filteredContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key);
return (
<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"
)}
onClick={() =>
handleSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
})
}
>
<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="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{unreadCount}
</span>
)}
</div>
);
})}
</>
)}
{/* Empty state */}
{filteredContacts.length === 0 && filteredChannels.length === 0 && (
<div className="p-5 text-center text-muted-foreground">
{query ? 'No matches found' : 'No conversations yet'}
</div>
)}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
interface StatusBarProps {
health: HealthStatus | null;
config: RadioConfig | null;
onConfigClick: () => void;
onAdvertise: () => void;
onMenuClick?: () => void;
}
export function StatusBar({ health, config, onConfigClick, onAdvertise, onMenuClick }: StatusBarProps) {
const connected = health?.radio_connected ?? false;
const [reconnecting, setReconnecting] = useState(false);
const handleReconnect = async () => {
setReconnecting(true);
try {
const result = await api.reconnectRadio();
if (result.connected) {
toast.success('Reconnected', { description: result.message });
}
} catch (err) {
toast.error('Reconnection failed', {
description: err instanceof Error ? err.message : 'Check radio connection and power',
});
} finally {
setReconnecting(false);
}
};
return (
<div className="flex items-center gap-4 px-4 py-2 bg-[#252525] border-b border-[#333] text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
onClick={onMenuClick}
className="md:hidden p-1 bg-transparent border-none text-[#e0e0e0] cursor-pointer"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
)}
<h1 className="hidden lg:block text-base font-semibold mr-auto">RemoteTerm</h1>
<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>
</div>
{health?.serial_port && (
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
</div>
)}
{config && (
<>
<div className="hidden lg:flex items-center gap-1 text-[#888]">
Name: <span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
Freq: <span className="text-[#e0e0e0]">{config.radio.freq} MHz</span>
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
SF{config.radio.sf}/CR{config.radio.cr}
</div>
<div className="hidden xl:flex items-center gap-1 text-[#888]">
TX: <span className="text-[#e0e0e0]">{config.tx_power} dBm</span>
</div>
</>
)}
{/* Spacer to push buttons right on mobile */}
<div className="flex-1 lg:hidden" />
{!connected && (
<button
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1 bg-[#4a3000] border border-[#6b4500] text-[#ffa500] rounded text-xs cursor-pointer hover:bg-[#5a3a00] disabled:opacity-50 disabled:cursor-not-allowed"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
</button>
)}
<button
onClick={onAdvertise}
disabled={!connected}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
>
Advertise
</button>
<button
onClick={onConfigClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
Config
</button>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
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",
{
variants: {
variant: {
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",
},
},
defaultVariants: {
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"
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"
export { Alert, AlertTitle, AlertDescription }
+56
View File
@@ -0,0 +1,56 @@
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"
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",
{
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",
},
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",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
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"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<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",
className
)}
{...props}
>
<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
export { Checkbox }
+122
View File
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<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",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<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",
className
)}
{...props}
>
{children}
<DialogPrimitive.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-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
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-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
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"
const labelVariants = cva(
"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>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
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
export { Separator }
+144
View File
@@ -0,0 +1,144 @@
"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 { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ 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",
className
)}
{...props}
ref={ref}
/>
))
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",
{
variants: {
side: {
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",
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",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
hideCloseButton?: boolean
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, hideCloseButton = false, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<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" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
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-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+28
View File
@@ -0,0 +1,28 @@
import { Toaster as Sonner, toast } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
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",
// 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]",
},
}}
{...props}
/>
)
}
export { Toaster, toast }
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<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",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<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",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }