Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/fluux/src/components/ContactSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<string, unknown> }) => unknown) =>
selector({ conversations: new Map(mockConversations.map((c) => [c.id, c])) }),
useContactTime: () => null, useLastActivity: vi.fn(),
}))

Expand Down
9 changes: 6 additions & 3 deletions apps/fluux/src/components/ContactSelector.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 10 additions & 3 deletions apps/fluux/src/components/RoomConfigModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<DataForm | null>(null)
const [loading, setLoading] = useState(true)
Expand Down
5 changes: 2 additions & 3 deletions apps/fluux/src/components/RoomMembersModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand All @@ -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<string, unknown> }) => unknown) =>
selector({ conversations: new Map() }),
useContactTime: () => null, useLastActivity: vi.fn(),
}))

Expand Down
8 changes: 6 additions & 2 deletions apps/fluux/src/components/sidebar-components/ContactList.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/fluux-sdk/src/hooks/adminCommands.ts
Original file line number Diff line number Diff line change
@@ -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',
])
13 changes: 1 addition & 12 deletions packages/fluux-sdk/src/hooks/useAdmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions packages/fluux-sdk/src/hooks/useAdminPermissions.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
1 change: 1 addition & 0 deletions packages/fluux-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading