diff --git a/apps/fluux/src/components/ContactSelector.test.tsx b/apps/fluux/src/components/ContactSelector.test.tsx index 3ff58bd9..3d998ebc 100644 --- a/apps/fluux/src/components/ContactSelector.test.tsx +++ b/apps/fluux/src/components/ContactSelector.test.tsx @@ -23,9 +23,6 @@ vi.mock('@fluux/sdk', () => ({ useRoster: () => ({ contacts: mockContacts, }), - useChat: () => ({ - conversations: mockConversations, - }), // JID utilities moved from app to SDK matchNameOrJid: (name: string, jid: string, query: string) => { const lowerQuery = query.toLowerCase() @@ -38,6 +35,8 @@ vi.mock('@fluux/sdk', () => ({ vi.mock('@fluux/sdk/react', () => ({ useConnectionStore: (selector: (state: { status: string }) => unknown) => selector({ status: 'online' }), + useChatStore: (selector: (state: { conversations: Map }) => unknown) => + selector({ conversations: new Map(mockConversations.map((c) => [c.id, c])) }), useContactTime: () => null, useLastActivity: vi.fn(), })) diff --git a/apps/fluux/src/components/ContactSelector.tsx b/apps/fluux/src/components/ContactSelector.tsx index 16374197..de7058cd 100644 --- a/apps/fluux/src/components/ContactSelector.tsx +++ b/apps/fluux/src/components/ContactSelector.tsx @@ -1,8 +1,9 @@ import { useState, useRef, useEffect, useLayoutEffect } from 'react' +import { useShallow } from 'zustand/react/shallow' import { TextInput } from './ui/TextInput' import { useTranslation } from 'react-i18next' -import { useRoster, useChat, matchNameOrJid } from '@fluux/sdk' -import { useConnectionStore } from '@fluux/sdk/react' +import { useRoster, matchNameOrJid } from '@fluux/sdk' +import { useChatStore, useConnectionStore } from '@fluux/sdk/react' import { X } from 'lucide-react' import { APP_OFFLINE_PRESENCE_COLOR, PRESENCE_COLORS } from '@/constants/ui' @@ -72,7 +73,9 @@ export function ContactSelector({ const { contacts } = useRoster() const connectionStatus = useConnectionStore((s) => s.status) const forceOffline = connectionStatus !== 'online' - const { conversations } = useChat() + // Focused selector — useChat() would also pull in typing/draft Maps, MAM + // state, active conversation, etc., which this selector doesn't need. + const conversations = useChatStore(useShallow((s) => Array.from(s.conversations.values()))) const [search, setSearch] = useState('') const [highlightedIndex, setHighlightedIndex] = useState(0) const [flipUp, setFlipUp] = useState(false) diff --git a/apps/fluux/src/components/RoomConfigModal.tsx b/apps/fluux/src/components/RoomConfigModal.tsx index 57ca24b0..685f1505 100644 --- a/apps/fluux/src/components/RoomConfigModal.tsx +++ b/apps/fluux/src/components/RoomConfigModal.tsx @@ -5,11 +5,11 @@ * with a subject field on top, and provides a danger zone for room * destruction (owner only). */ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { TextInput } from './ui/TextInput' import type { Room, DataForm } from '@fluux/sdk' -import { useAdmin } from '@fluux/sdk' +import { useXMPP } from '@fluux/sdk' import { ModalShell } from './ModalShell' import { ConfirmDialog } from './ConfirmDialog' import { FormField, useDataFormState } from './DataFormFields' @@ -31,7 +31,14 @@ export function RoomConfigModal({ destroyRoom, }: RoomConfigModalProps) { const { t } = useTranslation() - const { getRoomOptions } = useAdmin() + // Direct call via the XMPP client — avoids useAdmin()'s ~15 admin store + // subscriptions (sessions, command lists, vhosts, pagination, etc.) when + // we only need this one fetch. + const { client } = useXMPP() + const getRoomOptions = useCallback( + (roomJid: string) => client.admin.fetchRoomOptions(roomJid), + [client] + ) const [configForm, setConfigForm] = useState(null) const [loading, setLoading] = useState(true) diff --git a/apps/fluux/src/components/RoomMembersModal.test.tsx b/apps/fluux/src/components/RoomMembersModal.test.tsx index 7145768a..32d535f8 100644 --- a/apps/fluux/src/components/RoomMembersModal.test.tsx +++ b/apps/fluux/src/components/RoomMembersModal.test.tsx @@ -21,9 +21,6 @@ vi.mock('@fluux/sdk', () => ({ useRoster: () => ({ contacts: mockContacts, }), - useChat: () => ({ - conversations: [], - }), getAvailableAffiliations: (selfAff: RoomAffiliation, _targetAff: RoomAffiliation) => { if (selfAff === 'owner') return ['owner', 'admin', 'member', 'none', 'outcast'] return [] @@ -37,6 +34,8 @@ vi.mock('@fluux/sdk', () => ({ vi.mock('@fluux/sdk/react', () => ({ useConnectionStore: (selector: (state: { status: string }) => unknown) => selector({ status: 'online' }), + useChatStore: (selector: (state: { conversations: Map }) => unknown) => + selector({ conversations: new Map() }), useContactTime: () => null, useLastActivity: vi.fn(), })) diff --git a/apps/fluux/src/components/sidebar-components/ContactList.tsx b/apps/fluux/src/components/sidebar-components/ContactList.tsx index b39875bf..f81c05df 100644 --- a/apps/fluux/src/components/sidebar-components/ContactList.tsx +++ b/apps/fluux/src/components/sidebar-components/ContactList.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, memo } from 'react' import { useTranslation } from 'react-i18next' import { useContextMenu, useTypeToFocus, useListKeyboardNav } from '@/hooks' -import { useRoster, useAdmin, type Contact } from '@fluux/sdk' +import { useRoster, useAdminPermissions, type Contact } from '@fluux/sdk' import { useConnectionStore } from '@fluux/sdk/react' import { Avatar } from '../Avatar' import { RenameContactModal } from '../RenameContactModal' @@ -263,7 +263,11 @@ const ContactItem = memo(function ContactItem({ const { t } = useTranslation() const menu = useContextMenu() const [showRenameModal, setShowRenameModal] = useState(false) - const { isAdmin, hasUserCommands, canManageUser } = useAdmin() + // Focused permission hook — useAdmin() subscribes to ~15 admin store + // values; ContactItem only needs these three. Each ContactItem instance + // gets its own subscription, so narrower selectors mean fewer rows + // re-render on unrelated admin store updates. + const { isAdmin, hasUserCommands, canManageUser } = useAdminPermissions() // Check if admin can manage this specific user (based on vhost rights) const showManageOption = isAdmin && hasUserCommands && onManageUser && canManageUser(contact.jid) diff --git a/packages/fluux-sdk/src/hooks/adminCommands.ts b/packages/fluux-sdk/src/hooks/adminCommands.ts new file mode 100644 index 00000000..a75c5520 --- /dev/null +++ b/packages/fluux-sdk/src/hooks/adminCommands.ts @@ -0,0 +1,15 @@ +/** + * XEP-0050 admin command nodes that operate on a specific user JID. + * Shared by `useAdmin` (filters `userCommands`) and `useAdminPermissions` + * (derives `hasUserCommands`). + */ +export const USER_COMMANDS = new Set([ + 'http://jabber.org/protocol/admin#delete-user', + 'http://jabber.org/protocol/admin#disable-user', + 'http://jabber.org/protocol/admin#reenable-user', + 'http://jabber.org/protocol/admin#end-user-session', + 'http://jabber.org/protocol/admin#change-user-password', + 'http://jabber.org/protocol/admin#get-user-roster', + 'http://jabber.org/protocol/admin#get-user-lastlogin', + 'http://jabber.org/protocol/admin#user-stats', +]) diff --git a/packages/fluux-sdk/src/hooks/useAdmin.ts b/packages/fluux-sdk/src/hooks/useAdmin.ts index 12545634..1f6ca2dd 100644 --- a/packages/fluux-sdk/src/hooks/useAdmin.ts +++ b/packages/fluux-sdk/src/hooks/useAdmin.ts @@ -3,18 +3,7 @@ import { adminStore } from '../stores' import { useAdminStore } from '../react/storeHooks' import { useXMPPContext } from '../provider' import type { AdminCommand, AdminCommandCategory, AdminCategory, RSMRequest } from '../core/types' - -// Commands that operate on a specific user JID -const USER_COMMANDS = new Set([ - 'http://jabber.org/protocol/admin#delete-user', - 'http://jabber.org/protocol/admin#disable-user', - 'http://jabber.org/protocol/admin#reenable-user', - 'http://jabber.org/protocol/admin#end-user-session', - 'http://jabber.org/protocol/admin#change-user-password', - 'http://jabber.org/protocol/admin#get-user-roster', - 'http://jabber.org/protocol/admin#get-user-lastlogin', - 'http://jabber.org/protocol/admin#user-stats', -]) +import { USER_COMMANDS } from './adminCommands' /** * Hook for server administration via XEP-0050 Ad-Hoc Commands. diff --git a/packages/fluux-sdk/src/hooks/useAdminPermissions.ts b/packages/fluux-sdk/src/hooks/useAdminPermissions.ts new file mode 100644 index 00000000..eda8ef74 --- /dev/null +++ b/packages/fluux-sdk/src/hooks/useAdminPermissions.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import { adminStore } from '../stores' +import { useAdminStore } from '../react/storeHooks' +import { USER_COMMANDS } from './adminCommands' + +/** + * Focused subscription for admin permission checks. + * + * Returns the three values needed to gate admin UI in normal product code: + * `isAdmin`, `hasUserCommands`, and `canManageUser(jid)`. Subscribes only to + * the admin store fields these depend on — not to the full admin state that + * `useAdmin()` exposes (sessions, command queues, user/room lists, vhosts, + * pagination, etc.). + * + * Use this in list items (e.g. `ContactItem`) where each row would otherwise + * subscribe to the whole admin store via `useAdmin()`. + * + * @category Hooks + */ +export function useAdminPermissions() { + const isAdmin = useAdminStore((s) => s.isAdmin) + // Boolean reduction — Zustand only re-renders when the value flips, even + // though the selector re-runs on every commands change. + const hasUserCommands = useAdminStore((s) => + s.commands.some((cmd) => USER_COMMANDS.has(cmd.node)) + ) + const canManageUser = useCallback((userJid: string): boolean => { + const store = adminStore.getState() + const domain = userJid.split('@')[1]?.split('/')[0] + if (!domain) return false + const adminVhosts = store.vhosts + if (adminVhosts.length === 0) return store.isAdmin + return adminVhosts.includes(domain) + }, []) + + return { isAdmin, hasUserCommands, canManageUser } +} diff --git a/packages/fluux-sdk/src/index.ts b/packages/fluux-sdk/src/index.ts index 7f43275e..b2a987f2 100644 --- a/packages/fluux-sdk/src/index.ts +++ b/packages/fluux-sdk/src/index.ts @@ -98,6 +98,7 @@ export { useRoomActive } from './hooks/useRoomActive' export { useRoomActions } from './hooks/useRoomActions' export { useXMPP } from './hooks/useXMPP' export { useAdmin } from './hooks/useAdmin' +export { useAdminPermissions } from './hooks/useAdminPermissions' export { useBlocking } from './hooks/useBlocking' export { useIgnore } from './hooks/useIgnore' export { usePresence } from './hooks/usePresence'