Frontend overhaul
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 30 KiB |
@@ -1,8 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="700pt" height="700pt" version="1.1" viewBox="0 0 700 700" xmlns="http://www.w3.org/2000/svg">
|
<svg width="512pt" height="512pt" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="m405.89 352.77c0 30.797-25.02 55.762-55.887 55.762s-55.887-24.965-55.887-55.762c0-30.797 25.02-55.762 55.887-55.762s55.887 24.965 55.887 55.762z"/>
|
<path d="m455.68 85.902c-31.289 0-56.32 25.031-56.32 56.32 0 11.379 3.4141 21.617 8.5352 30.152l-106.38 135.39c12.516 6.2578 23.895 15.359 32.996 25.602l107.52-136.54c4.5508 1.1367 9.1016 1.707 13.652 1.707 31.289 0 56.32-25.031 56.32-56.32 0-30.719-25.031-56.32-56.32-56.32z"/>
|
||||||
<path d="m333.07 352.77h33.871v297.37h-33.871z"/>
|
<path d="m256 343.04c-5.6875 0-10.809 0.57031-15.93 2.2773l-106.38-135.96c-9.1016 10.809-20.48 19.344-32.996 25.602l106.38 135.96c-5.1211 8.5352-7.3945 18.203-7.3945 28.445 0 31.289 25.031 56.32 56.32 56.32s56.32-25.031 56.32-56.32c0-31.293-25.031-56.324-56.32-56.324z"/>
|
||||||
<path d="m412.71 495.07-13.648-30.926c44.27-19.438 72.879-63.152 72.879-111.37 0-67.082-54.699-121.65-121.94-121.65-67.242 0-121.94 54.57-121.94 121.65 0 48.215 28.609 91.93 72.879 111.37l-13.648 30.926c-56.547-24.844-93.094-80.695-93.094-142.3 0-85.715 69.887-155.44 155.8-155.44 85.918 0 155.8 69.727 155.8 155.44-0.003906 61.594-36.551 117.46-93.094 142.3z"/>
|
<path d="m356.69 114.91c3.9805-13.652 10.238-26.738 19.344-37.547-38.113-13.652-78.508-21.047-120.04-21.047-59.164 0-115.48 14.789-166.12 42.668-9.1016-6.8281-21.051-10.809-33.562-10.809-31.289-0.57031-56.32 25.027-56.32 55.75 0 31.289 25.031 56.32 56.32 56.32 31.289 0 56.32-25.031 56.32-56.32 0-3.4141-0.57031-6.8281-1.1367-9.6719 44.371-23.895 93.297-36.41 144.5-36.41 34.703 0 68.836 5.6914 100.69 17.066z"/>
|
||||||
<path d="m410.17 581.6-8.5742-32.691c89.277-23.309 151.63-103.96 151.63-196.15 0-111.8-91.168-202.75-203.22-202.75-112.06 0.003907-203.23 90.961-203.23 202.77 0 92.184 62.348 172.83 151.63 196.15l-8.5742 32.691c-104.18-27.195-176.93-121.3-176.93-228.83 0-130.43 106.36-236.54 237.1-236.54 130.73 0 237.1 106.12 237.1 236.54-0.003906 107.52-72.754 201.62-176.93 228.82z"/>
|
|
||||||
<path d="m409.05 661.5-6.3125-33.199c132.34-25.047 228.39-140.93 228.39-275.53 0-154.66-126.12-280.48-281.13-280.48-155 0-281.13 125.82-281.13 280.48 0 134.6 96.055 250.48 228.39 275.53l-6.3164 33.199c-148.3-28.07-255.95-157.91-255.95-308.73 0-173.29 141.32-314.27 315-314.27s315 140.98 315 314.27c0 150.81-107.65 280.66-255.95 308.73z"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 24 KiB |
@@ -16,10 +16,15 @@ import {
|
|||||||
useUnreadCounts,
|
useUnreadCounts,
|
||||||
useConversationMessages,
|
useConversationMessages,
|
||||||
getMessageContentKey,
|
getMessageContentKey,
|
||||||
|
useRadioControl,
|
||||||
|
useAppSettings,
|
||||||
|
useConversationRouter,
|
||||||
|
useContactsAndChannels,
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
import * as messageCache from './messageCache';
|
import * as messageCache from './messageCache';
|
||||||
import { StatusBar } from './components/StatusBar';
|
import { StatusBar } from './components/StatusBar';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
|
import { ChatHeader } from './components/ChatHeader';
|
||||||
import { MessageList } from './components/MessageList';
|
import { MessageList } from './components/MessageList';
|
||||||
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
|
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
|
||||||
import { NewMessageModal } from './components/NewMessageModal';
|
import { NewMessageModal } from './components/NewMessageModal';
|
||||||
@@ -43,68 +48,28 @@ const CrackerPanel = lazy(() =>
|
|||||||
);
|
);
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
|
||||||
import { Toaster, toast } from './components/ui/sonner';
|
import { Toaster, toast } from './components/ui/sonner';
|
||||||
import {
|
import { getStateKey } from './utils/conversationState';
|
||||||
getStateKey,
|
|
||||||
initLastMessageTimes,
|
|
||||||
loadLocalStorageLastMessageTimes,
|
|
||||||
loadLocalStorageSortOrder,
|
|
||||||
clearLocalStorageConversationState,
|
|
||||||
} from './utils/conversationState';
|
|
||||||
import { formatTime } from './utils/messageParser';
|
|
||||||
import { getContactDisplayName } from './utils/pubkey';
|
|
||||||
import {
|
|
||||||
parseHashConversation,
|
|
||||||
updateUrlHash,
|
|
||||||
getMapFocusHash,
|
|
||||||
resolveChannelFromHashToken,
|
|
||||||
resolveContactFromHashToken,
|
|
||||||
} from './utils/urlHash';
|
|
||||||
import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils';
|
|
||||||
import {
|
|
||||||
isFavorite,
|
|
||||||
loadLocalStorageFavorites,
|
|
||||||
clearLocalStorageFavorites,
|
|
||||||
} from './utils/favorites';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
|
||||||
AppSettingsUpdate,
|
|
||||||
Contact,
|
Contact,
|
||||||
Channel,
|
|
||||||
Conversation,
|
Conversation,
|
||||||
Favorite,
|
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
Message,
|
Message,
|
||||||
MessagePath,
|
MessagePath,
|
||||||
RawPacket,
|
RawPacket,
|
||||||
RadioConfig,
|
|
||||||
RadioConfigUpdate,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const MAX_RAW_PACKETS = 500;
|
const MAX_RAW_PACKETS = 500;
|
||||||
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const activeConversationRef = useRef<Conversation | null>(null);
|
|
||||||
const rebootPollTokenRef = useRef(0);
|
|
||||||
const pendingDeleteFallbackRef = useRef(false);
|
|
||||||
// Track seen message content to prevent duplicate unread increments
|
// Track seen message content to prevent duplicate unread increments
|
||||||
// Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication
|
|
||||||
const seenMessageContentRef = useRef<Set<string>>(new Set());
|
const seenMessageContentRef = useRef<Set<string>>(new Set());
|
||||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
|
||||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
|
||||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
||||||
const [contactsLoaded, setContactsLoaded] = useState(false);
|
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
|
||||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
|
||||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
|
||||||
const [showCracker, setShowCracker] = useState(false);
|
const [showCracker, setShowCracker] = useState(false);
|
||||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||||
|
|
||||||
@@ -112,18 +77,39 @@ export function App() {
|
|||||||
const crackerMounted = useRef(false);
|
const crackerMounted = useRef(false);
|
||||||
if (showCracker) crackerMounted.current = true;
|
if (showCracker) crackerMounted.current = true;
|
||||||
|
|
||||||
// Favorites are now stored server-side in appSettings.
|
// Shared refs between useConversationRouter and useContactsAndChannels
|
||||||
// Stable empty array prevents a new reference every render when there are none.
|
const pendingDeleteFallbackRef = useRef(false);
|
||||||
const emptyFavorites = useRef<Favorite[]>([]).current;
|
const hasSetDefaultConversation = useRef(false);
|
||||||
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
|
|
||||||
|
|
||||||
// Track previous health status to detect changes
|
// Stable ref bridge: useContactsAndChannels needs setActiveConversation from
|
||||||
const prevHealthRef = useRef<HealthStatus | null>(null);
|
// useConversationRouter, but useConversationRouter needs channels/contacts from
|
||||||
useEffect(() => {
|
// useContactsAndChannels. We break the cycle with a ref-based indirection.
|
||||||
return () => {
|
const setActiveConversationRef = useRef<(conv: Conversation | null) => void>(() => {});
|
||||||
rebootPollTokenRef.current += 1;
|
|
||||||
};
|
// --- Extracted hooks ---
|
||||||
}, []);
|
|
||||||
|
const {
|
||||||
|
health,
|
||||||
|
setHealth,
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
|
prevHealthRef,
|
||||||
|
fetchConfig,
|
||||||
|
handleSaveConfig,
|
||||||
|
handleSetPrivateKey,
|
||||||
|
handleReboot,
|
||||||
|
handleAdvertise,
|
||||||
|
handleHealthRefresh,
|
||||||
|
} = useRadioControl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
appSettings,
|
||||||
|
favorites,
|
||||||
|
fetchAppSettings,
|
||||||
|
handleSaveAppSettings,
|
||||||
|
handleSortOrderChange,
|
||||||
|
handleToggleFavorite,
|
||||||
|
} = useAppSettings();
|
||||||
|
|
||||||
// Keep user's name in ref for mention detection in WebSocket callback
|
// Keep user's name in ref for mention detection in WebSocket callback
|
||||||
const myNameRef = useRef<string | null>(null);
|
const myNameRef = useRef<string | null>(null);
|
||||||
@@ -140,7 +126,47 @@ export function App() {
|
|||||||
return mentionPattern.test(text);
|
return mentionPattern.test(text);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Custom hooks for extracted functionality
|
// useContactsAndChannels is called first — it uses the ref bridge for setActiveConversation
|
||||||
|
const {
|
||||||
|
contacts,
|
||||||
|
contactsLoaded,
|
||||||
|
channels,
|
||||||
|
undecryptedCount,
|
||||||
|
setContacts,
|
||||||
|
setContactsLoaded,
|
||||||
|
setChannels,
|
||||||
|
fetchAllContacts,
|
||||||
|
fetchUndecryptedCount,
|
||||||
|
handleCreateContact,
|
||||||
|
handleCreateChannel,
|
||||||
|
handleCreateHashtagChannel,
|
||||||
|
handleDeleteChannel,
|
||||||
|
handleDeleteContact,
|
||||||
|
} = useContactsAndChannels({
|
||||||
|
setActiveConversation: (conv) => setActiveConversationRef.current(conv),
|
||||||
|
pendingDeleteFallbackRef,
|
||||||
|
hasSetDefaultConversation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// useConversationRouter is called second — it receives channels/contacts as inputs
|
||||||
|
const {
|
||||||
|
activeConversation,
|
||||||
|
setActiveConversation,
|
||||||
|
activeConversationRef,
|
||||||
|
handleSelectConversation,
|
||||||
|
} = useConversationRouter({
|
||||||
|
channels,
|
||||||
|
contacts,
|
||||||
|
contactsLoaded,
|
||||||
|
setSidebarOpen,
|
||||||
|
pendingDeleteFallbackRef,
|
||||||
|
hasSetDefaultConversation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire up the ref bridge so useContactsAndChannels handlers reach the real setter
|
||||||
|
setActiveConversationRef.current = setActiveConversation;
|
||||||
|
|
||||||
|
// Custom hooks for conversation-specific functionality
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
messagesLoading,
|
messagesLoading,
|
||||||
@@ -229,10 +255,6 @@ export function App() {
|
|||||||
const contentKey = getMessageContentKey(msg);
|
const contentKey = getMessageContentKey(msg);
|
||||||
|
|
||||||
// Dedup: check if we've already seen this content BEFORE marking it seen.
|
// Dedup: check if we've already seen this content BEFORE marking it seen.
|
||||||
// This must happen before the active/non-active branch so that messages
|
|
||||||
// first seen in the active conversation are tracked — otherwise a mesh
|
|
||||||
// duplicate arriving after the user switches away would create a phantom
|
|
||||||
// unread badge.
|
|
||||||
const alreadySeen = !msg.outgoing && seenMessageContentRef.current.has(contentKey);
|
const alreadySeen = !msg.outgoing && seenMessageContentRef.current.has(contentKey);
|
||||||
if (!msg.outgoing) {
|
if (!msg.outgoing) {
|
||||||
seenMessageContentRef.current.add(contentKey);
|
seenMessageContentRef.current.add(contentKey);
|
||||||
@@ -269,8 +291,6 @@ export function App() {
|
|||||||
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
const idx = prev.findIndex((c) => c.public_key === contact.public_key);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const existing = prev[idx];
|
const existing = prev[idx];
|
||||||
// Skip update if all incoming fields are identical — avoids a new
|
|
||||||
// array reference (and Sidebar re-render) on every advertisement.
|
|
||||||
const merged = { ...existing, ...contact };
|
const merged = { ...existing, ...contact };
|
||||||
const unchanged = (Object.keys(merged) as (keyof Contact)[]).every(
|
const unchanged = (Object.keys(merged) as (keyof Contact)[]).every(
|
||||||
(k) => existing[k] === merged[k]
|
(k) => existing[k] === merged[k]
|
||||||
@@ -300,60 +320,12 @@ export function App() {
|
|||||||
messageCache.updateAck(messageId, ackCount, paths);
|
messageCache.updateAck(messageId, ackCount, paths);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention]
|
[addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention, prevHealthRef, setHealth, setConfig, activeConversationRef, setContacts]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Connect to WebSocket
|
// Connect to WebSocket
|
||||||
useWebSocket(wsHandlers);
|
useWebSocket(wsHandlers);
|
||||||
|
|
||||||
// Fetch radio config (not sent via WebSocket)
|
|
||||||
const fetchConfig = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await (takePrefetch('config') ?? api.getRadioConfig());
|
|
||||||
setConfig(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch config:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch app settings
|
|
||||||
const fetchAppSettings = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await (takePrefetch('settings') ?? api.getSettings());
|
|
||||||
setAppSettings(data);
|
|
||||||
// Initialize in-memory cache with server data
|
|
||||||
initLastMessageTimes(data.last_message_times ?? {});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch app settings:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch undecrypted packet count
|
|
||||||
const fetchUndecryptedCount = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await (takePrefetch('undecryptedCount') ?? api.getUndecryptedPacketCount());
|
|
||||||
setUndecryptedCount(data.count);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch undecrypted count:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch all contacts, paginating if >1000
|
|
||||||
const fetchAllContacts = useCallback(async (): Promise<Contact[]> => {
|
|
||||||
const pageSize = 1000;
|
|
||||||
const first = await (takePrefetch('contacts') ?? api.getContacts(pageSize, 0));
|
|
||||||
if (first.length < pageSize) return first;
|
|
||||||
let all = [...first];
|
|
||||||
let offset = pageSize;
|
|
||||||
while (true) {
|
|
||||||
const page = await api.getContacts(pageSize, offset);
|
|
||||||
all = all.concat(page);
|
|
||||||
if (page.length < pageSize) break;
|
|
||||||
offset += pageSize;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initial fetch for config, settings, and data
|
// Initial fetch for config, settings, and data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
@@ -371,204 +343,13 @@ export function App() {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
setContactsLoaded(true);
|
setContactsLoaded(true);
|
||||||
});
|
});
|
||||||
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts]);
|
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts, setChannels, setContacts, setContactsLoaded]);
|
||||||
|
|
||||||
// One-time migration of localStorage preferences to server
|
|
||||||
const hasMigratedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
// Only run once we have appSettings loaded
|
|
||||||
if (!appSettings || hasMigratedRef.current) return;
|
|
||||||
|
|
||||||
// Skip if already migrated on server
|
|
||||||
if (appSettings.preferences_migrated) {
|
|
||||||
// Just clear any leftover localStorage
|
|
||||||
clearLocalStorageFavorites();
|
|
||||||
clearLocalStorageConversationState();
|
|
||||||
hasMigratedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have any localStorage data to migrate
|
|
||||||
const localFavorites = loadLocalStorageFavorites();
|
|
||||||
const localSortOrder = loadLocalStorageSortOrder();
|
|
||||||
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
|
|
||||||
|
|
||||||
const hasLocalData =
|
|
||||||
localFavorites.length > 0 ||
|
|
||||||
localSortOrder !== 'recent' ||
|
|
||||||
Object.keys(localLastMessageTimes).length > 0;
|
|
||||||
|
|
||||||
if (!hasLocalData) {
|
|
||||||
// No local data to migrate, just mark as done
|
|
||||||
hasMigratedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as migrating immediately to prevent duplicate calls
|
|
||||||
hasMigratedRef.current = true;
|
|
||||||
|
|
||||||
// Migrate localStorage to server
|
|
||||||
const migratePreferences = async () => {
|
|
||||||
try {
|
|
||||||
const result = await api.migratePreferences({
|
|
||||||
favorites: localFavorites,
|
|
||||||
sort_order: localSortOrder,
|
|
||||||
last_message_times: localLastMessageTimes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.migrated) {
|
|
||||||
toast.success('Preferences migrated', {
|
|
||||||
description: `Migrated ${localFavorites.length} favorites to server`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local state with migrated settings
|
|
||||||
setAppSettings(result.settings);
|
|
||||||
// Reinitialize cache with migrated data
|
|
||||||
initLastMessageTimes(result.settings.last_message_times ?? {});
|
|
||||||
|
|
||||||
// Clear localStorage after successful migration
|
|
||||||
clearLocalStorageFavorites();
|
|
||||||
clearLocalStorageConversationState();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to migrate preferences:', err);
|
|
||||||
// Don't block the app on migration failure
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
migratePreferences();
|
|
||||||
}, [appSettings]);
|
|
||||||
|
|
||||||
// Phase 1: Set initial conversation from URL hash or default to Public channel
|
|
||||||
// Only needs channels (fast path) - doesn't wait for contacts
|
|
||||||
const hasSetDefaultConversation = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
|
||||||
if (channels.length === 0) return;
|
|
||||||
|
|
||||||
const hashConv = parseHashConversation();
|
|
||||||
|
|
||||||
// Handle non-data views immediately
|
|
||||||
if (hashConv?.type === 'raw') {
|
|
||||||
setActiveConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' });
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hashConv?.type === 'map') {
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'map',
|
|
||||||
id: 'map',
|
|
||||||
name: 'Node Map',
|
|
||||||
mapFocusKey: hashConv.mapFocusKey,
|
|
||||||
});
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hashConv?.type === 'visualizer') {
|
|
||||||
setActiveConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle channel hash (ID-first with legacy-name fallback)
|
|
||||||
if (hashConv?.type === 'channel') {
|
|
||||||
const channel = resolveChannelFromHashToken(hashConv.name, channels);
|
|
||||||
if (channel) {
|
|
||||||
setActiveConversation({ type: 'channel', id: channel.key, name: channel.name });
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact hash — wait for phase 2
|
|
||||||
if (hashConv?.type === 'contact') return;
|
|
||||||
|
|
||||||
// No hash or unresolvable — default to Public
|
|
||||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
|
||||||
if (publicChannel) {
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: publicChannel.key,
|
|
||||||
name: publicChannel.name,
|
|
||||||
});
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
}
|
|
||||||
}, [channels, activeConversation]);
|
|
||||||
|
|
||||||
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
|
||||||
|
|
||||||
const hashConv = parseHashConversation();
|
|
||||||
if (hashConv?.type === 'contact') {
|
|
||||||
// Wait until the initial contacts load finishes so we don't fall back early.
|
|
||||||
if (!contactsLoaded) return;
|
|
||||||
|
|
||||||
const contact = resolveContactFromHashToken(hashConv.name, contacts);
|
|
||||||
if (contact) {
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'contact',
|
|
||||||
id: contact.public_key,
|
|
||||||
name: getContactDisplayName(contact.name, contact.public_key),
|
|
||||||
});
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact hash didn't match — fall back to Public if channels loaded.
|
|
||||||
if (channels.length > 0) {
|
|
||||||
const publicChannel = channels.find((c) => c.name === 'Public');
|
|
||||||
if (publicChannel) {
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: publicChannel.key,
|
|
||||||
name: publicChannel.name,
|
|
||||||
});
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [contacts, channels, activeConversation, contactsLoaded]);
|
|
||||||
|
|
||||||
// Keep ref in sync and update URL hash
|
|
||||||
useEffect(() => {
|
|
||||||
activeConversationRef.current = activeConversation;
|
|
||||||
if (activeConversation) {
|
|
||||||
updateUrlHash(activeConversation);
|
|
||||||
}
|
|
||||||
}, [activeConversation]);
|
|
||||||
|
|
||||||
// If a delete action left us without an active conversation, recover to Public
|
|
||||||
// once channels are available. This is scoped to delete flows only so it doesn't
|
|
||||||
// interfere with hash-based startup resolution.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingDeleteFallbackRef.current) return;
|
|
||||||
if (activeConversation) {
|
|
||||||
pendingDeleteFallbackRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicChannel =
|
|
||||||
channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
|
|
||||||
channels.find((c) => c.name === 'Public');
|
|
||||||
if (!publicChannel) return;
|
|
||||||
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
pendingDeleteFallbackRef.current = false;
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: publicChannel.key,
|
|
||||||
name: publicChannel.name,
|
|
||||||
});
|
|
||||||
}, [activeConversation, channels]);
|
|
||||||
|
|
||||||
// Send message handler
|
// Send message handler
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
if (!activeConversation) return;
|
if (!activeConversation) return;
|
||||||
|
|
||||||
// Capture conversation identity before the await — the user could switch
|
|
||||||
// conversations while the send is in flight.
|
|
||||||
const conversationId = activeConversation.id;
|
const conversationId = activeConversation.id;
|
||||||
|
|
||||||
let sent: Message;
|
let sent: Message;
|
||||||
@@ -578,94 +359,13 @@ export function App() {
|
|||||||
sent = await api.sendDirectMessage(activeConversation.id, text);
|
sent = await api.sendDirectMessage(activeConversation.id, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the returned message directly instead of re-fetching all messages.
|
|
||||||
// A full fetchMessages() replaces the array, which resets messagesAdded to 0
|
|
||||||
// and skips the scroll-to-bottom — especially when racing with the initial
|
|
||||||
// conversation load or a WebSocket delivery that already incremented the count.
|
|
||||||
// The send endpoints do NOT broadcast via WebSocket, so for DMs this is the
|
|
||||||
// only path that shows the sent message. For channels, the radio echo may
|
|
||||||
// eventually arrive via WS, but addMessageIfNew deduplicates by content key.
|
|
||||||
if (activeConversationRef.current?.id === conversationId) {
|
if (activeConversationRef.current?.id === conversationId) {
|
||||||
addMessageIfNew(sent);
|
addMessageIfNew(sent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeConversation, addMessageIfNew]
|
[activeConversation, addMessageIfNew, activeConversationRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Config save handler
|
|
||||||
const handleSaveConfig = useCallback(
|
|
||||||
async (update: RadioConfigUpdate) => {
|
|
||||||
await api.updateRadioConfig(update);
|
|
||||||
await fetchConfig();
|
|
||||||
},
|
|
||||||
[fetchConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
// App settings save handler
|
|
||||||
const handleSaveAppSettings = useCallback(
|
|
||||||
async (update: AppSettingsUpdate) => {
|
|
||||||
await api.updateSettings(update);
|
|
||||||
await fetchAppSettings();
|
|
||||||
},
|
|
||||||
[fetchAppSettings]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set private key handler
|
|
||||||
const handleSetPrivateKey = useCallback(
|
|
||||||
async (key: string) => {
|
|
||||||
await api.setPrivateKey(key);
|
|
||||||
await fetchConfig();
|
|
||||||
},
|
|
||||||
[fetchConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reboot radio handler
|
|
||||||
const handleReboot = useCallback(async () => {
|
|
||||||
await api.rebootRadio();
|
|
||||||
setHealth((prev) => (prev ? { ...prev, radio_connected: false } : prev));
|
|
||||||
const pollToken = ++rebootPollTokenRef.current;
|
|
||||||
const pollUntilReconnected = async () => {
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
if (rebootPollTokenRef.current !== pollToken) return;
|
|
||||||
try {
|
|
||||||
const data = await api.getHealth();
|
|
||||||
if (rebootPollTokenRef.current !== pollToken) return;
|
|
||||||
setHealth(data);
|
|
||||||
if (data.radio_connected) {
|
|
||||||
fetchConfig();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep polling
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
pollUntilReconnected();
|
|
||||||
}, [fetchConfig]);
|
|
||||||
|
|
||||||
// Send flood advertisement handler
|
|
||||||
const handleAdvertise = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await api.sendAdvertisement();
|
|
||||||
toast.success('Advertisement sent');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to send advertisement:', err);
|
|
||||||
toast.error('Failed to send advertisement', {
|
|
||||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleHealthRefresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.getHealth();
|
|
||||||
setHealth(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to refresh health:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle resend channel message
|
// Handle resend channel message
|
||||||
const handleResendChannelMessage = useCallback(async (messageId: number) => {
|
const handleResendChannelMessage = useCallback(async (messageId: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -683,164 +383,6 @@ export function App() {
|
|||||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle conversation selection (closes sidebar on mobile)
|
|
||||||
const handleSelectConversation = useCallback((conv: Conversation) => {
|
|
||||||
setActiveConversation(conv);
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Toggle favorite status for a conversation (via API) with optimistic update
|
|
||||||
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
|
|
||||||
// Read current favorites inside the callback to avoid a dependency on the
|
|
||||||
// derived `favorites` array (which creates a new reference every render).
|
|
||||||
setAppSettings((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const currentFavorites = prev.favorites ?? [];
|
|
||||||
const wasFavorited = isFavorite(currentFavorites, type, id);
|
|
||||||
const optimisticFavorites = wasFavorited
|
|
||||||
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
|
|
||||||
: [...currentFavorites, { type, id }];
|
|
||||||
return { ...prev, favorites: optimisticFavorites };
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedSettings = await api.toggleFavorite(type, id);
|
|
||||||
setAppSettings(updatedSettings);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle favorite:', err);
|
|
||||||
// Revert: re-fetch would be safest, but restoring from server state on next sync
|
|
||||||
// is acceptable. For now, just refetch settings.
|
|
||||||
try {
|
|
||||||
const settings = await api.getSettings();
|
|
||||||
setAppSettings(settings);
|
|
||||||
} catch {
|
|
||||||
// If refetch also fails, leave optimistic state
|
|
||||||
}
|
|
||||||
toast.error('Failed to update favorite');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Delete channel handler
|
|
||||||
const handleDeleteChannel = useCallback(async (key: string) => {
|
|
||||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
|
||||||
try {
|
|
||||||
pendingDeleteFallbackRef.current = true;
|
|
||||||
await api.deleteChannel(key);
|
|
||||||
messageCache.remove(key);
|
|
||||||
const refreshedChannels = await api.getChannels();
|
|
||||||
setChannels(refreshedChannels);
|
|
||||||
const publicChannel =
|
|
||||||
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
|
|
||||||
refreshedChannels.find((c) => c.name === 'Public');
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
|
|
||||||
name: publicChannel?.name || 'Public',
|
|
||||||
});
|
|
||||||
toast.success('Channel deleted');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete channel:', err);
|
|
||||||
toast.error('Failed to delete channel', {
|
|
||||||
description: err instanceof Error ? err.message : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Delete contact handler
|
|
||||||
const handleDeleteContact = useCallback(async (publicKey: string) => {
|
|
||||||
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
|
||||||
try {
|
|
||||||
pendingDeleteFallbackRef.current = true;
|
|
||||||
await api.deleteContact(publicKey);
|
|
||||||
messageCache.remove(publicKey);
|
|
||||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
|
||||||
const refreshedChannels = await api.getChannels();
|
|
||||||
setChannels(refreshedChannels);
|
|
||||||
const publicChannel =
|
|
||||||
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
|
|
||||||
refreshedChannels.find((c) => c.name === 'Public');
|
|
||||||
hasSetDefaultConversation.current = true;
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
|
|
||||||
name: publicChannel?.name || 'Public',
|
|
||||||
});
|
|
||||||
toast.success('Contact deleted');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete contact:', err);
|
|
||||||
toast.error('Failed to delete contact', {
|
|
||||||
description: err instanceof Error ? err.message : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Create contact handler
|
|
||||||
const handleCreateContact = useCallback(
|
|
||||||
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
|
||||||
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
|
||||||
const data = await fetchAllContacts();
|
|
||||||
setContacts(data);
|
|
||||||
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'contact',
|
|
||||||
id: created.public_key,
|
|
||||||
name: getContactDisplayName(created.name, created.public_key),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[fetchAllContacts]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create channel handler
|
|
||||||
const handleCreateChannel = useCallback(
|
|
||||||
async (name: string, key: string, tryHistorical: boolean) => {
|
|
||||||
const created = await api.createChannel(name, key);
|
|
||||||
const data = await api.getChannels();
|
|
||||||
setChannels(data);
|
|
||||||
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: created.key,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tryHistorical) {
|
|
||||||
await api.decryptHistoricalPackets({
|
|
||||||
key_type: 'channel',
|
|
||||||
channel_key: created.key,
|
|
||||||
});
|
|
||||||
fetchUndecryptedCount();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchUndecryptedCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create hashtag channel handler
|
|
||||||
const handleCreateHashtagChannel = useCallback(
|
|
||||||
async (name: string, tryHistorical: boolean) => {
|
|
||||||
const channelName = name.startsWith('#') ? name : `#${name}`;
|
|
||||||
|
|
||||||
const created = await api.createChannel(channelName);
|
|
||||||
const data = await api.getChannels();
|
|
||||||
setChannels(data);
|
|
||||||
|
|
||||||
setActiveConversation({
|
|
||||||
type: 'channel',
|
|
||||||
id: created.key,
|
|
||||||
name: channelName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tryHistorical) {
|
|
||||||
await api.decryptHistoricalPackets({
|
|
||||||
key_type: 'channel',
|
|
||||||
channel_name: channelName,
|
|
||||||
});
|
|
||||||
fetchUndecryptedCount();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[fetchUndecryptedCount]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle direct trace request
|
// Handle direct trace request
|
||||||
const handleTrace = useCallback(async () => {
|
const handleTrace = useCallback(async () => {
|
||||||
if (!activeConversation || activeConversation.type !== 'contact') return;
|
if (!activeConversation || activeConversation.type !== 'contact') return;
|
||||||
@@ -859,28 +401,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [activeConversation]);
|
}, [activeConversation]);
|
||||||
|
|
||||||
// Handle sort order change via API with optimistic update
|
|
||||||
const handleSortOrderChange = useCallback(
|
|
||||||
async (order: 'recent' | 'alpha') => {
|
|
||||||
// Capture previous value for rollback on error
|
|
||||||
const previousOrder = appSettings?.sidebar_sort_order ?? 'recent';
|
|
||||||
|
|
||||||
// Optimistic update for responsive UI
|
|
||||||
setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: order } : prev));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedSettings = await api.updateSettings({ sidebar_sort_order: order });
|
|
||||||
setAppSettings(updatedSettings);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to update sort order:', err);
|
|
||||||
// Revert to previous value on error (not inverting the new value)
|
|
||||||
setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: previousOrder } : prev));
|
|
||||||
toast.error('Failed to save sort preference');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[appSettings?.sidebar_sort_order]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCloseSettingsView = useCallback(() => {
|
const handleCloseSettingsView = useCallback(() => {
|
||||||
startTransition(() => setShowSettings(false));
|
startTransition(() => setShowSettings(false));
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
@@ -936,7 +456,7 @@ export function App() {
|
|||||||
title="Back to conversations"
|
title="Back to conversations"
|
||||||
aria-label="Back to conversations"
|
aria-label="Back to conversations"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
@@ -1029,162 +549,16 @@ export function App() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
<ChatHeader
|
||||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
conversation={activeConversation}
|
||||||
<span className="flex-shrink-0 font-semibold text-base">
|
contacts={contacts}
|
||||||
{activeConversation.type === 'channel' &&
|
config={config}
|
||||||
!activeConversation.name.startsWith('#') &&
|
favorites={favorites}
|
||||||
activeConversation.name !== 'Public'
|
onTrace={handleTrace}
|
||||||
? '#'
|
onToggleFavorite={handleToggleFavorite}
|
||||||
: ''}
|
onDeleteChannel={handleDeleteChannel}
|
||||||
{activeConversation.name}
|
onDeleteContact={handleDeleteContact}
|
||||||
</span>
|
/>
|
||||||
<span
|
|
||||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(activeConversation.id);
|
|
||||||
toast.success(
|
|
||||||
activeConversation.type === 'channel'
|
|
||||||
? 'Room key copied!'
|
|
||||||
: 'Contact key copied!'
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
title="Click to copy"
|
|
||||||
>
|
|
||||||
{activeConversation.type === 'channel'
|
|
||||||
? activeConversation.id.toLowerCase()
|
|
||||||
: activeConversation.id}
|
|
||||||
</span>
|
|
||||||
{activeConversation.type === 'contact' &&
|
|
||||||
(() => {
|
|
||||||
const contact = contacts.find(
|
|
||||||
(c) => c.public_key === activeConversation.id
|
|
||||||
);
|
|
||||||
if (!contact) return null;
|
|
||||||
const parts: React.ReactNode[] = [];
|
|
||||||
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' : ''}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Add coordinate link if contact has valid location
|
|
||||||
if (isValidLocation(contact.lat, contact.lon)) {
|
|
||||||
// Calculate distance from us if we have valid location
|
|
||||||
const distFromUs =
|
|
||||||
config && isValidLocation(config.lat, config.lon)
|
|
||||||
? calculateDistance(
|
|
||||||
config.lat,
|
|
||||||
config.lon,
|
|
||||||
contact.lat,
|
|
||||||
contact.lon
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
parts.push(
|
|
||||||
<span key="coords">
|
|
||||||
<span
|
|
||||||
className="font-mono cursor-pointer hover:text-primary hover:underline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const url =
|
|
||||||
window.location.origin +
|
|
||||||
window.location.pathname +
|
|
||||||
getMapFocusHash(contact.public_key);
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}}
|
|
||||||
title="View on map"
|
|
||||||
>
|
|
||||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
|
||||||
</span>
|
|
||||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? (
|
|
||||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
|
||||||
(
|
|
||||||
{parts.map((part, i) => (
|
|
||||||
<span key={i}>
|
|
||||||
{i > 0 && ', '}
|
|
||||||
{part}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
|
||||||
{/* Direct trace button (contacts only) */}
|
|
||||||
{activeConversation.type === 'contact' && (
|
|
||||||
<button
|
|
||||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
|
||||||
onClick={handleTrace}
|
|
||||||
title="Direct Trace"
|
|
||||||
>
|
|
||||||
🛎
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Favorite button */}
|
|
||||||
{(activeConversation.type === 'channel' ||
|
|
||||||
activeConversation.type === 'contact') && (
|
|
||||||
<button
|
|
||||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
handleToggleFavorite(
|
|
||||||
activeConversation.type as 'channel' | 'contact',
|
|
||||||
activeConversation.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
isFavorite(
|
|
||||||
favorites,
|
|
||||||
activeConversation.type as 'channel' | 'contact',
|
|
||||||
activeConversation.id
|
|
||||||
)
|
|
||||||
? 'Remove from favorites'
|
|
||||||
: 'Add to favorites'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isFavorite(
|
|
||||||
favorites,
|
|
||||||
activeConversation.type as 'channel' | 'contact',
|
|
||||||
activeConversation.id
|
|
||||||
) ? (
|
|
||||||
<span className="text-amber-400">★</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">☆</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Delete button */}
|
|
||||||
{!(
|
|
||||||
activeConversation.type === 'channel' &&
|
|
||||||
activeConversation.name === 'Public'
|
|
||||||
) && (
|
|
||||||
<button
|
|
||||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
if (activeConversation.type === 'channel') {
|
|
||||||
handleDeleteChannel(activeConversation.id);
|
|
||||||
} else {
|
|
||||||
handleDeleteContact(activeConversation.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
🗑
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<MessageList
|
<MessageList
|
||||||
key={activeConversation.id}
|
key={activeConversation.id}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { toast } from './ui/sonner';
|
||||||
|
import { formatTime } from '../utils/messageParser';
|
||||||
|
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||||
|
import { getMapFocusHash } from '../utils/urlHash';
|
||||||
|
import { isFavorite } from '../utils/favorites';
|
||||||
|
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||||
|
|
||||||
|
interface ChatHeaderProps {
|
||||||
|
conversation: Conversation;
|
||||||
|
contacts: Contact[];
|
||||||
|
config: RadioConfig | null;
|
||||||
|
favorites: Favorite[];
|
||||||
|
onTrace: () => void;
|
||||||
|
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||||
|
onDeleteChannel: (key: string) => void;
|
||||||
|
onDeleteContact: (publicKey: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatHeader({
|
||||||
|
conversation,
|
||||||
|
contacts,
|
||||||
|
config,
|
||||||
|
favorites,
|
||||||
|
onTrace,
|
||||||
|
onToggleFavorite,
|
||||||
|
onDeleteChannel,
|
||||||
|
onDeleteContact,
|
||||||
|
}: ChatHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||||
|
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||||
|
<span className="flex-shrink-0 font-semibold text-base">
|
||||||
|
{conversation.type === 'channel' &&
|
||||||
|
!conversation.name.startsWith('#') &&
|
||||||
|
conversation.name !== 'Public'
|
||||||
|
? '#'
|
||||||
|
: ''}
|
||||||
|
{conversation.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigator.clipboard.writeText(conversation.id);
|
||||||
|
toast.success(
|
||||||
|
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
||||||
|
</span>
|
||||||
|
{conversation.type === 'contact' &&
|
||||||
|
(() => {
|
||||||
|
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||||
|
if (!contact) return null;
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
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' : ''}`);
|
||||||
|
}
|
||||||
|
if (isValidLocation(contact.lat, contact.lon)) {
|
||||||
|
const distFromUs =
|
||||||
|
config && isValidLocation(config.lat, config.lon)
|
||||||
|
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||||
|
: null;
|
||||||
|
parts.push(
|
||||||
|
<span key="coords">
|
||||||
|
<span
|
||||||
|
className="font-mono cursor-pointer hover:text-primary hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const url =
|
||||||
|
window.location.origin +
|
||||||
|
window.location.pathname +
|
||||||
|
getMapFocusHash(contact.public_key);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}}
|
||||||
|
title="View on map"
|
||||||
|
>
|
||||||
|
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? (
|
||||||
|
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||||
|
(
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && ', '}
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
{/* Direct trace button (contacts only) */}
|
||||||
|
{conversation.type === 'contact' && (
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||||
|
onClick={onTrace}
|
||||||
|
title="Direct Trace"
|
||||||
|
>
|
||||||
|
🛎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Favorite button */}
|
||||||
|
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||||
|
? 'Remove from favorites'
|
||||||
|
: 'Add to favorites'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isFavorite(
|
||||||
|
favorites,
|
||||||
|
conversation.type as 'channel' | 'contact',
|
||||||
|
conversation.id
|
||||||
|
) ? (
|
||||||
|
<span className="text-amber-400">★</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">☆</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Delete button */}
|
||||||
|
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
|
||||||
|
<button
|
||||||
|
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
if (conversation.type === 'channel') {
|
||||||
|
onDeleteChannel(conversation.id);
|
||||||
|
} else {
|
||||||
|
onDeleteContact(conversation.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,18 +52,16 @@ export function StatusBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="text-sm font-semibold tracking-tight mr-auto text-foreground flex items-center gap-1.5">
|
<h1 className="text-base font-semibold tracking-tight mr-auto text-foreground flex items-center gap-1.5">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 shrink-0"
|
className="h-5 w-5 shrink-0 text-white"
|
||||||
viewBox="0 0 700 700"
|
viewBox="0 0 512 512"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="m405.89 352.77c0 30.797-25.02 55.762-55.887 55.762s-55.887-24.965-55.887-55.762c0-30.797 25.02-55.762 55.887-55.762s55.887 24.965 55.887 55.762z" />
|
<path d="m455.68 85.902c-31.289 0-56.32 25.031-56.32 56.32 0 11.379 3.4141 21.617 8.5352 30.152l-106.38 135.39c12.516 6.2578 23.895 15.359 32.996 25.602l107.52-136.54c4.5508 1.1367 9.1016 1.707 13.652 1.707 31.289 0 56.32-25.031 56.32-56.32 0-30.719-25.031-56.32-56.32-56.32z" />
|
||||||
<path d="m333.07 352.77h33.871v297.37h-33.871z" />
|
<path d="m256 343.04c-5.6875 0-10.809 0.57031-15.93 2.2773l-106.38-135.96c-9.1016 10.809-20.48 19.344-32.996 25.602l106.38 135.96c-5.1211 8.5352-7.3945 18.203-7.3945 28.445 0 31.289 25.031 56.32 56.32 56.32s56.32-25.031 56.32-56.32c0-31.293-25.031-56.324-56.32-56.324z" />
|
||||||
<path d="m412.71 495.07-13.648-30.926c44.27-19.438 72.879-63.152 72.879-111.37 0-67.082-54.699-121.65-121.94-121.65-67.242 0-121.94 54.57-121.94 121.65 0 48.215 28.609 91.93 72.879 111.37l-13.648 30.926c-56.547-24.844-93.094-80.695-93.094-142.3 0-85.715 69.887-155.44 155.8-155.44 85.918 0 155.8 69.727 155.8 155.44-0.003906 61.594-36.551 117.46-93.094 142.3z" />
|
<path d="m356.69 114.91c3.9805-13.652 10.238-26.738 19.344-37.547-38.113-13.652-78.508-21.047-120.04-21.047-59.164 0-115.48 14.789-166.12 42.668-9.1016-6.8281-21.051-10.809-33.562-10.809-31.289-0.57031-56.32 25.027-56.32 55.75 0 31.289 25.031 56.32 56.32 56.32 31.289 0 56.32-25.031 56.32-56.32 0-3.4141-0.57031-6.8281-1.1367-9.6719 44.371-23.895 93.297-36.41 144.5-36.41 34.703 0 68.836 5.6914 100.69 17.066z" />
|
||||||
<path d="m410.17 581.6-8.5742-32.691c89.277-23.309 151.63-103.96 151.63-196.15 0-111.8-91.168-202.75-203.22-202.75-112.06 0.003907-203.23 90.961-203.23 202.77 0 92.184 62.348 172.83 151.63 196.15l-8.5742 32.691c-104.18-27.195-176.93-121.3-176.93-228.83 0-130.43 106.36-236.54 237.1-236.54 130.73 0 237.1 106.12 237.1 236.54-0.003906 107.52-72.754 201.62-176.93 228.82z" />
|
|
||||||
<path d="m409.05 661.5-6.3125-33.199c132.34-25.047 228.39-140.93 228.39-275.53 0-154.66-126.12-280.48-281.13-280.48-155 0-281.13 125.82-281.13 280.48 0 134.6 96.055 250.48 228.39 275.53l-6.3164 33.199c-148.3-28.07-255.95-157.91-255.95-308.73 0-173.29 141.32-314.27 315-314.27s315 140.98 315 314.27c0 150.81-107.65 280.66-255.95 308.73z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
RemoteTerm
|
RemoteTerm
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export { useRepeaterMode } from './useRepeaterMode';
|
export { useRepeaterMode } from './useRepeaterMode';
|
||||||
export { useUnreadCounts } from './useUnreadCounts';
|
export { useUnreadCounts } from './useUnreadCounts';
|
||||||
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
|
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
|
||||||
|
export { useRadioControl } from './useRadioControl';
|
||||||
|
export { useAppSettings } from './useAppSettings';
|
||||||
|
export { useConversationRouter } from './useConversationRouter';
|
||||||
|
export { useContactsAndChannels } from './useContactsAndChannels';
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { takePrefetch } from '../prefetch';
|
||||||
|
import { toast } from '../components/ui/sonner';
|
||||||
|
import {
|
||||||
|
initLastMessageTimes,
|
||||||
|
loadLocalStorageLastMessageTimes,
|
||||||
|
loadLocalStorageSortOrder,
|
||||||
|
clearLocalStorageConversationState,
|
||||||
|
} from '../utils/conversationState';
|
||||||
|
import {
|
||||||
|
isFavorite,
|
||||||
|
loadLocalStorageFavorites,
|
||||||
|
clearLocalStorageFavorites,
|
||||||
|
} from '../utils/favorites';
|
||||||
|
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||||
|
|
||||||
|
export function useAppSettings() {
|
||||||
|
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||||
|
|
||||||
|
// Stable empty array prevents a new reference every render when there are none.
|
||||||
|
const emptyFavorites = useRef<Favorite[]>([]).current;
|
||||||
|
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
|
||||||
|
|
||||||
|
// One-time migration guard
|
||||||
|
const hasMigratedRef = useRef(false);
|
||||||
|
|
||||||
|
const fetchAppSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await (takePrefetch('settings') ?? api.getSettings());
|
||||||
|
setAppSettings(data);
|
||||||
|
initLastMessageTimes(data.last_message_times ?? {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch app settings:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveAppSettings = useCallback(
|
||||||
|
async (update: AppSettingsUpdate) => {
|
||||||
|
await api.updateSettings(update);
|
||||||
|
await fetchAppSettings();
|
||||||
|
},
|
||||||
|
[fetchAppSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSortOrderChange = useCallback(
|
||||||
|
async (order: 'recent' | 'alpha') => {
|
||||||
|
const previousOrder = appSettings?.sidebar_sort_order ?? 'recent';
|
||||||
|
|
||||||
|
// Optimistic update for responsive UI
|
||||||
|
setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: order } : prev));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedSettings = await api.updateSettings({ sidebar_sort_order: order });
|
||||||
|
setAppSettings(updatedSettings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update sort order:', err);
|
||||||
|
setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: previousOrder } : prev));
|
||||||
|
toast.error('Failed to save sort preference');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appSettings?.sidebar_sort_order]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
|
||||||
|
setAppSettings((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const currentFavorites = prev.favorites ?? [];
|
||||||
|
const wasFavorited = isFavorite(currentFavorites, type, id);
|
||||||
|
const optimisticFavorites = wasFavorited
|
||||||
|
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
|
||||||
|
: [...currentFavorites, { type, id }];
|
||||||
|
return { ...prev, favorites: optimisticFavorites };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedSettings = await api.toggleFavorite(type, id);
|
||||||
|
setAppSettings(updatedSettings);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle favorite:', err);
|
||||||
|
try {
|
||||||
|
const settings = await api.getSettings();
|
||||||
|
setAppSettings(settings);
|
||||||
|
} catch {
|
||||||
|
// If refetch also fails, leave optimistic state
|
||||||
|
}
|
||||||
|
toast.error('Failed to update favorite');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// One-time migration of localStorage preferences to server
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appSettings || hasMigratedRef.current) return;
|
||||||
|
|
||||||
|
if (appSettings.preferences_migrated) {
|
||||||
|
clearLocalStorageFavorites();
|
||||||
|
clearLocalStorageConversationState();
|
||||||
|
hasMigratedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localFavorites = loadLocalStorageFavorites();
|
||||||
|
const localSortOrder = loadLocalStorageSortOrder();
|
||||||
|
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
|
||||||
|
|
||||||
|
const hasLocalData =
|
||||||
|
localFavorites.length > 0 ||
|
||||||
|
localSortOrder !== 'recent' ||
|
||||||
|
Object.keys(localLastMessageTimes).length > 0;
|
||||||
|
|
||||||
|
if (!hasLocalData) {
|
||||||
|
hasMigratedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMigratedRef.current = true;
|
||||||
|
|
||||||
|
const migratePreferences = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.migratePreferences({
|
||||||
|
favorites: localFavorites,
|
||||||
|
sort_order: localSortOrder,
|
||||||
|
last_message_times: localLastMessageTimes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.migrated) {
|
||||||
|
toast.success('Preferences migrated', {
|
||||||
|
description: `Migrated ${localFavorites.length} favorites to server`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppSettings(result.settings);
|
||||||
|
initLastMessageTimes(result.settings.last_message_times ?? {});
|
||||||
|
|
||||||
|
clearLocalStorageFavorites();
|
||||||
|
clearLocalStorageConversationState();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to migrate preferences:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
migratePreferences();
|
||||||
|
}, [appSettings]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appSettings,
|
||||||
|
setAppSettings,
|
||||||
|
favorites,
|
||||||
|
fetchAppSettings,
|
||||||
|
handleSaveAppSettings,
|
||||||
|
handleSortOrderChange,
|
||||||
|
handleToggleFavorite,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useCallback, type MutableRefObject } from 'react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { takePrefetch } from '../prefetch';
|
||||||
|
import { toast } from '../components/ui/sonner';
|
||||||
|
import * as messageCache from '../messageCache';
|
||||||
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
|
import type { Channel, Contact, Conversation } from '../types';
|
||||||
|
|
||||||
|
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
|
||||||
|
|
||||||
|
interface UseContactsAndChannelsArgs {
|
||||||
|
setActiveConversation: (conv: Conversation | null) => void;
|
||||||
|
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||||
|
hasSetDefaultConversation: MutableRefObject<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContactsAndChannels({
|
||||||
|
setActiveConversation,
|
||||||
|
pendingDeleteFallbackRef,
|
||||||
|
hasSetDefaultConversation,
|
||||||
|
}: UseContactsAndChannelsArgs) {
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [contactsLoaded, setContactsLoaded] = useState(false);
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [undecryptedCount, setUndecryptedCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchUndecryptedCountInternal = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await (takePrefetch('undecryptedCount') ?? api.getUndecryptedPacketCount());
|
||||||
|
setUndecryptedCount(data.count);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch undecrypted count:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch all contacts, paginating if >1000
|
||||||
|
const fetchAllContacts = useCallback(async (): Promise<Contact[]> => {
|
||||||
|
const pageSize = 1000;
|
||||||
|
const first = await (takePrefetch('contacts') ?? api.getContacts(pageSize, 0));
|
||||||
|
if (first.length < pageSize) return first;
|
||||||
|
let all = [...first];
|
||||||
|
let offset = pageSize;
|
||||||
|
while (true) {
|
||||||
|
const page = await api.getContacts(pageSize, offset);
|
||||||
|
all = all.concat(page);
|
||||||
|
if (page.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateContact = useCallback(
|
||||||
|
async (name: string, publicKey: string, tryHistorical: boolean) => {
|
||||||
|
const created = await api.createContact(publicKey, name || undefined, tryHistorical);
|
||||||
|
const data = await fetchAllContacts();
|
||||||
|
setContacts(data);
|
||||||
|
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'contact',
|
||||||
|
id: created.public_key,
|
||||||
|
name: getContactDisplayName(created.name, created.public_key),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[fetchAllContacts, setActiveConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateChannel = useCallback(
|
||||||
|
async (name: string, key: string, tryHistorical: boolean) => {
|
||||||
|
const created = await api.createChannel(name, key);
|
||||||
|
const data = await api.getChannels();
|
||||||
|
setChannels(data);
|
||||||
|
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: created.key,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tryHistorical) {
|
||||||
|
await api.decryptHistoricalPackets({
|
||||||
|
key_type: 'channel',
|
||||||
|
channel_key: created.key,
|
||||||
|
});
|
||||||
|
fetchUndecryptedCountInternal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchUndecryptedCountInternal, setActiveConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateHashtagChannel = useCallback(
|
||||||
|
async (name: string, tryHistorical: boolean) => {
|
||||||
|
const channelName = name.startsWith('#') ? name : `#${name}`;
|
||||||
|
|
||||||
|
const created = await api.createChannel(channelName);
|
||||||
|
const data = await api.getChannels();
|
||||||
|
setChannels(data);
|
||||||
|
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: created.key,
|
||||||
|
name: channelName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tryHistorical) {
|
||||||
|
await api.decryptHistoricalPackets({
|
||||||
|
key_type: 'channel',
|
||||||
|
channel_name: channelName,
|
||||||
|
});
|
||||||
|
fetchUndecryptedCountInternal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchUndecryptedCountInternal, setActiveConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteChannel = useCallback(
|
||||||
|
async (key: string) => {
|
||||||
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||||
|
try {
|
||||||
|
pendingDeleteFallbackRef.current = true;
|
||||||
|
await api.deleteChannel(key);
|
||||||
|
messageCache.remove(key);
|
||||||
|
const refreshedChannels = await api.getChannels();
|
||||||
|
setChannels(refreshedChannels);
|
||||||
|
const publicChannel =
|
||||||
|
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
|
||||||
|
refreshedChannels.find((c) => c.name === 'Public');
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
|
||||||
|
name: publicChannel?.name || 'Public',
|
||||||
|
});
|
||||||
|
toast.success('Channel deleted');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete channel:', err);
|
||||||
|
toast.error('Failed to delete channel', {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteContact = useCallback(
|
||||||
|
async (publicKey: string) => {
|
||||||
|
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
||||||
|
try {
|
||||||
|
pendingDeleteFallbackRef.current = true;
|
||||||
|
await api.deleteContact(publicKey);
|
||||||
|
messageCache.remove(publicKey);
|
||||||
|
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||||
|
const refreshedChannels = await api.getChannels();
|
||||||
|
setChannels(refreshedChannels);
|
||||||
|
const publicChannel =
|
||||||
|
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
|
||||||
|
refreshedChannels.find((c) => c.name === 'Public');
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
|
||||||
|
name: publicChannel?.name || 'Public',
|
||||||
|
});
|
||||||
|
toast.success('Contact deleted');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete contact:', err);
|
||||||
|
toast.error('Failed to delete contact', {
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contacts,
|
||||||
|
contactsLoaded,
|
||||||
|
channels,
|
||||||
|
undecryptedCount,
|
||||||
|
setContacts,
|
||||||
|
setContactsLoaded,
|
||||||
|
setChannels,
|
||||||
|
fetchAllContacts,
|
||||||
|
fetchUndecryptedCount: fetchUndecryptedCountInternal,
|
||||||
|
handleCreateContact,
|
||||||
|
handleCreateChannel,
|
||||||
|
handleCreateHashtagChannel,
|
||||||
|
handleDeleteChannel,
|
||||||
|
handleDeleteContact,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import {
|
||||||
|
parseHashConversation,
|
||||||
|
updateUrlHash,
|
||||||
|
resolveChannelFromHashToken,
|
||||||
|
resolveContactFromHashToken,
|
||||||
|
} from '../utils/urlHash';
|
||||||
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
|
import type { Channel, Contact, Conversation } from '../types';
|
||||||
|
|
||||||
|
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
|
||||||
|
|
||||||
|
interface UseConversationRouterArgs {
|
||||||
|
channels: Channel[];
|
||||||
|
contacts: Contact[];
|
||||||
|
contactsLoaded: boolean;
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||||
|
hasSetDefaultConversation: MutableRefObject<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationRouter({
|
||||||
|
channels,
|
||||||
|
contacts,
|
||||||
|
contactsLoaded,
|
||||||
|
setSidebarOpen,
|
||||||
|
pendingDeleteFallbackRef,
|
||||||
|
hasSetDefaultConversation,
|
||||||
|
}: UseConversationRouterArgs) {
|
||||||
|
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||||
|
const activeConversationRef = useRef<Conversation | null>(null);
|
||||||
|
|
||||||
|
// Phase 1: Set initial conversation from URL hash or default to Public channel
|
||||||
|
// Only needs channels (fast path) - doesn't wait for contacts
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
|
if (channels.length === 0) return;
|
||||||
|
|
||||||
|
const hashConv = parseHashConversation();
|
||||||
|
|
||||||
|
// Handle non-data views immediately
|
||||||
|
if (hashConv?.type === 'raw') {
|
||||||
|
setActiveConversation({ type: 'raw', id: 'raw', name: 'Raw Packet Feed' });
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hashConv?.type === 'map') {
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'map',
|
||||||
|
id: 'map',
|
||||||
|
name: 'Node Map',
|
||||||
|
mapFocusKey: hashConv.mapFocusKey,
|
||||||
|
});
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hashConv?.type === 'visualizer') {
|
||||||
|
setActiveConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle channel hash (ID-first with legacy-name fallback)
|
||||||
|
if (hashConv?.type === 'channel') {
|
||||||
|
const channel = resolveChannelFromHashToken(hashConv.name, channels);
|
||||||
|
if (channel) {
|
||||||
|
setActiveConversation({ type: 'channel', id: channel.key, name: channel.name });
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact hash — wait for phase 2
|
||||||
|
if (hashConv?.type === 'contact') return;
|
||||||
|
|
||||||
|
// No hash or unresolvable — default to Public
|
||||||
|
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||||
|
if (publicChannel) {
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: publicChannel.key,
|
||||||
|
name: publicChannel.name,
|
||||||
|
});
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
}
|
||||||
|
}, [channels, activeConversation]);
|
||||||
|
|
||||||
|
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||||
|
|
||||||
|
const hashConv = parseHashConversation();
|
||||||
|
if (hashConv?.type === 'contact') {
|
||||||
|
if (!contactsLoaded) return;
|
||||||
|
|
||||||
|
const contact = resolveContactFromHashToken(hashConv.name, contacts);
|
||||||
|
if (contact) {
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'contact',
|
||||||
|
id: contact.public_key,
|
||||||
|
name: getContactDisplayName(contact.name, contact.public_key),
|
||||||
|
});
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact hash didn't match — fall back to Public if channels loaded.
|
||||||
|
if (channels.length > 0) {
|
||||||
|
const publicChannel = channels.find((c) => c.name === 'Public');
|
||||||
|
if (publicChannel) {
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: publicChannel.key,
|
||||||
|
name: publicChannel.name,
|
||||||
|
});
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [contacts, channels, activeConversation, contactsLoaded]);
|
||||||
|
|
||||||
|
// Keep ref in sync and update URL hash
|
||||||
|
useEffect(() => {
|
||||||
|
activeConversationRef.current = activeConversation;
|
||||||
|
if (activeConversation) {
|
||||||
|
updateUrlHash(activeConversation);
|
||||||
|
}
|
||||||
|
}, [activeConversation]);
|
||||||
|
|
||||||
|
// If a delete action left us without an active conversation, recover to Public
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingDeleteFallbackRef.current) return;
|
||||||
|
if (activeConversation) {
|
||||||
|
pendingDeleteFallbackRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicChannel =
|
||||||
|
channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
|
||||||
|
channels.find((c) => c.name === 'Public');
|
||||||
|
if (!publicChannel) return;
|
||||||
|
|
||||||
|
hasSetDefaultConversation.current = true;
|
||||||
|
pendingDeleteFallbackRef.current = false;
|
||||||
|
setActiveConversation({
|
||||||
|
type: 'channel',
|
||||||
|
id: publicChannel.key,
|
||||||
|
name: publicChannel.name,
|
||||||
|
});
|
||||||
|
}, [activeConversation, channels]);
|
||||||
|
|
||||||
|
// Handle conversation selection (closes sidebar on mobile)
|
||||||
|
const handleSelectConversation = useCallback(
|
||||||
|
(conv: Conversation) => {
|
||||||
|
setActiveConversation(conv);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
},
|
||||||
|
[setSidebarOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeConversation,
|
||||||
|
setActiveConversation,
|
||||||
|
activeConversationRef,
|
||||||
|
handleSelectConversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { takePrefetch } from '../prefetch';
|
||||||
|
import { toast } from '../components/ui/sonner';
|
||||||
|
import type { HealthStatus, RadioConfig, RadioConfigUpdate } from '../types';
|
||||||
|
|
||||||
|
export function useRadioControl() {
|
||||||
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
|
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||||
|
|
||||||
|
const prevHealthRef = useRef<HealthStatus | null>(null);
|
||||||
|
const rebootPollTokenRef = useRef(0);
|
||||||
|
|
||||||
|
// Cancel any in-flight reboot polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
rebootPollTokenRef.current += 1;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await (takePrefetch('config') ?? api.getRadioConfig());
|
||||||
|
setConfig(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch config:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveConfig = useCallback(
|
||||||
|
async (update: RadioConfigUpdate) => {
|
||||||
|
await api.updateRadioConfig(update);
|
||||||
|
await fetchConfig();
|
||||||
|
},
|
||||||
|
[fetchConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetPrivateKey = useCallback(
|
||||||
|
async (key: string) => {
|
||||||
|
await api.setPrivateKey(key);
|
||||||
|
await fetchConfig();
|
||||||
|
},
|
||||||
|
[fetchConfig]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReboot = useCallback(async () => {
|
||||||
|
await api.rebootRadio();
|
||||||
|
setHealth((prev) => (prev ? { ...prev, radio_connected: false } : prev));
|
||||||
|
const pollToken = ++rebootPollTokenRef.current;
|
||||||
|
const pollUntilReconnected = async () => {
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
if (rebootPollTokenRef.current !== pollToken) return;
|
||||||
|
try {
|
||||||
|
const data = await api.getHealth();
|
||||||
|
if (rebootPollTokenRef.current !== pollToken) return;
|
||||||
|
setHealth(data);
|
||||||
|
if (data.radio_connected) {
|
||||||
|
fetchConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pollUntilReconnected();
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
const handleAdvertise = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.sendAdvertisement();
|
||||||
|
toast.success('Advertisement sent');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send advertisement:', err);
|
||||||
|
toast.error('Failed to send advertisement', {
|
||||||
|
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleHealthRefresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.getHealth();
|
||||||
|
setHealth(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh health:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
health,
|
||||||
|
setHealth,
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
|
prevHealthRef,
|
||||||
|
fetchConfig,
|
||||||
|
handleSaveConfig,
|
||||||
|
handleSetPrivateKey,
|
||||||
|
handleReboot,
|
||||||
|
handleAdvertise,
|
||||||
|
handleHealthRefresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,34 +52,38 @@ vi.mock('../useWebSocket', () => ({
|
|||||||
useWebSocket: vi.fn(),
|
useWebSocket: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', async (importOriginal) => {
|
||||||
useConversationMessages: () => ({
|
const actual = await importOriginal<typeof import('../hooks')>();
|
||||||
messages: [],
|
return {
|
||||||
messagesLoading: false,
|
...actual,
|
||||||
loadingOlder: false,
|
useConversationMessages: () => ({
|
||||||
hasOlderMessages: false,
|
messages: [],
|
||||||
setMessages: mocks.hookFns.setMessages,
|
messagesLoading: false,
|
||||||
fetchMessages: mocks.hookFns.fetchMessages,
|
loadingOlder: false,
|
||||||
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
|
hasOlderMessages: false,
|
||||||
addMessageIfNew: mocks.hookFns.addMessageIfNew,
|
setMessages: mocks.hookFns.setMessages,
|
||||||
updateMessageAck: mocks.hookFns.updateMessageAck,
|
fetchMessages: mocks.hookFns.fetchMessages,
|
||||||
}),
|
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
|
||||||
useUnreadCounts: () => ({
|
addMessageIfNew: mocks.hookFns.addMessageIfNew,
|
||||||
unreadCounts: {},
|
updateMessageAck: mocks.hookFns.updateMessageAck,
|
||||||
mentions: {},
|
}),
|
||||||
lastMessageTimes: {},
|
useUnreadCounts: () => ({
|
||||||
incrementUnread: mocks.hookFns.incrementUnread,
|
unreadCounts: {},
|
||||||
markAllRead: mocks.hookFns.markAllRead,
|
mentions: {},
|
||||||
trackNewMessage: mocks.hookFns.trackNewMessage,
|
lastMessageTimes: {},
|
||||||
}),
|
incrementUnread: mocks.hookFns.incrementUnread,
|
||||||
useRepeaterMode: () => ({
|
markAllRead: mocks.hookFns.markAllRead,
|
||||||
repeaterLoggedIn: false,
|
trackNewMessage: mocks.hookFns.trackNewMessage,
|
||||||
activeContactIsRepeater: false,
|
}),
|
||||||
handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest,
|
useRepeaterMode: () => ({
|
||||||
handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand,
|
repeaterLoggedIn: false,
|
||||||
}),
|
activeContactIsRepeater: false,
|
||||||
getMessageContentKey: () => 'content-key',
|
handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest,
|
||||||
}));
|
handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand,
|
||||||
|
}),
|
||||||
|
getMessageContentKey: () => 'content-key',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../messageCache', () => ({
|
vi.mock('../messageCache', () => ({
|
||||||
addMessage: vi.fn(),
|
addMessage: vi.fn(),
|
||||||
|
|||||||
@@ -21,34 +21,38 @@ vi.mock('../useWebSocket', () => ({
|
|||||||
useWebSocket: vi.fn(),
|
useWebSocket: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', async (importOriginal) => {
|
||||||
useConversationMessages: () => ({
|
const actual = await importOriginal<typeof import('../hooks')>();
|
||||||
messages: [],
|
return {
|
||||||
messagesLoading: false,
|
...actual,
|
||||||
loadingOlder: false,
|
useConversationMessages: () => ({
|
||||||
hasOlderMessages: false,
|
messages: [],
|
||||||
setMessages: vi.fn(),
|
messagesLoading: false,
|
||||||
fetchMessages: vi.fn(async () => {}),
|
loadingOlder: false,
|
||||||
fetchOlderMessages: vi.fn(async () => {}),
|
hasOlderMessages: false,
|
||||||
addMessageIfNew: vi.fn(),
|
setMessages: vi.fn(),
|
||||||
updateMessageAck: vi.fn(),
|
fetchMessages: vi.fn(async () => {}),
|
||||||
}),
|
fetchOlderMessages: vi.fn(async () => {}),
|
||||||
useUnreadCounts: () => ({
|
addMessageIfNew: vi.fn(),
|
||||||
unreadCounts: {},
|
updateMessageAck: vi.fn(),
|
||||||
mentions: {},
|
}),
|
||||||
lastMessageTimes: {},
|
useUnreadCounts: () => ({
|
||||||
incrementUnread: vi.fn(),
|
unreadCounts: {},
|
||||||
markAllRead: vi.fn(),
|
mentions: {},
|
||||||
trackNewMessage: vi.fn(),
|
lastMessageTimes: {},
|
||||||
}),
|
incrementUnread: vi.fn(),
|
||||||
useRepeaterMode: () => ({
|
markAllRead: vi.fn(),
|
||||||
repeaterLoggedIn: false,
|
trackNewMessage: vi.fn(),
|
||||||
activeContactIsRepeater: false,
|
}),
|
||||||
handleTelemetryRequest: vi.fn(),
|
useRepeaterMode: () => ({
|
||||||
handleRepeaterCommand: vi.fn(),
|
repeaterLoggedIn: false,
|
||||||
}),
|
activeContactIsRepeater: false,
|
||||||
getMessageContentKey: () => 'content-key',
|
handleTelemetryRequest: vi.fn(),
|
||||||
}));
|
handleRepeaterCommand: vi.fn(),
|
||||||
|
}),
|
||||||
|
getMessageContentKey: () => 'content-key',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../messageCache', () => ({
|
vi.mock('../messageCache', () => ({
|
||||||
addMessage: vi.fn(),
|
addMessage: vi.fn(),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ test.describe('Bot functionality', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByText('Connected')).toBeVisible();
|
await expect(page.getByText('Connected')).toBeVisible();
|
||||||
|
|
||||||
await page.getByText('Radio & Config').click();
|
await page.getByText('Settings').click();
|
||||||
await page.getByRole('button', { name: /Bot/i }).click();
|
await page.getByRole('button', { name: /Bot/i }).click();
|
||||||
|
|
||||||
// The bot name should be visible in the bot list
|
// The bot name should be visible in the bot list
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test.describe('Radio settings', () => {
|
|||||||
await expect(page.getByText('Connected')).toBeVisible();
|
await expect(page.getByText('Connected')).toBeVisible();
|
||||||
|
|
||||||
// --- Step 1: Change the name via settings UI ---
|
// --- Step 1: Change the name via settings UI ---
|
||||||
await page.getByText('Radio & Config').click();
|
await page.getByText('Settings').click();
|
||||||
await page.getByRole('button', { name: /Identity/i }).click();
|
await page.getByRole('button', { name: /Identity/i }).click();
|
||||||
|
|
||||||
const nameInput = page.locator('#name');
|
const nameInput = page.locator('#name');
|
||||||
@@ -47,7 +47,7 @@ test.describe('Radio settings', () => {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
await page.getByText('Radio & Config').click();
|
await page.getByText('Settings').click();
|
||||||
await page.getByRole('button', { name: /Identity/i }).click();
|
await page.getByRole('button', { name: /Identity/i }).click();
|
||||||
await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 });
|
await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|||||||