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
49 changes: 22 additions & 27 deletions apps/fluux/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CommandPaletteContent {...props} />
}

function CommandPaletteContent({
onClose,
onSidebarViewChange,
onOpenSettings,
Expand All @@ -162,7 +173,7 @@ export function CommandPalette({
const selectedIndexRef = useRef(0) // Ref for synchronous access in event handlers
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(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

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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]))

Expand Down
4 changes: 2 additions & 2 deletions apps/fluux/src/components/CreateQuickChatModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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('')
Expand Down
7 changes: 4 additions & 3 deletions apps/fluux/src/components/CreateRoomModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion apps/fluux/src/components/InviteToRoomModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ vi.mock('./ContactSelector', () => ({

// Mock SDK hooks
vi.mock('@fluux/sdk', () => ({
useRoom: () => ({
useRoomActions: () => ({
inviteMultipleToRoom: mockInviteMultipleToRoom,
}),
// JID utilities used by ContactSelector
Expand Down
27 changes: 13 additions & 14 deletions apps/fluux/src/components/InviteToRoomModal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 <InviteToRoomModalContent {...props} />
}

function InviteToRoomModalContent({ onClose, room }: InviteToRoomModalProps) {
const { t } = useTranslation()
const { inviteMultipleToRoom } = useRoom()
const { inviteMultipleToRoom } = useRoomActions()
const addToast = useToastStore((s) => s.addToast)
const [selectedContacts, setSelectedContacts] = useState<string[]>([])
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)
Expand All @@ -56,8 +57,6 @@ export function InviteToRoomModal({ isOpen, onClose, room }: InviteToRoomModalPr
}
}

if (!isOpen) return null

const title = (
<span className="flex items-center gap-2">
<UserPlus className="w-5 h-5 text-fluux-brand" />
Expand Down
2 changes: 1 addition & 1 deletion apps/fluux/src/components/JoinRoomModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vi.mock('@fluux/sdk', () => ({
jid: mockUserJid,
ownNickname: mockOwnNickname,
}),
useRoom: () => ({
useRoomActions: () => ({
joinRoom: mockJoinRoom,
setActiveRoom: mockSetActiveRoom,
}),
Expand Down
4 changes: 2 additions & 2 deletions apps/fluux/src/components/JoinRoomModal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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('')
Expand Down
8 changes: 6 additions & 2 deletions apps/fluux/src/components/MemberList.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion apps/fluux/src/components/OccupantPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions apps/fluux/src/components/OccupantPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<GroupedOccupant | null>(null)

Expand Down
2 changes: 1 addition & 1 deletion apps/fluux/src/components/RoomHatsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const mockAssignHat = vi.fn()
const mockUnassignHat = vi.fn()

vi.mock('@fluux/sdk', () => ({
useRoom: () => ({
useRoomActions: () => ({
listHats: mockListHats,
createHat: mockCreateHat,
destroyHat: mockDestroyHat,
Expand Down
4 changes: 2 additions & 2 deletions apps/fluux/src/components/RoomHatsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 ---
Expand Down
2 changes: 1 addition & 1 deletion apps/fluux/src/components/RoomMembersModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const mockContacts = [
]

vi.mock('@fluux/sdk', () => ({
useRoom: () => ({
useRoomActions: () => ({
setAffiliation: mockSetAffiliation,
queryAffiliationList: mockQueryAffiliationList,
}),
Expand Down
4 changes: 2 additions & 2 deletions apps/fluux/src/components/RoomMembersModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
Loading
Loading