Skip to content
Clone
main
152 lines 6.1 KB
/**
* 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<RoomBrief | undefined>
members: AsyncState<RoomMember[]>
pins: AsyncState<ChatMessage[]>
threads: AsyncState<ThreadBrief[]>
/** Empty outside a space (the personal `@me` scope has no presence list). */
presences: AsyncState<Presence[]>
emojis: AsyncState<CustomEmoji[]>
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<typeof setInterval> | 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<string, ThreadBrief>()
for (const thread of threads.data ?? []) map.set(thread.root_message_id, thread)
return map
}, [threads.data])
const users = useMemo(() => {
const map = new Map<number, string>()
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 }
}