From 0dd0e21a31dd9d920070a6f6950e59d7385be140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Mon, 4 May 2026 11:58:04 +0200 Subject: [PATCH 1/2] fix(perf): cut over-subscription in CommandPalette and room modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several components were calling useChat()/useRoom() but only using a small subset of actions. These hooks subscribe to ~15 store values each (activeRoom, activeMessages, joinedRooms, roomsWithUnreadCount, totalUnreadCount, drafts Map, MAM states, etc.), so the consumers re-rendered on every chat/room store update during background MAM sync — feeding the same render-loop pressure that caused the ConversationList freeze on Linux. SDK - Add useRoomActions() and useChatActions(): action-only counterparts to useRoom()/useChat() with ZERO store subscriptions. They read actions directly via *Store.getState() in stable useCallbacks. App - CommandPalette: split into wrapper + content. The palette is always mounted by ChatLayout; the wrapper now returns null when closed so the content's hooks (useChat, useRoom, useRoster) only subscribe while visible. - InviteToRoomModal: same wrapper/content split (also always mounted). - RoomMembersModal, RoomHatsModal, JoinRoomModal, CreateQuickChatModal, OccupantPanel: swap useRoom() for useRoomActions(). - CreateRoomModal: useRoomActions() for actions + focused useAdminStore subscription for mucServiceJid. Tests updated to mock useRoomActions where applicable. --- apps/fluux/src/components/CommandPalette.tsx | 49 +- .../src/components/CreateQuickChatModal.tsx | 4 +- apps/fluux/src/components/CreateRoomModal.tsx | 7 +- .../src/components/InviteToRoomModal.test.tsx | 2 +- .../src/components/InviteToRoomModal.tsx | 27 +- .../src/components/JoinRoomModal.test.tsx | 2 +- apps/fluux/src/components/JoinRoomModal.tsx | 4 +- .../src/components/OccupantPanel.test.tsx | 2 +- apps/fluux/src/components/OccupantPanel.tsx | 4 +- .../src/components/RoomHatsModal.test.tsx | 2 +- apps/fluux/src/components/RoomHatsModal.tsx | 4 +- .../src/components/RoomMembersModal.test.tsx | 2 +- .../fluux/src/components/RoomMembersModal.tsx | 4 +- .../fluux-sdk/src/hooks/useChatActions.ts | 246 +++++++++ .../fluux-sdk/src/hooks/useRoomActions.ts | 483 ++++++++++++++++++ packages/fluux-sdk/src/index.ts | 2 + 16 files changed, 785 insertions(+), 59 deletions(-) create mode 100644 packages/fluux-sdk/src/hooks/useChatActions.ts create mode 100644 packages/fluux-sdk/src/hooks/useRoomActions.ts diff --git a/apps/fluux/src/components/CommandPalette.tsx b/apps/fluux/src/components/CommandPalette.tsx index efdfe660..02ca1a54 100644 --- a/apps/fluux/src/components/CommandPalette.tsx +++ b/apps/fluux/src/components/CommandPalette.tsx @@ -145,8 +145,19 @@ function groupItemsByType(items: CommandItem[], t: (key: string) => string): Ite // Component // ============================================================================= -export function CommandPalette({ - isOpen, +/** + * Top-level wrapper. Returns null when closed so the heavy hooks inside + * `CommandPaletteContent` (`useChat`, `useRoom`, `useRoster`) are NOT + * subscribed when the palette isn't visible. The palette is always mounted + * by ChatLayout, so without this guard it would re-render on every chat / + * room store update during background MAM sync. + */ +export function CommandPalette(props: CommandPaletteProps) { + if (!props.isOpen) return null + return +} + +function CommandPaletteContent({ onClose, onSidebarViewChange, onOpenSettings, @@ -162,7 +173,7 @@ export function CommandPalette({ const selectedIndexRef = useRef(0) // Ref for synchronous access in event handlers const inputRef = useRef(null) const listRef = useRef(null) - const ignoreMouseRef = useRef(false) + const ignoreMouseRef = useRef(true) // start true; cleared after first paint to avoid stale-hover index changes const [isKeyboardNav, setIsKeyboardNav] = useState(false) // Track keyboard navigation mode const lastMousePosRef = useRef<{ x: number; y: number } | null>(null) // Track mouse position to detect real movement @@ -401,29 +412,15 @@ export function CommandPalette({ // Effects // ============================================================================= - // Reset state synchronously when palette opens (before paint, before user can interact) - useLayoutEffect(() => { - if (isOpen) { - setQuery('') - setSelectedIndex(0) - selectedIndexRef.current = 0 - setIsKeyboardNav(false) - lastMousePosRef.current = null - // Ignore mouse events briefly to prevent stale hover from setting wrong index - ignoreMouseRef.current = true - } - }, [isOpen]) - - // Focus input and re-enable mouse after paint + // Content mounts when the palette opens, so on-open setup runs in mount effects. + // useState initial values already reset query/index/keyboard-nav; we just need + // to suppress mouseEnter from any stale hover and focus the input. useEffect(() => { - if (isOpen) { - inputRef.current?.focus() - // Re-enable mouse after a frame to avoid stale hover events - requestAnimationFrame(() => { - ignoreMouseRef.current = false - }) - } - }, [isOpen]) + inputRef.current?.focus() + requestAnimationFrame(() => { + ignoreMouseRef.current = false + }) + }, []) // Reset selection synchronously when query changes useLayoutEffect(() => { @@ -500,8 +497,6 @@ export function CommandPalette({ // Render // ============================================================================= - if (!isOpen) return null - // Pre-compute a map from item id to flat index (avoids O(n²) findIndex in render) const indexById = new Map(flatItems.map((item, i) => [item.id, i])) diff --git a/apps/fluux/src/components/CreateQuickChatModal.tsx b/apps/fluux/src/components/CreateQuickChatModal.tsx index 3120bd74..b01e5458 100644 --- a/apps/fluux/src/components/CreateQuickChatModal.tsx +++ b/apps/fluux/src/components/CreateQuickChatModal.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' import { Zap } from 'lucide-react' -import { useConnection, useRoom } from '@fluux/sdk' +import { useConnection, useRoomActions } from '@fluux/sdk' import { useChatStore } from '@fluux/sdk/react' import { useModalInput } from '@/hooks' import { ModalShell } from './ModalShell' @@ -15,7 +15,7 @@ interface CreateQuickChatModalProps { export function CreateQuickChatModal({ onClose }: CreateQuickChatModalProps) { const { t } = useTranslation() const { jid: userJid, ownNickname } = useConnection() - const { createQuickChat, setActiveRoom } = useRoom() + const { createQuickChat, setActiveRoom } = useRoomActions() // NOTE: Use direct store subscription to avoid re-renders from activeMessages changes const setActiveConversation = useChatStore((s) => s.setActiveConversation) const [topic, setTopic] = useState('') diff --git a/apps/fluux/src/components/CreateRoomModal.tsx b/apps/fluux/src/components/CreateRoomModal.tsx index a7dc1b17..820c715a 100644 --- a/apps/fluux/src/components/CreateRoomModal.tsx +++ b/apps/fluux/src/components/CreateRoomModal.tsx @@ -7,8 +7,8 @@ import { useState, useEffect } from 'react' import { TextInput, TextArea } from './ui/TextInput' import { useTranslation } from 'react-i18next' -import { useRoom } from '@fluux/sdk' -import { useConnectionStore } from '@fluux/sdk/react' +import { useRoomActions } from '@fluux/sdk' +import { useAdminStore, useConnectionStore } from '@fluux/sdk/react' import { getLocalPart } from '@fluux/sdk' import { ModalShell } from './ModalShell' import { Loader2, AlertCircle, Lock, Globe, EyeOff, HelpCircle } from 'lucide-react' @@ -28,7 +28,8 @@ interface CreateRoomModalProps { export function CreateRoomModal({ onClose }: CreateRoomModalProps) { const { t } = useTranslation() - const { createRoom, mucServiceJid, setActiveRoom, roomExists } = useRoom() + const { createRoom, setActiveRoom, roomExists } = useRoomActions() + const mucServiceJid = useAdminStore((s) => s.mucServiceJid) const jid = useConnectionStore((s) => s.jid) const ownNickname = useConnectionStore((s) => s.ownNickname) diff --git a/apps/fluux/src/components/InviteToRoomModal.test.tsx b/apps/fluux/src/components/InviteToRoomModal.test.tsx index 1e535968..cdff0cd4 100644 --- a/apps/fluux/src/components/InviteToRoomModal.test.tsx +++ b/apps/fluux/src/components/InviteToRoomModal.test.tsx @@ -61,7 +61,7 @@ vi.mock('./ContactSelector', () => ({ // Mock SDK hooks vi.mock('@fluux/sdk', () => ({ - useRoom: () => ({ + useRoomActions: () => ({ inviteMultipleToRoom: mockInviteMultipleToRoom, }), // JID utilities used by ContactSelector diff --git a/apps/fluux/src/components/InviteToRoomModal.tsx b/apps/fluux/src/components/InviteToRoomModal.tsx index a371a5b4..339c1f37 100644 --- a/apps/fluux/src/components/InviteToRoomModal.tsx +++ b/apps/fluux/src/components/InviteToRoomModal.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' -import { useRoom, type Room } from '@fluux/sdk' +import { useRoomActions, type Room } from '@fluux/sdk' import { UserPlus, Send } from 'lucide-react' import { ContactSelector } from './ContactSelector' import { ModalShell } from './ModalShell' @@ -21,22 +21,23 @@ interface InviteToRoomModalProps { * when bytes are sent. Server rejections arrive as separate async error * stanzas, handled via the `room:invite-error` SDK event and surfaced * through the toast system. + * + * The wrapper returns null when closed so the inner content (and its hooks) + * does not subscribe while the modal isn't visible. Always-mounted callers + * would otherwise re-render on every room store update. */ -export function InviteToRoomModal({ isOpen, onClose, room }: InviteToRoomModalProps) { +export function InviteToRoomModal(props: InviteToRoomModalProps) { + if (!props.isOpen) return null + return +} + +function InviteToRoomModalContent({ onClose, room }: InviteToRoomModalProps) { const { t } = useTranslation() - const { inviteMultipleToRoom } = useRoom() + const { inviteMultipleToRoom } = useRoomActions() const addToast = useToastStore((s) => s.addToast) const [selectedContacts, setSelectedContacts] = useState([]) const [reason, setReason] = useState('') - // Reset state when modal opens - useEffect(() => { - if (isOpen) { - setSelectedContacts([]) - setReason('') - } - }, [isOpen]) - // Get list of JIDs already in the room (occupants) const occupantJids = Array.from(room.occupants.values()) .map(o => o.jid) @@ -56,8 +57,6 @@ export function InviteToRoomModal({ isOpen, onClose, room }: InviteToRoomModalPr } } - if (!isOpen) return null - const title = ( diff --git a/apps/fluux/src/components/JoinRoomModal.test.tsx b/apps/fluux/src/components/JoinRoomModal.test.tsx index 1b1cac6b..c81556db 100644 --- a/apps/fluux/src/components/JoinRoomModal.test.tsx +++ b/apps/fluux/src/components/JoinRoomModal.test.tsx @@ -14,7 +14,7 @@ vi.mock('@fluux/sdk', () => ({ jid: mockUserJid, ownNickname: mockOwnNickname, }), - useRoom: () => ({ + useRoomActions: () => ({ joinRoom: mockJoinRoom, setActiveRoom: mockSetActiveRoom, }), diff --git a/apps/fluux/src/components/JoinRoomModal.tsx b/apps/fluux/src/components/JoinRoomModal.tsx index 6da61dc9..39789df4 100644 --- a/apps/fluux/src/components/JoinRoomModal.tsx +++ b/apps/fluux/src/components/JoinRoomModal.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' -import { useConnection, useRoom } from '@fluux/sdk' +import { useConnection, useRoomActions } from '@fluux/sdk' import { useChatStore } from '@fluux/sdk/react' import { useModalInput } from '@/hooks' import { ModalShell } from './ModalShell' @@ -13,7 +13,7 @@ interface JoinRoomModalProps { export function JoinRoomModal({ onClose }: JoinRoomModalProps) { const { t } = useTranslation() const { jid: userJid, ownNickname } = useConnection() - const { joinRoom, setActiveRoom } = useRoom() + const { joinRoom, setActiveRoom } = useRoomActions() const setActiveConversation = useChatStore((s) => s.setActiveConversation) const [roomJid, setRoomJid] = useState('') const [nickname, setNickname] = useState('') diff --git a/apps/fluux/src/components/OccupantPanel.test.tsx b/apps/fluux/src/components/OccupantPanel.test.tsx index 9eeed4a9..050d8527 100644 --- a/apps/fluux/src/components/OccupantPanel.test.tsx +++ b/apps/fluux/src/components/OccupantPanel.test.tsx @@ -60,7 +60,7 @@ vi.mock('@fluux/sdk', async () => { unblockAll: vi.fn(), isBlocked: () => false, }), - useRoom: () => ({ + useRoomActions: () => ({ setAffiliation: vi.fn(), setRole: vi.fn(), queryAffiliationList: vi.fn(), diff --git a/apps/fluux/src/components/OccupantPanel.tsx b/apps/fluux/src/components/OccupantPanel.tsx index b05ac4a6..7f0c27c6 100644 --- a/apps/fluux/src/components/OccupantPanel.tsx +++ b/apps/fluux/src/components/OccupantPanel.tsx @@ -13,7 +13,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { Room, RoomOccupant, Contact, PresenceShow, RoomAffiliation, RoomRole } from '@fluux/sdk' import { getPresenceFromShow, getBareJid, getBestPresenceShow, generateConsistentColorHexSync, canKick, canBan, getAvailableAffiliations, getAvailableRoles } from '@fluux/sdk' -import { useRoom } from '@fluux/sdk' +import { useRoomActions } from '@fluux/sdk' import { useConnectionStore, useIgnoreStore } from '@fluux/sdk/react' import { ignoreStore, type IgnoredUser } from '@fluux/sdk/stores' import { Avatar } from './Avatar' @@ -121,7 +121,7 @@ export function OccupantPanel({ } // Moderation actions - const { setAffiliation, setRole } = useRoom() + const { setAffiliation, setRole } = useRoomActions() const addToast = useToastStore((s) => s.addToast) const [moderationTarget, setModerationTarget] = useState(null) diff --git a/apps/fluux/src/components/RoomHatsModal.test.tsx b/apps/fluux/src/components/RoomHatsModal.test.tsx index d9091d38..64e86892 100644 --- a/apps/fluux/src/components/RoomHatsModal.test.tsx +++ b/apps/fluux/src/components/RoomHatsModal.test.tsx @@ -14,7 +14,7 @@ const mockAssignHat = vi.fn() const mockUnassignHat = vi.fn() vi.mock('@fluux/sdk', () => ({ - useRoom: () => ({ + useRoomActions: () => ({ listHats: mockListHats, createHat: mockCreateHat, destroyHat: mockDestroyHat, diff --git a/apps/fluux/src/components/RoomHatsModal.tsx b/apps/fluux/src/components/RoomHatsModal.tsx index 27e1da12..36d3c2ea 100644 --- a/apps/fluux/src/components/RoomHatsModal.tsx +++ b/apps/fluux/src/components/RoomHatsModal.tsx @@ -11,7 +11,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' import type { Room, Hat } from '@fluux/sdk' -import { useRoom, generateConsistentColorHexSync } from '@fluux/sdk' +import { useRoomActions, generateConsistentColorHexSync } from '@fluux/sdk' import { ModalShell } from './ModalShell' import { ConfirmDialog } from './ConfirmDialog' import { ContactSelector } from './ContactSelector' @@ -59,7 +59,7 @@ function getHatColors(hat: { uri: string; hue?: number }) { export function RoomHatsModal({ room, onClose }: RoomHatsModalProps) { const { t } = useTranslation() - const { listHats, createHat, destroyHat, listHatAssignments, assignHat, unassignHat } = useRoom() + const { listHats, createHat, destroyHat, listHatAssignments, assignHat, unassignHat } = useRoomActions() const addToast = useToastStore((s) => s.addToast) // --- Tab state --- diff --git a/apps/fluux/src/components/RoomMembersModal.test.tsx b/apps/fluux/src/components/RoomMembersModal.test.tsx index 8b926eb0..7145768a 100644 --- a/apps/fluux/src/components/RoomMembersModal.test.tsx +++ b/apps/fluux/src/components/RoomMembersModal.test.tsx @@ -14,7 +14,7 @@ const mockContacts = [ ] vi.mock('@fluux/sdk', () => ({ - useRoom: () => ({ + useRoomActions: () => ({ setAffiliation: mockSetAffiliation, queryAffiliationList: mockQueryAffiliationList, }), diff --git a/apps/fluux/src/components/RoomMembersModal.tsx b/apps/fluux/src/components/RoomMembersModal.tsx index a755b9ad..7b046c14 100644 --- a/apps/fluux/src/components/RoomMembersModal.tsx +++ b/apps/fluux/src/components/RoomMembersModal.tsx @@ -13,7 +13,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' import type { Room, RoomAffiliation } from '@fluux/sdk' -import { useRoom, getAvailableAffiliations } from '@fluux/sdk' +import { useRoomActions, getAvailableAffiliations } from '@fluux/sdk' import { ModalShell } from './ModalShell' import { ContactSelector } from './ContactSelector' import { buildRoomContactSuggestions } from '@/utils/roomSuggestions' @@ -37,7 +37,7 @@ const TABS: AffiliationTab[] = ['owner', 'admin', 'member', 'outcast'] export function RoomMembersModal({ room, onClose }: RoomMembersModalProps) { const { t } = useTranslation() - const { setAffiliation, queryAffiliationList } = useRoom() + const { setAffiliation, queryAffiliationList } = useRoomActions() const addToast = useToastStore((s) => s.addToast) const selfOccupant = room.nickname ? room.occupants.get(room.nickname) : undefined diff --git a/packages/fluux-sdk/src/hooks/useChatActions.ts b/packages/fluux-sdk/src/hooks/useChatActions.ts new file mode 100644 index 00000000..5719c0b7 --- /dev/null +++ b/packages/fluux-sdk/src/hooks/useChatActions.ts @@ -0,0 +1,246 @@ +import { useCallback, useMemo } from 'react' +import { chatStore, connectionStore } from '../stores' +import { useXMPPContext } from '../provider' +import type { Conversation, ChatStateNotification, FileAttachment } from '../core' +import { createFetchOlderHistory } from './shared' + +/** + * Action-only counterpart to `useChat()`. + * + * Returns the same actions as `useChat()` but performs ZERO store subscriptions. + * Use this in components that only need to invoke chat actions and do not need + * to react to chat state changes. + * + * Calling `useChat()` subscribes the component to many chat store values + * (`conversations`, `activeMessages`, `typingStates`, `drafts`, MAM state, etc.). + * `useChatActions()` reads actions directly via `chatStore.getState()`, avoiding + * any subscription. + * + * @returns A stable object of chat action callbacks + * + * @category Hooks + */ +export function useChatActions() { + const { client } = useXMPPContext() + + const sendMessage = useCallback( + async ( + to: string, + body: string, + type: 'chat' | 'groupchat' = 'chat', + replyTo?: { id: string; to?: string; fallback?: { author: string; body: string } }, + attachment?: FileAttachment + ): Promise => { + return await client.chat.sendMessage(to, body, type, replyTo, undefined, attachment) + }, + [client] + ) + + const setActiveConversation = useCallback(async (id: string | null) => { + if (id) { + await chatStore.getState().loadMessagesFromCache(id, { limit: 100 }) + } + chatStore.getState().setActiveConversation(id) + }, []) + + const addConversation = useCallback((conv: Conversation) => { + chatStore.getState().addConversation(conv) + }, []) + + const deleteConversation = useCallback((id: string) => { + chatStore.getState().deleteConversation(id) + }, []) + + const markAsRead = useCallback((conversationId: string) => { + chatStore.getState().markAsRead(conversationId) + }, []) + + const archiveConversation = useCallback((id: string) => { + chatStore.getState().archiveConversation(id) + }, []) + + const unarchiveConversation = useCallback((id: string) => { + chatStore.getState().unarchiveConversation(id) + }, []) + + const isArchived = useCallback((id: string) => { + return chatStore.getState().isArchived(id) + }, []) + + const sendChatState = useCallback( + async (to: string, state: ChatStateNotification, type: 'chat' | 'groupchat' = 'chat') => { + await client.chat.sendChatState(to, state, type) + }, + [client] + ) + + const sendReaction = useCallback( + async (to: string, messageId: string, emojis: string[], type: 'chat' | 'groupchat' = 'chat') => { + await client.chat.sendReaction(to, messageId, emojis, type) + }, + [client] + ) + + const sendCorrection = useCallback( + async (conversationId: string, messageId: string, newBody: string, attachment?: FileAttachment) => { + await client.chat.sendCorrection(conversationId, messageId, newBody, 'chat', attachment) + }, + [client] + ) + + const retractMessage = useCallback( + async (conversationId: string, messageId: string) => { + await client.chat.sendRetraction(conversationId, messageId, 'chat') + }, + [client] + ) + + const sendEasterEgg = useCallback( + async (to: string, type: 'chat' | 'groupchat', animation: string) => { + await client.chat.sendEasterEgg(to, type, animation) + }, + [client] + ) + + const clearAnimation = useCallback(() => { + chatStore.getState().clearAnimation() + }, []) + + const setDraft = useCallback((conversationId: string, text: string) => { + chatStore.getState().setDraft(conversationId, text) + }, []) + + const getDraft = useCallback((conversationId: string) => { + return chatStore.getState().getDraft(conversationId) + }, []) + + const clearDraft = useCallback((conversationId: string) => { + chatStore.getState().clearDraft(conversationId) + }, []) + + const clearFirstNewMessageId = useCallback((conversationId: string) => { + chatStore.getState().clearFirstNewMessageId(conversationId) + }, []) + + const updateLastSeenMessageId = useCallback((conversationId: string, messageId: string) => { + chatStore.getState().updateLastSeenMessageId(conversationId, messageId) + }, []) + + const fetchHistory = useCallback( + async (conversationId?: string): Promise => { + const connectionStatus = connectionStore.getState().status + if (connectionStatus !== 'online') return + + const targetId = conversationId ?? chatStore.getState().activeConversationId + if (!targetId) return + + const conversation = chatStore.getState().conversations.get(targetId) + if (!conversation || conversation.type !== 'chat') return + + const mamState = chatStore.getState().getMAMQueryState(targetId) + if (mamState.isLoading) return + + chatStore.getState().setMAMLoading(targetId, true) + + try { + let cachedMessages = chatStore.getState().messages.get(targetId) + if (!cachedMessages || cachedMessages.length === 0) { + await chatStore.getState().loadMessagesFromCache(targetId, { limit: 100 }) + cachedMessages = chatStore.getState().messages.get(targetId) + } + + const newestCachedMessage = cachedMessages?.[cachedMessages.length - 1] + + const queryOptions: { with: string; start?: string } = { with: conversation.id } + + if (newestCachedMessage?.timestamp) { + const startTime = new Date(newestCachedMessage.timestamp.getTime() + 1) + queryOptions.start = startTime.toISOString() + } + + await client.chat.queryMAM(queryOptions) + } catch (error) { + console.error('Failed to fetch history:', error) + } finally { + chatStore.getState().setMAMLoading(targetId, false) + } + }, + [client] + ) + + const fetchOlderHistory = useMemo( + () => + createFetchOlderHistory({ + getActiveId: () => chatStore.getState().activeConversationId, + isValidTarget: (id) => { + const conversation = chatStore.getState().conversations.get(id) + return !!conversation && conversation.type === 'chat' + }, + getMAMState: (id) => chatStore.getState().getMAMQueryState(id), + setMAMLoading: (id, loading) => chatStore.getState().setMAMLoading(id, loading), + loadFromCache: (id, limit) => chatStore.getState().loadOlderMessagesFromCache(id, limit), + getOldestMessageId: (id) => { + const messages = chatStore.getState().messages.get(id) + if (!messages || messages.length === 0) return undefined + return messages[0].stanzaId || messages[0].id + }, + queryMAM: async (id, beforeId) => { + const conversation = chatStore.getState().conversations.get(id) + if (conversation) { + await client.chat.queryMAM({ with: conversation.id, before: beforeId }) + } + }, + errorLogPrefix: 'Failed to fetch older chat history', + }), + [client] + ) + + return useMemo( + () => ({ + sendMessage, + setActiveConversation, + addConversation, + deleteConversation, + markAsRead, + archiveConversation, + unarchiveConversation, + isArchived, + sendChatState, + sendReaction, + sendCorrection, + retractMessage, + sendEasterEgg, + clearAnimation, + setDraft, + getDraft, + clearDraft, + clearFirstNewMessageId, + updateLastSeenMessageId, + fetchHistory, + fetchOlderHistory, + }), + [ + sendMessage, + setActiveConversation, + addConversation, + deleteConversation, + markAsRead, + archiveConversation, + unarchiveConversation, + isArchived, + sendChatState, + sendReaction, + sendCorrection, + retractMessage, + sendEasterEgg, + clearAnimation, + setDraft, + getDraft, + clearDraft, + clearFirstNewMessageId, + updateLastSeenMessageId, + fetchHistory, + fetchOlderHistory, + ] + ) +} diff --git a/packages/fluux-sdk/src/hooks/useRoomActions.ts b/packages/fluux-sdk/src/hooks/useRoomActions.ts new file mode 100644 index 00000000..1de9e1b9 --- /dev/null +++ b/packages/fluux-sdk/src/hooks/useRoomActions.ts @@ -0,0 +1,483 @@ +import { useCallback, useMemo } from 'react' +import { roomStore } from '../stores' +import { useXMPPContext } from '../provider' +import type { + MentionReference, + ChatStateNotification, + FileAttachment, + RSMRequest, + AdminRoom, + RSMResponse, + RoomAffiliation, + RoomRole, + PollData, + PollSettings, +} from '../core/types' +import { createFetchOlderHistory } from './shared' + +/** + * Action-only counterpart to `useRoom()`. + * + * Returns the same actions as `useRoom()` but performs ZERO store subscriptions. + * Use this in components that only need to invoke room actions and do not need + * to react to room state changes (e.g. modals that fire-and-close). + * + * Calling `useRoom()` subscribes the component to ~15 room store values + * (`activeRoom`, `activeMessages`, `joinedRooms`, `roomsWithUnreadCount`, etc.), + * causing it to re-render on every room store update. During background MAM + * sync this can produce hundreds of re-renders per second. `useRoomActions()` + * avoids this by reading actions directly via `roomStore.getState()`. + * + * @returns A stable object of room action callbacks + * + * @example + * ```tsx + * function InviteModal({ room }) { + * const { inviteMultipleToRoom } = useRoomActions() + * // No re-render when other rooms update during sync + * } + * ``` + * + * @category Hooks + */ +export function useRoomActions() { + const { client } = useXMPPContext() + + const joinRoom = useCallback( + async (roomJid: string, nickname: string, options?: { maxHistory?: number; password?: string }) => { + await client.muc.joinRoom(roomJid, nickname, options) + }, + [client] + ) + + const createQuickChat = useCallback( + async (nickname: string, topic?: string, invitees?: string[]): Promise => { + return await client.muc.createQuickChat(nickname, topic, invitees) + }, + [client] + ) + + const leaveRoom = useCallback( + async (roomJid: string) => { + await client.muc.leaveRoom(roomJid) + }, + [client] + ) + + const getRoom = useCallback( + (roomJid: string) => roomStore.getState().rooms.get(roomJid), + [] + ) + + const setActiveRoom = useCallback(async (roomJid: string | null) => { + if (roomJid) { + await roomStore.getState().loadMessagesFromCache(roomJid, { limit: 100 }) + } + roomStore.getState().setActiveRoom(roomJid) + }, []) + + const markAsRead = useCallback((roomJid: string) => { + roomStore.getState().markAsRead(roomJid) + }, []) + + const sendMessage = useCallback( + async ( + roomJid: string, + body: string, + replyTo?: { id: string; to: string; fallback?: { author: string; body: string } }, + references?: MentionReference[], + attachment?: FileAttachment + ): Promise => { + return await client.chat.sendMessage(roomJid, body, 'groupchat', replyTo, references, attachment) + }, + [client] + ) + + const sendReaction = useCallback( + async (roomJid: string, messageId: string, emojis: string[]) => { + await client.chat.sendReaction(roomJid, messageId, emojis, 'groupchat') + }, + [client] + ) + + const sendPoll = useCallback( + async (roomJid: string, title: string, options: string[], settings?: Partial, description?: string, deadline?: string, customEmojis?: string[]) => { + return await client.poll.sendPoll(roomJid, title, options, settings, description, deadline, customEmojis) + }, + [client] + ) + + const votePoll = useCallback( + async (roomJid: string, messageId: string, optionEmoji: string, currentMyReactions: string[], poll: PollData, isClosed?: boolean) => { + await client.poll.vote(roomJid, messageId, optionEmoji, currentMyReactions, poll, isClosed) + }, + [client] + ) + + const closePoll = useCallback( + async (roomJid: string, messageId: string) => { + return await client.poll.closePoll(roomJid, messageId) + }, + [client] + ) + + const sendCorrection = useCallback( + async (roomJid: string, messageId: string, newBody: string, attachment?: FileAttachment) => { + await client.chat.sendCorrection(roomJid, messageId, newBody, 'groupchat', attachment) + }, + [client] + ) + + const retractMessage = useCallback( + async (roomJid: string, messageId: string) => { + await client.chat.sendRetraction(roomJid, messageId, 'groupchat') + }, + [client] + ) + + const moderateMessage = useCallback( + async (roomJid: string, stanzaId: string, reason?: string) => { + await client.muc.moderateMessage(roomJid, stanzaId, reason) + }, + [client] + ) + + const setBookmark = useCallback( + async ( + roomJid: string, + options: { name: string; nick: string; autojoin?: boolean; password?: string } + ) => { + await client.muc.setBookmark(roomJid, options) + }, + [client] + ) + + const removeBookmark = useCallback( + async (roomJid: string) => { + await client.muc.removeBookmark(roomJid) + }, + [client] + ) + + const setRoomNotifyAll = useCallback( + async (roomJid: string, notifyAll: boolean, persistent: boolean = false) => { + await client.muc.setRoomNotifyAll(roomJid, notifyAll, persistent) + }, + [client] + ) + + const sendChatState = useCallback( + async (roomJid: string, state: ChatStateNotification) => { + await client.chat.sendChatState(roomJid, state, 'groupchat') + }, + [client] + ) + + const sendEasterEgg = useCallback( + async (roomJid: string, animation: string) => { + await client.chat.sendEasterEgg(roomJid, 'groupchat', animation) + }, + [client] + ) + + const clearAnimation = useCallback(() => { + roomStore.getState().clearAnimation() + }, []) + + const setDraft = useCallback((roomJid: string, text: string) => { + roomStore.getState().setDraft(roomJid, text) + }, []) + + const getDraft = useCallback((roomJid: string) => { + return roomStore.getState().getDraft(roomJid) + }, []) + + const clearDraft = useCallback((roomJid: string) => { + roomStore.getState().clearDraft(roomJid) + }, []) + + const clearFirstNewMessageId = useCallback((roomJid: string) => { + roomStore.getState().clearFirstNewMessageId(roomJid) + }, []) + + const updateLastSeenMessageId = useCallback((roomJid: string, messageId: string) => { + roomStore.getState().updateLastSeenMessageId(roomJid, messageId) + }, []) + + const setRoomAvatar = useCallback( + async (roomJid: string, imageData: Uint8Array, mimeType: string) => { + const base64 = btoa(String.fromCharCode(...Array.from(imageData))) + const dataUrl = `data:${mimeType};base64,${base64}` + await client.profile.setRoomAvatar(roomJid, dataUrl, mimeType) + }, + [client] + ) + + const clearRoomAvatar = useCallback( + async (roomJid: string) => { + await client.profile.clearRoomAvatar(roomJid) + }, + [client] + ) + + const restoreRoomAvatarFromCache = useCallback( + async (roomJid: string, avatarHash: string) => { + return client.profile.restoreRoomAvatarFromCache(roomJid, avatarHash) + }, + [client] + ) + + const browsePublicRooms = useCallback( + async (mucServiceJid?: string, rsm?: RSMRequest): Promise<{ rooms: AdminRoom[]; pagination: RSMResponse }> => { + return client.admin.fetchRoomList(mucServiceJid, rsm) + }, + [client] + ) + + const inviteToRoom = useCallback( + async (roomJid: string, inviteeJid: string, reason?: string) => { + await client.muc.sendMediatedInvitation(roomJid, inviteeJid, reason) + }, + [client] + ) + + const inviteMultipleToRoom = useCallback( + async (roomJid: string, inviteeJids: string[], reason?: string) => { + await client.muc.sendMediatedInvitations(roomJid, inviteeJids, reason) + }, + [client] + ) + + const submitRoomConfig = useCallback( + async (roomJid: string, values: Record) => { + await client.muc.submitRoomConfig(roomJid, values) + }, + [client] + ) + + const setSubject = useCallback( + async (roomJid: string, subject: string) => { + await client.muc.setSubject(roomJid, subject) + }, + [client] + ) + + const createRoom = useCallback( + async ( + roomJid: string, + nickname: string, + config: { + name: string + description?: string + isPublic?: boolean + membersOnly?: boolean + extraFields?: Record + }, + options?: { invitees?: string[] } + ) => { + await client.muc.createRoom(roomJid, nickname, config, options) + }, + [client] + ) + + const destroyRoom = useCallback( + async (roomJid: string, reason?: string, alternateRoomJid?: string) => { + await client.muc.destroyRoom(roomJid, reason, alternateRoomJid) + }, + [client] + ) + + const roomExists = useCallback( + async (roomJid: string): Promise => { + return client.muc.roomExists(roomJid) + }, + [client] + ) + + const setAffiliation = useCallback( + async (roomJid: string, userJid: string, affiliation: RoomAffiliation, reason?: string) => { + await client.muc.setAffiliation(roomJid, userJid, affiliation, reason) + }, + [client] + ) + + const setRole = useCallback( + async (roomJid: string, nick: string, role: RoomRole, reason?: string) => { + await client.muc.setRole(roomJid, nick, role, reason) + }, + [client] + ) + + const queryAffiliationList = useCallback( + async (roomJid: string, affiliation: RoomAffiliation) => { + return client.muc.queryAffiliationList(roomJid, affiliation) + }, + [client] + ) + + const listHats = useCallback( + async (roomJid: string) => { + return client.muc.listHats(roomJid) + }, + [client] + ) + + const createHat = useCallback( + async (roomJid: string, title: string, uri: string, hue?: number) => { + await client.muc.createHat(roomJid, title, uri, hue) + }, + [client] + ) + + const destroyHat = useCallback( + async (roomJid: string, uri: string) => { + await client.muc.destroyHat(roomJid, uri) + }, + [client] + ) + + const listHatAssignments = useCallback( + async (roomJid: string) => { + return client.muc.listHatAssignments(roomJid) + }, + [client] + ) + + const assignHat = useCallback( + async (roomJid: string, userJid: string, hatUri: string) => { + await client.muc.assignHat(roomJid, userJid, hatUri) + }, + [client] + ) + + const unassignHat = useCallback( + async (roomJid: string, userJid: string, hatUri: string) => { + await client.muc.unassignHat(roomJid, userJid, hatUri) + }, + [client] + ) + + const fetchOlderHistory = useMemo( + () => + createFetchOlderHistory({ + getActiveId: () => roomStore.getState().activeRoomJid, + isValidTarget: (id) => { + const room = roomStore.getState().rooms.get(id) + return !!room && !room.isQuickChat + }, + getMAMState: (id) => roomStore.getState().getRoomMAMQueryState(id), + setMAMLoading: (id, loading) => roomStore.getState().setRoomMAMLoading(id, loading), + loadFromCache: (id, limit) => roomStore.getState().loadOlderMessagesFromCache(id, limit), + getOldestMessageId: (id) => { + const room = roomStore.getState().rooms.get(id) + const messages = room?.messages + if (!messages || messages.length === 0) return undefined + return messages[0].stanzaId || messages[0].id + }, + queryMAM: async (id, beforeId) => { + await client.chat.queryRoomMAM({ roomJid: id, before: beforeId }) + }, + errorLogPrefix: 'Failed to fetch older room history', + }), + [client] + ) + + return useMemo( + () => ({ + joinRoom, + createQuickChat, + leaveRoom, + getRoom, + setActiveRoom, + markAsRead, + sendMessage, + sendReaction, + sendPoll, + votePoll, + closePoll, + sendCorrection, + retractMessage, + moderateMessage, + sendChatState, + setBookmark, + removeBookmark, + setRoomNotifyAll, + sendEasterEgg, + clearAnimation, + setDraft, + getDraft, + clearDraft, + clearFirstNewMessageId, + updateLastSeenMessageId, + setRoomAvatar, + clearRoomAvatar, + restoreRoomAvatarFromCache, + browsePublicRooms, + inviteToRoom, + inviteMultipleToRoom, + submitRoomConfig, + setSubject, + createRoom, + destroyRoom, + roomExists, + setAffiliation, + setRole, + queryAffiliationList, + listHats, + createHat, + destroyHat, + listHatAssignments, + assignHat, + unassignHat, + fetchOlderHistory, + }), + [ + joinRoom, + createQuickChat, + leaveRoom, + getRoom, + setActiveRoom, + markAsRead, + sendMessage, + sendReaction, + sendPoll, + votePoll, + closePoll, + sendCorrection, + retractMessage, + moderateMessage, + sendChatState, + setBookmark, + removeBookmark, + setRoomNotifyAll, + sendEasterEgg, + clearAnimation, + setDraft, + getDraft, + clearDraft, + clearFirstNewMessageId, + updateLastSeenMessageId, + setRoomAvatar, + clearRoomAvatar, + restoreRoomAvatarFromCache, + browsePublicRooms, + inviteToRoom, + inviteMultipleToRoom, + submitRoomConfig, + setSubject, + createRoom, + destroyRoom, + roomExists, + setAffiliation, + setRole, + queryAffiliationList, + listHats, + createHat, + destroyHat, + listHatAssignments, + assignHat, + unassignHat, + fetchOlderHistory, + ] + ) +} diff --git a/packages/fluux-sdk/src/index.ts b/packages/fluux-sdk/src/index.ts index 6ab28645..7f43275e 100644 --- a/packages/fluux-sdk/src/index.ts +++ b/packages/fluux-sdk/src/index.ts @@ -86,6 +86,7 @@ export type { XMPPProviderProps } from './provider' export { useConnection } from './hooks/useConnection' export { useChat } from './hooks/useChat' export { useChatActive } from './hooks/useChatActive' +export { useChatActions } from './hooks/useChatActions' export { useRoster } from './hooks/useRoster' export { useRosterActions } from './hooks/useRosterActions' export { useContactIdentities, type ContactIdentity } from './hooks/useContactIdentities' @@ -94,6 +95,7 @@ export { useEvents } from './hooks/useEvents' export { useActivityLog } from './hooks/useActivityLog' export { useRoom } from './hooks/useRoom' export { useRoomActive } from './hooks/useRoomActive' +export { useRoomActions } from './hooks/useRoomActions' export { useXMPP } from './hooks/useXMPP' export { useAdmin } from './hooks/useAdmin' export { useBlocking } from './hooks/useBlocking' From eddfff8701f83cfd8d84188b1f40aec1d594d6dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20R=C3=A9mond?= Date: Mon, 4 May 2026 12:15:55 +0200 Subject: [PATCH 2/2] fix(perf): cut over-subscription in MemberList and RoomsList MemberList and RoomsList are always mounted (right sidebar + main sidebar), so over-subscription here re-renders unrelated UI on every chat/room store update during background MAM sync. - MemberList: use useChatActive() instead of useChat(). It only needed activeConversation; useChat() pulls in conversations list, typing states, drafts Map, MAM state, etc. - RoomsList: replace useRoom() with focused useRoomStore selectors for allRooms / activeRoomJid plus useRoomActions() for the actions. Drop the list-level drafts Map subscription and push it down to a per-item useRoomStore subscription inside RoomItem (same pattern as ConversationItem) so a draft change in one room only re-renders that row, not the whole list. --- apps/fluux/src/components/MemberList.tsx | 8 ++++-- .../sidebar-components/RoomsList.tsx | 27 ++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/fluux/src/components/MemberList.tsx b/apps/fluux/src/components/MemberList.tsx index 87433232..9d41c650 100644 --- a/apps/fluux/src/components/MemberList.tsx +++ b/apps/fluux/src/components/MemberList.tsx @@ -1,10 +1,14 @@ -import { useChat, useRoster, type PresenceStatus } from '@fluux/sdk' +import { useChatActive, useRoster, type PresenceStatus } from '@fluux/sdk' import { useConnectionStore } from '@fluux/sdk/react' import { Avatar } from './Avatar' import { UserInfoPopover } from './conversation/UserInfoPopover' export function MemberList() { - const { activeConversation } = useChat() + // useChatActive subscribes only to the active conversation, not the full conversation + // list / typingStates / drafts that useChat() pulls in. MemberList is always mounted + // in ChatLayout, so over-subscription here re-renders the right sidebar on every + // chat store update during sync. + const { activeConversation } = useChatActive() const { sortedContacts } = useRoster() const connectionStatus = useConnectionStore((s) => s.status) const forceOffline = connectionStatus !== 'online' diff --git a/apps/fluux/src/components/sidebar-components/RoomsList.tsx b/apps/fluux/src/components/sidebar-components/RoomsList.tsx index 3e335d63..d2af05b7 100644 --- a/apps/fluux/src/components/sidebar-components/RoomsList.tsx +++ b/apps/fluux/src/components/sidebar-components/RoomsList.tsx @@ -1,14 +1,15 @@ import React, { useState, useRef, memo } from 'react' import { useTranslation } from 'react-i18next' +import { useShallow } from 'zustand/react/shallow' import { useContextMenu, useListKeyboardNav, useRouteSync } from '@/hooks' import { - useRoom, + useRoomActions, roomStore, generateConsistentColorHexSync, formatMessagePreview, type Room, } from '@fluux/sdk' -import { useChatStore } from '@fluux/sdk/react' +import { useChatStore, useRoomStore } from '@fluux/sdk/react' import { EditBookmarkModal } from '../EditBookmarkModal' import { Tooltip } from '../Tooltip' import { useSidebarZone } from './types' @@ -30,8 +31,15 @@ import { export function RoomsList() { const { t } = useTranslation() - const { allRooms: rooms, joinRoom, leaveRoom, setBookmark, removeBookmark, activeRoomJid, setActiveRoom, drafts } = useRoom() - // NOTE: Use direct store subscription to avoid re-renders from activeMessages changes + // Focused subscriptions — avoid useRoom() which subscribes to ~15 values + // (activeRoom, activeMessages, totalUnreadCount, etc.) that this list-level + // component doesn't need. RoomsList is always mounted in the Sidebar, so each + // unrelated subscription would re-render the whole list during sync. + // drafts is intentionally NOT subscribed here — each RoomItem subscribes to + // its own draft entry to avoid full-list re-renders on per-room keystrokes. + const rooms = useRoomStore(useShallow((s) => s.allRooms())) + const activeRoomJid = useRoomStore((s) => s.activeRoomJid) + const { joinRoom, leaveRoom, setBookmark, removeBookmark, setActiveRoom } = useRoomActions() const setActiveConversation = useChatStore((s) => s.setActiveConversation) const { navigateToRooms } = useRouteSync() const [editingRoom, setEditingRoom] = useState(null) @@ -146,7 +154,6 @@ export function RoomsList() {
{quickChats.map((room) => { const flatIndex = jidToIndex.get(room.jid) ?? -1 - const draft = drafts.get(room.jid) return ( handleRoomClick(room.jid, true)} onDoubleClick={() => handleRoomDoubleClick(room.jid, true, room.nickname)} onJoin={() => joinRoom(room.jid, room.nickname)} @@ -184,7 +190,6 @@ export function RoomsList() {
{joinedRooms.map((room) => { const flatIndex = jidToIndex.get(room.jid) ?? -1 - const draft = drafts.get(room.jid) return ( handleRoomClick(room.jid, true)} onDoubleClick={() => handleRoomDoubleClick(room.jid, true, room.nickname)} onJoin={() => joinRoom(room.jid, room.nickname)} @@ -225,7 +229,6 @@ export function RoomsList() {
{bookmarkedNotJoined.map((room) => { const flatIndex = jidToIndex.get(room.jid) ?? -1 - const draft = drafts.get(room.jid) return ( handleRoomClick(room.jid, false)} onDoubleClick={() => handleRoomDoubleClick(room.jid, false, room.nickname)} onJoin={() => joinRoom(room.jid, room.nickname)} @@ -279,7 +281,6 @@ interface RoomItemProps { isActive: boolean isSelected?: boolean isKeyboardNav?: boolean - draft?: string onClick: () => void onDoubleClick: () => void onJoin: () => void @@ -299,7 +300,6 @@ const RoomItem = memo(function RoomItem({ isActive, isSelected, isKeyboardNav, - draft, onClick, onDoubleClick, onJoin, @@ -317,6 +317,9 @@ const RoomItem = memo(function RoomItem({ const menu = useContextMenu() const currentLang = i18n.language.split('-')[0] const timeFormat = useSettingsStore((s) => s.timeFormat) + // Per-item subscription: only this row re-renders when ITS draft changes, + // not the whole list when any room's draft changes. + const draft = useRoomStore((s) => s.drafts.get(room.jid)) // Get last message for preview (uses pre-computed lastMessage from metadata for better performance) const lastMessage = room.lastMessage ?? null