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