Compactify some things for LLM wins

This commit is contained in:
Jack Kingsman
2026-03-09 23:53:19 -07:00
parent 18e1408292
commit 39b745f8b0
7 changed files with 453 additions and 1055 deletions

View File

@@ -15,7 +15,7 @@ import app.radio as radio_module
class RadioRuntime:
"""Thin wrapper around the process-global RadioManager."""
"""Thin forwarding wrapper around the process-global RadioManager."""
def __init__(self, manager_or_getter=None):
if manager_or_getter is None:
@@ -29,45 +29,25 @@ class RadioRuntime:
def manager(self) -> Any:
return self._manager_getter()
@property
def meshcore(self):
return self.manager.meshcore
def __getattr__(self, name: str) -> Any:
"""Forward unknown attributes to the current global manager."""
return getattr(self.manager, name)
@property
def connection_info(self) -> str | None:
return self.manager.connection_info
@staticmethod
def _is_local_runtime_attr(name: str) -> bool:
return name.startswith("_") or hasattr(RadioRuntime, name)
@property
def is_connected(self) -> bool:
return self.manager.is_connected
def __setattr__(self, name: str, value: Any) -> None:
if self._is_local_runtime_attr(name):
object.__setattr__(self, name, value)
return
setattr(self.manager, name, value)
@property
def is_reconnecting(self) -> bool:
return self.manager.is_reconnecting
@property
def is_setup_in_progress(self) -> bool:
return self.manager.is_setup_in_progress
@property
def is_setup_complete(self) -> bool:
return self.manager.is_setup_complete
@property
def path_hash_mode(self) -> int:
return self.manager.path_hash_mode
@path_hash_mode.setter
def path_hash_mode(self, mode: int) -> None:
self.manager.path_hash_mode = mode
@property
def path_hash_mode_supported(self) -> bool:
return self.manager.path_hash_mode_supported
@path_hash_mode_supported.setter
def path_hash_mode_supported(self, supported: bool) -> None:
self.manager.path_hash_mode_supported = supported
def __delattr__(self, name: str) -> None:
if self._is_local_runtime_attr(name):
object.__delattr__(self, name)
return
delattr(self.manager, name)
def require_connected(self):
"""Return MeshCore when available, mirroring existing HTTP semantics."""

View File

@@ -4,7 +4,6 @@ import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
useAppShell,
useAppShellProps,
useUnreadCounts,
useConversationMessages,
useRadioControl,
@@ -222,81 +221,130 @@ export function App() {
handleToggleBlockedName,
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 {
statusProps,
sidebarProps,
conversationPaneProps,
searchProps,
settingsProps,
crackerProps,
newMessageModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
} = useAppShellProps({
const statusProps = { health, config };
const sidebarProps = {
contacts,
channels,
activeConversation,
onSelectConversation: handleSelectConversationWithTargetReset,
onNewMessage: handleOpenNewMessage,
lastMessageTimes,
unreadCounts,
mentions,
showCracker,
crackerRunning,
onToggleCracker: handleToggleCracker,
onMarkAllRead: () => {
void markAllRead();
},
favorites,
sortOrder: appSettings?.sidebar_sort_order ?? 'recent',
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
void handleSortOrderChange(sortOrder);
},
};
const conversationPaneProps = {
activeConversation,
contacts,
channels,
rawPackets,
undecryptedCount,
activeConversation,
config,
health,
favorites,
appSettings,
unreadCounts,
mentions,
lastMessageTimes,
showCracker,
crackerRunning,
messageInputRef,
targetMessageId,
infoPaneContactKey,
infoPaneFromChannel,
infoPaneChannelKey,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
targetMessageId,
hasNewerMessages,
loadingNewer,
handleOpenNewMessage,
handleToggleCracker,
markAllRead,
handleSortOrderChange,
handleSelectConversationWithTargetReset,
handleNavigateToMessage,
handleSaveConfig,
handleSaveAppSettings,
handleSetPrivateKey,
handleReboot,
handleAdvertise,
handleHealthRefresh,
fetchAppSettings,
setChannels,
fetchUndecryptedCount,
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleDeleteContact,
handleDeleteChannel,
handleToggleFavorite,
handleSetChannelFloodScopeOverride,
handleOpenContactInfo,
handleOpenChannelInfo,
handleCloseContactInfo,
handleCloseChannelInfo,
handleSenderClick,
handleResendChannelMessage,
handleTrace,
handleSendMessage,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
setTargetMessageId,
handleNavigateToChannel,
handleBlockKey,
handleBlockName,
});
messageInputRef,
onTrace: handleTrace,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
};
const searchProps = {
contacts,
channels,
onNavigateToMessage: handleNavigateToMessage,
};
const settingsProps = {
config,
health,
appSettings,
onSave: handleSaveConfig,
onSaveAppSettings: handleSaveAppSettings,
onSetPrivateKey: handleSetPrivateKey,
onReboot: handleReboot,
onAdvertise: handleAdvertise,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
};
const crackerProps = {
packets: rawPackets,
channels,
onChannelCreate: handleCreateCrackedChannel,
};
const newMessageModalProps = {
contacts,
undecryptedCount,
onSelectConversation: handleSelectConversationWithTargetReset,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
};
const contactInfoPaneProps = {
contactKey: infoPaneContactKey,
fromChannel: infoPaneFromChannel,
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
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);

View File

@@ -9,4 +9,3 @@ export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useAppShellProps } from './useAppShellProps';

View File

@@ -1,308 +0,0 @@
import { useCallback, type ComponentProps, type Dispatch, type SetStateAction } from 'react';
import { api } from '../api';
import { ChannelInfoPane } from '../components/ChannelInfoPane';
import { ContactInfoPane } from '../components/ContactInfoPane';
import { ConversationPane } from '../components/ConversationPane';
import { NewMessageModal } from '../components/NewMessageModal';
import { SearchView } from '../components/SearchView';
import { SettingsModal } from '../components/SettingsModal';
import { Sidebar } from '../components/Sidebar';
import { StatusBar } from '../components/StatusBar';
import { CrackerPanel } from '../components/CrackerPanel';
import type {
AppSettings,
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
RawPacket,
} from '../types';
type StatusProps = Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type SearchProps = ComponentProps<typeof SearchView>;
type SettingsProps = Omit<
ComponentProps<typeof SettingsModal>,
'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange'
>;
type CrackerProps = Omit<ComponentProps<typeof CrackerPanel>, 'visible' | 'onRunningChange'>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface UseAppShellPropsArgs {
contacts: Contact[];
channels: Channel[];
rawPackets: RawPacket[];
undecryptedCount: number;
activeConversation: Conversation | null;
config: RadioConfig | null;
health: HealthStatus | null;
favorites: Favorite[];
appSettings: AppSettings | null;
unreadCounts: Record<string, number>;
mentions: Record<string, boolean>;
lastMessageTimes: Record<string, number>;
showCracker: boolean;
crackerRunning: boolean;
messageInputRef: ConversationPaneProps['messageInputRef'];
targetMessageId: number | null;
infoPaneContactKey: string | null;
infoPaneFromChannel: boolean;
infoPaneChannelKey: string | null;
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
handleOpenNewMessage: () => void;
handleToggleCracker: () => void;
markAllRead: () => void;
handleSortOrderChange: (sortOrder: 'recent' | 'alpha') => Promise<void>;
handleSelectConversationWithTargetReset: (
conv: Conversation,
options?: { preserveTarget?: boolean }
) => void;
handleNavigateToMessage: SearchProps['onNavigateToMessage'];
handleSaveConfig: SettingsProps['onSave'];
handleSaveAppSettings: SettingsProps['onSaveAppSettings'];
handleSetPrivateKey: SettingsProps['onSetPrivateKey'];
handleReboot: SettingsProps['onReboot'];
handleAdvertise: SettingsProps['onAdvertise'];
handleHealthRefresh: SettingsProps['onHealthRefresh'];
fetchAppSettings: () => Promise<void>;
setChannels: Dispatch<SetStateAction<Channel[]>>;
fetchUndecryptedCount: () => Promise<void>;
handleCreateContact: NewMessageModalProps['onCreateContact'];
handleCreateChannel: NewMessageModalProps['onCreateChannel'];
handleCreateHashtagChannel: NewMessageModalProps['onCreateHashtagChannel'];
handleDeleteContact: ConversationPaneProps['onDeleteContact'];
handleDeleteChannel: ConversationPaneProps['onDeleteChannel'];
handleToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
handleSetChannelFloodScopeOverride: ConversationPaneProps['onSetChannelFloodScopeOverride'];
handleOpenContactInfo: ConversationPaneProps['onOpenContactInfo'];
handleOpenChannelInfo: ConversationPaneProps['onOpenChannelInfo'];
handleCloseContactInfo: () => void;
handleCloseChannelInfo: () => void;
handleSenderClick: NonNullable<ConversationPaneProps['onSenderClick']>;
handleResendChannelMessage: NonNullable<ConversationPaneProps['onResendChannelMessage']>;
handleTrace: ConversationPaneProps['onTrace'];
handleSendMessage: ConversationPaneProps['onSendMessage'];
fetchOlderMessages: ConversationPaneProps['onLoadOlder'];
fetchNewerMessages: ConversationPaneProps['onLoadNewer'];
jumpToBottom: ConversationPaneProps['onJumpToBottom'];
setTargetMessageId: Dispatch<SetStateAction<number | null>>;
handleNavigateToChannel: ContactInfoPaneProps['onNavigateToChannel'];
handleBlockKey: NonNullable<ContactInfoPaneProps['onToggleBlockedKey']>;
handleBlockName: NonNullable<ContactInfoPaneProps['onToggleBlockedName']>;
}
interface UseAppShellPropsResult {
statusProps: StatusProps;
sidebarProps: SidebarProps;
conversationPaneProps: ConversationPaneProps;
searchProps: SearchProps;
settingsProps: SettingsProps;
crackerProps: CrackerProps;
newMessageModalProps: NewMessageModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
export function useAppShellProps({
contacts,
channels,
rawPackets,
undecryptedCount,
activeConversation,
config,
health,
favorites,
appSettings,
unreadCounts,
mentions,
lastMessageTimes,
showCracker,
crackerRunning,
messageInputRef,
targetMessageId,
infoPaneContactKey,
infoPaneFromChannel,
infoPaneChannelKey,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
handleOpenNewMessage,
handleToggleCracker,
markAllRead,
handleSortOrderChange,
handleSelectConversationWithTargetReset,
handleNavigateToMessage,
handleSaveConfig,
handleSaveAppSettings,
handleSetPrivateKey,
handleReboot,
handleAdvertise,
handleHealthRefresh,
fetchAppSettings,
setChannels,
fetchUndecryptedCount,
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleDeleteContact,
handleDeleteChannel,
handleToggleFavorite,
handleSetChannelFloodScopeOverride,
handleOpenContactInfo,
handleOpenChannelInfo,
handleCloseContactInfo,
handleCloseChannelInfo,
handleSenderClick,
handleResendChannelMessage,
handleTrace,
handleSendMessage,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
setTargetMessageId,
handleNavigateToChannel,
handleBlockKey,
handleBlockName,
}: UseAppShellPropsArgs): UseAppShellPropsResult {
const handleCreateCrackedChannel = useCallback<CrackerProps['onChannelCreate']>(
async (name, key) => {
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]
);
return {
statusProps: { health, config },
sidebarProps: {
contacts,
channels,
activeConversation,
onSelectConversation: handleSelectConversationWithTargetReset,
onNewMessage: handleOpenNewMessage,
lastMessageTimes,
unreadCounts,
mentions,
showCracker,
crackerRunning,
onToggleCracker: handleToggleCracker,
onMarkAllRead: () => {
void markAllRead();
},
favorites,
sortOrder: appSettings?.sidebar_sort_order ?? 'recent',
onSortOrderChange: (sortOrder) => {
void handleSortOrderChange(sortOrder);
},
},
conversationPaneProps: {
activeConversation,
contacts,
channels,
rawPackets,
config,
health,
favorites,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
targetMessageId,
hasNewerMessages,
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
},
searchProps: {
contacts,
channels,
onNavigateToMessage: handleNavigateToMessage,
},
settingsProps: {
config,
health,
appSettings,
onSave: handleSaveConfig,
onSaveAppSettings: handleSaveAppSettings,
onSetPrivateKey: handleSetPrivateKey,
onReboot: handleReboot,
onAdvertise: handleAdvertise,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
},
crackerProps: {
packets: rawPackets,
channels,
onChannelCreate: handleCreateCrackedChannel,
},
newMessageModalProps: {
contacts,
undecryptedCount,
onSelectConversation: handleSelectConversationWithTargetReset,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
},
contactInfoPaneProps: {
contactKey: infoPaneContactKey,
fromChannel: infoPaneFromChannel,
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
},
channelInfoPaneProps: {
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
},
};
}

View File

@@ -1,14 +1,19 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from 'react';
import { useConversationTimeline } from './useConversationTimeline';
import { toast } from '../components/ui/sonner';
import { api, isAbortError } from '../api';
import * as messageCache from '../messageCache';
import type { Conversation, Message, MessagePath } from '../types';
const MAX_PENDING_ACKS = 500;
const MESSAGE_PAGE_SIZE = 200;
interface PendingAckUpdate {
ackCount: number;
@@ -77,6 +82,10 @@ interface UseConversationMessagesResult {
triggerReconcile: () => void;
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
}
export function useConversationMessages(
activeConversation: Conversation | null,
targetMessageId?: number | null
@@ -119,28 +128,322 @@ export function useConversationMessages(
...(pending.paths !== undefined && { paths: pending.paths }),
};
}, []);
const [messages, setMessages] = useState<Message[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [hasNewerMessages, setHasNewerMessages] = useState(false);
const [loadingNewer, setLoadingNewer] = useState(false);
const {
messages,
messagesRef,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
triggerReconcile,
} = useConversationTimeline({
activeConversation,
targetMessageId,
applyPendingAck,
getMessageContentKey,
seenMessageContentRef: seenMessageContent,
});
const abortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const messagesRef = useRef<Message[]>([]);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
const prevConversationIdRef = useRef<string | null>(null);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
useEffect(() => {
hasNewerMessagesRef.current = hasNewerMessages;
}, [hasNewerMessages]);
const syncSeenContent = useCallback(
(nextMessages: Message[]) => {
seenMessageContent.current.clear();
for (const msg of nextMessages) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
},
[seenMessageContent]
);
const fetchLatestMessages = useCallback(
async (showLoading = false, signal?: AbortSignal) => {
if (!isMessageConversation(activeConversation)) {
setMessages([]);
setHasOlderMessages(false);
return;
}
const conversationId = activeConversation.id;
if (showLoading) {
setMessagesLoading(true);
setMessages([]);
}
try {
const data = await api.getMessages(
{
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
},
signal
);
if (fetchingConversationIdRef.current !== conversationId) {
return;
}
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
setMessages(messagesWithPendingAck);
syncSeenContent(messagesWithPendingAck);
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
if (isAbortError(err)) {
return;
}
console.error('Failed to fetch messages:', err);
toast.error('Failed to load messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
if (showLoading) {
setMessagesLoading(false);
}
}
},
[activeConversation, applyPendingAck, syncSeenContent]
);
const reconcileFromBackend = useCallback(
(conversation: Conversation, signal: AbortSignal) => {
const conversationId = conversation.id;
api
.getMessages(
{
type: conversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
},
signal
)
.then((data) => {
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
if (!merged) return;
setMessages(merged);
syncSeenContent(merged);
if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) {
setHasOlderMessages(true);
}
})
.catch((err) => {
if (isAbortError(err)) return;
console.debug('Background reconciliation failed:', err);
});
},
[applyPendingAck, syncSeenContent]
);
const fetchOlderMessages = useCallback(async () => {
if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return;
const conversationId = activeConversation.id;
const oldestMessage = 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 (!oldestMessage) return;
setLoadingOlder(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
before: oldestMessage.received_at,
before_id: oldestMessage.id,
});
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
if (dataWithPendingAck.length > 0) {
setMessages((prev) => [...prev, ...dataWithPendingAck]);
for (const msg of dataWithPendingAck) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch older messages:', err);
toast.error('Failed to load older messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
setLoadingOlder(false);
}
}, [activeConversation, applyPendingAck, hasOlderMessages, loadingOlder, messages]);
const fetchNewerMessages = useCallback(async () => {
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
const conversationId = activeConversation.id;
const newestMessage = messages.reduce(
(newest, msg) => {
if (!newest) return msg;
if (msg.received_at > newest.received_at) return msg;
if (msg.received_at === newest.received_at && msg.id > newest.id) return msg;
return newest;
},
null as Message | null
);
if (!newestMessage) return;
setLoadingNewer(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
after: newestMessage.received_at,
after_id: newestMessage.id,
});
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
const newMessages = dataWithPendingAck.filter(
(msg) => !seenMessageContent.current.has(getMessageContentKey(msg))
);
if (newMessages.length > 0) {
setMessages((prev) => [...prev, ...newMessages]);
for (const msg of newMessages) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch newer messages:', err);
toast.error('Failed to load newer messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
setLoadingNewer(false);
}
}, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]);
const jumpToBottom = useCallback(() => {
if (!activeConversation) return;
setHasNewerMessages(false);
messageCache.remove(activeConversation.id);
void fetchLatestMessages(true);
}, [activeConversation, fetchLatestMessages]);
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const controller = new AbortController();
reconcileFromBackend(activeConversation, controller.signal);
}, [activeConversation, reconcileFromBackend]);
useEffect(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const prevId = prevConversationIdRef.current;
const newId = activeConversation?.id ?? null;
const conversationChanged = prevId !== newId;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
if (!conversationChanged && !targetMessageId) {
return;
}
setLoadingOlder(false);
setLoadingNewer(false);
if (conversationChanged) {
setHasNewerMessages(false);
}
if (
conversationChanged &&
prevId &&
messagesRef.current.length > 0 &&
!hasNewerMessagesRef.current
) {
messageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContent.current),
hasOlderMessages: hasOlderMessagesRef.current,
});
}
if (!isMessageConversation(activeConversation)) {
setMessages([]);
setHasOlderMessages(false);
return;
}
const controller = new AbortController();
abortControllerRef.current = controller;
if (targetMessageId) {
setMessagesLoading(true);
setMessages([]);
const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV';
void api
.getMessagesAround(
targetMessageId,
msgType as 'PRIV' | 'CHAN',
activeConversation.id,
controller.signal
)
.then((response) => {
if (fetchingConversationIdRef.current !== activeConversation.id) return;
const withAcks = response.messages.map((msg) => applyPendingAck(msg));
setMessages(withAcks);
syncSeenContent(withAcks);
setHasOlderMessages(response.has_older);
setHasNewerMessages(response.has_newer);
})
.catch((err) => {
if (isAbortError(err)) return;
console.error('Failed to fetch messages around target:', err);
toast.error('Failed to jump to message');
})
.finally(() => {
setMessagesLoading(false);
});
} else {
const cached = messageCache.get(activeConversation.id);
if (cached) {
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
reconcileFromBackend(activeConversation, controller.signal);
} else {
void fetchLatestMessages(true, controller.signal);
}
}
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate

View File

@@ -1,399 +0,0 @@
import {
useState,
useCallback,
useEffect,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from 'react';
import { toast } from '../components/ui/sonner';
import { api, isAbortError } from '../api';
import * as messageCache from '../messageCache';
import type { Conversation, Message } from '../types';
const MESSAGE_PAGE_SIZE = 200;
interface UseConversationTimelineArgs {
activeConversation: Conversation | null;
targetMessageId?: number | null;
applyPendingAck: (msg: Message) => Message;
getMessageContentKey: (msg: Message) => string;
seenMessageContentRef: MutableRefObject<Set<string>>;
}
interface UseConversationTimelineResult {
messages: Message[];
messagesRef: MutableRefObject<Message[]>;
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: MutableRefObject<boolean>;
setMessages: Dispatch<SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
triggerReconcile: () => void;
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
}
export function useConversationTimeline({
activeConversation,
targetMessageId,
applyPendingAck,
getMessageContentKey,
seenMessageContentRef,
}: UseConversationTimelineArgs): UseConversationTimelineResult {
const [messages, setMessages] = useState<Message[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [hasNewerMessages, setHasNewerMessages] = useState(false);
const [loadingNewer, setLoadingNewer] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const messagesRef = useRef<Message[]>([]);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
const prevConversationIdRef = useRef<string | null>(null);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
useEffect(() => {
hasNewerMessagesRef.current = hasNewerMessages;
}, [hasNewerMessages]);
const syncSeenContent = useCallback(
(nextMessages: Message[]) => {
seenMessageContentRef.current.clear();
for (const msg of nextMessages) {
seenMessageContentRef.current.add(getMessageContentKey(msg));
}
},
[getMessageContentKey, seenMessageContentRef]
);
const fetchLatestMessages = useCallback(
async (showLoading = false, signal?: AbortSignal) => {
if (!isMessageConversation(activeConversation)) {
setMessages([]);
setHasOlderMessages(false);
return;
}
const conversationId = activeConversation.id;
if (showLoading) {
setMessagesLoading(true);
setMessages([]);
}
try {
const data = await api.getMessages(
{
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
},
signal
);
if (fetchingConversationIdRef.current !== conversationId) {
return;
}
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
setMessages(messagesWithPendingAck);
syncSeenContent(messagesWithPendingAck);
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
if (isAbortError(err)) {
return;
}
console.error('Failed to fetch messages:', err);
toast.error('Failed to load messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
if (showLoading) {
setMessagesLoading(false);
}
}
},
[activeConversation, applyPendingAck, syncSeenContent]
);
const reconcileFromBackend = useCallback(
(conversation: Conversation, signal: AbortSignal) => {
const conversationId = conversation.id;
api
.getMessages(
{
type: conversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
},
signal
)
.then((data) => {
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
if (!merged) return;
setMessages(merged);
syncSeenContent(merged);
if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) {
setHasOlderMessages(true);
}
})
.catch((err) => {
if (isAbortError(err)) return;
console.debug('Background reconciliation failed:', err);
});
},
[applyPendingAck, syncSeenContent]
);
const fetchOlderMessages = useCallback(async () => {
if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return;
const conversationId = activeConversation.id;
const oldestMessage = 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 (!oldestMessage) return;
setLoadingOlder(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
before: oldestMessage.received_at,
before_id: oldestMessage.id,
});
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
if (dataWithPendingAck.length > 0) {
setMessages((prev) => [...prev, ...dataWithPendingAck]);
for (const msg of dataWithPendingAck) {
seenMessageContentRef.current.add(getMessageContentKey(msg));
}
}
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch older messages:', err);
toast.error('Failed to load older messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
setLoadingOlder(false);
}
}, [
activeConversation,
applyPendingAck,
getMessageContentKey,
hasOlderMessages,
loadingOlder,
messages,
seenMessageContentRef,
]);
const fetchNewerMessages = useCallback(async () => {
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
const conversationId = activeConversation.id;
const newestMessage = messages.reduce(
(newest, msg) => {
if (!newest) return msg;
if (msg.received_at > newest.received_at) return msg;
if (msg.received_at === newest.received_at && msg.id > newest.id) return msg;
return newest;
},
null as Message | null
);
if (!newestMessage) return;
setLoadingNewer(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
after: newestMessage.received_at,
after_id: newestMessage.id,
});
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
const newMessages = dataWithPendingAck.filter(
(msg) => !seenMessageContentRef.current.has(getMessageContentKey(msg))
);
if (newMessages.length > 0) {
setMessages((prev) => [...prev, ...newMessages]);
for (const msg of newMessages) {
seenMessageContentRef.current.add(getMessageContentKey(msg));
}
}
setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch newer messages:', err);
toast.error('Failed to load newer messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
setLoadingNewer(false);
}
}, [
activeConversation,
applyPendingAck,
getMessageContentKey,
hasNewerMessages,
loadingNewer,
messages,
seenMessageContentRef,
]);
const jumpToBottom = useCallback(() => {
if (!activeConversation) return;
setHasNewerMessages(false);
messageCache.remove(activeConversation.id);
fetchLatestMessages(true);
}, [activeConversation, fetchLatestMessages]);
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const controller = new AbortController();
reconcileFromBackend(activeConversation, controller.signal);
}, [activeConversation, reconcileFromBackend]);
useEffect(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const prevId = prevConversationIdRef.current;
const newId = activeConversation?.id ?? null;
const conversationChanged = prevId !== newId;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
if (!conversationChanged && !targetMessageId) {
return;
}
setLoadingOlder(false);
setLoadingNewer(false);
if (conversationChanged) {
setHasNewerMessages(false);
}
if (
conversationChanged &&
prevId &&
messagesRef.current.length > 0 &&
!hasNewerMessagesRef.current
) {
messageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContentRef.current),
hasOlderMessages: hasOlderMessagesRef.current,
});
}
if (!isMessageConversation(activeConversation)) {
setMessages([]);
setHasOlderMessages(false);
return;
}
const controller = new AbortController();
abortControllerRef.current = controller;
if (targetMessageId) {
setMessagesLoading(true);
setMessages([]);
const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV';
api
.getMessagesAround(
targetMessageId,
msgType as 'PRIV' | 'CHAN',
activeConversation.id,
controller.signal
)
.then((response) => {
if (fetchingConversationIdRef.current !== activeConversation.id) return;
const withAcks = response.messages.map((msg) => applyPendingAck(msg));
setMessages(withAcks);
syncSeenContent(withAcks);
setHasOlderMessages(response.has_older);
setHasNewerMessages(response.has_newer);
})
.catch((err) => {
if (isAbortError(err)) return;
console.error('Failed to fetch messages around target:', err);
toast.error('Failed to jump to message');
})
.finally(() => {
setMessagesLoading(false);
});
} else {
const cached = messageCache.get(activeConversation.id);
if (cached) {
setMessages(cached.messages);
seenMessageContentRef.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
reconcileFromBackend(activeConversation, controller.signal);
} else {
fetchLatestMessages(true, controller.signal);
}
}
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
return {
messages,
messagesRef,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
triggerReconcile,
};
}

View File

@@ -1,225 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { useAppShellProps } from '../hooks/useAppShellProps';
import type {
AppSettings,
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
RawPacket,
} from '../types';
const mocks = vi.hoisted(() => ({
api: {
createChannel: vi.fn(),
getChannels: vi.fn(),
decryptHistoricalPackets: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
const publicChannel: Channel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: false,
};
const health: HealthStatus = {
status: 'connected',
radio_connected: true,
radio_initializing: false,
connection_info: null,
database_size_mb: 1,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
};
const appSettings: AppSettings = {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
blocked_keys: [],
blocked_names: [],
};
function createArgs(overrides: Partial<Parameters<typeof useAppShellProps>[0]> = {}) {
const activeConversation: Conversation = {
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
};
const contacts: Contact[] = [];
const channels: Channel[] = [publicChannel];
const rawPackets: RawPacket[] = [];
const favorites: Favorite[] = [];
const messages: Message[] = [];
return {
contacts,
channels,
rawPackets,
undecryptedCount: 0,
activeConversation,
config,
health,
favorites,
appSettings,
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
showCracker: false,
crackerRunning: false,
messageInputRef: { current: null },
targetMessageId: null,
infoPaneContactKey: null,
infoPaneFromChannel: false,
infoPaneChannelKey: null,
messages,
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
handleOpenNewMessage: vi.fn(),
handleToggleCracker: vi.fn(),
markAllRead: vi.fn(async () => {}),
handleSortOrderChange: vi.fn(async () => {}),
handleSelectConversationWithTargetReset: vi.fn(),
handleNavigateToMessage: vi.fn(),
handleSaveConfig: vi.fn(async () => {}),
handleSaveAppSettings: vi.fn(async () => {}),
handleSetPrivateKey: vi.fn(async () => {}),
handleReboot: vi.fn(async () => {}),
handleAdvertise: vi.fn(async () => {}),
handleHealthRefresh: vi.fn(async () => {}),
fetchAppSettings: vi.fn(async () => {}),
setChannels: vi.fn(),
fetchUndecryptedCount: vi.fn(async () => {}),
handleCreateContact: vi.fn(async () => {}),
handleCreateChannel: vi.fn(async () => {}),
handleCreateHashtagChannel: vi.fn(async () => {}),
handleDeleteContact: vi.fn(async () => {}),
handleDeleteChannel: vi.fn(async () => {}),
handleToggleFavorite: vi.fn(async () => {}),
handleSetChannelFloodScopeOverride: vi.fn(async () => {}),
handleOpenContactInfo: vi.fn(),
handleOpenChannelInfo: vi.fn(),
handleCloseContactInfo: vi.fn(),
handleCloseChannelInfo: vi.fn(),
handleSenderClick: vi.fn(),
handleResendChannelMessage: vi.fn(async () => {}),
handleTrace: vi.fn(async () => {}),
handleSendMessage: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
setTargetMessageId: vi.fn(),
handleNavigateToChannel: vi.fn(),
handleBlockKey: vi.fn(async () => {}),
handleBlockName: vi.fn(async () => {}),
...overrides,
};
}
describe('useAppShellProps', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates a cracked channel, refreshes channels, decrypts history, and refreshes undecrypted count', async () => {
mocks.api.createChannel.mockResolvedValue({
key: '11'.repeat(16),
name: 'Found',
is_hashtag: false,
});
mocks.api.getChannels.mockResolvedValue([
publicChannel,
{ ...publicChannel, key: '11'.repeat(16), name: 'Found' },
]);
mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 });
const args = createArgs();
const { result } = renderHook(() => useAppShellProps(args));
await act(async () => {
await result.current.crackerProps.onChannelCreate('Found', '11'.repeat(16));
});
expect(mocks.api.createChannel).toHaveBeenCalledWith('Found', '11'.repeat(16));
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
expect(args.setChannels).toHaveBeenCalledWith([
publicChannel,
{ ...publicChannel, key: '11'.repeat(16), name: 'Found' },
]);
expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({
key_type: 'channel',
channel_key: '11'.repeat(16),
});
expect(args.fetchUndecryptedCount).toHaveBeenCalledTimes(1);
});
it('does not fail cracked channel creation when undecrypted count refresh rejects', async () => {
mocks.api.createChannel.mockResolvedValue({
key: '22'.repeat(16),
name: 'Found',
is_hashtag: false,
});
mocks.api.getChannels.mockResolvedValue([
publicChannel,
{ ...publicChannel, key: '22'.repeat(16), name: 'Found' },
]);
mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 });
const args = createArgs({
fetchUndecryptedCount: vi.fn(async () => {
throw new Error('refresh failed');
}),
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const { result } = renderHook(() => useAppShellProps(args));
await act(async () => {
await result.current.crackerProps.onChannelCreate('Found', '22'.repeat(16));
});
expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({
key_type: 'channel',
channel_key: '22'.repeat(16),
});
expect(consoleError).toHaveBeenCalledWith(
'Failed to refresh undecrypted count after cracked channel create:',
expect.any(Error)
);
consoleError.mockRestore();
});
});