/** * Everything a room needs besides its transcript: the authoritative room row, * the member directory (names, avatars, @-mention targets), online presence, * the pin list, the thread index and the space's custom emojis. * * These are five independent reads with different failure modes, so each one * keeps its own state and the room stays usable when any of them is missing: * a private room whose member list is forbidden still chats, an unreachable * presence endpoint just leaves every dot grey. */ import { type ChatMessage, type CustomEmoji, getRoom, listPins, listRoomMembers, listRoomThreads, listSpaceEmojis, listSpacePresences, type Presence, type RoomBrief, type RoomMember, type ThreadBrief, } from '@app/domains/chat/model' import { t } from '@app/i18n' import { type AsyncState, roomKey, spaceEmojisKey, useAsync } from '@app/lib' import { useWsUserEvents } from '@app/services/realtime' import { useCallback, useEffect, useMemo, useState } from 'react' export interface RoomData { /** `GET /chat/rooms/{id}` — the room as the server sees it right now. */ room: AsyncState members: AsyncState pins: AsyncState threads: AsyncState /** Empty outside a space (the personal `@me` scope has no presence list). */ presences: AsyncState emojis: AsyncState reload: () => void /** Thread indexed by its root message, so a row knows its own topic. */ threadOf: (messageId: string) => ThreadBrief | undefined /** Display name for a user id, resolved from the room's member list. */ nameOf: (userId: number | null | undefined) => string } export interface UseRoomDataOptions { roomId: string /** Personal scope (`@me`) has no space-scoped presence/emoji endpoints. */ spaceName?: string enabled?: boolean /** Bumped by live `room_updated` / `thread_updated` traffic. */ version?: number } export function useRoomData({ roomId, spaceName, enabled = true, version = 0 }: UseRoomDataOptions): RoomData { const [reloadKey, setReloadKey] = useState(0) const gate = enabled && Boolean(roomId) // `version` rides along with the dependency list: live traffic that changes // room state (a pin by someone else, a new thread) refreshes the side data // without the user having to reopen the panel. Every read is local-first: // reopening a room paints the stored payload while the refresh runs. const deps = [gate ? roomId : '', gate, version, reloadKey] const room = useAsync(() => (gate ? getRoom(roomId) : Promise.resolve(undefined)), deps, { cacheKey: gate ? roomKey(roomId, 'room') : null, }) const members = useAsync(() => (gate ? listRoomMembers(spaceName ?? '', roomId).catch(() => []) : Promise.resolve([])), deps, { cacheKey: gate ? roomKey(roomId, 'members') : null, }) const pins = useAsync(() => (gate ? listPins(roomId).catch(() => []) : Promise.resolve([])), deps, { cacheKey: gate ? roomKey(roomId, 'pins') : null, }) const threads = useAsync(() => (gate ? listRoomThreads(roomId).catch(() => []) : Promise.resolve([])), deps, { cacheKey: gate ? roomKey(roomId, 'threads') : null, }) // Presence is live-only state: a stale online dot is worse than no dot. // 队友的在线变化没有广播通道(`presence` 事件只回发给本人),所以在标签页 // 可见时每 30s 重拉一次,切回前台立刻补一次;隐藏时停表,不空转网络。 const [presenceTick, setPresenceTick] = useState(0) const livePresence = gate && Boolean(spaceName) && spaceName !== '@me' useEffect(() => { if (!livePresence) return const tick = () => setPresenceTick((value) => value + 1) let timer: ReturnType | undefined const start = () => { if (timer === undefined) timer = setInterval(tick, 30_000) } const stop = () => { if (timer !== undefined) { clearInterval(timer) timer = undefined } } const onVisibility = () => { if (document.visibilityState === 'visible') { tick() start() } else { stop() } } if (document.visibilityState === 'visible') start() document.addEventListener('visibilitychange', onVisibility) return () => { stop() document.removeEventListener('visibilitychange', onVisibility) } }, [livePresence]) // 自己上线/改状态(`presence` 事件只回发给本人)时立即补拉一次:硬刷新直接 // 进房间时,挂载那一刻 socket 可能还没连上,首屏的在线状态会短暂缺失。 useWsUserEvents((event) => { if (event.kind === 'presence') setPresenceTick((value) => value + 1) }) const presences = useAsync( () => gate && spaceName && spaceName !== '@me' ? listSpacePresences(spaceName).catch(() => []) : Promise.resolve([]), [gate, spaceName, reloadKey, presenceTick], ) const emojis = useAsync( () => (gate && spaceName && spaceName !== '@me' ? listSpaceEmojis(spaceName).catch(() => []) : Promise.resolve([])), [gate, spaceName, reloadKey], { cacheKey: gate && spaceName && spaceName !== '@me' ? spaceEmojisKey(spaceName) : null }, ) const reload = useCallback(() => setReloadKey((key) => key + 1), []) const byRoot = useMemo(() => { const map = new Map() for (const thread of threads.data ?? []) map.set(thread.root_message_id, thread) return map }, [threads.data]) const users = useMemo(() => { const map = new Map() for (const member of members.data ?? []) { map.set(member.user.user_id, member.nick || member.user.display_name || member.user.username) } return map }, [members.data]) const threadOf = useCallback((messageId: string) => byRoot.get(messageId), [byRoot]) const nameOf = useCallback( (userId: number | null | undefined) => { if (!userId) return t('common.state.unknownUser') return users.get(userId) ?? t('shell.chat.userFallback', { id: String(userId).slice(0, 8) }) }, [users], ) return { room, members, pins, threads, presences, emojis, reload, threadOf, nameOf } }