/** * 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 ? (
{root.author?.display_name || root.author?.username || nameOf(root.author_id)} {clockStamp(root.created_at)}
{root.attachments.length > 0 ? ( undefined} /> ) : null}
) : null} 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={t('chat.thread.title')} customEmojis={customEmojis} spaceName={spaceName} />
{ void composer.submit().then((ok) => ok && onChanged()) }} />
) } function ThreadTools({ thread, viewerId, canManage, onChanged, }: { thread: ThreadBrief viewerId?: number canManage: boolean onChanged: () => void }) { const { t } = useT() const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [followOverride, setFollowOverride] = useState<{ threadId: string; following: boolean } | null>(null) // A membership is the server's source of truth for reply notifications. The // override keeps the label responsive after a toggle, before a later panel // refresh has fetched the updated membership list. const memberships = useAsync(listMyThreads, [thread.id]) const following = followOverride?.threadId === thread.id ? followOverride.following : (memberships.data?.some((item) => item.id === thread.id) ?? thread.owner_id === viewerId) const run = (action: () => Promise, onSuccess?: () => void) => { if (busy) return setBusy(true) setError(null) void action().then( () => { setBusy(false) onSuccess?.() onChanged() }, (reason) => { setBusy(false) setError(describeFailure(reason)) }, ) } const toggleFollow = () => { const next = !following run( () => (next ? followThread(thread.id, 'All') : unfollowThread(thread.id)), () => setFollowOverride({ threadId: thread.id, following: next }), ) } return (
{error ? ( {error} ) : null} {canManage ? ( ) : null} {busy ? : null}
) } /* -------------------------------------------------------------------- pins */ export function PinsPanel({ pins, loading, nameOf, onJump, onUnpin, onClose, }: { pins: ChatMessage[] loading: boolean nameOf: (userId: number | null | undefined) => string onJump: (messageId: string) => void onUnpin: (message: ChatMessage) => void onClose: () => void }) { const { t } = useT() return ( <>
{loading ? ( ) : pins.length === 0 ? ( }>{t('chat.pins.empty')} ) : (
    {pins.map((message) => (
  • ))}
)}
) } /* ----------------------------------------------------------------- members */ export function MembersPanel({ memberCount, members, loading, error, presences, viewerId, onDm, onClose, }: { memberCount: number members: RoomMember[] loading: boolean error?: unknown presences: Presence[] viewerId?: number onDm: (user: UserInfo) => void onClose: () => void }) { const { t, tc } = useT() const online = useMemo(() => { const map = new Map() for (const presence of presences) map.set(presence.user_id, presence) return map }, [presences]) const sorted = useMemo( () => [...members].sort( (a, b) => presenceRank(online.get(a.user.user_id)) - presenceRank(online.get(b.user.user_id)) || a.user.username.localeCompare(b.user.username), ), [members, online], ) const onlineMembers = useMemo( () => sorted.filter((member) => online.get(member.user.user_id)?.online), [sorted, online], ) const offlineMembers = useMemo( () => sorted.filter((member) => !online.get(member.user.user_id)?.online), [sorted, online], ) const renderMember = (member: RoomMember) => { const presence = online.get(member.user.user_id) const self = member.user.user_id === viewerId return (
  • ) } return ( <>
    {error && members.length === 0 ? ( {Number((error as { code?: number }).code ?? 0) === 403 ? t('chat.member.forbidden') : t('chat.member.loadFailed')} ) : loading && members.length === 0 ? ( ) : (
    {onlineMembers.length > 0 ? (

    {t('label.presence.online')} · {onlineMembers.length}

      {onlineMembers.map(renderMember)}
    ) : null} {offlineMembers.length > 0 ? (

    {t('label.presence.offline')} · {offlineMembers.length}

      {offlineMembers.map(renderMember)}
    ) : null}
    )}
    ) } function presenceRank(presence: Presence | undefined): number { if (!presence?.online) return 3 if (presence.status === 'Active') return 0 if (presence.status === 'Dnd') return 1 return 2 } /* ----------------------------------------------------------------- threads */ export function ThreadsPanel({ threads, loading, nameOf, onOpen, onClose, }: { threads: ThreadBrief[] loading: boolean nameOf: (userId: number | null | undefined) => string onOpen: (thread: ThreadBrief) => void onClose: () => void }) { const { t, tc } = useT() return ( <>
    {loading ? ( ) : threads.length === 0 ? ( }>{t('chat.thread.emptyHint')} ) : (
      {threads.map((thread) => (
    • ))}
    )}
    ) } /* ------------------------------------------------------------------- parts */ function PanelBusy({ label }: { label?: string }) { const { t } = useT() return (

    {label ?? t('common.action.loading')}

    ) } function PanelEmpty({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) { return (
    {icon ? {icon} : null}

    {children}

    ) } function PanelNote({ children }: { children: React.ReactNode }) { return

    {children}

    }