Skip to content
Clone
9568e72
1026 lines 38.1 KB
/**
* The 聊天室 column: header, connection banners, transcript, composer and the
* right-hand panel. It is a *view* — the page owns the URL, this owns the room —
* and every piece of it survives a missing backend answer:
*
* - the header paints from the sidebar's own row while `GET /rooms/{id}` is in
* flight, so a channel switch never flashes a placeholder title;
* - the socket status is a banner, never a blocker: reading and posting keep
* working over HTTP while realtime is reconnecting;
* - a room that is gone (deleted, or access revoked mid-read) replaces the column
* with the reason instead of an empty transcript that looks like "no messages";
* - voice channels keep the call surface on top and reuse the same text API:
* posts fly over the call as bullet comments, and the header switches to the
* full transcript whenever someone wants to read it in order.
*/
import {
type ChatMessage,
closeDm as closeDmApi,
createThread as createThreadApi,
deleteRoom as deleteRoomApi,
failureCode,
failureReason,
getMessage as getMessageApi,
getThread,
isDmKind,
isVoiceRoom,
listVoiceParticipants,
openDm as openDmApi,
type RoomBrief,
type RoomKindName,
type ThreadBrief,
type UserInfo,
updateRoom,
updateRoomSetting,
type VoiceParticipant,
} from '@app/domains/chat/model'
import { FormModal } from '@app/domains/shared'
import { useT } from '@app/i18n'
import { useAsync } from '@app/lib'
import { getRealtimeHub, sendTypingSignal } from '@app/services/realtime'
import { useVoiceSession } from '@app/services/voice'
import {
Bell,
BellOff,
CalendarX,
Hash,
Lock,
LogOut,
MessageSquare,
Phone,
Pin,
RefreshCw,
Settings2,
Star,
Users,
WifiOff,
} from 'lucide-react'
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useComposer } from '../model/use-composer'
import { useRoomData } from '../model/use-room-data'
import { useRoomStream } from '../model/use-room-stream'
import type { ChatViewState } from '../model/view-state'
import { ChatComposer } from './chat-composer'
import { IconAction, Popover, UserAvatar } from './parts'
import { MembersPanel, PanelHeader, PinsPanel, ThreadPanel, ThreadsPanel } from './side-panel'
import { type MessageInteractions, Transcript } from './transcript'
import { VoiceDanmakuInput } from './voice/voice-danmaku-input'
import { VoiceRoomPanel } from './voice/voice-room-panel'
import './chat-polish.css'
import './voice/voice.css'
/** The detail pane stays useful while always leaving a visible chat column. */
const PANEL_MIN_WIDTH = 280
const CHAT_MIN_WIDTH = 100
const PANEL_RESIZE_STEP = 24
const PANEL_DEFAULT_WIDTH = 380
export interface ChatRoomProps {
spaceName: string
roomId: string
viewerId?: number
/** Sidebar row, used only until the authoritative room row arrives. */
hint?: { name?: string; topic?: string | null; kind?: RoomKindName; peer?: UserInfo | null }
/** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
titleMenu?: (title: string) => ReactNode
/** Space-level `manage_channels`: channel settings and deletion. */
canManageSpace?: boolean
state: ChatViewState
onStateChange: (patch: Partial<ChatViewState>) => void
/** Unread cursor moved: the shell refetches its badges. */
onUnreadChanged?: () => void
/** The room was deleted or left: the page navigates away. */
onRoomGone: (reason: 'deleted' | 'left' | 'dm_closed') => void
/** A DM room was opened for a roster member: the page owns the route change. */
onDmOpened: (roomId: string) => void
}
export function ChatRoom({
spaceName,
roomId,
viewerId,
hint,
titleMenu,
canManageSpace = false,
state,
onStateChange,
onUnreadChanged,
onRoomGone,
onDmOpened,
}: ChatRoomProps) {
const { t } = useT()
const [version, setVersion] = useState(0)
const [flash, setFlash] = useState<string | null>(null)
useEffect(() => {
if (!flash) return
const timer = setTimeout(() => setFlash(null), 6_000)
return () => clearTimeout(timer)
}, [flash])
const data = useRoomData({ roomId, spaceName, version })
const room = data.room.data
const kind = room?.kind ?? hint?.kind
const voice = isVoiceRoom(kind)
// Which half of a voice channel the column shows: the call (with bullet
// comments) or the plain transcript. The call keeps running either way.
const [voicePane, setVoicePane] = useState<'call' | 'text'>('call')
const dm = isDmKind(kind) || spaceName === '@me'
const memberCount = room?.member_count ?? 0
const slowModeSeconds = room?.rate_limit_per_user ?? 0
const isOwner = Boolean(viewerId && room && room.owner_id === viewerId)
const canModerateRoom = canManageSpace || isOwner
const chatRoomRef = useRef<HTMLDivElement>(null)
const resizeRef = useRef<{ pointerId: number; startX: number; moved: boolean } | null>(null)
const [panelWidth, setPanelWidth] = useState(PANEL_DEFAULT_WIDTH)
const [resizingPanel, setResizingPanel] = useState(false)
const [panelHasCustomWidth, setPanelHasCustomWidth] = useState(false)
const [focusedPanelKey, setFocusedPanelKey] = useState<string | null>(null)
const activePanelKey = state.panel === 'thread' ? `thread:${state.threadId ?? ''}` : state.panel
const panelFocused = activePanelKey !== null && focusedPanelKey === activePanelKey
const panelResized = Boolean(state.panel && panelHasCustomWidth)
const clampPanelWidth = useCallback((width: number) => {
const frame = chatRoomRef.current?.getBoundingClientRect()
const maxWidth = frame
? Math.max(PANEL_MIN_WIDTH, Math.floor(frame.width - CHAT_MIN_WIDTH))
: Math.max(PANEL_MIN_WIDTH, Math.round(width))
return Math.min(maxWidth, Math.max(PANEL_MIN_WIDTH, Math.round(width)))
}, [])
const beginPanelResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
resizeRef.current = { pointerId: event.pointerId, startX: event.clientX, moved: false }
event.currentTarget.focus()
event.currentTarget.setPointerCapture(event.pointerId)
setResizingPanel(true)
event.preventDefault()
}, [])
const movePanelResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const resize = resizeRef.current
const frame = chatRoomRef.current?.getBoundingClientRect()
if (!resize || resize.pointerId !== event.pointerId || !frame) return
if (!resize.moved && Math.abs(event.clientX - resize.startX) > 2) {
resize.moved = true
setPanelHasCustomWidth(true)
if (activePanelKey) setFocusedPanelKey(activePanelKey)
}
setPanelWidth(clampPanelWidth(frame.right - event.clientX))
},
[activePanelKey, clampPanelWidth],
)
const finishPanelResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (resizeRef.current?.pointerId !== event.pointerId) return
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
resizeRef.current = null
setResizingPanel(false)
}, [])
const resizePanelWithKeyboard = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
let next: number | null = null
if (event.key === 'ArrowLeft') next = panelWidth + PANEL_RESIZE_STEP
if (event.key === 'ArrowRight') next = panelWidth - PANEL_RESIZE_STEP
if (event.key === 'Home') next = PANEL_MIN_WIDTH
if (event.key === 'End') next = clampPanelWidth(Number.MAX_SAFE_INTEGER)
if (next === null) return
event.preventDefault()
setPanelWidth(clampPanelWidth(next))
setPanelHasCustomWidth(true)
if (activePanelKey) setFocusedPanelKey(activePanelKey)
},
[activePanelKey, clampPanelWidth, panelWidth],
)
// Voice rooms replace the transcript with the call surface; the participant
// list is webhook-backed, so the room re-reads it whenever a chat event says
// the room changed while a call is live.
const voiceState = useVoiceSession()
// 弹幕只在通话中发送:未加入(或连接失败)时,输入栏不该出现。
const inVoiceCall = voiceState.roomId === roomId && voiceState.status !== 'idle' && voiceState.status !== 'failed'
const voiceParticipants = useAsync(
() =>
voice
? listVoiceParticipants(roomId).catch(() => [] as VoiceParticipant[])
: Promise.resolve([] as VoiceParticipant[]),
// The call status is a dependency: joining or leaving changes who is in
// the room, and neither produces a chat event on its own. The raw status is
// used (not a joined/not-joined flag) so the refetch also re-runs on
// connecting -> connected, when the participant actually appears.
[voice, roomId, version, room?.kind, voiceState.status],
)
const stream = useRoomStream({
roomId,
viewerId,
slowModeSeconds,
onUnreadChanged,
onRoomEvent: (kind) => {
if (kind === 'room_updated' || kind === 'thread_updated' || kind === 'message_pinned') {
setVersion((value) => value + 1)
}
if (kind === 'member_joined' || kind === 'member_left') setVersion((value) => value + 1)
if (kind === 'voice_presence' || kind === 'voice_moderation') setVersion((value) => value + 1)
},
})
// Quote-reply and edit targets are *URL state resolved against the loaded
// rows* rather than local mirrors: a row that is deleted or paged out of the
// window therefore drops the context by itself, and reloading the address
// restores the composer in the same state it was left in.
const replyTo = state.replyToId ? findRow(stream.state.rows, state.replyToId) : undefined
const editing = state.editId ? findRow(stream.state.rows, state.editId) : undefined
const composer = useComposer({
roomId,
enabled: !stream.state.accessRevoked,
editingId: editing?.id ?? null,
editingContent: editing?.content ?? '',
onSubmitted: (content, attachments) => stream.send({ content, attachments, replyToId: replyTo?.id ?? null }),
onTyping: () => sendTypingSignal(roomId),
})
// A deep `?jump=` is applied once per id: the transcript fetches its own window
// around the row, which is the only way to reach a message that was never
// loaded, and clearing the parameter would erase a link worth keeping. A jump
// inside an open thread belongs to the thread panel's own transcript.
const jumpingInThread = state.panel === 'thread' && Boolean(state.threadId)
useEffect(() => {
const id = state.jumpId
if (!id || jumped.current === id) return
// 话题内的跳转由 ThreadPanel 自己完成;这里只把它标记成已处理,
// 免得关掉话题面板后同一条深链又到主时间线里再跳一次。
jumped.current = id
if (jumpingInThread) return
stream.jumpTo(id)
}, [state.jumpId, jumpingInThread, stream])
const jumped = useRef<string | null>(null)
/* -------------------------------------------------------- interactions */
const nameOf = useCallback(
(userId: number | null | undefined) => {
if (!userId) return t('common.state.unknownUser')
if (userId === viewerId) return t('shell.chat.me')
const member = (data.members.data ?? []).find((row) => row.user.user_id === userId)
if (member) return member.nick || member.user.display_name || member.user.username
const author = stream.state.rows.find((row) => row.author_id === userId)?.author
return (
author?.display_name || author?.username || t('shell.chat.userFallback', { id: String(userId).slice(0, 8) })
)
},
[data.members.data, stream.state.rows, t, viewerId],
)
const openThread = useCallback(
async (message: ChatMessage) => {
const existing = data.threadOf(message.id)
if (existing) {
setFocusedPanelKey(null)
onStateChange({ panel: 'thread', threadId: existing.id })
return
}
try {
const thread = await createThreadApi(roomId, message.id)
setVersion((value) => value + 1)
setFocusedPanelKey(null)
onStateChange({ panel: 'thread', threadId: thread.id })
} catch (error) {
// A root that cannot be threaded (locked room, missing message) must say
// so instead of silently doing nothing.
setFlash(failureReason(error))
}
},
[data, onStateChange, roomId],
)
const interactions = useMemo<MessageInteractions>(
() => ({
onReply: (message) => onStateChange({ replyToId: message.id }),
onOpenThread: (message) => void openThread(message),
onEdit: (message) => onStateChange({ editId: message.id }),
onDelete: (message) => void stream.deleteMessage(message.id),
onTogglePin: (message) => void stream.togglePin(message.id, !message.pinned),
onReact: (message, emoji, on) => void stream.toggleReaction(message.id, emoji, on),
// Jumping always goes through the URL, so the address bar and the window the
// transcript ended up showing stay one and the same thing.
onJump: (messageId) => onStateChange({ jumpId: messageId }),
canModerate: (message) => canModerateRoom || Boolean(viewerId && message.author_id === viewerId),
canParticipate: !stream.state.accessRevoked,
threadOf: (messageId) => data.threadOf(messageId),
nameOf,
}),
[canModerateRoom, data, nameOf, onStateChange, openThread, stream, viewerId],
)
/**
* A dead socket is *not* a dead room: HTTP keeps working, so "reconnect" only
* restarts the transport (a terminal auth_required/failed state needs an
* explicit start) and re-pulls whatever the gap window missed.
*/
const reconnect = useCallback(() => {
getRealtimeHub().retry()
stream.refresh()
}, [stream])
/* --------------------------------------------------------------- header */
const title = dm
? hint?.peer?.display_name || hint?.peer?.username || room?.name || t('shell.chat.dm')
: room?.name || hint?.name || t('shell.chat.channel')
const topic = room?.topic ?? hint?.topic ?? undefined
// The room itself is unreachable: anything below it would be an empty
// transcript pretending to be a quiet channel, so replace the column instead.
const roomErrorCode = data.room.error && !data.room.data ? failureCode(data.room.error) : undefined
const roomGone = roomErrorCode === 404 || roomErrorCode === 403 || roomErrorCode === 401
// The global search trigger lives above the workspace shell, outside this
// flex row. Publish the live panel width so it remains aligned with the
// chat column's right edge while the user resizes the detail pane.
useEffect(() => {
if (!state.panel || roomGone) return
const root = document.documentElement
root.style.setProperty('--global-chat-panel-width', `${panelWidth}px`)
return () => {
root.style.removeProperty('--global-chat-panel-width')
}
}, [panelWidth, roomGone, state.panel])
const [settingsOpen, setSettingsOpen] = useState(false)
const [menu, setMenu] = useState(false)
// Opening a DM is a write that can fail (the peer left, the quota is spent),
// so it is reported here rather than swallowed by the page's navigation.
const [dmBusy, setDmBusy] = useState(false)
/**
* Room writes are fire-and-forget from the menu's point of view, so every one
* them report through the same banner: a silent 403 on 「离开频道」 would
* otherwise read as success, because the row simply stays where it was.
*/
const write = useCallback(
(action: () => Promise<unknown>) => {
setMenu(false)
void action().then(
() => {
// Success needs no banner: the refetched room row flips the menu label
// (静音 → 取消静音), which is the confirmation the reader can verify.
setVersion((value) => value + 1)
onUnreadChanged?.()
setFlash(null)
},
(error) => setFlash(failureReason(error)),
)
},
[onUnreadChanged],
)
const openDmWith = useCallback(
(user: UserInfo) => {
// One in flight at a time: a double click would open (and report) twice.
if (user.user_id === viewerId || dmBusy) return
setDmBusy(true)
openDmApi(user.user_id).then(
(row) => {
setDmBusy(false)
onDmOpened(row.id)
},
(error) => {
setDmBusy(false)
setFlash(failureReason(error))
},
)
},
[dmBusy, onDmOpened, viewerId],
)
// Narrow screens turn the panel into a full-height overlay; a scrim is the
// only affordance that lets a tap outside dismiss it again.
const closePanel = useCallback(() => {
setFocusedPanelKey(null)
setPanelHasCustomWidth(false)
onStateChange({ panel: null, threadId: undefined })
}, [onStateChange])
const togglePanel = useCallback(
(panel: 'pins' | 'threads' | 'members') => {
if (state.panel === panel) {
closePanel()
return
}
setFocusedPanelKey(null)
onStateChange({ panel })
},
[closePanel, onStateChange, state.panel],
)
useEffect(() => {
if (!state.panel) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') closePanel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [state.panel, closePanel])
const header = (
<header className="chat-header">
<span className="chat-channel-icon" aria-hidden={!dm}>
{dm ? <UserAvatarOrInitial hint={hint} /> : <Hash className="h-5 w-5" />}
</span>
<div className="chat-heading">
<div className="flex min-w-0 items-center gap-2">
{titleMenu ? (
titleMenu(title)
) : (
<h1 className="truncate text-[16px] font-semibold" title={title}>
{title}
</h1>
)}
{room?.is_private ? <Lock className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" /> : null}
{slowModeSeconds > 0 ? (
<span
title={t('chat.room.slowModeTitle', { seconds: slowModeSeconds })}
className="shrink-0 rounded bg-shell-field px-1.5 py-px text-[10.5px] text-shell-ink-muted"
>
{slowModeSeconds}s
</span>
) : null}
</div>
<p className="chat-topic" title={topic || undefined}>
{topic || (dm ? t('chat.room.dmTopicPlaceholder') : t('chat.room.topicPlaceholder'))}
</p>
</div>
<div className="chat-header-actions">
{voice ? (
<IconAction
label={voicePane === 'call' ? t('chat.room.transcriptPane') : t('chat.room.callPane')}
active={voicePane === 'text'}
onClick={() => setVoicePane((value) => (value === 'call' ? 'text' : 'call'))}
>
{voicePane === 'call' ? (
<MessageSquare className="h-[17px] w-[17px]" />
) : (
<Phone className="h-[17px] w-[17px]" />
)}
<span className="chat-action-label">
{voicePane === 'call' ? t('chat.room.transcriptPane') : t('chat.room.callPane')}
</span>
</IconAction>
) : null}
<IconAction label={t('chat.room.pins')} active={state.panel === 'pins'} onClick={() => togglePanel('pins')}>
<Pin className="h-[17px] w-[17px]" />
</IconAction>
<IconAction
label={t('chat.room.threads')}
active={state.panel === 'threads'}
onClick={() => togglePanel('threads')}
>
<MessageSquare className="h-[17px] w-[17px]" />
</IconAction>
<IconAction
label={memberCount > 0 ? t('chat.room.membersWithCount', { count: memberCount }) : t('chat.room.members')}
active={state.panel === 'members'}
onClick={() => togglePanel('members')}
>
<Users className="h-[17px] w-[17px]" />
</IconAction>
<div className="relative">
<IconAction label={t('chat.room.options')} active={menu} onClick={() => setMenu((value) => !value)}>
<Settings2 className="h-[17px] w-[17px]" />
</IconAction>
<Popover open={menu} onClose={() => setMenu(false)} align="end" side="bottom" className="w-52 py-1">
<MenuRow
icon={room?.muted ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />}
label={room?.muted ? t('chat.room.unmute') : t('chat.room.mute')}
onClick={() => write(() => updateRoomSetting(roomId, { muted: !room?.muted }))}
/>
<MenuRow
icon={<Star className="h-3.5 w-3.5" />}
label={room?.favorite ? t('chat.room.unfavorite') : t('chat.room.favorite')}
onClick={() => write(() => updateRoomSetting(roomId, { favorite: !room?.favorite }))}
/>
<MenuRow
icon={<CalendarX className="h-3.5 w-3.5" />}
label={t('chat.room.clearDraft')}
onClick={() => {
setMenu(false)
// The composer owns the draft write: resetting it also deletes
// the server-side row, so one call is enough.
composer.reset()
}}
/>
<div className="my-1 h-px bg-shell-hairline" />
{dm ? (
<MenuRow
icon={<LogOut className="h-3.5 w-3.5" />}
label={t('chat.room.closeDm')}
danger
onClick={() => write(() => closeDmApi(roomId).then(() => onRoomGone('dm_closed')))}
/>
) : (
<>
<MenuRow
icon={<Settings2 className="h-3.5 w-3.5" />}
label={canModerateRoom ? t('chat.room.settings') : t('chat.room.info')}
onClick={() => {
setMenu(false)
setSettingsOpen(true)
}}
/>
</>
)}
</Popover>
</div>
</div>
</header>
)
/* ---------------------------------------------------------------- body */
const body = (
<div className="flex min-h-0 flex-1 flex-col">
<ConnectionBanner status={stream.status} onRetry={reconnect} />
{stream.notice || flash ? (
<div
role="status"
className="flex shrink-0 items-center gap-2 border-b border-shell-hairline bg-[#e5484d]/8 px-4 py-1.5 text-[12px] text-[#8c1c00]"
>
<span className="min-w-0 flex-1 truncate">{stream.notice ?? flash}</span>
<button
type="button"
onClick={() => {
stream.dismissNotice()
setFlash(null)
}}
className="shrink-0 underline"
>
{t('common.action.ignore')}
</button>
</div>
) : null}
{voice && voicePane === 'call' ? (
<>
<VoiceRoomPanel
key={roomId}
roomId={roomId}
roomName={title}
spaceName={spaceName}
isStage={kind === 'Stage'}
participants={voiceParticipants.data ?? []}
danmakuMessages={stream.state.rows}
danmakuSeeded={stream.state.seeded}
danmakuHasOlder={stream.state.hasOlder}
onLoadOlderDanmaku={stream.loadOlder}
nameOf={nameOf}
viewerId={viewerId}
canModerate={canModerateRoom}
/>
{inVoiceCall ? (
<VoiceDanmakuInput
value={composer.text}
onChange={composer.setText}
disabled={stream.state.accessRevoked}
notice={
composer.blocked ??
(stream.slowModeRemaining > 0
? t('chat.room.slowModeShort', { seconds: stream.slowModeRemaining })
: null)
}
onSend={() => {
void composer.submit().then((ok) => {
if (ok) onStateChange({ replyToId: undefined, editId: undefined })
})
}}
/>
) : null}
</>
) : (
<Transcript
state={stream.state}
viewerId={viewerId}
lastReadSeq={stream.lastReadSeq}
missedCount={stream.missedCount}
listRef={stream.listRef}
interactions={interactions}
editingId={editing?.id ?? null}
onSaveEdit={async (messageId, content) => {
const ok = await stream.editMessage(messageId, content)
if (ok) onStateChange({ editId: undefined })
return ok
}}
onCancelEdit={() => onStateChange({ editId: undefined })}
onLoadOlder={stream.loadOlder}
onRetrySeed={stream.refresh}
onAtBottom={stream.reportAtBottom}
onReturnToLatest={stream.returnToLatest}
atBottom={stream.atBottom}
retryPending={stream.retryPending}
dismissPending={stream.dismissPending}
typingNames={stream.typingUserIds.map(nameOf)}
roomLabel={title}
customEmojis={data.emojis.data ?? []}
spaceName={spaceName}
/>
)}
{voice && voicePane === 'call' ? null : (
<div className="chat-composer-band">
<ChatComposer
composer={composer}
roomLabel={title}
replyTo={replyTo}
onCancelReply={() => {
onStateChange({ replyToId: undefined })
stream.scrollToBottom()
}}
editing={editing}
onCancelEdit={() => onStateChange({ editId: undefined })}
slowModeSeconds={slowModeSeconds}
slowModeRemaining={stream.slowModeRemaining}
members={data.members.data ?? []}
customEmojis={data.emojis.data ?? []}
spaceName={spaceName}
onTyping={() => sendTypingSignal(roomId)}
onSubmit={() => {
void composer.submit().then((ok) => {
if (ok) onStateChange({ replyToId: undefined, editId: undefined })
})
}}
/>
</div>
)}
</div>
)
// `?thread=` is a deep link: the id may belong to a topic older than the
// first page of `list_threads`, so ask for that one row before giving up.
const listed = state.panel === 'thread' ? findThread(data.threads.data ?? [], state.threadId) : undefined
const needLookup = state.panel === 'thread' && !listed && Boolean(state.threadId)
const lookedUp = useAsync(
() => (needLookup ? getThread(state.threadId ?? '').catch(() => undefined) : Promise.resolve(undefined)),
[needLookup, state.threadId],
)
const thread = listed ?? lookedUp.data
// A thread's root is usually already on screen; when it is older than the
// loaded window it is fetched, so the panel never opens without context.
const rootInWindow = thread ? findRow(stream.state.rows, thread.root_message_id) : undefined
const rootFetch = useAsync(
() =>
thread && !rootInWindow
? getMessageApi(roomId, thread.root_message_id).catch(() => undefined)
: Promise.resolve(undefined),
[roomId, rootInWindow?.id, thread?.root_message_id],
)
const threadRoot = rootInWindow ?? rootFetch.data
return (
<div
ref={chatRoomRef}
className={`chat-room flex h-full min-h-0 min-w-0 flex-1${resizingPanel ? ' chat-panel-resizing' : ''}${panelResized ? ' chat-panel-resized' : ''}`}
>
<section className="chat-main flex min-w-0 flex-1 flex-col bg-shell-surface" aria-label={title}>
{header}
{roomGone ? <RoomGone code={roomErrorCode} /> : body}
{state.panel && !roomGone && panelFocused ? (
<button
type="button"
className="chat-panel-focus-scrim"
aria-label={t('chat.panel.clearFocus')}
onClick={() => setFocusedPanelKey(null)}
></button>
) : null}
</section>
{state.panel && !roomGone ? (
<>
<button
type="button"
className="chat-panel-scrim"
aria-label={t('chat.panel.close')}
tabIndex={-1}
onClick={closePanel}
/>
<aside
className="chat-side-panel flex shrink-0 flex-col border-l border-shell-hairline bg-shell-sidebar"
style={{ '--chat-panel-width': `${panelWidth}px` } as CSSProperties}
aria-label={t('chat.room.details')}
>
<div
role="separator"
aria-orientation="vertical"
aria-label={t('chat.panel.resize')}
aria-valuemin={PANEL_MIN_WIDTH}
aria-valuenow={panelWidth}
tabIndex={0}
className="chat-panel-resize-handle"
onPointerDown={beginPanelResize}
onPointerMove={movePanelResize}
onPointerUp={finishPanelResize}
onPointerCancel={finishPanelResize}
onLostPointerCapture={finishPanelResize}
onKeyDown={resizePanelWithKeyboard}
/>
{state.panel === 'thread' ? (
thread ? (
<ThreadPanel
roomId={roomId}
thread={thread}
root={threadRoot}
viewerId={viewerId}
spaceName={spaceName}
members={data.members.data ?? []}
customEmojis={data.emojis.data ?? []}
nameOf={nameOf}
interactions={interactions}
jumpId={state.jumpId}
onClose={closePanel}
onChanged={() => setVersion((value) => value + 1)}
/>
) : (
<ThreadMissing onClose={closePanel} />
)
) : state.panel === 'pins' ? (
<PinsPanel
pins={data.pins.data ?? []}
loading={data.pins.loading}
nameOf={nameOf}
onJump={(messageId) => stream.jumpTo(messageId)}
onUnpin={(message) => void stream.togglePin(message.id, false)}
onClose={closePanel}
/>
) : state.panel === 'threads' ? (
<ThreadsPanel
threads={data.threads.data ?? []}
loading={data.threads.loading}
nameOf={nameOf}
onOpen={(row) => {
setFocusedPanelKey(null)
onStateChange({ panel: 'thread', threadId: row.id })
}}
onClose={closePanel}
/>
) : state.panel === 'members' ? (
<MembersPanel
memberCount={memberCount}
members={data.members.data ?? []}
loading={data.members.loading}
error={data.members.error}
presences={data.presences.data ?? []}
viewerId={viewerId}
onDm={openDmWith}
onClose={closePanel}
/>
) : null}
</aside>
</>
) : null}
<RoomSettingsModal
open={settingsOpen}
room={room}
hintName={title}
hintTopic={topic ?? null}
dm={dm}
canManage={canModerateRoom}
onClose={() => setSettingsOpen(false)}
onChanged={() => {
setVersion((value) => value + 1)
stream.refresh()
}}
onDeleted={() => {
setSettingsOpen(false)
onRoomGone('deleted')
}}
/>
</div>
)
}
/* ------------------------------------------------------------------ pieces */
/** Rows are addressed by id; a target that is not loaded has no context to show. */
function findRow(rows: ChatMessage[], id: string | undefined): ChatMessage | undefined {
return id ? rows.find((row) => row.id === id) : undefined
}
function findThread(threads: ThreadBrief[], threadId: string | undefined): ThreadBrief | undefined {
if (!threadId) return undefined
return threads.find((candidate) => candidate.id === threadId)
}
function UserAvatarOrInitial({ hint }: { hint?: { peer?: UserInfo | null } }) {
if (!hint?.peer) return <Users className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />
return <UserAvatar user={hint.peer} id={hint.peer.user_id} size={38} />
}
function ConnectionBanner({ status, onRetry }: { status: string; onRetry: () => void }) {
const { t } = useT()
if (status === 'open') return null
const text =
status === 'auth_required'
? t('chat.connection.authRequired')
: status === 'failed'
? t('chat.connection.stopped')
: status === 'connecting' || status === 'reconnecting'
? t('chat.connection.connecting')
: t('chat.connection.closed')
return (
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-shell-hairline bg-[#f0b232]/18 px-4 text-[11.5px] text-[#804200]">
<WifiOff className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{text}</span>
<button
type="button"
onClick={onRetry}
className="flex shrink-0 items-center gap-1 rounded border border-[#804200]/30 px-1.5 py-px hover:bg-[#f0b232]/25"
>
<RefreshCw className="h-3 w-3" />
{t('chat.connection.retry')}
</button>
</div>
)
}
function RoomGone({ code }: { code?: number }) {
const { t } = useT()
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<Lock className="h-6 w-6 text-shell-ink-muted" />
<p className="text-[14px] font-semibold">{code === 403 ? t('chat.room.accessDenied') : t('chat.room.gone')}</p>
<p className="max-w-sm text-[12.5px] leading-[1.6] text-shell-ink-muted">{t('chat.room.goneHint')}</p>
</div>
)
}
function ThreadMissing({ onClose }: { onClose: () => void }) {
const { t } = useT()
return (
<>
<PanelHeader title={t('chat.thread.title')} subtitle={t('chat.thread.missing')} onClose={onClose} />
<div className="p-4 text-[12.5px] leading-[1.6] text-shell-ink-muted">{t('chat.thread.missingHint')}</div>
</>
)
}
function MenuRow({
icon,
label,
onClick,
danger = false,
disabled = false,
}: {
icon: React.ReactNode
label: string
onClick: () => void
danger?: boolean
disabled?: boolean
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-board disabled:pointer-events-none disabled:opacity-40 ${
danger ? 'text-[#e5484d]' : ''
}`}
>
{icon}
{label}
</button>
)
}
/** Channel settings: the writable subset of `PATCH /chat/rooms/{id}`. */
function RoomSettingsModal({
open,
room,
hintName,
hintTopic,
dm,
canManage,
onClose,
onChanged,
onDeleted,
}: {
open: boolean
room?: RoomBrief
hintName: string
hintTopic: string | null
dm: boolean
canManage: boolean
onClose: () => void
onChanged: () => void
onDeleted: () => void
}) {
const { t } = useT()
const [armed, setArmed] = useState(false)
// Gated on `open` rather than reset by an effect: closing the dialog must be
// enough to disarm the destructive step.
const confirmDelete = open && armed
return (
<FormModal
open={open}
title={dm ? t('chat.room.dmSettings') : t('chat.room.settings')}
description={
dm
? t('chat.room.settingsNoteDm')
: canManage
? t('chat.room.settingsNoteManage')
: t('chat.room.settingsNoteView')
}
wide
confirmLabel={canManage ? t('common.action.save') : t('common.action.close')}
fields={[
{
key: 'name',
label: t('chat.rooms.name'),
kind: 'text',
optional: false,
initialValue: room?.name ?? hintName,
maxLength: 128,
hint: t('chat.room.nameHint'),
},
{
key: 'topic',
label: t('chat.rooms.topic'),
kind: 'textarea',
optional: true,
initialValue: room?.topic ?? hintTopic ?? '',
maxLength: 1024,
hint: t('chat.room.topicHint'),
rows: 3,
},
{
key: 'is_private',
label: t('chat.room.private'),
kind: 'toggle',
initialValue: room?.is_private ?? false,
},
{
key: 'rate_limit',
label: t('chat.room.rateLimit'),
kind: 'number',
optional: true,
initialValue: String(room?.rate_limit_per_user ?? 0),
hint: t('chat.room.rateLimitHint'),
},
]}
onSubmit={async (values) => {
if (!canManage) return undefined
const name = String(values.name ?? '').trim()
const topic = String(values.topic ?? '').trim()
const rate = Number.parseInt(String(values.rate_limit ?? '0'), 10)
await updateRoom(room?.id ?? '', {
name: name || undefined,
topic,
is_private: Boolean(values.is_private),
rate_limit_per_user: Number.isFinite(rate) && rate > 0 ? rate : 0,
})
onChanged()
return t('chat.room.saved')
}}
onClose={onClose}
extra={
!dm && canManage ? (
confirmDelete ? (
<div className="rounded-md border-2 border-[#e5484d]/40 bg-[#e5484d]/8 p-2.5 text-[12px] leading-[1.6]">
{t('chat.room.deleteNote')}
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => void deleteRoomApi(room?.id ?? '').then(() => onDeleted())}
className="rounded bg-[#e5484d] px-2.5 py-1 text-[12px] font-semibold text-white"
>
{t('chat.room.deleteConfirm')}
</button>
<button
type="button"
onClick={() => setArmed(false)}
className="rounded px-2 py-1 text-[12px] hover:bg-shell-field"
>
{t('common.action.cancel')}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setArmed(true)}
className="rounded-md border-2 border-shell-hairline px-2.5 py-1 text-[12px] text-[#e5484d] hover:border-[#e5484d]/50"
>
{t('chat.room.deleteAction')}
</button>
)
) : null
}
/>
)
}