/**
* The right-hand column: one thread, the thread list, the pin list, or the
* member roster. Only one is mounted at a time and the choice lives in the URL
* (see `pages/workspace/model/chat-location`), so every panel is deep-linkable
* and survives a reload without a document navigation.
*
* A private room whose member list is forbidden says so instead of showing an
* empty roster.
*/
import {
type ChatMessage,
type CustomEmoji,
clockStamp,
describeFailure,
followThread,
fullStamp,
listMyThreads,
type Presence,
type RoomMember,
type ThreadBrief,
type UserInfo,
unfollowThread,
updateThread,
} from '@app/domains/chat/model'
import { useT } from '@app/i18n'
import { useAsync } from '@app/lib'
import { sendTypingSignal } from '@app/services/realtime'
import {
IconBell as Bell,
IconBellOff as BellOff,
IconClock as Clock,
IconCrosshair as Crosshair,
IconLoader2 as Loader2,
IconLock as Lock,
IconMessage as MessageSquare,
IconPin as Pin,
IconUsers as Users,
IconX as X,
} from '@tabler/icons-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useComposer } from '../model/use-composer'
import { useRoomStream } from '../model/use-room-stream'
import { ChatComposer } from './chat-composer'
import { AttachmentGrid, MessageBody, UserAvatar } from './parts'
import { type MessageInteractions, Transcript } from './transcript'
export function PanelHeader({
title,
subtitle,
onClose,
actions,
}: {
title: string
subtitle?: string
onClose: () => void
actions?: React.ReactNode
}) {
const { t } = useT()
return (
{title}
{subtitle ?
{subtitle}
: null}
{actions}
)
}
/* ------------------------------------------------------------------ thread */
export interface ThreadPanelProps {
roomId: string
thread: ThreadBrief
/** Root message, when it is part of the loaded window (or fetched for the panel). */
root?: ChatMessage
viewerId?: number
spaceName?: string
members: RoomMember[]
customEmojis?: CustomEmoji[]
nameOf: (userId: number | null | undefined) => string
interactions: MessageInteractions
/** A message to reveal inside this thread (deep link from global search). */
jumpId?: string
onClose: () => void
/** Thread list / reply counts changed: the room view refetches them. */
onChanged: () => void
}
export function ThreadPanel({
roomId,
thread,
root,
viewerId,
spaceName,
members,
customEmojis,
nameOf,
interactions,
jumpId,
onClose,
onChanged,
}: ThreadPanelProps) {
const { t } = useT()
const readOnly = thread.locked
? t('chat.thread.lockedNotice')
: thread.archived
? t('chat.thread.archivedReadOnly')
: null
const stream = useRoomStream({
roomId,
threadId: thread.id,
viewerId,
onRoomEvent: (kind) => {
if (kind === 'thread_updated') onChanged()
},
})
const composer = useComposer({
roomId,
threadId: thread.id,
enabled: readOnly === null,
onSubmitted: (content, attachments) => stream.send({ content, attachments }),
onTyping: () => sendTypingSignal(roomId, thread.id),
})
// Deep-linked reply: same one-shot rule as the room transcript, so a reload
// keeps the link and a second render does not re-fetch the window.
const jumped = useRef(null)
useEffect(() => {
if (!jumpId || jumped.current === jumpId) return
jumped.current = jumpId
stream.jumpTo(jumpId)
}, [jumpId, stream])
// Replying inside a thread must never open another thread or quote a reply.
const threadInteractions = useMemo(
() => ({
...interactions,
onOpenThread: () => undefined,
onReply: () => undefined,
}),
[interactions],
)
return (
<>
}
/>
{root ? (