Skip to content
Clone
951a522
780 lines 25.8 KB
/**
* 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 { useEffect, useMemo, useRef, useState } from 'react'
import {
Bell,
BellOff,
Clock,
Crosshair,
Loader2,
Lock,
MessageSquare,
Pin,
UserPlus,
Users,
X,
} from 'lucide-react'
import {
addRoomMembers,
clockStamp,
describeFailure,
followThread,
fullStamp,
listMyThreads,
listSpaceMembers,
unfollowThread,
updateThread,
type UserInfo,
type ChatMessage,
type CustomEmoji,
type Presence,
type RoomMember,
type ThreadBrief,
} from '@/entities/chat'
import { sendTypingSignal } from '@/shared/ws'
import { spaceMembersFlatKey, useAsync } from '@/shared/lib'
import { Transcript, type MessageInteractions } from './transcript'
import { ChatComposer } from './chat-composer'
import { AttachmentGrid, MessageBody, UserAvatar } from './parts'
import { useT } from '@/shared/i18n'
import { useComposer } from '../model/use-composer'
import { useRoomStream } from '../model/use-room-stream'
export function PanelHeader({
title,
subtitle,
onClose,
actions,
}: {
title: string
subtitle?: string
onClose: () => void
actions?: React.ReactNode
}) {
const { t } = useT()
return (
<div className="chat-panel-header flex shrink-0 items-center gap-2 border-b border-shell-hairline px-3">
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-semibold">{title}</p>
{subtitle ? <p className="truncate text-[11px] text-shell-ink-muted">{subtitle}</p> : null}
</div>
{actions}
<button
type="button"
aria-label={t('chat.panel.close')}
onClick={onClose}
className="chat-panel-close shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
>
<X className="h-4 w-4" />
</button>
</div>
)
}
/* ------------------------------------------------------------------ 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<string | null>(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<MessageInteractions>(
() => ({
...interactions,
onOpenThread: () => undefined,
onReply: () => undefined,
}),
[interactions],
)
return (
<>
<PanelHeader
title={thread.name || t('chat.thread.byOwner', { name: nameOf(thread.owner_id) })}
subtitle={t('chat.thread.summary', {
replies: thread.reply_count,
participants: thread.participant_count,
})}
onClose={onClose}
actions={
<ThreadTools
thread={thread}
viewerId={viewerId}
canManage={thread.owner_id === viewerId}
onChanged={onChanged}
/>
}
/>
{root ? (
<div className="shrink-0 border-b border-shell-hairline bg-shell-board/70 px-3 py-2">
<div className="flex items-baseline gap-2">
<UserAvatar user={root.author} id={root.author_id} size={22} />
<span className="truncate text-[12.5px] font-semibold">
{root.author?.display_name || root.author?.username || nameOf(root.author_id)}
</span>
<span className="shrink-0 text-[10.5px] text-shell-ink-muted" title={fullStamp(root.created_at)}>
{clockStamp(root.created_at)}
</span>
</div>
<div className="pl-8">
<MessageBody message={root} viewerId={viewerId} compact />
{root.attachments.length > 0 ? (
<AttachmentGrid attachments={root.attachments} onOpen={() => undefined} />
) : null}
</div>
</div>
) : null}
<Transcript
state={stream.state}
viewerId={viewerId}
lastReadSeq={0}
missedCount={stream.missedCount}
listRef={stream.listRef}
interactions={threadInteractions}
editingId={null}
onSaveEdit={stream.editMessage}
onCancelEdit={() => 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}
/>
<div className="shrink-0 px-3 pb-3">
<ChatComposer
composer={composer}
roomLabel={t('chat.thread.title')}
threadRoot={root}
members={members}
customEmojis={customEmojis}
spaceName={spaceName}
disabledNote={readOnly}
onSubmit={() => {
void composer.submit().then(ok => ok && onChanged())
}}
/>
</div>
</>
)
}
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<string | null>(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<unknown>, 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 (
<div className="flex shrink-0 items-center gap-1 text-shell-ink-muted">
{error ? (
<span title={error} className="max-w-24 truncate text-[10.5px] text-[#e5484d]">
{error}
</span>
) : null}
<button
type="button"
aria-pressed={following}
title={following ? t('chat.thread.followingTitle') : t('chat.thread.followTitle')}
onClick={toggleFollow}
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[11px] transition-colors hover:bg-shell-field disabled:cursor-wait disabled:opacity-60 ${
following
? 'border-shell-tile bg-shell-active text-shell-ink'
: 'border-shell-hairline text-shell-ink-muted'
}`}
disabled={busy}
>
{following ? <Bell className="h-3 w-3" aria-hidden /> : <BellOff className="h-3 w-3" aria-hidden />}
{following ? t('chat.thread.following') : t('chat.thread.follow')}
</button>
{canManage ? (
<button
type="button"
title={thread.locked ? t('chat.thread.unlock') : t('chat.thread.lock')}
onClick={() => run(() => updateThread(thread.id, { locked: !thread.locked }))}
className={`grid h-6 w-6 place-content-center rounded hover:bg-shell-field ${
thread.locked ? 'text-[#b26a00]' : ''
}`}
>
<Lock className="h-3.5 w-3.5" />
</button>
) : null}
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
</div>
)
}
/* -------------------------------------------------------------------- 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 (
<>
<PanelHeader
title={t('chat.room.pins')}
subtitle={t('chat.pins.count', { count: pins.length })}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<PanelBusy />
) : pins.length === 0 ? (
<PanelEmpty icon={<Pin className="h-[18px] w-[18px]" />}>{t('chat.pins.empty')}</PanelEmpty>
) : (
<ul className="space-y-0.5">
{pins.map(message => (
<li
key={message.id}
className="group flex items-start gap-2.5 rounded-[10px] p-2.5 transition-colors hover:bg-shell-field"
>
<UserAvatar user={message.author} id={message.author_id} size={26} />
<button
type="button"
onClick={() => onJump(message.id)}
title={t('chat.pins.jump')}
className="min-w-0 flex-1 text-left"
>
<span className="flex items-baseline gap-1.5 text-[12px]">
<span className="truncate font-semibold">{nameOf(message.author_id)}</span>
<span className="shrink-0 text-[10.5px] text-shell-ink-muted">
{clockStamp(message.created_at)}
</span>
</span>
<span className="mt-0.5 line-clamp-3 block whitespace-pre-wrap break-words text-[12.5px]">
{message.deleted_at
? t('chat.transcript.deletedShort')
: message.content || t('chat.composer.attachmentPlaceholder')}
</span>
</button>
<div className="flex shrink-0 flex-col gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
title={t('chat.pins.jumpShort')}
onClick={() => onJump(message.id)}
className="grid h-6 w-6 place-content-center rounded-md hover:bg-shell-active"
>
<Crosshair className="h-3.5 w-3.5 text-shell-ink-muted" />
</button>
<button
type="button"
title={t('chat.message.unpin')}
onClick={() => onUnpin(message)}
className="grid h-6 w-6 place-content-center rounded-md hover:bg-shell-active"
>
<Pin className="h-3.5 w-3.5 text-shell-ink-muted" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
</>
)
}
/* ----------------------------------------------------------------- members */
export function MembersPanel({
spaceName,
roomId,
memberCount,
members,
loading,
error,
presences,
viewerId,
canManage,
onDm,
onChanged,
onClose,
}: {
spaceName?: string
roomId: string
memberCount: number
members: RoomMember[]
loading: boolean
error?: unknown
presences: Presence[]
viewerId?: number
canManage: boolean
onDm: (user: UserInfo) => void
onChanged: () => void
onClose: () => void
}) {
const { t, tc } = useT()
const [adding, setAdding] = useState(false)
const online = useMemo(() => {
const map = new Map<number, Presence>()
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 (
<li key={member.user.user_id}>
<button
type="button"
disabled={self}
onClick={() => onDm(member.user)}
title={
self
? t('chat.member.self')
: t('chat.member.dmTitle', { username: member.user.username })
}
className="flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-left transition-colors hover:bg-shell-field disabled:cursor-default disabled:hover:bg-transparent"
>
<span className="relative shrink-0">
<UserAvatar user={member.user} id={member.user.user_id} size={32} />
{presence?.online ? (
<span
aria-hidden
title={presence.status}
className="absolute -bottom-0.5 -right-0.5 h-[11px] w-[11px] rounded-full border-2 border-shell-surface"
style={{
backgroundColor:
presence.status === 'Active'
? '#3ba55d'
: presence.status === 'Away'
? '#f0b232'
: '#da3e4c',
}}
/>
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">
{member.nick || member.user.display_name || member.user.username}
</span>
<span className="block truncate text-[11px] text-shell-ink-muted">
@{member.user.username}
</span>
</span>
{member.role !== 'member' ? (
<span className="shrink-0 rounded-full bg-shell-active px-1.5 py-px text-[10px] font-semibold text-shell-ink-muted">
{member.role === 'owner' ? t('chat.member.owner') : member.role}
</span>
) : null}
</button>
</li>
)
}
return (
<>
<PanelHeader
title={t('chat.member.list')}
subtitle={tc('chat.member.count', memberCount || members.length)}
onClose={onClose}
actions={
canManage && spaceName ? (
<button
type="button"
onClick={() => setAdding(value => !value)}
className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-0.5 text-[11px] hover:bg-shell-field"
>
<UserPlus className="h-3.5 w-3.5" />
{t('common.action.add')}
</button>
) : null
}
/>
{adding && spaceName ? (
<AddMembers
roomId={roomId}
spaceName={spaceName}
existing={new Set(members.map(member => member.user.user_id))}
onDone={() => {
setAdding(false)
onChanged()
}}
onCancel={() => setAdding(false)}
/>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{error && members.length === 0 ? (
<PanelNote>
{Number((error as { code?: number }).code ?? 0) === 403
? t('chat.member.forbidden')
: t('chat.member.loadFailed')}
</PanelNote>
) : loading && members.length === 0 ? (
<PanelBusy />
) : (
<div className="space-y-1">
{onlineMembers.length > 0 ? (
<section>
<p className="px-2.5 pb-0.5 pt-2 text-[11px] font-semibold text-shell-ink-muted">
{t('label.presence.online')} · {onlineMembers.length}
</p>
<ul className="space-y-0.5">{onlineMembers.map(renderMember)}</ul>
</section>
) : null}
{offlineMembers.length > 0 ? (
<section>
<p className="px-2.5 pb-0.5 pt-2 text-[11px] font-semibold text-shell-ink-muted">
{t('label.presence.offline')} · {offlineMembers.length}
</p>
<ul className="space-y-0.5">{offlineMembers.map(renderMember)}</ul>
</section>
) : null}
</div>
)}
</div>
</>
)
}
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
}
/**
* Picker that pulls existing space members into the room. People outside the
* space are deliberately absent: the backend refuses that add, so offering them
* would only manufacture errors.
*/
function AddMembers({
roomId,
spaceName,
existing,
onDone,
onCancel,
}: {
roomId: string
spaceName: string
existing: Set<number>
onDone: () => void
onCancel: () => void
}) {
const { t } = useT()
const [picked, setPicked] = useState<number[]>([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const people = useAsync(() => listSpaceMembers(spaceName), [spaceName], {
cacheKey: spaceMembersFlatKey(spaceName),
})
const rows = (people.data ?? []).filter(member => !existing.has(member.user_id))
return (
<div className="shrink-0 border-b border-shell-hairline bg-shell-board/70 p-2">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{people.loading ? (
<PanelBusy label={t('chat.panel.loadedMembers')} />
) : rows.length === 0 ? (
<PanelNote>{t('chat.member.allInRoom')}</PanelNote>
) : (
rows.map(member => (
<label
key={member.user_id}
className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-[12.5px] hover:bg-shell-field"
>
<input
type="checkbox"
checked={picked.includes(member.user_id)}
onChange={event =>
setPicked(current =>
event.target.checked
? [...current, member.user_id]
: current.filter(id => id !== member.user_id),
)
}
/>
<span className="truncate">{member.user?.display_name || member.user?.username || String(member.user_id)}</span>
<span className="min-w-0 flex-1 truncate text-[11px] text-shell-ink-muted">
@{member.user?.username}
</span>
</label>
))
)}
</div>
{error ? <p className="mt-1 text-[11.5px] text-[#e5484d]">{error}</p> : null}
<div className="mt-2 flex items-center gap-2">
<button
type="button"
disabled={busy || picked.length === 0}
onClick={() => {
setBusy(true)
setError(null)
addRoomMembers(roomId, picked).then(
() => {
setBusy(false)
onDone()
},
reason => {
setBusy(false)
setError(describeFailure(reason))
},
)
}}
className="rounded bg-shell-action px-2.5 py-1 text-[12px] font-semibold text-white disabled:opacity-50"
>
{picked.length > 0
? t('chat.member.addCount', { count: picked.length })
: t('common.action.add')}
</button>
<button
type="button"
onClick={onCancel}
className="rounded px-2 py-1 text-[12px] text-shell-ink-muted hover:bg-shell-field"
>
{t('common.action.cancel')}
</button>
{people.error ? (
<span className="text-[11px] text-shell-ink-muted">{t('chat.member.listFailed')}</span>
) : null}
</div>
</div>
)
}
/* ----------------------------------------------------------------- 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 (
<>
<PanelHeader
title={t('chat.thread.title')}
subtitle={t('chat.thread.count', { count: threads.length })}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{loading ? (
<PanelBusy />
) : threads.length === 0 ? (
<PanelEmpty icon={<MessageSquare className="h-[18px] w-[18px]" />}>
{t('chat.thread.emptyHint')}
</PanelEmpty>
) : (
<ul className="space-y-0.5">
{threads.map(thread => (
<li key={thread.id}>
<button
type="button"
onClick={() => onOpen(thread)}
className="flex w-full items-center gap-2.5 rounded-[10px] p-2.5 text-left transition-colors hover:bg-shell-field"
>
{thread.owner ? (
<UserAvatar user={thread.owner} id={thread.owner_id} size={34} />
) : (
<span className="grid h-[34px] w-[34px] shrink-0 place-items-center rounded-full bg-shell-field text-shell-ink-muted">
<MessageSquare className="h-4 w-4" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">
{thread.name || t('chat.thread.fromOwner', { name: nameOf(thread.owner_id) })}
</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-shell-ink-muted">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{t('chat.thread.replies', { count: thread.reply_count })}
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{tc('chat.thread.participants', thread.participant_count)}
</span>
{thread.last_reply_at ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{clockStamp(thread.last_reply_at)}
</span>
) : null}
{thread.archived ? <span>{t('chat.thread.archived')}</span> : null}
{thread.locked ? (
<span className="flex items-center gap-1">
<Lock className="h-3 w-3" />
{t('chat.thread.locked')}
</span>
) : null}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</>
)
}
/* ------------------------------------------------------------------- parts */
function PanelBusy({ label }: { label?: string }) {
const { t } = useT()
return (
<p className="py-6 text-center text-[12px] text-shell-ink-muted">
<Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
{label ?? t('common.action.loading')}
</p>
)
}
function PanelEmpty({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="chat-panel-empty">
{icon ? <span className="chat-panel-empty-icon">{icon}</span> : null}
<p className="text-[12.5px] leading-[1.65] text-shell-ink-muted">{children}</p>
</div>
)
}
function PanelNote({ children }: { children: React.ReactNode }) {
return <p className="px-2 py-4 text-[12px] leading-[1.6] text-shell-ink-muted">{children}</p>
}