mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 11:02:56 +02:00
700 lines
22 KiB
TypeScript
700 lines
22 KiB
TypeScript
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
|
|
import { api } from './api';
|
|
import { takePrefetchOrFetch } from './prefetch';
|
|
import { useWebSocket } from './useWebSocket';
|
|
import {
|
|
useAppShell,
|
|
useUnreadCounts,
|
|
useConversationMessages,
|
|
useRadioControl,
|
|
useAppSettings,
|
|
useConversationRouter,
|
|
useContactsAndChannels,
|
|
useConversationActions,
|
|
useConversationNavigation,
|
|
useRealtimeAppState,
|
|
useBrowserNotifications,
|
|
useFaviconBadge,
|
|
useUnreadTitle,
|
|
useRawPacketStatsSession,
|
|
} from './hooks';
|
|
import { AppShell } from './components/AppShell';
|
|
import type { MessageInputHandle } from './components/MessageInput';
|
|
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
|
import { messageContainsMention } from './utils/messageParser';
|
|
import { getStateKey } from './utils/conversationState';
|
|
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
|
import { CONTACT_TYPE_ROOM } from './types';
|
|
|
|
interface ChannelUnreadMarker {
|
|
channelId: string;
|
|
lastReadAt: number | null;
|
|
}
|
|
|
|
interface NewMessagePrefillRequest {
|
|
tab: 'hashtag';
|
|
hashtagName: string;
|
|
nonce: number;
|
|
}
|
|
|
|
interface UnreadBoundaryBackfillParams {
|
|
activeConversation: Conversation | null;
|
|
unreadMarker: ChannelUnreadMarker | null;
|
|
messages: Message[];
|
|
messagesLoading: boolean;
|
|
loadingOlder: boolean;
|
|
hasOlderMessages: boolean;
|
|
}
|
|
|
|
export function getUnreadBoundaryBackfillKey({
|
|
activeConversation,
|
|
unreadMarker,
|
|
messages,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
}: UnreadBoundaryBackfillParams): string | null {
|
|
if (activeConversation?.type !== 'channel') return null;
|
|
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
|
|
if (unreadMarker.lastReadAt === null) return null;
|
|
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
|
|
|
|
const oldestLoadedMessage = messages.reduce(
|
|
(oldest, msg) => {
|
|
if (!oldest) return msg;
|
|
if (msg.received_at < oldest.received_at) return msg;
|
|
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
|
|
return oldest;
|
|
},
|
|
null as Message | null
|
|
);
|
|
|
|
if (!oldestLoadedMessage) return null;
|
|
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
|
|
|
|
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
|
|
}
|
|
|
|
export function App() {
|
|
const quoteSearchOperatorValue = useCallback((value: string) => {
|
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
}, []);
|
|
|
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
|
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
|
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
|
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
|
useState<NewMessagePrefillRequest | null>(null);
|
|
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
|
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
|
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
|
const {
|
|
notificationsSupported,
|
|
notificationsPermission,
|
|
isConversationNotificationsEnabled,
|
|
toggleConversationNotifications,
|
|
notifyIncomingMessage,
|
|
} = useBrowserNotifications();
|
|
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
|
const {
|
|
showNewMessage,
|
|
showSettings,
|
|
settingsSection,
|
|
sidebarOpen,
|
|
showCracker,
|
|
crackerRunning,
|
|
localLabel,
|
|
distanceUnit,
|
|
setSettingsSection,
|
|
setSidebarOpen,
|
|
setCrackerRunning,
|
|
setLocalLabel,
|
|
setDistanceUnit,
|
|
handleCloseSettingsView,
|
|
handleToggleSettingsView,
|
|
handleOpenNewMessage: openNewMessageModal,
|
|
handleCloseNewMessage: closeNewMessageModal,
|
|
handleToggleCracker,
|
|
} = useAppShell();
|
|
|
|
// Shared refs between useConversationRouter and useContactsAndChannels
|
|
const pendingDeleteFallbackRef = useRef(false);
|
|
const hasSetDefaultConversation = useRef(false);
|
|
|
|
// Stable ref bridge: useContactsAndChannels needs setActiveConversation from
|
|
// useConversationRouter, but useConversationRouter needs channels/contacts from
|
|
// useContactsAndChannels. We break the cycle with a ref-based indirection.
|
|
const setActiveConversationRef = useRef<(conv: Conversation | null) => void>(() => {});
|
|
const removeConversationMessagesRef = useRef<(conversationId: string) => void>(() => {});
|
|
|
|
// --- Extracted hooks ---
|
|
|
|
const {
|
|
health,
|
|
setHealth,
|
|
config,
|
|
prevHealthRef,
|
|
fetchConfig,
|
|
handleSaveConfig,
|
|
handleSetPrivateKey,
|
|
handleReboot,
|
|
handleDisconnect,
|
|
handleReconnect,
|
|
handleAdvertise,
|
|
meshDiscovery,
|
|
meshDiscoveryLoadingTarget,
|
|
handleDiscoverMesh,
|
|
handleHealthRefresh,
|
|
} = useRadioControl();
|
|
|
|
const {
|
|
appSettings,
|
|
favorites,
|
|
fetchAppSettings,
|
|
handleSaveAppSettings,
|
|
handleToggleFavorite,
|
|
handleToggleBlockedKey,
|
|
handleToggleBlockedName,
|
|
handleToggleTrackedTelemetry,
|
|
} = useAppSettings();
|
|
|
|
// Keep user's name in ref for mention detection in WebSocket callback
|
|
const myNameRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
myNameRef.current = config?.name ?? null;
|
|
}, [config?.name]);
|
|
|
|
// Keep block lists in refs for WS callback filtering
|
|
const blockedKeysRef = useRef<string[]>([]);
|
|
const blockedNamesRef = useRef<string[]>([]);
|
|
useEffect(() => {
|
|
blockedKeysRef.current = appSettings?.blocked_keys ?? [];
|
|
blockedNamesRef.current = appSettings?.blocked_names ?? [];
|
|
}, [appSettings?.blocked_keys, appSettings?.blocked_names]);
|
|
|
|
// Check if a message mentions the user
|
|
const checkMention = useCallback(
|
|
(text: string): boolean => messageContainsMention(text, myNameRef.current),
|
|
[]
|
|
);
|
|
|
|
// useContactsAndChannels is called first — it uses the ref bridge for setActiveConversation
|
|
const {
|
|
contacts,
|
|
contactsLoaded,
|
|
channels,
|
|
undecryptedCount,
|
|
setContacts,
|
|
setContactsLoaded,
|
|
setChannels,
|
|
fetchAllContacts,
|
|
fetchUndecryptedCount,
|
|
handleCreateContact,
|
|
handleCreateChannel,
|
|
handleCreateHashtagChannel,
|
|
handleBulkCreateHashtagChannels,
|
|
handleDeleteChannel,
|
|
handleDeleteContact,
|
|
} = useContactsAndChannels({
|
|
setActiveConversation: (conv) => setActiveConversationRef.current(conv),
|
|
pendingDeleteFallbackRef,
|
|
hasSetDefaultConversation,
|
|
removeConversationMessages: (conversationId) =>
|
|
removeConversationMessagesRef.current(conversationId),
|
|
});
|
|
|
|
// useConversationRouter is called second — it receives channels/contacts as inputs
|
|
const {
|
|
activeConversation,
|
|
setActiveConversation,
|
|
activeConversationRef,
|
|
handleSelectConversation,
|
|
} = useConversationRouter({
|
|
channels,
|
|
contacts,
|
|
contactsLoaded,
|
|
suspendHashSync: showSettings,
|
|
setSidebarOpen,
|
|
pendingDeleteFallbackRef,
|
|
hasSetDefaultConversation,
|
|
});
|
|
|
|
// Wire up the ref bridge so useContactsAndChannels handlers reach the real setter
|
|
setActiveConversationRef.current = setActiveConversation;
|
|
|
|
const {
|
|
targetMessageId,
|
|
setTargetMessageId,
|
|
infoPaneContactKey,
|
|
infoPaneFromChannel,
|
|
infoPaneChannelKey,
|
|
searchPrefillRequest,
|
|
handleOpenContactInfo,
|
|
handleCloseContactInfo,
|
|
handleOpenChannelInfo,
|
|
handleCloseChannelInfo,
|
|
handleSelectConversationWithTargetReset,
|
|
handleNavigateToChannel,
|
|
handleNavigateToMessage,
|
|
handleOpenSearchWithQuery,
|
|
} = useConversationNavigation({
|
|
channels,
|
|
handleSelectConversation,
|
|
});
|
|
|
|
// Custom hooks for conversation-specific functionality
|
|
const {
|
|
messages,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
hasNewerMessages,
|
|
loadingNewer,
|
|
fetchOlderMessages,
|
|
fetchNewerMessages,
|
|
jumpToBottom,
|
|
reloadCurrentConversation,
|
|
observeMessage,
|
|
receiveMessageAck,
|
|
reconcileOnReconnect,
|
|
renameConversationMessages,
|
|
removeConversationMessages,
|
|
clearConversationMessages,
|
|
} = useConversationMessages(activeConversation, targetMessageId);
|
|
removeConversationMessagesRef.current = removeConversationMessages;
|
|
|
|
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
|
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
|
// so the display reflects the original send order rather than our radio's receipt order.
|
|
const activeContactIsRoom =
|
|
activeConversation?.type === 'contact' &&
|
|
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
|
const sortedMessages = useMemo(() => {
|
|
if (!activeContactIsRoom || messages.length === 0) return messages;
|
|
return [...messages].sort((a, b) => {
|
|
const aTs = a.sender_timestamp ?? a.received_at;
|
|
const bTs = b.sender_timestamp ?? b.received_at;
|
|
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
|
});
|
|
}, [activeContactIsRoom, messages]);
|
|
|
|
const {
|
|
unreadCounts,
|
|
mentions,
|
|
lastMessageTimes,
|
|
unreadLastReadAts,
|
|
recordMessageEvent,
|
|
renameConversationState,
|
|
removeConversationState,
|
|
markAllRead,
|
|
refreshUnreads,
|
|
} = useUnreadCounts(channels, contacts, activeConversation);
|
|
useFaviconBadge(unreadCounts, mentions, favorites);
|
|
useUnreadTitle(unreadCounts, favorites);
|
|
|
|
useEffect(() => {
|
|
if (activeConversation?.type !== 'channel') {
|
|
setChannelUnreadMarker(null);
|
|
return;
|
|
}
|
|
|
|
const activeChannelId = activeConversation.id;
|
|
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
|
|
|
|
setChannelUnreadMarker((prev) => {
|
|
if (prev?.channelId === activeChannelId) {
|
|
return prev;
|
|
}
|
|
if (activeChannelUnreadCount <= 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
channelId: activeChannelId,
|
|
lastReadAt: unreadLastReadAts[getStateKey('channel', activeChannelId)] ?? null,
|
|
};
|
|
});
|
|
}, [activeConversation, unreadCounts, unreadLastReadAts]);
|
|
|
|
useEffect(() => {
|
|
lastUnreadBackfillAttemptRef.current = null;
|
|
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
|
|
|
|
useEffect(() => {
|
|
const backfillKey = getUnreadBoundaryBackfillKey({
|
|
activeConversation,
|
|
unreadMarker: channelUnreadMarker,
|
|
messages,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
});
|
|
|
|
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
|
|
return;
|
|
}
|
|
|
|
lastUnreadBackfillAttemptRef.current = backfillKey;
|
|
void fetchOlderMessages();
|
|
}, [
|
|
activeConversation,
|
|
channelUnreadMarker,
|
|
messages,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
fetchOlderMessages,
|
|
]);
|
|
|
|
const wsHandlers = useRealtimeAppState({
|
|
prevHealthRef,
|
|
setHealth,
|
|
fetchConfig,
|
|
setRawPackets,
|
|
reconcileOnReconnect,
|
|
refreshUnreads,
|
|
setChannels,
|
|
fetchAllContacts,
|
|
setContacts,
|
|
blockedKeysRef,
|
|
blockedNamesRef,
|
|
activeConversationRef,
|
|
observeMessage,
|
|
recordMessageEvent,
|
|
renameConversationState,
|
|
removeConversationState,
|
|
checkMention,
|
|
pendingDeleteFallbackRef,
|
|
setActiveConversation,
|
|
renameConversationMessages,
|
|
removeConversationMessages,
|
|
receiveMessageAck,
|
|
notifyIncomingMessage,
|
|
recordRawPacketObservation,
|
|
});
|
|
const handleVisibilityPolicyChanged = useCallback(() => {
|
|
clearConversationMessages();
|
|
reloadCurrentConversation();
|
|
void refreshUnreads();
|
|
setVisibilityVersion((current) => current + 1);
|
|
}, [clearConversationMessages, refreshUnreads, reloadCurrentConversation]);
|
|
|
|
const handleBlockKey = useCallback(
|
|
async (key: string) => {
|
|
await handleToggleBlockedKey(key);
|
|
handleVisibilityPolicyChanged();
|
|
},
|
|
[handleToggleBlockedKey, handleVisibilityPolicyChanged]
|
|
);
|
|
|
|
const handleBlockName = useCallback(
|
|
async (name: string) => {
|
|
await handleToggleBlockedName(name);
|
|
handleVisibilityPolicyChanged();
|
|
},
|
|
[handleToggleBlockedName, handleVisibilityPolicyChanged]
|
|
);
|
|
const {
|
|
handleSendMessage,
|
|
handleResendChannelMessage,
|
|
handleSetChannelFloodScopeOverride,
|
|
handleSetChannelPathHashModeOverride,
|
|
handleSenderClick,
|
|
handleTrace,
|
|
handlePathDiscovery,
|
|
} = useConversationActions({
|
|
activeConversation,
|
|
activeConversationRef,
|
|
setContacts,
|
|
setChannels,
|
|
observeMessage,
|
|
messageInputRef,
|
|
});
|
|
const handleCreateCrackedChannel = useCallback(
|
|
async (name: string, key: string) => {
|
|
const created = await api.createChannel(name, key);
|
|
const updatedChannels = await api.getChannels();
|
|
setChannels(updatedChannels);
|
|
await api.decryptHistoricalPackets({
|
|
key_type: 'channel',
|
|
channel_key: created.key,
|
|
});
|
|
void fetchUndecryptedCount().catch((error) => {
|
|
console.error('Failed to refresh undecrypted count after cracked channel create:', error);
|
|
});
|
|
},
|
|
[fetchUndecryptedCount, setChannels]
|
|
);
|
|
|
|
const handleOpenNewMessage = useCallback(
|
|
(event?: MouseEvent<HTMLButtonElement>) => {
|
|
setNewMessagePrefillRequest(null);
|
|
setShowBulkAddChannelTab(event?.altKey === true);
|
|
openNewMessageModal();
|
|
},
|
|
[openNewMessageModal]
|
|
);
|
|
|
|
const handleCloseNewMessage = useCallback(() => {
|
|
setNewMessagePrefillRequest(null);
|
|
setShowBulkAddChannelTab(false);
|
|
closeNewMessageModal();
|
|
}, [closeNewMessageModal]);
|
|
|
|
const handleCloseBulkAddResults = useCallback(() => {
|
|
setBulkAddResult(null);
|
|
}, []);
|
|
|
|
const handleChannelReferenceClick = useCallback(
|
|
(channelName: string) => {
|
|
const existingChannel = channels.find((channel) => channel.name === channelName);
|
|
if (existingChannel) {
|
|
handleNavigateToChannel(existingChannel.key);
|
|
return;
|
|
}
|
|
|
|
setNewMessagePrefillRequest((previous) => ({
|
|
tab: 'hashtag',
|
|
hashtagName: channelName.slice(1),
|
|
nonce: (previous?.nonce ?? 0) + 1,
|
|
}));
|
|
setShowBulkAddChannelTab(false);
|
|
openNewMessageModal();
|
|
},
|
|
[channels, handleNavigateToChannel, openNewMessageModal]
|
|
);
|
|
|
|
const handleBulkAddChannels = useCallback(
|
|
async (channelNames: string[], tryHistorical: boolean) => {
|
|
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
|
|
setBulkAddResult(result);
|
|
},
|
|
[handleBulkCreateHashtagChannels]
|
|
);
|
|
|
|
const statusProps = {
|
|
health,
|
|
config,
|
|
};
|
|
const sidebarProps = {
|
|
contacts,
|
|
channels,
|
|
activeConversation,
|
|
onSelectConversation: handleSelectConversationWithTargetReset,
|
|
onNewMessage: handleOpenNewMessage,
|
|
lastMessageTimes,
|
|
unreadCounts,
|
|
mentions,
|
|
showCracker,
|
|
crackerRunning,
|
|
onToggleCracker: handleToggleCracker,
|
|
onMarkAllRead: () => {
|
|
void markAllRead();
|
|
},
|
|
favorites,
|
|
isConversationNotificationsEnabled,
|
|
blockedKeys: appSettings?.blocked_keys ?? [],
|
|
blockedNames: appSettings?.blocked_names ?? [],
|
|
};
|
|
const bulkAddChannelResultModalProps = {
|
|
result: bulkAddResult,
|
|
};
|
|
const conversationPaneProps = {
|
|
activeConversation,
|
|
contacts,
|
|
channels,
|
|
rawPackets,
|
|
rawPacketStatsSession,
|
|
config,
|
|
health,
|
|
favorites,
|
|
messages: sortedMessages,
|
|
preSorted: activeContactIsRoom,
|
|
messagesLoading,
|
|
loadingOlder,
|
|
hasOlderMessages,
|
|
unreadMarkerLastReadAt:
|
|
activeConversation?.type === 'channel' &&
|
|
channelUnreadMarker?.channelId === activeConversation.id
|
|
? channelUnreadMarker.lastReadAt
|
|
: undefined,
|
|
targetMessageId,
|
|
hasNewerMessages,
|
|
loadingNewer,
|
|
messageInputRef,
|
|
onTrace: handleTrace,
|
|
onRunTracePath: api.requestRadioTrace,
|
|
onPathDiscovery: handlePathDiscovery,
|
|
onToggleFavorite: handleToggleFavorite,
|
|
onDeleteContact: handleDeleteContact,
|
|
onDeleteChannel: handleDeleteChannel,
|
|
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
|
|
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
|
|
onOpenContactInfo: handleOpenContactInfo,
|
|
onOpenChannelInfo: handleOpenChannelInfo,
|
|
onSenderClick: handleSenderClick,
|
|
onChannelReferenceClick: handleChannelReferenceClick,
|
|
onLoadOlder: fetchOlderMessages,
|
|
onResendChannelMessage: handleResendChannelMessage,
|
|
onTargetReached: () => setTargetMessageId(null),
|
|
onLoadNewer: fetchNewerMessages,
|
|
onJumpToBottom: jumpToBottom,
|
|
onSendMessage: handleSendMessage,
|
|
onDismissUnreadMarker: () => setChannelUnreadMarker(null),
|
|
notificationsSupported,
|
|
notificationsPermission,
|
|
notificationsEnabled:
|
|
activeConversation?.type === 'contact' || activeConversation?.type === 'channel'
|
|
? isConversationNotificationsEnabled(activeConversation.type, activeConversation.id)
|
|
: false,
|
|
onToggleNotifications: () => {
|
|
if (activeConversation?.type === 'contact' || activeConversation?.type === 'channel') {
|
|
void toggleConversationNotifications(
|
|
activeConversation.type,
|
|
activeConversation.id,
|
|
activeConversation.name
|
|
);
|
|
}
|
|
},
|
|
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
|
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
|
};
|
|
const searchProps = {
|
|
contacts,
|
|
channels,
|
|
visibilityVersion,
|
|
onNavigateToMessage: handleNavigateToMessage,
|
|
prefillRequest: searchPrefillRequest,
|
|
};
|
|
const settingsProps = {
|
|
config,
|
|
health,
|
|
appSettings,
|
|
onSave: handleSaveConfig,
|
|
onSaveAppSettings: handleSaveAppSettings,
|
|
onSetPrivateKey: handleSetPrivateKey,
|
|
onReboot: handleReboot,
|
|
onDisconnect: handleDisconnect,
|
|
onReconnect: handleReconnect,
|
|
onAdvertise: handleAdvertise,
|
|
meshDiscovery,
|
|
meshDiscoveryLoadingTarget,
|
|
onDiscoverMesh: handleDiscoverMesh,
|
|
onHealthRefresh: handleHealthRefresh,
|
|
onRefreshAppSettings: fetchAppSettings,
|
|
blockedKeys: appSettings?.blocked_keys,
|
|
blockedNames: appSettings?.blocked_names,
|
|
onToggleBlockedKey: handleBlockKey,
|
|
onToggleBlockedName: handleBlockName,
|
|
contacts,
|
|
onBulkDeleteContacts: (deletedKeys: string[]) => {
|
|
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
|
|
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
|
|
},
|
|
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
|
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
|
};
|
|
const crackerProps = {
|
|
packets: rawPackets,
|
|
channels,
|
|
onChannelCreate: handleCreateCrackedChannel,
|
|
};
|
|
const newMessageModalProps = {
|
|
undecryptedCount,
|
|
showBulkAddChannelTab,
|
|
prefillRequest: newMessagePrefillRequest,
|
|
onCreateContact: handleCreateContact,
|
|
onCreateChannel: handleCreateChannel,
|
|
onCreateHashtagChannel: handleCreateHashtagChannel,
|
|
onBulkAddHashtagChannels: handleBulkAddChannels,
|
|
};
|
|
const contactInfoPaneProps = {
|
|
contactKey: infoPaneContactKey,
|
|
fromChannel: infoPaneFromChannel,
|
|
onClose: handleCloseContactInfo,
|
|
contacts,
|
|
config,
|
|
favorites,
|
|
onToggleFavorite: handleToggleFavorite,
|
|
onNavigateToChannel: handleNavigateToChannel,
|
|
onSearchMessagesByKey: (publicKey: string) => {
|
|
handleOpenSearchWithQuery(`user:${publicKey}`);
|
|
},
|
|
onSearchMessagesByName: (name: string) => {
|
|
handleOpenSearchWithQuery(`user:${quoteSearchOperatorValue(name)}`);
|
|
},
|
|
onToggleBlockedKey: handleBlockKey,
|
|
onToggleBlockedName: handleBlockName,
|
|
blockedKeys: appSettings?.blocked_keys ?? [],
|
|
blockedNames: appSettings?.blocked_names ?? [],
|
|
};
|
|
const channelInfoPaneProps = {
|
|
channelKey: infoPaneChannelKey,
|
|
onClose: handleCloseChannelInfo,
|
|
channels,
|
|
favorites,
|
|
onToggleFavorite: handleToggleFavorite,
|
|
};
|
|
|
|
// Connect to WebSocket
|
|
useWebSocket(wsHandlers);
|
|
|
|
// Initial fetch for config, settings, and data
|
|
useEffect(() => {
|
|
fetchConfig();
|
|
fetchAppSettings();
|
|
fetchUndecryptedCount();
|
|
|
|
// Fetch contacts and channels via REST (parallel, faster than WS serial push)
|
|
takePrefetchOrFetch('channels', api.getChannels).then(setChannels).catch(console.error);
|
|
fetchAllContacts()
|
|
.then((data) => {
|
|
setContacts(data);
|
|
setContactsLoaded(true);
|
|
})
|
|
.catch((err) => {
|
|
console.error(err);
|
|
setContactsLoaded(true);
|
|
});
|
|
}, [
|
|
fetchConfig,
|
|
fetchAppSettings,
|
|
fetchUndecryptedCount,
|
|
fetchAllContacts,
|
|
setChannels,
|
|
setContacts,
|
|
setContactsLoaded,
|
|
]);
|
|
return (
|
|
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
|
|
<AppShell
|
|
localLabel={localLabel}
|
|
showNewMessage={showNewMessage}
|
|
showBulkAddResults={bulkAddResult !== null}
|
|
showSettings={showSettings}
|
|
settingsSection={settingsSection}
|
|
sidebarOpen={sidebarOpen}
|
|
showCracker={showCracker}
|
|
onSettingsSectionChange={setSettingsSection}
|
|
onSidebarOpenChange={setSidebarOpen}
|
|
onCrackerRunningChange={setCrackerRunning}
|
|
onToggleSettingsView={handleToggleSettingsView}
|
|
onCloseSettingsView={handleCloseSettingsView}
|
|
onCloseNewMessage={handleCloseNewMessage}
|
|
onCloseBulkAddResults={handleCloseBulkAddResults}
|
|
onLocalLabelChange={setLocalLabel}
|
|
statusProps={statusProps}
|
|
sidebarProps={sidebarProps}
|
|
conversationPaneProps={conversationPaneProps}
|
|
searchProps={searchProps}
|
|
settingsProps={settingsProps}
|
|
crackerProps={crackerProps}
|
|
newMessageModalProps={newMessageModalProps}
|
|
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
|
contactInfoPaneProps={contactInfoPaneProps}
|
|
channelInfoPaneProps={channelInfoPaneProps}
|
|
/>
|
|
</DistanceUnitProvider>
|
|
);
|
|
}
|