mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-05 04:52:59 +02:00
Compactify some things for LLM wins
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,4 +9,3 @@ export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useAppShellProps } from './useAppShellProps';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user