mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-05 17:32:10 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { api } from './api';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { MessageList } from './components/MessageList';
|
||||
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
|
||||
import { NewMessageModal } from './components/NewMessageModal';
|
||||
import { ConfigModal } from './components/ConfigModal';
|
||||
import { RawPacketList } from './components/RawPacketList';
|
||||
import { CrackerPanel } from './components/CrackerPanel';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
||||
import { Toaster, toast } from './components/ui/sonner';
|
||||
import {
|
||||
getLastMessageTimes,
|
||||
getLastReadTimes,
|
||||
setLastMessageTime,
|
||||
setLastReadTime,
|
||||
getStateKey,
|
||||
type ConversationTimes,
|
||||
} from './utils/conversationState';
|
||||
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
Channel,
|
||||
Conversation,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
} from './types';
|
||||
|
||||
const MAX_RAW_PACKETS = 500; // Limit stored packets to prevent memory issues
|
||||
|
||||
// Generate a key for deduplicating messages by content
|
||||
function getMessageContentKey(msg: Message): string {
|
||||
return `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const activeConversationRef = useRef<Conversation | null>(null);
|
||||
const seenMessageContent = useRef<Set<string>>(new Set());
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [hasOlderMessages, setHasOlderMessages] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
||||
// Track last message times (persisted in localStorage, used for sorting)
|
||||
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
|
||||
// Track unread counts (calculated on load and incremented during session)
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
|
||||
// Track previous health status to detect changes
|
||||
const prevHealthRef = useRef<HealthStatus | null>(null);
|
||||
|
||||
// WebSocket handlers - memoized to prevent reconnection loops
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
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;
|
||||
|
||||
// Skip duplicate messages (same content + timestamp)
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
if (seenMessageContent.current.has(contentKey)) {
|
||||
console.debug('Duplicate message content ignored:', contentKey.slice(0, 50));
|
||||
return;
|
||||
}
|
||||
seenMessageContent.current.add(contentKey);
|
||||
// Limit set size to prevent memory issues (keep last 1000)
|
||||
if (seenMessageContent.current.size > 1000) {
|
||||
const entries = Array.from(seenMessageContent.current);
|
||||
seenMessageContent.current = new Set(entries.slice(-500));
|
||||
}
|
||||
|
||||
// Determine conversation key for this message
|
||||
let conversationKey: string | null = null;
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
conversationKey = getStateKey('channel', msg.conversation_key);
|
||||
} else if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
conversationKey = getStateKey('contact', msg.conversation_key);
|
||||
}
|
||||
|
||||
// Check if message belongs to the active conversation
|
||||
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') {
|
||||
// Match by public key or prefix (either could be full key or prefix)
|
||||
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) {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, msg];
|
||||
});
|
||||
}
|
||||
|
||||
// Track last message time for sorting and unread detection
|
||||
if (conversationKey) {
|
||||
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
|
||||
const updated = setLastMessageTime(conversationKey, timestamp);
|
||||
setLastMessageTimes(updated);
|
||||
|
||||
// Count unread messages during this session (for non-active, incoming messages)
|
||||
if (!msg.outgoing && !isForActiveConversation) {
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
[conversationKey]: (prev[conversationKey] || 0) + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
// Update or add contact, preserving existing non-null values
|
||||
setContacts((prev) => {
|
||||
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
const existing = prev[idx];
|
||||
// Merge: prefer new non-null values, but keep existing values if new is null
|
||||
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) => {
|
||||
// Check if packet already exists
|
||||
if (prev.some((p) => p.id === packet.id)) {
|
||||
return prev;
|
||||
}
|
||||
// Limit to MAX_RAW_PACKETS, removing oldest
|
||||
const updated = [...prev, packet];
|
||||
if (updated.length > MAX_RAW_PACKETS) {
|
||||
return updated.slice(-MAX_RAW_PACKETS);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
onMessageAcked: (messageId: number) => {
|
||||
// Update message acked status
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === messageId);
|
||||
if (idx >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...prev[idx], acked: true };
|
||||
return updated;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
}), []);
|
||||
|
||||
// Connect to WebSocket
|
||||
useWebSocket(wsHandlers);
|
||||
|
||||
// Fetch radio config (not sent via WebSocket)
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getRadioConfig();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch app settings
|
||||
const fetchAppSettings = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getSettings();
|
||||
setAppSettings(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch app settings:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch undecrypted packet count
|
||||
const fetchUndecryptedCount = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getUndecryptedPacketCount();
|
||||
setUndecryptedCount(data.count);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch undecrypted count:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 200;
|
||||
|
||||
// Fetch messages for active conversation
|
||||
const fetchMessages = useCallback(async (showLoading = false) => {
|
||||
if (!activeConversation) {
|
||||
setMessages([]);
|
||||
setHasOlderMessages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLoading) {
|
||||
setMessagesLoading(true);
|
||||
}
|
||||
try {
|
||||
const data = await api.getMessages({
|
||||
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||
conversation_key: activeConversation.id,
|
||||
limit: MESSAGE_PAGE_SIZE,
|
||||
});
|
||||
setMessages(data);
|
||||
// If we got a full page, there might be more
|
||||
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages:', err);
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
// Fetch older messages (pagination)
|
||||
const fetchOlderMessages = useCallback(async () => {
|
||||
if (!activeConversation || loadingOlder || !hasOlderMessages) return;
|
||||
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const data = await api.getMessages({
|
||||
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||
conversation_key: activeConversation.id,
|
||||
limit: MESSAGE_PAGE_SIZE,
|
||||
offset: messages.length,
|
||||
});
|
||||
|
||||
if (data.length > 0) {
|
||||
// Prepend older messages (they come sorted DESC, so older are at the end)
|
||||
setMessages(prev => [...prev, ...data]);
|
||||
}
|
||||
// If we got less than a full page, no more messages
|
||||
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch older messages:', err);
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
|
||||
|
||||
// Initial fetch for config and settings (WebSocket handles health/contacts/channels)
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
fetchAppSettings();
|
||||
fetchUndecryptedCount();
|
||||
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount]);
|
||||
|
||||
// Select Public channel by default when channels first load
|
||||
const hasSetDefaultConversation = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasSetDefaultConversation.current || channels.length === 0 || activeConversation) return;
|
||||
|
||||
const publicChannel = channels.find(c => c.name === 'Public');
|
||||
if (publicChannel) {
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}, [channels, activeConversation]);
|
||||
|
||||
// Fetch messages and count unreads for all conversations on load (single bulk request)
|
||||
const fetchedChannels = useRef<Set<string>>(new Set());
|
||||
const fetchedContacts = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// Find channels and contacts we haven't fetched yet
|
||||
const newChannels = channels.filter(c => !fetchedChannels.current.has(c.key));
|
||||
const newContacts = contacts.filter(c => c.public_key && !fetchedContacts.current.has(c.public_key));
|
||||
|
||||
if (newChannels.length === 0 && newContacts.length === 0) return;
|
||||
|
||||
// Mark as fetched before starting (to avoid duplicate fetches if effect re-runs)
|
||||
newChannels.forEach(c => fetchedChannels.current.add(c.key));
|
||||
newContacts.forEach(c => fetchedContacts.current.add(c.public_key));
|
||||
|
||||
const fetchAndCountUnreads = async () => {
|
||||
// Build list of conversations to fetch
|
||||
const conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }> = [
|
||||
...newChannels.map(c => ({ type: 'CHAN' as const, conversation_key: c.key })),
|
||||
...newContacts.map(c => ({ type: 'PRIV' as const, conversation_key: c.public_key })),
|
||||
];
|
||||
|
||||
if (conversations.length === 0) return;
|
||||
|
||||
try {
|
||||
// Single bulk request for all conversations
|
||||
const bulkMessages = await api.getMessagesBulk(conversations, 100);
|
||||
|
||||
// Read lastReadTimes fresh from localStorage for accurate comparison
|
||||
const currentReadTimes = getLastReadTimes();
|
||||
const newUnreadCounts: Record<string, number> = {};
|
||||
const newLastMessageTimes: Record<string, number> = {};
|
||||
|
||||
// Process channel messages
|
||||
for (const channel of newChannels) {
|
||||
const msgs = bulkMessages[`CHAN:${channel.key}`] || [];
|
||||
if (msgs.length > 0) {
|
||||
const key = getStateKey('channel', channel.key);
|
||||
const lastRead = currentReadTimes[key] || 0;
|
||||
|
||||
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
|
||||
if (unreadCount > 0) {
|
||||
newUnreadCounts[key] = unreadCount;
|
||||
}
|
||||
|
||||
const latestTime = Math.max(...msgs.map(m => m.received_at));
|
||||
newLastMessageTimes[key] = latestTime;
|
||||
setLastMessageTime(key, latestTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Process contact messages
|
||||
for (const contact of newContacts) {
|
||||
const msgs = bulkMessages[`PRIV:${contact.public_key}`] || [];
|
||||
if (msgs.length > 0) {
|
||||
const key = getStateKey('contact', contact.public_key);
|
||||
const lastRead = currentReadTimes[key] || 0;
|
||||
|
||||
const unreadCount = msgs.filter(m => !m.outgoing && m.received_at > lastRead).length;
|
||||
if (unreadCount > 0) {
|
||||
newUnreadCounts[key] = unreadCount;
|
||||
}
|
||||
|
||||
const latestTime = Math.max(...msgs.map(m => m.received_at));
|
||||
newLastMessageTimes[key] = latestTime;
|
||||
setLastMessageTime(key, latestTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state with all the counts and times
|
||||
if (Object.keys(newUnreadCounts).length > 0) {
|
||||
setUnreadCounts(prev => ({ ...prev, ...newUnreadCounts }));
|
||||
}
|
||||
setLastMessageTimes(getLastMessageTimes());
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch messages bulk:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAndCountUnreads();
|
||||
}, [channels, contacts]);
|
||||
|
||||
// Keep ref in sync with state and mark conversation as read when viewed
|
||||
useEffect(() => {
|
||||
activeConversationRef.current = activeConversation;
|
||||
|
||||
// Mark conversation as read when user views it
|
||||
if (activeConversation && activeConversation.type !== 'raw') {
|
||||
const key = getStateKey(
|
||||
activeConversation.type as 'channel' | 'contact',
|
||||
activeConversation.id
|
||||
);
|
||||
// Update localStorage-based read time
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setLastReadTime(key, now);
|
||||
|
||||
// Clear unread count for this conversation
|
||||
setUnreadCounts((prev) => {
|
||||
if (prev[key]) {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
// Fetch messages when conversation changes
|
||||
useEffect(() => {
|
||||
fetchMessages(true);
|
||||
}, [fetchMessages]);
|
||||
|
||||
// Send message handler
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
if (!activeConversation) return;
|
||||
|
||||
if (activeConversation.type === 'channel') {
|
||||
await api.sendChannelMessage(activeConversation.id, text);
|
||||
} else {
|
||||
await api.sendDirectMessage(activeConversation.id, text);
|
||||
}
|
||||
// Message will arrive via WebSocket, but fetch to be safe
|
||||
await fetchMessages();
|
||||
},
|
||||
[activeConversation, fetchMessages]
|
||||
);
|
||||
|
||||
// Config save handler
|
||||
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]);
|
||||
|
||||
// Set private key handler
|
||||
const handleSetPrivateKey = useCallback(async (key: string) => {
|
||||
await api.setPrivateKey(key);
|
||||
await fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Reboot radio handler
|
||||
const handleReboot = useCallback(async () => {
|
||||
await api.rebootRadio();
|
||||
// Immediately show disconnected state
|
||||
setHealth((prev) =>
|
||||
prev ? { ...prev, radio_connected: false } : prev
|
||||
);
|
||||
// Health updates will come via WebSocket when reconnected
|
||||
// But also poll as backup
|
||||
const pollUntilReconnected = async () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try {
|
||||
const data = await api.getHealth();
|
||||
setHealth(data);
|
||||
if (data.radio_connected) {
|
||||
fetchConfig();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling
|
||||
}
|
||||
}
|
||||
};
|
||||
pollUntilReconnected();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Send flood advertisement handler
|
||||
const handleAdvertise = useCallback(async () => {
|
||||
try {
|
||||
await api.sendAdvertisement(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to send advertisement:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle sender click to add mention
|
||||
const handleSenderClick = useCallback((sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
}, []);
|
||||
|
||||
// Handle conversation selection (closes sidebar on mobile)
|
||||
const handleSelectConversation = useCallback((conv: Conversation) => {
|
||||
setActiveConversation(conv);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
// Delete channel handler
|
||||
const handleDeleteChannel = useCallback(async (key: string) => {
|
||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||
try {
|
||||
await api.deleteChannel(key);
|
||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
||||
setActiveConversation(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete channel:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Delete contact handler
|
||||
const handleDeleteContact = useCallback(async (publicKey: string) => {
|
||||
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
||||
try {
|
||||
await api.deleteContact(publicKey);
|
||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||
setActiveConversation(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete contact:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create contact handler
|
||||
const handleCreateContact = useCallback(
|
||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
||||
const newContact: Contact = {
|
||||
public_key: publicKey,
|
||||
name,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
};
|
||||
setContacts((prev) => [...prev, newContact]);
|
||||
|
||||
// Open the new contact
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: publicKey,
|
||||
name: getContactDisplayName(name, publicKey),
|
||||
});
|
||||
|
||||
if (tryHistorical) {
|
||||
console.log('Contact historical decryption not yet supported');
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Create channel handler
|
||||
const handleCreateChannel = useCallback(
|
||||
async (name: string, key: string, tryHistorical: boolean) => {
|
||||
const created = await api.createChannel(name, key);
|
||||
// Channel will be broadcast via WebSocket, but fetch to be safe
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
|
||||
// Open the new channel (use created.key as the id)
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: created.key,
|
||||
name,
|
||||
});
|
||||
|
||||
if (tryHistorical) {
|
||||
await api.decryptHistoricalPackets({
|
||||
key_type: 'channel',
|
||||
channel_key: created.key,
|
||||
});
|
||||
fetchUndecryptedCount();
|
||||
}
|
||||
},
|
||||
[fetchUndecryptedCount]
|
||||
);
|
||||
|
||||
// Create hashtag channel handler
|
||||
const handleCreateHashtagChannel = useCallback(
|
||||
async (name: string, tryHistorical: boolean) => {
|
||||
const channelName = name.startsWith('#') ? name : `#${name}`;
|
||||
|
||||
const created = await api.createChannel(channelName);
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
|
||||
// Open the new channel (use created.key as the id)
|
||||
setActiveConversation({
|
||||
type: 'channel',
|
||||
id: created.key,
|
||||
name: channelName,
|
||||
});
|
||||
|
||||
if (tryHistorical) {
|
||||
await api.decryptHistoricalPackets({
|
||||
key_type: 'channel',
|
||||
channel_name: channelName,
|
||||
});
|
||||
fetchUndecryptedCount();
|
||||
}
|
||||
},
|
||||
[fetchUndecryptedCount]
|
||||
);
|
||||
|
||||
// Sidebar content (shared between desktop and mobile)
|
||||
const sidebarContent = (
|
||||
<Sidebar
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
activeConversation={activeConversation}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewMessage={() => {
|
||||
setShowNewMessage(true);
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
lastMessageTimes={lastMessageTimes}
|
||||
unreadCounts={unreadCounts}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<StatusBar
|
||||
health={health}
|
||||
config={config}
|
||||
onConfigClick={() => setShowConfig(true)}
|
||||
onAdvertise={handleAdvertise}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Desktop sidebar - hidden on mobile */}
|
||||
<div className="hidden md:block">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar - Sheet that slides in */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex-1 flex flex-col bg-background">
|
||||
{activeConversation ? (
|
||||
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-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<RawPacketList packets={rawPackets} />
|
||||
</div>
|
||||
<div className="h-[280px] flex-shrink-0 border-t border-border overflow-hidden">
|
||||
<CrackerPanel
|
||||
packets={rawPackets}
|
||||
channels={channels}
|
||||
onChannelCreate={async (name, key) => {
|
||||
// Create channel without navigating to it
|
||||
const created = await api.createChannel(name, key);
|
||||
const data = await api.getChannels();
|
||||
setChannels(data);
|
||||
// Try to decrypt historical packets with this key
|
||||
await api.decryptHistoricalPackets({
|
||||
key_type: 'channel',
|
||||
channel_key: created.key,
|
||||
});
|
||||
fetchUndecryptedCount();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium">
|
||||
<span className="flex flex-col sm:flex-row sm:items-center sm:gap-2">
|
||||
<span>
|
||||
{activeConversation.type === 'channel' && !activeConversation.name.startsWith('#') ? '#' : ''}
|
||||
{activeConversation.name}
|
||||
</span>
|
||||
<span className="font-normal text-xs text-muted-foreground font-mono">
|
||||
{activeConversation.id}
|
||||
</span>
|
||||
</span>
|
||||
{!(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"
|
||||
onClick={() => {
|
||||
if (activeConversation.type === 'channel') {
|
||||
handleDeleteChannel(activeConversation.id);
|
||||
} else {
|
||||
handleDeleteContact(activeConversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
onSenderClick={activeConversation.type === 'channel' ? handleSenderClick : undefined}
|
||||
onLoadOlder={fetchOlderMessages}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={handleSendMessage}
|
||||
disabled={!health?.radio_connected}
|
||||
placeholder={
|
||||
health?.radio_connected
|
||||
? `Message ${activeConversation.name}...`
|
||||
: 'Radio not connected'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewMessageModal
|
||||
open={showNewMessage}
|
||||
contacts={contacts}
|
||||
undecryptedCount={undecryptedCount}
|
||||
onClose={() => setShowNewMessage(false)}
|
||||
onSelectConversation={(conv) => {
|
||||
setActiveConversation(conv);
|
||||
setShowNewMessage(false);
|
||||
}}
|
||||
onCreateContact={handleCreateContact}
|
||||
onCreateChannel={handleCreateChannel}
|
||||
onCreateHashtagChannel={handleCreateHashtagChannel}
|
||||
/>
|
||||
|
||||
<ConfigModal
|
||||
open={showConfig}
|
||||
config={config}
|
||||
appSettings={appSettings}
|
||||
onClose={() => setShowConfig(false)}
|
||||
onSave={handleSaveConfig}
|
||||
onSaveAppSettings={handleSaveAppSettings}
|
||||
onSetPrivateKey={handleSetPrivateKey}
|
||||
onReboot={handleReboot}
|
||||
/>
|
||||
|
||||
<Toaster position="top-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Channel,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
interface DecryptResult {
|
||||
started: boolean;
|
||||
total_packets: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Health
|
||||
getHealth: () => fetchJson<HealthStatus>('/health'),
|
||||
|
||||
// Radio config
|
||||
getRadioConfig: () => fetchJson<RadioConfig>('/radio/config'),
|
||||
updateRadioConfig: (config: RadioConfigUpdate) =>
|
||||
fetchJson<RadioConfig>('/radio/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
setPrivateKey: (privateKey: string) =>
|
||||
fetchJson<{ status: string }>('/radio/private-key', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ private_key: privateKey }),
|
||||
}),
|
||||
sendAdvertisement: (flood = true) =>
|
||||
fetchJson<{ status: string; flood: boolean }>(
|
||||
`/radio/advertise?flood=${flood}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
rebootRadio: () =>
|
||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||
method: 'POST',
|
||||
}),
|
||||
reconnectRadio: () =>
|
||||
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Contacts
|
||||
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' }),
|
||||
addContactToRadio: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}/add-to-radio`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
removeContactFromRadio: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}/remove-from-radio`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Channels
|
||||
getChannels: () => fetchJson<Channel[]>('/channels'),
|
||||
getChannel: (key: string) => fetchJson<Channel>(`/channels/${key}`),
|
||||
createChannel: (name: string, key?: string) =>
|
||||
fetchJson<Channel>('/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, key }),
|
||||
}),
|
||||
syncChannels: () =>
|
||||
fetchJson<{ synced: number }>('/channels/sync', { method: 'POST' }),
|
||||
deleteChannel: (key: string) =>
|
||||
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
|
||||
|
||||
// Messages
|
||||
getMessages: (params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
type?: 'PRIV' | 'CHAN';
|
||||
conversation_key?: string;
|
||||
}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
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);
|
||||
const query = searchParams.toString();
|
||||
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`);
|
||||
},
|
||||
getMessagesBulk: (
|
||||
conversations: Array<{ type: 'PRIV' | 'CHAN'; conversation_key: string }>,
|
||||
limitPerConversation: number = 100
|
||||
) =>
|
||||
fetchJson<Record<string, Message[]>>(
|
||||
`/messages/bulk?limit_per_conversation=${limitPerConversation}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(conversations),
|
||||
}
|
||||
),
|
||||
sendDirectMessage: (destination: string, text: string) =>
|
||||
fetchJson<Message>('/messages/direct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ destination, text }),
|
||||
}),
|
||||
sendChannelMessage: (channelKey: string, text: string) =>
|
||||
fetchJson<Message>('/messages/channel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
|
||||
// Packets
|
||||
getUndecryptedPacketCount: () =>
|
||||
fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
decryptHistoricalPackets: (params: {
|
||||
key_type: 'channel' | 'contact';
|
||||
channel_key?: string;
|
||||
channel_name?: string;
|
||||
}) =>
|
||||
fetchJson<DecryptResult>('/packets/decrypt/historical', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
|
||||
// App Settings
|
||||
getSettings: () => fetchJson<AppSettings>('/settings'),
|
||||
updateSettings: (settings: AppSettingsUpdate) =>
|
||||
fetchJson<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(settings),
|
||||
}),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 10%;
|
||||
--foreground: 0 0% 88%;
|
||||
--card: 0 0% 14%;
|
||||
--card-foreground: 0 0% 88%;
|
||||
--popover: 0 0% 14%;
|
||||
--popover-foreground: 0 0% 88%;
|
||||
--primary: 122 39% 49%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 20%;
|
||||
--secondary-foreground: 0 0% 88%;
|
||||
--muted: 0 0% 20%;
|
||||
--muted-foreground: 0 0% 53%;
|
||||
--accent: 0 0% 20%;
|
||||
--accent-foreground: 0 0% 88%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 20%;
|
||||
--input: 0 0% 20%;
|
||||
--ring: 122 39% 49%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Base styles - minimal CSS that Tailwind/shadcn can't handle */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Mobile sidebar override - ensures sidebar fills Sheet container */
|
||||
[data-state] .sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAvatarText, getAvatarColor, getContactAvatar, CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
|
||||
|
||||
describe('getAvatarText', () => {
|
||||
it('returns first emoji when name contains emoji', () => {
|
||||
expect(getAvatarText('John 🚀 Doe', 'abc123')).toBe('🚀');
|
||||
expect(getAvatarText('🎉 Party', 'abc123')).toBe('🎉');
|
||||
expect(getAvatarText('Test 😀 More 🎯', 'abc123')).toBe('😀');
|
||||
});
|
||||
|
||||
it('returns initials when name has space', () => {
|
||||
expect(getAvatarText('John Doe', 'abc123')).toBe('JD');
|
||||
expect(getAvatarText('Alice Bob Charlie', 'abc123')).toBe('AB');
|
||||
expect(getAvatarText('jane smith', 'abc123')).toBe('JS');
|
||||
});
|
||||
|
||||
it('returns single letter when no space', () => {
|
||||
expect(getAvatarText('John', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('alice', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('falls back to public key when name is null', () => {
|
||||
expect(getAvatarText(null, 'abc123def456')).toBe('AB');
|
||||
});
|
||||
|
||||
it('falls back to public key when name has no letters', () => {
|
||||
expect(getAvatarText('123 456', 'xyz789')).toBe('XY');
|
||||
expect(getAvatarText('---', 'def456')).toBe('DE');
|
||||
});
|
||||
|
||||
it('handles space but no letter after', () => {
|
||||
expect(getAvatarText('John ', 'abc123')).toBe('J');
|
||||
expect(getAvatarText('A 123', 'abc123')).toBe('A');
|
||||
});
|
||||
|
||||
it('emoji takes priority over initials', () => {
|
||||
expect(getAvatarText('John 🎯 Doe', 'abc123')).toBe('🎯');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatarColor', () => {
|
||||
it('returns consistent colors for same public key', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('abc123def456');
|
||||
expect(color1).toEqual(color2);
|
||||
});
|
||||
|
||||
it('returns different colors for different public keys', () => {
|
||||
const color1 = getAvatarColor('abc123def456');
|
||||
const color2 = getAvatarColor('xyz789uvw012');
|
||||
expect(color1.background).not.toBe(color2.background);
|
||||
});
|
||||
|
||||
it('returns valid HSL background color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(color.background).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
|
||||
});
|
||||
|
||||
it('returns white or black text color', () => {
|
||||
const color = getAvatarColor('test123');
|
||||
expect(['#ffffff', '#000000']).toContain(color.text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
const avatar = getContactAvatar('John Doe', 'abc123def456');
|
||||
expect(avatar.text).toBe('JD');
|
||||
expect(avatar.background).toMatch(/^hsl\(/);
|
||||
expect(['#ffffff', '#000000']).toContain(avatar.textColor);
|
||||
});
|
||||
|
||||
it('handles null name', () => {
|
||||
const avatar = getContactAvatar(null, 'abc123def456');
|
||||
expect(avatar.text).toBe('AB');
|
||||
});
|
||||
|
||||
it('returns repeater avatar for type=2', () => {
|
||||
const avatar = getContactAvatar('Some Repeater', 'abc123def456', CONTACT_TYPE_REPEATER);
|
||||
expect(avatar.text).toBe('🛜');
|
||||
expect(avatar.background).toBe('#444444');
|
||||
expect(avatar.textColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('repeater avatar ignores name', () => {
|
||||
const avatar1 = getContactAvatar('🚀 Rocket', 'abc123', CONTACT_TYPE_REPEATER);
|
||||
const avatar2 = getContactAvatar(null, 'xyz789', CONTACT_TYPE_REPEATER);
|
||||
expect(avatar1.text).toBe('🛜');
|
||||
expect(avatar2.text).toBe('🛜');
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('non-repeater types use normal avatar', () => {
|
||||
const avatar0 = getContactAvatar('John', 'abc123', 0);
|
||||
const avatar1 = getContactAvatar('John', 'abc123', 1);
|
||||
expect(avatar0.text).toBe('J');
|
||||
expect(avatar1.text).toBe('J');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for message deduplication in MessageList.
|
||||
*
|
||||
* Messages arriving via different packet paths should be deduplicated
|
||||
* based on (type, conversation_key, text, sender_timestamp).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Message } from '../types';
|
||||
|
||||
/**
|
||||
* Deduplication logic extracted from MessageList for testing.
|
||||
* Same message via different paths = same (type, conversation_key, text, timestamp)
|
||||
*/
|
||||
function deduplicateMessages(messages: Message[]): Message[] {
|
||||
return 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;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<Message>): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
|
||||
text: 'Test message',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
path_len: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Message Deduplication', () => {
|
||||
it('keeps unique messages', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, text: 'Message 1', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, text: 'Message 2', sender_timestamp: 2000 }),
|
||||
createMessage({ id: 3, text: 'Message 3', sender_timestamp: 3000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('deduplicates same channel message via different paths', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
|
||||
createMessage({ id: 3, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }), // duplicate
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1); // keeps first occurrence
|
||||
});
|
||||
|
||||
it('keeps messages with same text but different timestamps', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, text: 'Hello', sender_timestamp: 2000 }), // different timestamp
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps messages with same text but different channels', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, conversation_key: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', text: 'Hello', sender_timestamp: 1000 }), // different channel
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deduplicates same DM via different paths', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }), // duplicate
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps DMs from different senders with same text', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hi', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'def456789012345678901234567890123456789012345678901234567890', text: 'Hi', sender_timestamp: 1000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps channel message and DM with same text', () => {
|
||||
const messages = [
|
||||
createMessage({ id: 1, type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', text: 'Hello', sender_timestamp: 1000 }),
|
||||
createMessage({ id: 2, type: 'PRIV', conversation_key: 'abc123def456789012345678901234567890123456789012345678901234', text: 'Hello', sender_timestamp: 1000 }),
|
||||
];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const result = deduplicateMessages([]);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles single message', () => {
|
||||
const messages = [createMessage({ id: 1 })];
|
||||
|
||||
const result = deduplicateMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for message parsing utilities.
|
||||
*
|
||||
* These tests verify the sender extraction logic used to parse
|
||||
* channel messages in "sender: message" format.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseSenderFromText, formatTime } from '../utils/messageParser';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
describe('parseSenderFromText', () => {
|
||||
it('extracts sender and content from "sender: message" format', () => {
|
||||
const result = parseSenderFromText('Alice: Hello everyone!');
|
||||
|
||||
expect(result.sender).toBe('Alice');
|
||||
expect(result.content).toBe('Hello everyone!');
|
||||
});
|
||||
|
||||
it('handles sender names with spaces', () => {
|
||||
const result = parseSenderFromText('Bob Smith: How are you?');
|
||||
|
||||
expect(result.sender).toBe('Bob Smith');
|
||||
expect(result.content).toBe('How are you?');
|
||||
});
|
||||
|
||||
it('returns null sender for plain messages without colon-space', () => {
|
||||
const result = parseSenderFromText('Just a plain message');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('Just a plain message');
|
||||
});
|
||||
|
||||
it('returns null sender when colon has no space after', () => {
|
||||
const result = parseSenderFromText('Note:this is not a sender');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('Note:this is not a sender');
|
||||
});
|
||||
|
||||
it('rejects sender containing square brackets', () => {
|
||||
const result = parseSenderFromText('[System]: Alert message');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('[System]: Alert message');
|
||||
});
|
||||
|
||||
it('rejects sender containing colon', () => {
|
||||
const result = parseSenderFromText('12:30: Time announcement');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('12:30: Time announcement');
|
||||
});
|
||||
|
||||
it('rejects sender names longer than 50 characters', () => {
|
||||
const longName = 'A'.repeat(60);
|
||||
const result = parseSenderFromText(`${longName}: message`);
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const result = parseSenderFromText('');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe('');
|
||||
});
|
||||
|
||||
it('handles message with multiple colons', () => {
|
||||
const result = parseSenderFromText('User: Check this URL: https://example.com');
|
||||
|
||||
expect(result.sender).toBe('User');
|
||||
expect(result.content).toBe('Check this URL: https://example.com');
|
||||
});
|
||||
|
||||
it('handles colon at start of message', () => {
|
||||
const result = parseSenderFromText(': no sender here');
|
||||
|
||||
expect(result.sender).toBeNull();
|
||||
expect(result.content).toBe(': no sender here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats today timestamp as time only', () => {
|
||||
// Use current time to ensure it's "today"
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const result = formatTime(now);
|
||||
|
||||
// Should be just time (HH:MM format)
|
||||
expect(result).toMatch(/^\d{1,2}:\d{2}( [AP]M)?$/);
|
||||
});
|
||||
|
||||
it('formats older timestamp with date and time', () => {
|
||||
// Use a timestamp from 2023 (definitely not today)
|
||||
const timestamp = 1700000000; // 2023-11-14
|
||||
|
||||
const result = formatTime(timestamp);
|
||||
|
||||
// Should contain month, day, and time
|
||||
expect(result).toMatch(/\w+ \d{1,2}/); // e.g., "Nov 14"
|
||||
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStateKey', () => {
|
||||
it('creates channel state key with full id', () => {
|
||||
const key = getStateKey('channel', '5');
|
||||
|
||||
expect(key).toBe('channel-5');
|
||||
});
|
||||
|
||||
it('creates contact state key with 12-char prefix', () => {
|
||||
const fullKey = 'abcdef123456789012345678901234567890';
|
||||
const key = getStateKey('contact', fullKey);
|
||||
|
||||
expect(key).toBe('contact-abcdef123456');
|
||||
});
|
||||
|
||||
it('handles contact key shorter than 12 chars', () => {
|
||||
const shortKey = 'abc123';
|
||||
const key = getStateKey('contact', shortKey);
|
||||
|
||||
expect(key).toBe('contact-abc123');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for unread count tracking logic.
|
||||
*
|
||||
* These tests verify the unread message counting behavior
|
||||
* without involving React component rendering.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Message, Conversation } from '../types';
|
||||
import { getPubkeyPrefix, pubkeysMatch } from '../utils/pubkey';
|
||||
|
||||
/**
|
||||
* Determine if a message should increment unread count.
|
||||
* Extracted logic from App.tsx for testing.
|
||||
*/
|
||||
function shouldIncrementUnread(
|
||||
msg: Message,
|
||||
activeConversation: Conversation | null
|
||||
): { key: string } | null {
|
||||
// Only count incoming messages
|
||||
if (msg.outgoing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
const key = `channel-${msg.conversation_key}`;
|
||||
// Don't count if this channel is active
|
||||
if (activeConversation?.type === 'channel' && activeConversation?.id === msg.conversation_key) {
|
||||
return null;
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// 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)) {
|
||||
return null;
|
||||
}
|
||||
return { key };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a conversation from the counts map.
|
||||
* Extracted logic from Sidebar.tsx for testing.
|
||||
*/
|
||||
function getUnreadCount(
|
||||
type: 'channel' | 'contact',
|
||||
id: string,
|
||||
unreadCounts: Record<string, number>
|
||||
): number {
|
||||
if (type === 'channel') {
|
||||
return unreadCounts[`channel-${id}`] || 0;
|
||||
}
|
||||
// For contacts, use prefix
|
||||
const prefix = `contact-${getPubkeyPrefix(id)}`;
|
||||
return unreadCounts[prefix] || 0;
|
||||
}
|
||||
|
||||
describe('shouldIncrementUnread', () => {
|
||||
const createMessage = (overrides: Partial<Message>): Message => ({
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0', // 32-char hex channel key
|
||||
text: 'Test',
|
||||
sender_timestamp: null,
|
||||
received_at: Date.now(),
|
||||
path_len: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
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 result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3' });
|
||||
});
|
||||
|
||||
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 result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for outgoing messages', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3', outgoing: true });
|
||||
|
||||
const result = shouldIncrementUnread(msg, null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
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 result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'contact-abc123456789' });
|
||||
});
|
||||
|
||||
it('returns null for incoming direct message when viewing that contact', () => {
|
||||
const msg = createMessage({ type: 'PRIV', conversation_key: 'abc123456789012345678901234567890123456789012345678901234567' });
|
||||
const activeConversation: Conversation = {
|
||||
type: 'contact',
|
||||
id: 'abc123456789fullkey12345678901234567890123456789012345678',
|
||||
name: 'Alice',
|
||||
};
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns key when no conversation is active', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
|
||||
|
||||
const result = shouldIncrementUnread(msg, null);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0' });
|
||||
});
|
||||
|
||||
it('returns key when viewing raw packet feed', () => {
|
||||
const msg = createMessage({ type: 'CHAN', conversation_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
|
||||
const activeConversation: Conversation = { type: 'raw', id: 'raw', name: 'Packets' };
|
||||
|
||||
const result = shouldIncrementUnread(msg, activeConversation);
|
||||
|
||||
expect(result).toEqual({ key: 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnreadCount', () => {
|
||||
it('returns count for channel by exact key match', () => {
|
||||
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
|
||||
|
||||
expect(getUnreadCount('channel', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5', counts)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns 0 for channel with no unread', () => {
|
||||
const counts = { 'channel-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5': 3 };
|
||||
|
||||
expect(getUnreadCount('channel', 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB9', counts)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns count for contact using 12-char prefix', () => {
|
||||
const counts = { 'contact-abc123456789': 5 };
|
||||
|
||||
// Full public key lookup should match the prefix
|
||||
expect(getUnreadCount('contact', 'abc123456789fullpublickey123456789012345678901234', counts)).toBe(5);
|
||||
});
|
||||
|
||||
it('handles contact key shorter than 12 chars', () => {
|
||||
const counts = { 'contact-short': 2 };
|
||||
|
||||
expect(getUnreadCount('contact', 'short', counts)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 for contact with no unread', () => {
|
||||
const counts = { 'contact-abc123456789': 5 };
|
||||
|
||||
expect(getUnreadCount('contact', 'xyz999999999fullkey12345678901234567890123456789', counts)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for WebSocket message parsing.
|
||||
*
|
||||
* These tests verify that WebSocket messages are correctly parsed
|
||||
* and routed to the appropriate handlers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { HealthStatus, Contact, Channel, Message, RawPacket } from '../types';
|
||||
|
||||
/**
|
||||
* Parse and route a WebSocket message.
|
||||
* Extracted logic from useWebSocket.ts for testing.
|
||||
*/
|
||||
function parseWebSocketMessage(
|
||||
data: string,
|
||||
handlers: {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onContacts?: (contacts: Contact[]) => void;
|
||||
onChannels?: (channels: Channel[]) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
}
|
||||
): { type: string; handled: boolean } {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'health':
|
||||
handlers.onHealth?.(msg.data as HealthStatus);
|
||||
return { type: msg.type, handled: !!handlers.onHealth };
|
||||
case 'contacts':
|
||||
handlers.onContacts?.(msg.data as Contact[]);
|
||||
return { type: msg.type, handled: !!handlers.onContacts };
|
||||
case 'channels':
|
||||
handlers.onChannels?.(msg.data as Channel[]);
|
||||
return { type: msg.type, handled: !!handlers.onChannels };
|
||||
case 'message':
|
||||
handlers.onMessage?.(msg.data as Message);
|
||||
return { type: msg.type, handled: !!handlers.onMessage };
|
||||
case 'contact':
|
||||
handlers.onContact?.(msg.data as Contact);
|
||||
return { type: msg.type, handled: !!handlers.onContact };
|
||||
case 'raw_packet':
|
||||
handlers.onRawPacket?.(msg.data as RawPacket);
|
||||
return { type: msg.type, handled: !!handlers.onRawPacket };
|
||||
case 'message_acked':
|
||||
handlers.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
return { type: msg.type, handled: !!handlers.onMessageAcked };
|
||||
case 'pong':
|
||||
return { type: msg.type, handled: true };
|
||||
default:
|
||||
return { type: msg.type, handled: false };
|
||||
}
|
||||
} catch {
|
||||
return { type: 'error', handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseWebSocketMessage', () => {
|
||||
it('routes health message to onHealth handler', () => {
|
||||
const onHealth = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'health',
|
||||
data: { radio_connected: true, serial_port: '/dev/ttyUSB0' },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onHealth });
|
||||
|
||||
expect(result.type).toBe('health');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onHealth).toHaveBeenCalledWith({
|
||||
radio_connected: true,
|
||||
serial_port: '/dev/ttyUSB0',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with message ID', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
const data = JSON.stringify({
|
||||
type: 'message_acked',
|
||||
data: { message_id: 42 },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessageAcked });
|
||||
|
||||
expect(result.type).toBe('message_acked');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('routes new message to onMessage handler', () => {
|
||||
const onMessage = vi.fn();
|
||||
const messageData = {
|
||||
id: 123,
|
||||
type: 'CHAN',
|
||||
channel_idx: 0,
|
||||
text: 'Hello',
|
||||
received_at: 1700000000,
|
||||
outgoing: false,
|
||||
acked: false,
|
||||
};
|
||||
const data = JSON.stringify({ type: 'message', data: messageData });
|
||||
|
||||
const result = parseWebSocketMessage(data, { onMessage });
|
||||
|
||||
expect(result.type).toBe('message');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onMessage).toHaveBeenCalledWith(messageData);
|
||||
});
|
||||
|
||||
it('handles pong messages silently', () => {
|
||||
const data = JSON.stringify({ type: 'pong' });
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('pong');
|
||||
expect(result.handled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unhandled for unknown message types', () => {
|
||||
const data = JSON.stringify({ type: 'unknown_type', data: {} });
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('unknown_type');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', () => {
|
||||
const data = 'not valid json {';
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('error');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call handler when not provided', () => {
|
||||
const data = JSON.stringify({
|
||||
type: 'health',
|
||||
data: { radio_connected: true },
|
||||
});
|
||||
|
||||
const result = parseWebSocketMessage(data, {});
|
||||
|
||||
expect(result.type).toBe('health');
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('routes raw_packet to onRawPacket handler', () => {
|
||||
const onRawPacket = vi.fn();
|
||||
const packetData = {
|
||||
id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: 'deadbeef',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
decrypted: true,
|
||||
decrypted_info: { channel_name: '#test', sender: 'Alice' },
|
||||
};
|
||||
const data = JSON.stringify({ type: 'raw_packet', data: packetData });
|
||||
|
||||
const result = parseWebSocketMessage(data, { onRawPacket });
|
||||
|
||||
expect(result.type).toBe('raw_packet');
|
||||
expect(result.handled).toBe(true);
|
||||
expect(onRawPacket).toHaveBeenCalledWith(packetData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Type aliases for key types used throughout the application.
|
||||
* These are all hex strings but serve different purposes.
|
||||
*/
|
||||
|
||||
/** 64-character hex string identifying a contact/node */
|
||||
export type PublicKey = string;
|
||||
|
||||
/** 12-character hex prefix of a public key (used in message routing) */
|
||||
export type PubkeyPrefix = string;
|
||||
|
||||
/** 32-character hex string identifying a channel */
|
||||
export type ChannelKey = string;
|
||||
|
||||
export interface RadioSettings {
|
||||
freq: number;
|
||||
bw: number;
|
||||
sf: number;
|
||||
cr: number;
|
||||
}
|
||||
|
||||
export interface RadioConfig {
|
||||
public_key: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
tx_power: number;
|
||||
max_tx_power: number;
|
||||
radio: RadioSettings;
|
||||
}
|
||||
|
||||
export interface RadioConfigUpdate {
|
||||
name?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
tx_power?: number;
|
||||
radio?: RadioSettings;
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
status: string;
|
||||
radio_connected: boolean;
|
||||
serial_port: string | null;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
public_key: PublicKey;
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
last_advert: number | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
last_seen: number | null;
|
||||
on_radio: boolean;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
key: ChannelKey;
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
/** For PRIV: sender's PublicKey (or prefix). For CHAN: ChannelKey */
|
||||
conversation_key: string;
|
||||
text: string;
|
||||
sender_timestamp: number | null;
|
||||
received_at: number;
|
||||
path_len: number | null;
|
||||
txt_type: number;
|
||||
signature: string | null;
|
||||
outgoing: boolean;
|
||||
acked: boolean;
|
||||
}
|
||||
|
||||
export type ConversationType = 'contact' | 'channel' | 'raw';
|
||||
|
||||
export interface Conversation {
|
||||
type: ConversationType;
|
||||
/** PublicKey for contacts, ChannelKey for channels, 'raw' for raw feed */
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RawPacket {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
data: string; // hex
|
||||
payload_type: string;
|
||||
snr: number | null; // Signal-to-noise ratio in dB
|
||||
rssi: number | null; // Received signal strength in dBm
|
||||
decrypted: boolean;
|
||||
decrypted_info: {
|
||||
channel_name: string | null;
|
||||
sender: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
}
|
||||
|
||||
export interface AppSettingsUpdate {
|
||||
max_radio_contacts?: number;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import type { HealthStatus, Contact, Channel, Message, RawPacket } from './types';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface ErrorEvent {
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onContacts?: (contacts: Contact[]) => void;
|
||||
onChannels?: (channels: Channel[]) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number) => void;
|
||||
onError?: (error: ErrorEvent) => void;
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Determine WebSocket URL based on current location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// In development, connect directly to backend; in production, use same host
|
||||
const isDev = window.location.port === '5173';
|
||||
const wsUrl = isDev
|
||||
? `ws://localhost:8000/api/ws`
|
||||
: `${protocol}//${window.location.host}/api/ws`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
console.log('Attempting WebSocket reconnect...');
|
||||
connect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: WebSocketMessage = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'health':
|
||||
options.onHealth?.(msg.data as HealthStatus);
|
||||
break;
|
||||
case 'contacts':
|
||||
options.onContacts?.(msg.data as Contact[]);
|
||||
break;
|
||||
case 'channels':
|
||||
options.onChannels?.(msg.data as Channel[]);
|
||||
break;
|
||||
case 'message':
|
||||
options.onMessage?.(msg.data as Message);
|
||||
break;
|
||||
case 'contact':
|
||||
options.onContact?.(msg.data as Contact);
|
||||
break;
|
||||
case 'raw_packet':
|
||||
options.onRawPacket?.(msg.data as RawPacket);
|
||||
break;
|
||||
case 'message_acked':
|
||||
options.onMessageAcked?.((msg.data as { message_id: number }).message_id);
|
||||
break;
|
||||
case 'error':
|
||||
options.onError?.(msg.data as ErrorEvent);
|
||||
break;
|
||||
case 'pong':
|
||||
// Heartbeat response, ignore
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', msg.type);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
// Ping every 30 seconds to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send('ping');
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval);
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { connected };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Generate consistent profile "images" for contacts.
|
||||
*
|
||||
* Uses the contact's public key to generate a consistent background color,
|
||||
* and extracts initials or emoji from the name for display.
|
||||
* Repeaters (type=2) always show 🛜 with a gray background.
|
||||
*/
|
||||
|
||||
// Contact type constants (matches backend)
|
||||
export const CONTACT_TYPE_REPEATER = 2;
|
||||
|
||||
// Repeater avatar styling
|
||||
const REPEATER_AVATAR = {
|
||||
text: '🛜',
|
||||
background: '#444444',
|
||||
textColor: '#ffffff',
|
||||
};
|
||||
|
||||
// Simple hash function for strings
|
||||
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 & 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;
|
||||
|
||||
/**
|
||||
* Extract display characters from a contact name.
|
||||
* Priority:
|
||||
* 1. First emoji in the name
|
||||
* 2. First letter + first letter after first space (initials)
|
||||
* 3. First letter only
|
||||
*/
|
||||
export function getAvatarText(name: string | null, publicKey: string): string {
|
||||
if (!name) {
|
||||
// Use first 2 chars of public key as fallback
|
||||
return publicKey.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Check for emoji first
|
||||
const emojiMatch = name.match(emojiRegex);
|
||||
if (emojiMatch) {
|
||||
return emojiMatch[0];
|
||||
}
|
||||
|
||||
// Find first letter
|
||||
const letters = name.match(/[a-zA-Z]/g);
|
||||
if (!letters || letters.length === 0) {
|
||||
// No letters, use first 2 chars of public key
|
||||
return publicKey.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// Check for space - get initials
|
||||
const spaceIndex = name.indexOf(' ');
|
||||
if (spaceIndex !== -1) {
|
||||
const firstLetter = letters[0];
|
||||
// Find first letter after the space
|
||||
const afterSpace = name.slice(spaceIndex + 1).match(/[a-zA-Z]/);
|
||||
if (afterSpace) {
|
||||
return (firstLetter + afterSpace[0]).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Single letter
|
||||
return letters[0].toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a consistent HSL color from a public key.
|
||||
* Uses saturation and lightness ranges that work well for backgrounds.
|
||||
*/
|
||||
export function getAvatarColor(publicKey: string): {
|
||||
background: string;
|
||||
text: string;
|
||||
} {
|
||||
const hash = hashString(publicKey);
|
||||
|
||||
// Use hash to generate hue (0-360)
|
||||
const hue = hash % 360;
|
||||
|
||||
// Use different bits of hash for saturation variation (50-80%)
|
||||
const saturation = 50 + ((hash >> 8) % 30);
|
||||
|
||||
// Lightness in a range that allows readable text (35-55%)
|
||||
const lightness = 35 + ((hash >> 16) % 20);
|
||||
|
||||
const background = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
|
||||
// Calculate perceived luminance to determine text color
|
||||
// For HSL, we can approximate: if lightness < 50%, use white text
|
||||
// We'll use a slightly lower threshold since saturated colors appear darker
|
||||
const textColor = lightness < 45 ? '#ffffff' : '#000000';
|
||||
|
||||
return { background, text: textColor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all avatar properties for a contact.
|
||||
* Repeaters (type=2) always get a special gray avatar with 🛜.
|
||||
*/
|
||||
export function getContactAvatar(
|
||||
name: string | null,
|
||||
publicKey: string,
|
||||
contactType?: number
|
||||
): {
|
||||
text: string;
|
||||
background: string;
|
||||
textColor: string;
|
||||
} {
|
||||
// Repeaters always get the repeater avatar
|
||||
if (contactType === CONTACT_TYPE_REPEATER) {
|
||||
return REPEATER_AVATAR;
|
||||
}
|
||||
|
||||
const text = getAvatarText(name, publicKey);
|
||||
const colors = getAvatarColor(publicKey);
|
||||
|
||||
return {
|
||||
text,
|
||||
background: colors.background,
|
||||
textColor: colors.text,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* localStorage utilities for tracking conversation read/message state.
|
||||
*
|
||||
* Stores two maps:
|
||||
* - lastMessageTime: when each conversation last received a message
|
||||
* - lastReadTime: when the user last viewed each conversation
|
||||
*
|
||||
* A conversation has unread messages if lastMessageTime > lastReadTime.
|
||||
*/
|
||||
|
||||
import { getPubkeyPrefix } from './pubkey';
|
||||
|
||||
const LAST_MESSAGE_KEY = 'remoteterm-lastMessageTime';
|
||||
const LAST_READ_KEY = 'remoteterm-lastReadTime';
|
||||
|
||||
export type ConversationTimes = Record<string, number>;
|
||||
|
||||
function loadTimes(key: string): ConversationTimes {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveTimes(key: string, times: ConversationTimes): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(times));
|
||||
} catch {
|
||||
// localStorage might be full or disabled
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastMessageTimes(): ConversationTimes {
|
||||
return loadTimes(LAST_MESSAGE_KEY);
|
||||
}
|
||||
|
||||
export function getLastReadTimes(): ConversationTimes {
|
||||
return loadTimes(LAST_READ_KEY);
|
||||
}
|
||||
|
||||
export function setLastMessageTime(stateKey: string, timestamp: number): ConversationTimes {
|
||||
const times = loadTimes(LAST_MESSAGE_KEY);
|
||||
// Only update if this is a newer message
|
||||
if (!times[stateKey] || timestamp > times[stateKey]) {
|
||||
times[stateKey] = timestamp;
|
||||
saveTimes(LAST_MESSAGE_KEY, times);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
export function setLastReadTime(stateKey: string, timestamp: number): ConversationTimes {
|
||||
const times = loadTimes(LAST_READ_KEY);
|
||||
times[stateKey] = timestamp;
|
||||
saveTimes(LAST_READ_KEY, times);
|
||||
return times;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a state tracking key for unread counts and message times.
|
||||
*
|
||||
* This is NOT the same as Message.conversation_key (the database field).
|
||||
* This creates prefixed keys for localStorage/state tracking:
|
||||
* - Channels: "channel-{channelKey}"
|
||||
* - Contacts: "contact-{12-char-pubkey-prefix}"
|
||||
*
|
||||
* 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 {
|
||||
if (type === 'channel') {
|
||||
return `channel-${id}`;
|
||||
}
|
||||
// For contacts, use 12-char prefix for consistent matching
|
||||
return `contact-${getPubkeyPrefix(id)}`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Parse sender from channel message text.
|
||||
* Channel messages have format "sender: message".
|
||||
*/
|
||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||
const colonIndex = text.indexOf(': ');
|
||||
if (colonIndex > 0 && colonIndex < 50) {
|
||||
const potentialSender = text.substring(0, colonIndex);
|
||||
// Check for invalid characters that would indicate it's not a sender
|
||||
if (!/[:\[\]]/.test(potentialSender)) {
|
||||
return {
|
||||
sender: potentialSender,
|
||||
content: text.substring(colonIndex + 2),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { sender: null, content: text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp to a time string.
|
||||
* Shows date for messages not from today.
|
||||
*/
|
||||
export function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
if (isToday) {
|
||||
return time;
|
||||
}
|
||||
|
||||
// Show short date for older messages
|
||||
const dateStr = date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return `${dateStr} ${time}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Public key utilities for consistent handling of 64-char full keys
|
||||
* and 12-char prefixes throughout the application.
|
||||
*
|
||||
* MeshCore uses 64-character hex strings for public keys, but messages
|
||||
* and some radio operations only provide 12-character prefixes. This
|
||||
* module provides utilities for working with both formats consistently.
|
||||
*/
|
||||
|
||||
/** Length of a full public key in hex characters */
|
||||
export const PUBKEY_FULL_LENGTH = 64;
|
||||
|
||||
/** Length of a public key prefix in hex characters */
|
||||
export const PUBKEY_PREFIX_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Extract the 12-character prefix from a public key.
|
||||
* Works with both full keys and existing prefixes.
|
||||
*/
|
||||
export function getPubkeyPrefix(key: string): string {
|
||||
return key.slice(0, PUBKEY_PREFIX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two public keys match by comparing their prefixes.
|
||||
* This handles the case where one key is full (64 chars) and
|
||||
* the other is a prefix (12 chars).
|
||||
*/
|
||||
export function pubkeysMatch(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
return getPubkeyPrefix(a) === getPubkeyPrefix(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a public key starts with the given prefix.
|
||||
* More explicit than using .startsWith() directly.
|
||||
*/
|
||||
export function pubkeyMatchesPrefix(fullKey: string, prefix: string): boolean {
|
||||
if (!fullKey || !prefix) return false;
|
||||
return fullKey.startsWith(prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a display name for a contact, falling back to pubkey prefix.
|
||||
*/
|
||||
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
|
||||
return name || getPubkeyPrefix(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a full 64-character public key.
|
||||
*/
|
||||
export function isFullPubkey(key: string): boolean {
|
||||
return key.length === PUBKEY_FULL_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a 12-character prefix.
|
||||
*/
|
||||
export function isPubkeyPrefix(key: string): boolean {
|
||||
return key.length === PUBKEY_PREFIX_LENGTH;
|
||||
}
|
||||
Reference in New Issue
Block a user