feat(web/app): add the chat domain with a message pipeline context
Bring the transcript engine, composer, panels and voice UI over. Messages now also live in a pipeline provider that keeps a bounded window per room, merges create/update/delete by id and seq, and raises a toast with a jump action when a message lands in a room the viewer is not currently reading.
ZhenYi committed
Added view/app/src/domains/chat/model/api.ts view/app/src/domains/chat/model/api.ts +418 −0 View view/app/src/domains/chat/model/api.ts
@@ -0,0 +1,418 @@
1/**
2 * Chat transport: every call the 聊天室 pages make, wrapped in the shared
3 * envelope contract so the UI only ever sees unwrapped rows or an `ApiFailure`.
4 *
5 * Two schema quirks are handled here and nowhere else:
6 * - `list_threads` is typed against the generic `Page` schema (items untyped),
7 * so the thread row is asserted once, in {@link listRoomThreads}.
8 * - `POST /chat/dms` carries the *list* query parameters in its generated
9 * `pathParams`, because the spec shares one path with `GET /chat/dms`; they
10 * are never substituted into the URL, so {@link openDm} passes nulls.
11 *
12 * Empty strings are dropped (`nz`), not sent: Alova omits `undefined` query
13 * values but forwards `''`, and the backend's `Option<Uuid>` deserialization
14 * rejects an empty `thread_id=` with a 400.
15 */
16
17import { getLocale, translate } from '@app/i18n'
18import { type ApiEnvelope, unwrapApi } from '@app/lib'
19import Apis from '@app/services/api'
20import type {
21 Attachment,
22 ChatMessage,
23 CustomEmoji,
24 DmBrief,
25 Draft,
26 MessageEdit,
27 MessagePage,
28 PinnedPage,
29 Presence,
30 ReactionAgg,
31 ReactionUser,
32 ReadState,
33 RoomBrief,
34 RoomSetting,
35 SendMessageInput,
36 SpaceReadStates,
37 ThreadBrief,
38 UploadIntent,
39} from './chat'
40
41const nz = <T>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
42
43const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
44 unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then((rows) => rows ?? [])
45
46const nothing = (method: PromiseLike<ApiEnvelope<unknown>>) => unwrapApi<null>(method as PromiseLike<ApiEnvelope<null>>)
47
48/* ------------------------------------------------------------------ rooms */
49
50export const getRoom = (roomId: string) => unwrapApi<RoomBrief>(Apis.chat.get_room({ pathParams: { room_id: roomId } }))
51
52/** Three-state fields: `null` clears, `undefined` leaves untouched. */
53export const updateRoom = (
54 roomId: string,
55 cmd: {
56 name?: string
57 topic?: string | null
58 is_private?: boolean
59 nsfw?: boolean
60 rate_limit_per_user?: number | null
61 position?: number
62 },
63) => unwrapApi<RoomBrief>(Apis.chat.update_room({ pathParams: { room_id: roomId }, data: cmd }))
64
65export const deleteRoom = (roomId: string) => nothing(Apis.chat.delete_room({ pathParams: { room_id: roomId } }))
66
67export const listRoomMembers = (_roomId: string): Promise<import('./chat').RoomMember[]> => Promise.resolve([])
68
69/** Per-viewer channel preferences (mute / favorite / hidden / notification level). */
70export const getRoomSetting = (roomId: string) =>
71 unwrapApi<RoomSetting>(Apis.chat.get_room_setting({ pathParams: { room_id: roomId } }))
72
73export const updateRoomSetting = (
74 roomId: string,
75 cmd: {
76 muted?: boolean
77 favorite?: boolean
78 hidden?: boolean
79 mute_minutes?: number | null
80 notification_level?: 'Default' | 'All' | 'Mentions' | 'Nothing' | null
81 },
82) => unwrapApi<RoomSetting>(Apis.chat.update_room_setting({ pathParams: { room_id: roomId }, data: cmd }))
83
84/* -------------------------------------------------------------------- dms */
85
86export const openDm = (peerUserId: number) =>
87 unwrapApi<DmBrief>(Apis.chat.open_dm({ data: { peer_user_id: peerUserId } }))
88
89export const closeDm = (roomId: string) => nothing(Apis.chat.close_dm({ pathParams: { room_id: roomId } }))
90
91/* -------------------------------------------------------------- transcript */
92
93export interface MessageQuery {
94 beforeSeq?: number
95 afterSeq?: number
96 limit?: number
97 threadId?: string
98 authorId?: number
99}
100
101/** Ascending by `seq`; `has_more` means *older* rows exist (before_seq path). */
102export const listMessages = (roomId: string, query: MessageQuery = {}): Promise<MessagePage> =>
103 unwrapApi<MessagePage | null>(
104 Apis.chat.list_messages({
105 pathParams: { room_id: roomId },
106 params: {
107 before_seq: query.beforeSeq,
108 after_seq: query.afterSeq,
109 limit: query.limit,
110 thread_id: nz(query.threadId),
111 author_id: nz(query.authorId),
112 },
113 }),
114 ).then(
115 (page) => page ?? { items: [], has_more: false, oldest_seq: null, newest_seq: null, limit: 0, offset: 0, total: 0 },
116 )
117
118export const getMessage = (roomId: string, messageId: string) =>
119 unwrapApi<ChatMessage>(Apis.chat.get_message({ pathParams: { room_id: roomId, message_id: messageId } }))
120
121export const sendMessage = (roomId: string, input: SendMessageInput) =>
122 unwrapApi<ChatMessage>(
123 Apis.chat.send_message({
124 pathParams: { room_id: roomId },
125 data: {
126 content: input.content,
127 nonce: nz(input.nonce),
128 reply_to_id: input.reply_to_id ?? null,
129 thread_id: input.thread_id ?? null,
130 thread_broadcast: input.thread_broadcast ?? false,
131 attachments: input.attachments ?? [],
132 urgent: input.urgent ?? false,
133 },
134 }),
135 )
136
137export const editMessage = (roomId: string, messageId: string, content: string) =>
138 unwrapApi<ChatMessage>(
139 Apis.chat.update_message({
140 pathParams: { room_id: roomId, message_id: messageId },
141 data: { content },
142 }),
143 )
144
145export const deleteMessage = (roomId: string, messageId: string) =>
146 nothing(Apis.chat.delete_message({ pathParams: { room_id: roomId, message_id: messageId } }))
147
148export const bulkDeleteMessages = (roomId: string, messageIds: string[]) =>
149 nothing(Apis.chat.bulk_delete_messages({ pathParams: { room_id: roomId }, data: { message_ids: messageIds } }))
150
151export const listMessageEdits = (roomId: string, messageId: string) =>
152 listOf<MessageEdit>(Apis.chat.list_message_history({ pathParams: { room_id: roomId, message_id: messageId } }))
153
154/** Forward to another room: the backend copies the payload, no re-upload. */
155export const forwardMessage = (roomId: string, messageId: string, targetRoomId: string, comment?: string) =>
156 unwrapApi<ChatMessage>(
157 Apis.chat.forward_message({
158 pathParams: { room_id: roomId, message_id: messageId },
159 data: { target_room_id: targetRoomId, comment: nz(comment) ?? null },
160 }),
161 )
162
163/* --------------------------------------------------------------- reactions */
164
165export const addReaction = (roomId: string, messageId: string, emoji: string) =>
166 listOf<ReactionAgg>(
167 Apis.chat.add_reaction({
168 pathParams: { room_id: roomId, message_id: messageId },
169 data: { emoji },
170 }),
171 )
172
173export const removeReaction = (roomId: string, messageId: string, emoji: string) =>
174 listOf<ReactionAgg>(
175 Apis.chat.remove_reaction({
176 pathParams: { room_id: roomId, message_id: messageId },
177 data: { emoji },
178 }),
179 )
180
181/** Who reacted, per emoji — the popover's detail list. */
182export const listReactionUsers = (roomId: string, messageId: string) =>
183 listOf<ReactionUser>(Apis.chat.list_reactions({ pathParams: { room_id: roomId, message_id: messageId } }))
184
185/** Add or withdraw the viewer's reaction; returns the authoritative aggregate. */
186export const setReaction = (roomId: string, messageId: string, emoji: string, on: boolean) =>
187 on ? addReaction(roomId, messageId, emoji) : removeReaction(roomId, messageId, emoji)
188
189/* -------------------------------------------------------------------- pins */
190
191export const setMessagePin = (roomId: string, messageId: string, pinned: boolean) =>
192 unwrapApi<ChatMessage>(
193 Apis.chat.set_message_pin({
194 pathParams: { room_id: roomId, message_id: messageId },
195 data: { pinned },
196 }),
197 )
198
199/** Offset-paged: `items` only, room pin lists are small by design. */
200export const listPins = (roomId: string) =>
201 unwrapApi<PinnedPage | null>(Apis.chat.list_pins({ pathParams: { room_id: roomId } })).then(
202 (page) => page?.items ?? [],
203 )
204
205/* -------------------------------------------------------------- read state */
206
207export const getReadState = (roomId: string) =>
208 unwrapApi<ReadState>(Apis.chat.get_read_state({ pathParams: { room_id: roomId } }))
209
210/** No cursor at all means "read everything" — the backend clamps to the tip. */
211export const markRead = (roomId: string, cursor?: { messageId?: string; seq?: number }) =>
212 unwrapApi<ReadState>(
213 Apis.chat.mark_read({
214 pathParams: { room_id: roomId },
215 data: { message_id: nz(cursor?.messageId) ?? null, seq: cursor?.seq ?? null },
216 }),
217 )
218
219/** Unread rows for every room of a space — one call for the whole sidebar. */
220export const listSpaceReadStates = (spaceName: string) =>
221 unwrapApi<SpaceReadStates>(Apis.chat.list_space_read_states({ pathParams: { space_name: spaceName } })).then(
222 (state) => state?.items ?? [],
223 )
224
225/* ------------------------------------------------------------------- drafts */
226
227export const getDraft = (roomId: string, threadId?: string) =>
228 unwrapApi<Draft | null>(
229 Apis.chat.get_draft({
230 pathParams: { room_id: roomId },
231 params: { thread_id: nz(threadId) },
232 }),
233 )
234
235/** Empty content clears the draft server-side, so `''` is a meaningful write. */
236export const saveDraft = (roomId: string, content: string, threadId?: string) =>
237 unwrapApi<Draft>(
238 Apis.chat.upsert_draft({
239 pathParams: { room_id: roomId },
240 data: { content, thread_id: nz(threadId) ?? null },
241 }),
242 )
243
244export const clearDraft = (roomId: string, threadId?: string) =>
245 nothing(
246 Apis.chat.delete_draft({
247 pathParams: { room_id: roomId },
248 params: { thread_id: nz(threadId) },
249 }),
250 )
251
252/* ------------------------------------------------------------------ threads */
253
254/** Idempotent: a root that already has a thread returns the existing one. */
255export const createThread = (roomId: string, rootMessageId: string, name?: string) =>
256 unwrapApi<ThreadBrief>(
257 Apis.chat.create_thread({
258 pathParams: { room_id: roomId, message_id: rootMessageId },
259 data: { name: nz(name) ?? null },
260 }),
261 )
262
263export const listRoomThreads = (roomId: string) =>
264 unwrapApi<{ items?: ThreadBrief[] } | null>(
265 Apis.chat.list_threads({
266 pathParams: { room_id: roomId },
267 params: { limit: 100 },
268 }) as unknown as PromiseLike<ApiEnvelope<{ items?: ThreadBrief[] } | null>>,
269 ).then((page) => page?.items ?? [])
270
271export const listMyThreads = () =>
272 unwrapApi<{ items?: ThreadBrief[] } | null>(Apis.chat.list_my_threads({ params: { limit: 50 } })).then(
273 (page) => page?.items ?? [],
274 )
275
276export const getThread = (threadId: string) =>
277 unwrapApi<ThreadBrief>(Apis.chat.get_thread({ pathParams: { thread_id: threadId } }))
278
279export const updateThread = (
280 threadId: string,
281 cmd: { name?: string | null; archived?: boolean | null; locked?: boolean | null },
282) => unwrapApi<ThreadBrief>(Apis.chat.update_thread({ pathParams: { thread_id: threadId }, data: cmd }))
283
284// `notify_level` is a three-state field, so both follow calls carry a body.
285export const followThread = (threadId: string, notifyLevel: 'All' | 'None' | null = null) =>
286 nothing(
287 Apis.chat.follow_thread({
288 pathParams: { thread_id: threadId },
289 data: { notify_level: notifyLevel },
290 }),
291 )
292
293export const unfollowThread = (threadId: string) =>
294 nothing(Apis.chat.unfollow_thread({ pathParams: { thread_id: threadId }, data: { notify_level: null } }))
295
296export const markThreadRead = (threadId: string, messageId?: string) =>
297 nothing(
298 Apis.chat.mark_thread_read({
299 pathParams: { thread_id: threadId },
300 data: { message_id: nz(messageId) ?? null },
301 }),
302 )
303
304/* ------------------------------------------------------------ space people */
305
306/**
307 * A space member row, as the generic `Page` schema leaves it. `MemberDto` nests
308 * the identity under `user` and ships no `user_id`/`role`/`group_ids`, so the id
309 * is flattened in `listSpaceMembers` below — every caller reads `user_id`.
310 */
311export interface SpaceMemberRow {
312 user?: import('@app/services/api').UserDto | null
313 alias_name?: string | null
314 status?: string | null
315}
316
317export interface SpaceMemberOption extends SpaceMemberRow {
318 user_id: number
319}
320
321/**
322 * People who can be pulled into a room. Only space members may join one, so this
323 * is the source for the add-member picker (the same list the 问题集 uses for
324 * assignees — both are "who is in this space", read from the space endpoint).
325 */
326export const listSpaceMembers = (spaceName: string) =>
327 unwrapApi<{ items?: SpaceMemberRow[] } | null>(
328 Apis.space.list_space_members({
329 pathParams: { space_name: spaceName },
330 params: { limit: 100 },
331 }) as unknown as PromiseLike<ApiEnvelope<{ items?: SpaceMemberRow[] } | null>>,
332 ).then((page) =>
333 (page?.items ?? [])
334 .map((row) => ({ ...row, user_id: row.user?.user_id ?? '' }))
335 .filter((row): row is SpaceMemberOption => Boolean(row.user_id)),
336 )
337
338/* ---------------------------------------------------------------- presence */
339
340export const listSpacePresences = (spaceName: string) =>
341 listOf<import('./chat').Presence>(Apis.chat.list_space_presences({ pathParams: { space_name: spaceName } }))
342
343export const getUserPresence = (userId: number) =>
344 unwrapApi<Presence>(Apis.chat.get_presence({ pathParams: { user_id: userId } }))
345
346/* ------------------------------------------------------------------ emojis */
347
348export const listSpaceEmojis = (spaceName: string) =>
349 // The endpoint answers with a bare array, not the paged { items } envelope
350 // the generated type advertises: reading .items silently returned nothing,
351 // so a space's custom emojis never reached the picker.
352 listOf<CustomEmoji>(Apis.chat.list_emojis({ pathParams: { space_name: spaceName }, params: { limit: 100 } }))
353
354/* -------------------------------------------------------------- attachments */
355
356/**
357 * Three-step upload: reserve a row + presigned PUT, stream the bytes, then
358 * `finalize` (which `head`s the object and refuses an empty/oversized blob).
359 * Only a `Ready`/`Uploaded` attachment the caller owns may be referenced by
360 * `send_message`, so a message never sends half-uploaded ids.
361 */
362export const createAttachment = (cmd: { filename: string; content_type: string; size: number }) =>
363 unwrapApi<UploadIntent>(Apis.chat.create_attachment({ data: cmd }))
364
365export const finalizeAttachment = (
366 attachmentId: string,
367 meta: { width?: number | null; height?: number | null; duration_secs?: number | null } = {},
368) =>
369 unwrapApi<Attachment>(
370 Apis.chat.finalize_attachment({
371 pathParams: { attachment_id: attachmentId },
372 data: {
373 width: meta.width ?? null,
374 height: meta.height ?? null,
375 duration_secs: meta.duration_secs ?? null,
376 },
377 }),
378 )
379
380/** Stream endpoint used by every `src`/download link: cookie-authed, no expiry. */
381export { attachmentUrl } from './meta'
382
383/**
384 * PUT the raw bytes to the presigned URL. The object store allows `*` origins,
385 * so the browser talks to it directly; `trackFetch` deliberately does not wrap
386 * this call — a dead bucket must not look like a dead API to the outage gate.
387 */
388export async function putUpload(uploadUrl: string, file: File): Promise<void> {
389 const response = await fetch(uploadUrl, {
390 method: 'PUT',
391 headers: { 'content-type': file.type || 'application/octet-stream' },
392 body: file,
393 })
394 if (!response.ok) {
395 throw new Error(translate(getLocale(), 'chat.upload.failedStatus', { status: response.status }))
396 }
397}
398
399/** Probe an image blob's intrinsic size for `finalize` (best effort). */
400export function readImageSize(file: File): Promise<{ width: number; height: number } | null> {
401 return new Promise((resolve) => {
402 if (!file.type.startsWith('image/')) return resolve(null)
403 const url = URL.createObjectURL(file)
404 const image = new Image()
405 const done = (value: { width: number; height: number } | null) => {
406 URL.revokeObjectURL(url)
407 resolve(value)
408 }
409 image.onload = () =>
410 done(
411 image.naturalWidth > 0 && image.naturalHeight > 0
412 ? { width: image.naturalWidth, height: image.naturalHeight }
413 : null,
414 )
415 image.onerror = () => done(null)
416 image.src = url
417 })
418}
Added view/app/src/domains/chat/model/chat.ts view/app/src/domains/chat/model/chat.ts +143 −0 View view/app/src/domains/chat/model/chat.ts
@@ -0,0 +1,143 @@
1/**
2 * Chat domain types, all derived from the generated response envelopes so they
3 * follow the API instead of a hand-written copy. Anything the transcript needs
4 * beyond the wire shape (optimistic rows, upload progress) is defined here too,
5 * because those are domain states, not component details.
6 */
7import type {
8 ChatMessageType,
9 EnvelopeAttachmentDto,
10 EnvelopeDmDto,
11 EnvelopeDraftDto,
12 EnvelopeMessageDto,
13 EnvelopeMessagePage,
14 EnvelopePageEmojiDto,
15 EnvelopePageMessageDto,
16 EnvelopeReadStateDto,
17 EnvelopeRoomBriefDto,
18 EnvelopeRoomSettingDto,
19 EnvelopeSpaceReadStates,
20 EnvelopeThreadDto,
21 EnvelopeUploadIntentDto,
22 EnvelopeVecMessageEditDto,
23 EnvelopeVecPresenceDto,
24 EnvelopeVecReactionUserDto,
25 EnvelopeVecRoomMemberDto,
26 PresenceStatus,
27 RoomKind,
28 UserDto,
29} from '@app/services/api'
30
31/** One row of `GET /chat/rooms/{id}/messages` — also the WS payload. */
32export type ChatMessage = NonNullable<EnvelopeMessageDto['data']>
33export type MessagePage = NonNullable<EnvelopeMessagePage['data']>
34export type Attachment = NonNullable<EnvelopeAttachmentDto['data']>
35export type ReactionAgg = NonNullable<ChatMessage['reactions']>[number]
36export type ReactionUser = NonNullable<EnvelopeVecReactionUserDto['data']>[number]
37export type MessageEdit = NonNullable<EnvelopeVecMessageEditDto['data']>[number]
38
39/** Room as the sidebar lists it (`GET /chat/spaces/{space}/rooms`). */
40export type RoomBrief = NonNullable<EnvelopeRoomBriefDto['data']>
41export type RoomKindName = RoomKind
42export type DmBrief = NonNullable<EnvelopeDmDto['data']>
43export type RoomSetting = NonNullable<EnvelopeRoomSettingDto['data']>
44export type RoomMember = NonNullable<EnvelopeVecRoomMemberDto['data']>[number]
45export type Presence = NonNullable<EnvelopeVecPresenceDto['data']>[number]
46export type PresenceState = PresenceStatus
47
48export type ThreadBrief = NonNullable<EnvelopeThreadDto['data']>
49export type ReadState = NonNullable<EnvelopeReadStateDto['data']>
50export type Draft = NonNullable<EnvelopeDraftDto['data']>
51export type SpaceReadStates = NonNullable<EnvelopeSpaceReadStates['data']>
52export type CustomEmoji = NonNullable<EnvelopePageEmojiDto['data']>['items'][number]
53export type UploadIntent = NonNullable<EnvelopeUploadIntentDto['data']>
54export type PinnedPage = NonNullable<EnvelopePageMessageDto['data']>
55export type UserInfo = UserDto
56
57/** What `POST /chat/rooms/{id}/messages` accepts; re-declared so the composer
58 * can build a command without importing the whole generated namespace. */
59export interface SendMessageInput {
60 content: string
61 nonce?: string
62 reply_to_id?: string | null
63 thread_id?: string | null
64 thread_broadcast?: boolean
65 attachments?: string[]
66 urgent?: boolean
67}
68
69/** Optimistic state of a message the local user pressed send on. */
70export type PendingState = 'sending' | 'failed'
71
72/** A local-only row: the server has not confirmed it (or confirmed and the echo
73 * has not arrived yet). It renders in the transcript with a state chip. */
74export interface PendingMessage {
75 /** Client-side key; never a uuid the server could confuse with a real id. */
76 localId: string
77 /** Idempotency key sent as `nonce`: a retry can never duplicate the post. */
78 nonce: string
79 room_id: string
80 author_id: number
81 content: string
82 created_at: string
83 reply_to_id?: string | null
84 thread_id?: string | null
85 thread_broadcast?: boolean
86 attachments: Attachment[]
87 state: PendingState
88 /** Server reason (`ApiFailure.message`) when state is failed. */
89 error?: string
90 /** True while the request is in flight; used to hide the retry chip. */
91 sending: boolean
92}
93
94/** One upload in the composer's staging strip. */
95export interface UploadItem {
96 localId: string
97 file: File
98 /** Object URL preview for images; revoked when the item is dropped. */
99 previewUrl?: string
100 /** Set once `POST /chat/attachments` answered. */
101 attachmentId?: string
102 /** Dimensions probed from an image, sent to `finalize`. */
103 width?: number
104 height?: number
105 /** Finalized metadata, present once `state` is ready. */
106 attachment?: Attachment
107 progress: number
108 state: 'checking' | 'uploading' | 'finalizing' | 'ready' | 'failed' | 'too_big'
109 error?: string
110}
111
112/** Message row the transcript renders: either a server row or a pending one. */
113export type TranscriptRow = { kind: 'message'; message: ChatMessage } | { kind: 'pending'; message: PendingMessage }
114
115/**
116 * System-post kinds get their own compact rendering. A quoted reply is *not* one:
117 * it carries the author's own text plus a `reply_to_id` quote and renders as a
118 * full message row.
119 */
120export const SYSTEM_MESSAGE_TYPES: readonly ChatMessageType[] = [
121 'ThreadCreated',
122 'ChannelJoin',
123 'ChannelLeave',
124 'ChannelTopic',
125 'ChannelPinnedItem',
126 'Reminder',
127 'System',
128]
129
130export function isSystemMessage(message: { message_type: ChatMessageType }): boolean {
131 return message.message_type !== 'Default' && SYSTEM_MESSAGE_TYPES.includes(message.message_type)
132}
133
134/** Room kinds that carry no text transcript. */
135const VOICE_KINDS: readonly RoomKindName[] = ['Voice', 'Stage']
136export function isVoiceRoom(kind: RoomKindName | undefined): boolean {
137 return kind !== undefined && VOICE_KINDS.includes(kind)
138}
139
140const DM_KINDS: readonly RoomKindName[] = ['Dm', 'DirectMessage', 'GroupDm']
141export function isDmKind(kind: RoomKindName | undefined): boolean {
142 return kind !== undefined && DM_KINDS.includes(kind)
143}
Added view/app/src/domains/chat/model/index.ts view/app/src/domains/chat/model/index.ts +39 −0 View view/app/src/domains/chat/model/index.ts
@@ -0,0 +1,39 @@
1// Public API of entities/chat — the 聊天室 domain: transport for every chat
2// endpoint the workspace uses, the row shapes read straight off the generated
3// envelopes, and the pure transcript maths (merge/dedupe/group/stamp) that both
4// the main stream and the thread panels share.
5// Consumers import from '@app/domains/chat/model', never from deeper files.
6
7export * from './api'
8export * from './chat'
9export * from './meta'
10export type {
11 VoiceConfigDto,
12 VoiceHand,
13 VoiceHandState,
14 VoiceHands,
15 VoiceLayoutTiles,
16 VoiceModeration,
17 VoiceParticipant,
18 VoiceSpaceStates,
19 VoiceTile,
20 VoiceTileWrite,
21 VoiceToken,
22} from './voice-api'
23export {
24 approveVoiceHand,
25 cancelVoiceHand,
26 denyVoiceHand,
27 getVoiceConfig,
28 getVoiceLayout,
29 getVoiceSpaceStates,
30 kickVoiceParticipant,
31 listVoiceHands,
32 listVoiceParticipants,
33 moderateVoiceMute,
34 raiseVoiceHand,
35 removeVoiceLayoutTiles,
36 requestVoiceToken,
37 revokeVoiceHand,
38 saveVoiceLayoutTiles,
39} from './voice-api'
Added view/app/src/domains/chat/model/message-pipeline.tsx view/app/src/domains/chat/model/message-pipeline.tsx +176 −0 View view/app/src/domains/chat/model/message-pipeline.tsx
@@ -0,0 +1,176 @@
1import type { ChatMessage } from '@app/domains/chat/model'
2import { useToast } from '@app/providers/toast-provider'
3import { useRealtime } from '@app/services/realtime/context'
4import { asEnvelope, CHAT_TOPIC, type KenEventEnvelope } from '@app/services/realtime/kenEvent'
5import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
6import { useNavigate } from 'react-router-dom'
7
8export interface RoomMeta {
9 name: string
10 href: string
11}
12
13interface RoomSnapshot {
14 messages: ChatMessage[]
15 updatedAt: number
16}
17
18interface MessagePipelineApi {
19 snapshot(roomId: string): RoomSnapshot | undefined
20 watch(roomId: string): () => void
21 activate(roomId: string | null): void
22 setRoomMeta(meta: Record<string, RoomMeta>): void
23}
24
25const MessagePipelineContext = createContext<MessagePipelineApi | null>(null)
26
27const MAX_PER_ROOM = 200
28const MESSAGE_TAGS = ['message-create', 'message-update', 'message-delete'] as const
29
30function messageOf(envelope: KenEventEnvelope): ChatMessage | null {
31 const body = envelope.body as { message?: ChatMessage } | null
32 if (!body || typeof body !== 'object') return null
33 const message = body.message
34 if (!message || typeof message.id !== 'string') return null
35 return message
36}
37
38function merge(list: ChatMessage[], incoming: ChatMessage): ChatMessage[] {
39 const index = list.findIndex((item) => item.id === incoming.id)
40 const next = index >= 0 ? [...list.slice(0, index), incoming, ...list.slice(index + 1)] : [...list, incoming]
41 next.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
42 return next.length > MAX_PER_ROOM ? next.slice(next.length - MAX_PER_ROOM) : next
43}
44
45export function MessagePipelineProvider({ children }: { children: ReactNode }) {
46 const realtime = useRealtime()
47 const toast = useToast()
48 const navigate = useNavigate()
49 const rooms = useRef(new Map<string, RoomSnapshot>())
50 const watchRefs = useRef(new Map<string, () => void>())
51 const meta = useRef(new Map<string, RoomMeta>())
52 const activeRoom = useRef<string | null>(null)
53 const selfId = useRef<number | undefined>(undefined)
54 const [, bump] = useState(0)
55
56 selfId.current = realtime.selfId
57
58 const activate = useCallback((roomId: string | null) => {
59 activeRoom.current = roomId
60 }, [])
61
62 const setRoomMeta = useCallback((next: Record<string, RoomMeta>) => {
63 meta.current = new Map(Object.entries(next))
64 }, [])
65
66 const watch = useCallback(
67 (roomId: string) => {
68 const existing = watchRefs.current.get(roomId)
69 if (existing) return existing
70 const releases = MESSAGE_TAGS.map((tag) =>
71 realtime.subscribe({ topic: CHAT_TOPIC, tag, filter: { room_id: roomId }, key: `chat:${tag}:${roomId}` }),
72 )
73 const release = () => {
74 for (const off of releases) off()
75 watchRefs.current.delete(roomId)
76 }
77 watchRefs.current.set(roomId, release)
78 return release
79 },
80 [realtime],
81 )
82
83 useEffect(() => {
84 return realtime.onEnvelope((envelope) => {
85 if (envelope.topic !== CHAT_TOPIC) return
86 if (!MESSAGE_TAGS.includes(envelope.tag as (typeof MESSAGE_TAGS)[number])) return
87 const roomId = (envelope.properties as { room_id?: string } | undefined)?.room_id
88 if (typeof roomId !== 'string' || !roomId) return
89
90 const message = messageOf(envelope)
91 if (!message) return
92
93 const current = rooms.current.get(roomId) ?? { messages: [], updatedAt: 0 }
94 if (envelope.tag === 'message-delete') {
95 rooms.current.set(roomId, {
96 messages: current.messages.filter((item) => item.id !== message.id),
97 updatedAt: Date.now(),
98 })
99 } else {
100 rooms.current.set(roomId, { messages: merge(current.messages, message), updatedAt: Date.now() })
101 }
102 bump((value) => value + 1)
103
104 if (envelope.tag !== 'message-create') return
105 if (activeRoom.current === roomId) return
106 if (selfId.current !== undefined && message.author_id === selfId.current) return
107
108 const info = meta.current.get(roomId)
109 const author = message.author?.display_name || message.author?.username || ''
110 toast.push({
111 title: info ? `${info.name}${author ? ` · ${author}` : ''}` : '新消息',
112 description: message.content?.slice(0, 120),
113 action: info
114 ? { label: '查看', onClick: () => navigate(info.href) }
115 : { label: '查看', onClick: () => navigate(`/app/@me/chat/${roomId}`) },
116 })
117 })
118 }, [realtime, toast, navigate])
119
120 const api = useMemo<MessagePipelineApi>(
121 () => ({ snapshot: (roomId) => rooms.current.get(roomId), watch, activate, setRoomMeta }),
122 [watch, activate, setRoomMeta],
123 )
124
125 return <MessagePipelineContext.Provider value={api}>{children}</MessagePipelineContext.Provider>
126}
127
128export function useMessagePipeline(): MessagePipelineApi {
129 const api = useContext(MessagePipelineContext)
130 if (!api) throw new Error('useMessagePipeline 必须在 MessagePipelineProvider 内使用')
131 return api
132}
133
134/** 订阅一个房间的消息流(引用计数),返回该房间的实时消息窗口。 */
135export function useRoomMessages(roomId: string | null | undefined): ChatMessage[] {
136 const api = useMessagePipeline()
137 const [, bump] = useState(0)
138 useEffect(() => {
139 if (!roomId) return
140 const release = api.watch(roomId)
141 return () => {
142 release()
143 bump((value) => value + 1)
144 }
145 }, [api, roomId])
146 return roomId ? (api.snapshot(roomId)?.messages ?? []) : []
147}
148
149/** 标记“当前正在看这个房间”,该房间的新消息不再弹 toast。 */
150export function useActiveRoom(roomId: string | null | undefined): void {
151 const api = useMessagePipeline()
152 useEffect(() => {
153 api.activate(roomId ?? null)
154 return () => api.activate(null)
155 }, [api, roomId])
156}
157
158/** 注册房间名与跳转地址,供“不在当前房间”的 toast 使用。 */
159export function useRoomMeta(meta: Record<string, RoomMeta>): void {
160 const api = useMessagePipeline()
161 const serialized = JSON.stringify(meta)
162 useEffect(() => {
163 api.setRoomMeta(JSON.parse(serialized) as Record<string, RoomMeta>)
164 }, [api, serialized])
165}
166
167/** 订阅房间但不挂载消息视图(侧栏未读、后台提醒)。 */
168export function useWatchRoom(roomId: string | null | undefined): void {
169 const api = useMessagePipeline()
170 useEffect(() => {
171 if (!roomId) return
172 return api.watch(roomId)
173 }, [api, roomId])
174}
175
176export { asEnvelope }
Added view/app/src/domains/chat/model/meta.ts view/app/src/domains/chat/model/meta.ts +364 −0 View view/app/src/domains/chat/model/meta.ts
@@ -0,0 +1,364 @@
1/**
2 * Pure chat-domain helpers: transcript maths (merge / dedupe / grouping),
3 * stamps, attachment classification and the limits the backend enforces.
4 *
5 * Every limit mirrors `gitdataai/lib/config/src/chat.rs` (defaults) — the server
6 * is still the authority, these only stop the composer from sending a post that
7 * is guaranteed to be rejected.
8 */
9
10import { getLocale, t } from '@app/i18n'
11import type { Attachment, ChatMessage, ReactionAgg } from './chat'
12import { isSystemMessage } from './chat'
13
14/** `chat.max_message_len` — counted in code points, like Rust's `chars().count()`. */
15export const MAX_MESSAGE_LEN = 4000
16/** `chat.max_attachments_per_message`. */
17export const MAX_ATTACHMENTS = 10
18/** `chat.upload.max_size` (100 MiB). */
19export const MAX_UPLOAD_BYTES = 104_857_600
20/** `chat.max_mentions_per_message`. */
21export const MAX_MENTIONS = 20
22/** `chat.ws.history_limit`: the transcript is capped so a huge room stays cheap. */
23export const TRANSCRIPT_CAP = 600
24/** Messages fetched per HTTP page (also the "load older" step). */
25export const MESSAGE_PAGE_SIZE = 50
26
27/** Emojis offered by the hover toolbar before the full picker opens. */
28export const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '😮', '👀', '🚀', '✅']
29
30/** Reaction suggestions must stay below `chat.max_reaction_emoji_len`. */
31export const MAX_EMOJI_LEN = 64
32
33/** Tokens the backend resolves to an @everyone ping. */
34export const EVERYONE_TOKENS = ['@everyone', '@here', '@channel']
35
36/** Code-point length, the same unit the backend validates against. */
37export function textLength(value: string): number {
38 return [...value].length
39}
40
41/** `crypto.randomUUID` with a fallback for non-secure contexts (http://LAN IP). */
42export function clientNonce(): string {
43 const random = globalThis.crypto
44 if (random && typeof random.randomUUID === 'function') return random.randomUUID()
45 const bytes = new Uint8Array(16)
46 if (random?.getRandomValues) random.getRandomValues(bytes)
47 else for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256)
48 bytes[6] = (bytes[6] ?? 0) & 0x0f
49 bytes[8] = (bytes[8] ?? 0) & 0x3f
50 const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0'))
51 return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
52}
53
54/**
55 * Parse a backend timestamp in *either* shape this API produces:
56 * - `2026-09-05T10:00:00.000000+00:00` (serde RFC 3339, chat/WS payloads);
57 * - `2026-09-03 10:55:00.863466 +00:00:00` (`time::OffsetDateTime`'s Display,
58 * which some rows still carry: a space instead of `T` and seconds inside the
59 * offset — both rejected by `Date`).
60 * Returns `NaN` for anything unparseable, so a caller shows the raw string
61 * instead of a confident-looking 1970.
62 */
63export function parseStamp(value: string | null | undefined): number {
64 if (!value) return Number.NaN
65 const iso = value
66 .trim()
67 .replace(/^(\d{4}-\d{2}-\d{2})[ T]/, '$1T')
68 .replace(/\s+(?=[+-]\d{2})/, '')
69 .replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2')
70 const stamp = Date.parse(iso)
71 return Number.isFinite(stamp) ? stamp : Number.NaN
72}
73
74const pad = (value: number) => String(value).padStart(2, '0')
75
76/** `HH:MM` in the viewer's zone; the raw value when it cannot be parsed. */
77export function clockStamp(value: string): string {
78 const stamp = parseStamp(value)
79 if (!Number.isFinite(stamp)) return value
80 const date = new Date(stamp)
81 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
82}
83
84/** Local calendar key, so grouping never splits at a UTC midnight. */
85export function dayKey(value: string): string {
86 const stamp = parseStamp(value)
87 if (!Number.isFinite(stamp)) return value
88 const date = new Date(stamp)
89 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
90}
91
92/** Divider label: today / yesterday / weekday / full date for anything older. */
93export function dayLabel(value: string): string {
94 const stamp = parseStamp(value)
95 if (!Number.isFinite(stamp)) return value
96 const date = new Date(stamp)
97 const now = new Date()
98 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
99 const startOfThat = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
100 const days = Math.round((startOfToday - startOfThat) / 86_400_000)
101 if (days === 0) return t('chat.day.today')
102 if (days === 1) return t('chat.day.yesterday')
103 // Within the last week the weekday alone is unambiguous; `weekday: 'short'`
104 // yields 周一 in Chinese and Mon in English without a hand-written table.
105 if (days > 1 && days < 7) {
106 return new Intl.DateTimeFormat(getLocale(), { weekday: 'short' }).format(date)
107 }
108 return new Intl.DateTimeFormat(getLocale(), {
109 year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric',
110 month: 'long',
111 day: 'numeric',
112 }).format(date)
113}
114
115/** Full stamp for tooltips and system rows. */
116export function fullStamp(value: string): string {
117 const stamp = parseStamp(value)
118 if (!Number.isFinite(stamp)) return value
119 const date = new Date(stamp)
120 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
121}
122
123/**
124 * `true` when a message may join the one above it: same author, same thread,
125 * plain post, and within ten minutes.
126 */
127export function isGrouped(previous: ChatMessage | undefined, message: ChatMessage): boolean {
128 if (!previous) return false
129 if (previous.author_id !== message.author_id) return false
130 if (isSystemMessage(previous) || isSystemMessage(message)) return false
131 if ((previous.thread_id ?? null) !== (message.thread_id ?? null)) return false
132 const gap = parseStamp(message.created_at) - parseStamp(previous.created_at)
133 return Number.isFinite(gap) && gap >= 0 && gap < 10 * 60_000
134}
135
136/**
137 * Embeds are replaced wholesale by the server's async unfurl pass, so rows whose
138 * cards changed must not compare equal: a cached row would otherwise keep
139 * rendering the stale card (or a broken iframe) after a reload.
140 */
141function embedsKey(embeds: ChatMessage['embeds']): string {
142 if (!embeds || embeds.length === 0) return ''
143 return embeds
144 .map((embed) => [embed.kind, embed.src, embed.url, embed.title, embed.image_url, embed.description].join('\u0001'))
145 .join('\u0002')
146}
147
148/**
149 * Field-level equality for the parts a row renders. The socket re-delivers rows
150 * (a `history` snapshot repeats what the channel already delivered), and treating
151 * an identical copy as a change would re-render the whole transcript on every
152 * reconnect.
153 */
154export function sameRow(a: ChatMessage, b: ChatMessage): boolean {
155 if (a === b) return true
156 if (a.content !== b.content) return false
157 if (embedsKey(a.embeds) !== embedsKey(b.embeds)) return false
158 if (a.edited_at !== b.edited_at || a.deleted_at !== b.deleted_at) return false
159 if (a.pinned !== b.pinned || a.pinned_at !== b.pinned_at) return false
160 if ((a.thread_id ?? null) !== (b.thread_id ?? null)) return false
161 if (a.has_thread !== b.has_thread) return false
162 if (a.attachments.length !== b.attachments.length) return false
163 if (authorKey(a) !== authorKey(b)) return false
164 const left = a.reactions ?? []
165 const right = b.reactions ?? []
166 if (left.length !== right.length) return false
167 return left.every((reaction, index) => {
168 const other = right[index]
169 return (
170 other !== undefined &&
171 reaction.emoji === other.emoji &&
172 reaction.count === other.count &&
173 reaction.me === other.me
174 )
175 })
176}
177
178function authorKey(message: ChatMessage): string {
179 const author = message.author
180 return author
181 ? `${author.user_id}|${author.username}|${author.display_name}|${author.avatar_url}`
182 : `id:${message.author_id}`
183}
184
185/** Highest seq in a list, ignoring pending rows (which have no seq). */
186export function tipSeq(messages: readonly { seq?: number }[]): number {
187 let tip = 0
188 for (const message of messages) {
189 const seq = typeof message.seq === 'number' && Number.isFinite(message.seq) ? message.seq : 0
190 if (seq > tip) tip = seq
191 }
192 return tip
193}
194
195/**
196 * Merge a batch into an ascending transcript: dedupe by id (a `history` snapshot
197 * repeats rows the room channel already delivered), keep the newest copy of an
198 * edited row, and drop the oldest rows past {@link TRANSCRIPT_CAP}.
199 * Returns the same array reference when nothing changed, so a React state update
200 * can bail out.
201 */
202export function mergeMessages(
203 current: readonly ChatMessage[],
204 incoming: readonly ChatMessage[],
205): { items: ChatMessage[]; changed: boolean; dropped: number } {
206 if (incoming.length === 0) return { items: current as ChatMessage[], changed: false, dropped: 0 }
207 const byId = new Map(current.map((message) => [message.id, message]))
208 let changed = false
209 for (const message of incoming) {
210 const known = byId.get(message.id)
211 if (!known) {
212 byId.set(message.id, message)
213 changed = true
214 continue
215 }
216 // A WS row is built for the *actor*, so it can lose the viewer's `me` flags:
217 // never let a fresher-looking payload erase what the local user reacted with.
218 const next = keepViewerReactions(known, message)
219 if (next !== known && !sameRow(known, next)) {
220 byId.set(message.id, next)
221 changed = true
222 }
223 }
224 if (!changed) return { items: current as ChatMessage[], changed: false, dropped: 0 }
225 const items = [...byId.values()].sort((a, b) => a.seq - b.seq)
226 let dropped = 0
227 if (items.length > TRANSCRIPT_CAP) {
228 dropped = items.length - TRANSCRIPT_CAP
229 items.splice(0, dropped)
230 }
231 return { items, changed: true, dropped }
232}
233
234/**
235 * Merge a reaction aggregate (`reaction_updated` carries no `me`), keeping the
236 * local user's own reaction flags.
237 */
238export function mergeReactions(
239 previous: readonly ReactionAgg[] | undefined,
240 next: readonly { emoji: string; count: number }[],
241): ReactionAgg[] {
242 const mine = new Map((previous ?? []).map((reaction) => [reaction.emoji, reaction.me]))
243 return next.map((reaction) => ({
244 emoji: reaction.emoji,
245 count: reaction.count,
246 me: mine.get(reaction.emoji) ?? false,
247 }))
248}
249
250/** Re-apply the local viewer flags to a row that arrived from the socket. */
251function keepViewerReactions(known: ChatMessage, incoming: ChatMessage): ChatMessage {
252 if (incoming.author_id !== known.author_id) return incoming
253 const knownReactions = known.reactions ?? []
254 if (knownReactions.some((reaction) => reaction.me) === false) return incoming
255 const mine = new Set(knownReactions.filter((reaction) => reaction.me).map((reaction) => reaction.emoji))
256 return {
257 ...incoming,
258 reactions: (incoming.reactions ?? []).map((reaction) =>
259 mine.has(reaction.emoji) ? { ...reaction, me: true } : reaction,
260 ),
261 }
262}
263
264/* ------------------------------------------------------------- attachments */
265
266export type AttachmentKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'file'
267
268export function attachmentKind(attachment: Pick<Attachment, 'content_type' | 'filename'>): AttachmentKind {
269 const type = (attachment.content_type ?? '').toLowerCase()
270 if (type.startsWith('image/')) return 'image'
271 if (type.startsWith('video/')) return 'video'
272 if (type.startsWith('audio/')) return 'audio'
273 if (type === 'application/pdf') return 'pdf'
274 if (type.startsWith('text/') || type === 'application/json' || type.endsWith('+json')) return 'text'
275 // Some uploaders send application/octet-stream; fall back to the extension.
276 const name = attachment.filename.toLowerCase()
277 if (/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/.test(name)) return 'image'
278 if (/\.(mp4|webm|mov|m4v)$/.test(name)) return 'video'
279 if (/\.(mp3|wav|ogg|m4a|flac)$/.test(name)) return 'audio'
280 if (name.endsWith('.pdf')) return 'pdf'
281 if (/\.(txt|md|markdown|json|ya?ml|toml|csv|log|rs|ts|tsx|js|jsx|py|go|java|c|cc|h|css|html|sh)$/.test(name))
282 return 'text'
283 return 'file'
284}
285
286/**
287 * Same-origin stream endpoint (session cookie, no presigned expiry).
288 *
289 * `variant` selects which representation the server streams: `preview` is the
290 * compressed derivative the transcript grid loads, `original` (the default) is
291 * the uploaded bytes — which is what the lightbox and the download link need.
292 * A missing preview degrades to the original server-side, so this is always a
293 * valid URL.
294 */
295export function attachmentUrl(attachmentId: string, variant: 'preview' | 'original' = 'original'): string {
296 const base = `/api/v1/chat/attachments/${encodeURIComponent(attachmentId)}/file`
297 return variant === 'preview' ? `${base}?variant=preview` : base
298}
299
300/** Custom space emoji image, same-origin for the same reason. */
301export function emojiImageUrl(spaceName: string, emojiId: string): string {
302 return `/api/v1/chat/spaces/${encodeURIComponent(spaceName)}/emojis/${encodeURIComponent(emojiId)}/image`
303}
304
305export { formatBytes as formatSize } from '@app/i18n'
306
307/** First glyph of a person's name, for the coloured avatar tile. */
308export function initialsOf(user?: { display_name?: string; username?: string } | null): string {
309 const name = user?.display_name || user?.username || '?'
310 return [...name][0]?.toUpperCase() ?? '?'
311}
312
313/* ---------------------------------------------------------------- failures */
314
315/**
316 * Why a message could not be sent, in one line the composer can show verbatim.
317 * The backend already answers with a human-readable `error`/`message`, so this
318 * only fills in the transport-level cases and translates the two status codes
319 * whose raw text would be meaningless in a chat window.
320 */
321export function describeFailure(error: unknown): string {
322 const code = failureCode(error)
323 if (code === 401) return t('chat.composer.unauthorized')
324 if (code === 403) return t('chat.composer.forbidden')
325 if (code === 404) return t('chat.composer.notFound')
326 if (code === 429) return t('chat.composer.rateLimited')
327 if (code === 0 || code === 502 || code === 503 || code === 504) {
328 return t('chat.composer.unavailable')
329 }
330 if (error instanceof Error && error.message) return error.message
331 return t('chat.composer.failed')
332}
333
334/** HTTP status mirrored by the envelope, or undefined when it is not one. */
335export function failureCode(error: unknown): number | undefined {
336 const candidate = error as { code?: unknown } | null | undefined
337 return typeof candidate?.code === 'number' ? candidate.code : undefined
338}
339
340/**
341 * Seconds the backend asked us to wait. Two producers: the room's slow mode
342 * ("slow mode active, retry in 12s") and the generic quota limiter. Reading the
343 * number beats showing a countdown of zero while the server refuses every post.
344 */
345export function findRetryAfterSeconds(error: unknown): number {
346 const message = error instanceof Error ? error.message : ''
347 const match = /retry in (\d+)s/i.exec(message)
348 const seconds = match ? Number.parseInt(match[1], 10) : 0
349 return Number.isFinite(seconds) && seconds > 0 && seconds < 3600 ? seconds : 0
350}
351
352/**
353 * The plain reason a request failed, with the retry countdown taken out: the
354 * composer renders that number as a live timer, so repeating it inside the
355 * sentence would read as two different clocks.
356 */
357export function failureReason(error: unknown): string {
358 const message = error instanceof Error ? error.message : ''
359 const cleaned = message
360 .replace(/\s*\(retry in \d+s\)/i, '')
361 .replace(/,?\s*retry in \d+s/i, '')
362 .trim()
363 return cleaned || describeFailure(error)
364}
Added view/app/src/domains/chat/model/prefetch.ts view/app/src/domains/chat/model/prefetch.ts +16 −0 View view/app/src/domains/chat/model/prefetch.ts
@@ -0,0 +1,16 @@
1import { getRoom, listMessages, listRoomMembers, MESSAGE_PAGE_SIZE } from '@app/domains/chat/model'
2import { chatSeedKey, prefetch, roomKey } from '@app/lib'
3
4/**
5 * Hover prefetch for one room: the expensive core of what the chat column
6 * loads on mount — the room row, the member directory and the tail seed page.
7 * Pins / threads / emojis are cheap and panel-dependent, so they stay
8 * mount-time fetches. Fire-and-forget by design: a failed prefetch is silent,
9 * the mounting hooks surface real errors themselves.
10 */
11export function prefetchRoomView(roomId: string): void {
12 if (!roomId) return
13 void prefetch(roomKey(roomId, 'room'), () => getRoom(roomId))
14 void prefetch(roomKey(roomId, 'members'), () => listRoomMembers(roomId).catch(() => []))
15 void prefetch(chatSeedKey(roomId, null), () => listMessages(roomId, { limit: MESSAGE_PAGE_SIZE }))
16}
Added view/app/src/domains/chat/model/transcript.ts view/app/src/domains/chat/model/transcript.ts +380 −0 View view/app/src/domains/chat/model/transcript.ts
@@ -0,0 +1,380 @@
1/**
2 * The transcript store: one reducer that owns every message-list mutation, so
3 * the HTTP seed, the WebSocket live stream and the user's own optimistic posts
4 * all funnel through the same ordering/dedupe rules.
5 *
6 * Invariants the reducer guarantees:
7 * - `rows` stays ascending by `seq` and free of duplicate ids (a `history`
8 * snapshot repeats rows the room channel already delivered);
9 * - `pending` holds only local rows, in send order, rendered after `rows`;
10 * - an edit or a reaction aggregate arriving over the socket never loses the
11 * local user's own `me` flag, because that payload is built for the *actor*;
12 * - trimming to {@link TRANSCRIPT_CAP} is counted in `olderTrimmed`, so the
13 * "load older" affordance keeps working instead of looking exhausted.
14 */
15
16import type { ChatMessage, PendingMessage } from '@app/domains/chat/model'
17import { mergeMessages, mergeReactions, tipSeq } from '@app/domains/chat/model'
18
19/** A reply-to target that is not part of the loaded window. */
20export type ResolvedRef = { state: 'loading' } | { state: 'ready'; message: ChatMessage } | { state: 'gone' }
21
22export interface TranscriptState {
23 roomId: string
24 /** Scope of the list: null is the room's main stream. */
25 threadId: string | null
26 rows: ChatMessage[]
27 pending: PendingMessage[]
28 refs: Record<string, ResolvedRef>
29 oldestSeq: number | null
30 newestSeq: number | null
31 hasOlder: boolean
32 loadingSeed: boolean
33 loadingOlder: boolean
34 loadingNewer: boolean
35 seeded: boolean
36 /** Fatal for this room: the transcript cannot be shown at all. */
37 error: string | null
38 /** The socket reported `resync_required{access revoked}` for this room. */
39 accessRevoked: boolean
40 /** Rows dropped from memory by the cap; older pages stay fetchable. */
41 olderTrimmed: number
42 highlightId: string | null
43 /** Bumped by live traffic so panels can refetch pins/members/threads. */
44 liveVersion: number
45 /** Bumped by live *message* traffic (read-state and scroll logic watch it). */
46 messageVersion: number
47}
48
49export type TranscriptAction =
50 /** New scope: drop everything, including the previous room's cursor. */
51 | { type: 'reset'; roomId: string; threadId: string | null }
52 | { type: 'seedStart' }
53 /**
54 * Prefill from the local-first store while the network seed is in flight.
55 * Same merge invariants as `seedDone` (dedupe by id, ascending by seq), but
56 * `loadingSeed` stays true — the fresh tail still replaces these rows.
57 */
58 | {
59 type: 'seedCached'
60 items: ChatMessage[]
61 hasMore: boolean
62 oldestSeq: number | null
63 newestSeq: number | null
64 }
65 | {
66 type: 'seedDone'
67 items: ChatMessage[]
68 hasMore: boolean
69 oldestSeq: number | null
70 newestSeq: number | null
71 }
72 | { type: 'seedError'; message: string }
73 | { type: 'olderStart' }
74 | { type: 'olderDone'; items: ChatMessage[]; hasMore: boolean; oldestSeq: number | null }
75 | { type: 'newerStart' }
76 /** Back to the tail: the window is replaced, never merged (a jumped-back reader
77 * has an intentional hole between what they read and what is new). */
78 | { type: 'tailReplaced'; items: ChatMessage[]; hasMore: boolean; oldestSeq: number | null; newestSeq: number | null }
79 | { type: 'liveHistory'; items: ChatMessage[] }
80 | { type: 'published'; message: ChatMessage }
81 | { type: 'edited'; message: ChatMessage }
82 | { type: 'deleted'; messageIds: string[] }
83 | { type: 'pinned'; messageId: string; pinned: boolean }
84 | { type: 'reactions'; messageId: string; reactions: readonly { emoji: string; count: number }[] }
85 | { type: 'pendingAdd'; message: PendingMessage }
86 | { type: 'pendingSent'; localId: string; message: ChatMessage }
87 | { type: 'pendingFail'; localId: string; error: string }
88 | { type: 'pendingRetry'; localId: string }
89 | { type: 'pendingDrop'; localId: string }
90 | { type: 'refStart'; messageId: string }
91 | { type: 'refDone'; messageId: string; message: ChatMessage | null }
92 | { type: 'highlight'; messageId: string | null }
93 | { type: 'accessRevoked' }
94 | { type: 'liveTick'; message?: boolean }
95
96export function createTranscriptState(roomId: string, threadId: string | null): TranscriptState {
97 return {
98 roomId,
99 threadId,
100 rows: [],
101 pending: [],
102 refs: {},
103 oldestSeq: null,
104 newestSeq: null,
105 hasOlder: false,
106 loadingSeed: true,
107 loadingOlder: false,
108 loadingNewer: false,
109 seeded: false,
110 error: null,
111 accessRevoked: false,
112 olderTrimmed: 0,
113 highlightId: null,
114 liveVersion: 0,
115 messageVersion: 0,
116 }
117}
118
119/**
120 * Does a live row belong to this list? The main stream and a thread panel never
121 * mix, mirroring the backend filter `thread_id IS NULL OR thread_broadcast`.
122 */
123export function belongsToScope(
124 message: Pick<ChatMessage, 'thread_id' | 'thread_broadcast'>,
125 threadId: string | null | undefined,
126): boolean {
127 if (threadId) return message.thread_id === threadId
128 return !message.thread_id || message.thread_broadcast === true
129}
130
131function merged(state: TranscriptState, incoming: ChatMessage[]) {
132 const { items, dropped } = mergeMessages(state.rows, incoming)
133 return {
134 rows: items,
135 newestSeq: Math.max(state.newestSeq ?? 0, tipSeq(items)),
136 olderTrimmed: state.olderTrimmed + dropped,
137 }
138}
139
140export function transcriptReducer(state: TranscriptState, action: TranscriptAction): TranscriptState {
141 switch (action.type) {
142 case 'reset':
143 if (state.roomId === action.roomId && state.threadId === action.threadId) return state
144 return createTranscriptState(action.roomId, action.threadId)
145
146 case 'seedStart':
147 return { ...state, loadingSeed: true, error: null }
148
149 case 'seedCached': {
150 // The stored page renders while the live seed is still in flight; the
151 // reducer's merge keeps dedupe/ordering, and `seedDone` later replaces
152 // any row the server has since edited (incoming wins per id).
153 const next = merged(state, action.items)
154 return {
155 ...state,
156 ...next,
157 oldestSeq: action.oldestSeq ?? (next.rows.length ? next.rows[0].seq : state.oldestSeq),
158 newestSeq: action.newestSeq ?? next.newestSeq,
159 hasOlder: action.hasMore,
160 seeded: true,
161 }
162 }
163
164 case 'seedDone': {
165 const next = merged(state, action.items)
166 return {
167 ...state,
168 ...next,
169 oldestSeq: action.oldestSeq ?? (next.rows.length ? next.rows[0].seq : state.oldestSeq),
170 newestSeq: action.newestSeq ?? next.newestSeq,
171 hasOlder: action.hasMore,
172 loadingSeed: false,
173 seeded: true,
174 error: null,
175 }
176 }
177
178 case 'seedError':
179 // Keep whatever is on screen: a failed refresh must not blank a room that
180 // already rendered, but the caller still needs the reason.
181 return { ...state, loadingSeed: false, error: action.message }
182
183 case 'olderStart':
184 return { ...state, loadingOlder: true }
185
186 case 'olderDone': {
187 const next = merged(state, action.items)
188 return {
189 ...state,
190 ...next,
191 hasOlder: action.hasMore,
192 oldestSeq: action.oldestSeq ?? next.rows[0]?.seq ?? state.oldestSeq,
193 loadingOlder: false,
194 }
195 }
196
197 case 'newerStart':
198 return { ...state, loadingNewer: true }
199
200 case 'tailReplaced': {
201 const rows = [...action.items].sort((a, b) => a.seq - b.seq)
202 return {
203 ...state,
204 rows,
205 oldestSeq: action.oldestSeq ?? rows[0]?.seq ?? null,
206 newestSeq: action.newestSeq ?? rows[rows.length - 1]?.seq ?? state.newestSeq,
207 hasOlder: action.hasMore,
208 loadingSeed: false,
209 loadingNewer: false,
210 seeded: true,
211 highlightId: null,
212 olderTrimmed: 0,
213 liveVersion: state.liveVersion + 1,
214 messageVersion: state.messageVersion + 1,
215 }
216 }
217
218 case 'liveHistory': {
219 if (action.items.length === 0) return state
220 return {
221 ...state,
222 ...merged(state, action.items),
223 loadingSeed: false,
224 seeded: true,
225 liveVersion: state.liveVersion + 1,
226 messageVersion: state.messageVersion + 1,
227 }
228 }
229
230 case 'published': {
231 if (state.rows.some((row) => row.id === action.message.id)) return state
232 return {
233 ...state,
234 ...merged(state, [action.message]),
235 loadingSeed: false,
236 seeded: true,
237 liveVersion: state.liveVersion + 1,
238 messageVersion: state.messageVersion + 1,
239 }
240 }
241
242 case 'edited': {
243 const index = state.rows.findIndex((row) => row.id === action.message.id)
244 if (index < 0) return state
245 const rows = [...state.rows]
246 const previous = rows[index]
247 rows[index] = {
248 ...action.message,
249 reactions: mergeReactions(previous.reactions, action.message.reactions ?? []),
250 }
251 return { ...state, rows, liveVersion: state.liveVersion + 1 }
252 }
253
254 case 'deleted': {
255 const gone = new Set(action.messageIds)
256 const rows = state.rows.filter((row) => !gone.has(row.id))
257 if (rows.length === state.rows.length) return state
258 return {
259 ...state,
260 rows,
261 liveVersion: state.liveVersion + 1,
262 messageVersion: state.messageVersion + 1,
263 }
264 }
265
266 case 'pinned': {
267 const index = state.rows.findIndex((row) => row.id === action.messageId)
268 if (index < 0) return state
269 const rows = [...state.rows]
270 rows[index] = { ...rows[index], pinned: action.pinned }
271 return { ...state, rows, liveVersion: state.liveVersion + 1 }
272 }
273
274 case 'reactions': {
275 const index = state.rows.findIndex((row) => row.id === action.messageId)
276 if (index < 0) return state
277 const rows = [...state.rows]
278 rows[index] = { ...rows[index], reactions: mergeReactions(rows[index].reactions, action.reactions) }
279 return { ...state, rows }
280 }
281
282 case 'pendingAdd':
283 return { ...state, pending: [...state.pending, action.message] }
284
285 case 'pendingSent': {
286 const pending = state.pending.filter((item) => item.localId !== action.localId)
287 if (state.rows.some((row) => row.id === action.message.id)) return { ...state, pending }
288 return {
289 ...state,
290 ...merged(state, [action.message]),
291 pending,
292 seeded: true,
293 loadingSeed: false,
294 }
295 }
296
297 case 'pendingFail':
298 return {
299 ...state,
300 pending: state.pending.map((item) =>
301 item.localId === action.localId ? { ...item, state: 'failed', error: action.error, sending: false } : item,
302 ),
303 }
304
305 case 'pendingRetry':
306 return {
307 ...state,
308 pending: state.pending.map((item) =>
309 item.localId === action.localId ? { ...item, state: 'sending', error: undefined, sending: true } : item,
310 ),
311 }
312
313 case 'pendingDrop':
314 return { ...state, pending: state.pending.filter((item) => item.localId !== action.localId) }
315
316 case 'refStart':
317 if (state.refs[action.messageId]) return state
318 return { ...state, refs: { ...state.refs, [action.messageId]: { state: 'loading' } } }
319
320 case 'refDone':
321 return {
322 ...state,
323 refs: {
324 ...state.refs,
325 [action.messageId]: action.message ? { state: 'ready', message: action.message } : { state: 'gone' },
326 },
327 }
328
329 case 'highlight':
330 return { ...state, highlightId: action.messageId }
331
332 case 'accessRevoked':
333 return { ...state, accessRevoked: true, rows: [], pending: [], hasOlder: false }
334
335 case 'liveTick':
336 return action.message
337 ? { ...state, liveVersion: state.liveVersion + 1, messageVersion: state.messageVersion + 1 }
338 : { ...state, liveVersion: state.liveVersion + 1 }
339
340 default:
341 return state
342 }
343}
344
345/**
346 * The POST response and the socket echo of your own message race each other. The
347 * id match in `pendingSent` covers the normal path; when the response was lost
348 * (timeout) the echo is matched by identical content, attachments, reply and
349 * thread so a stuck「发送中」row cannot outlive the message it stands for.
350 */
351/**
352 * Only a *failed* row is eligible: its request already reported an error, so the
353 * only way the message exists on the server is that the response was lost. A
354 * pending row that is merely still in flight is matched by id in `pendingSent`.
355 */
356export function findEchoedPending(
357 pending: readonly PendingMessage[],
358 message: ChatMessage,
359 viewerId?: number,
360): PendingMessage | undefined {
361 if (pending.length === 0 || message.author_type !== 'User') return undefined
362 if (viewerId && message.author_id !== viewerId) return undefined
363 const key = (attachments: readonly { id: string }[]) =>
364 attachments
365 .map((a) => a.id)
366 .sort()
367 .join(',')
368 return pending.find(
369 (item) =>
370 item.state === 'failed' &&
371 item.content === message.content &&
372 key(item.attachments) === key(message.attachments) &&
373 (item.reply_to_id ?? null) === (message.reply_to_id ?? null) &&
374 (item.thread_id ?? null) === (message.thread_id ?? null),
375 )
376}
377
378export function isEmptyTranscript(state: TranscriptState): boolean {
379 return state.rows.length === 0 && state.pending.length === 0
380}
Added view/app/src/domains/chat/model/use-composer.ts view/app/src/domains/chat/model/use-composer.ts +397 −0 View view/app/src/domains/chat/model/use-composer.ts
@@ -0,0 +1,397 @@
1/**
2 * Composer state: the draft, the attachment queue and the send/edit handshake.
3 *
4 * Drafts are server state (`PUT /chat/rooms/{id}/draft`, thread-scoped), so the
5 * text survives a device switch — but they are saved debounced and never during
6 * the initial load, otherwise typing one character would write a row and every
7 * room switch would overwrite a draft the reader never touched.
8 *
9 * Attachments follow the backend's three-step contract (reserve → presigned PUT
10 * → finalize) and only a `ready` id may be referenced by a message, so a half
11 * uploaded file can never produce a broken post. Uploads are held while a send
12 * is in flight and flushed with it, which is what makes "type, attach, send"
13 * read as one action.
14 */
15
16import {
17 type Attachment,
18 clearDraft,
19 createAttachment,
20 describeFailure,
21 finalizeAttachment,
22 getDraft,
23 MAX_ATTACHMENTS,
24 MAX_MESSAGE_LEN,
25 MAX_UPLOAD_BYTES,
26 putUpload,
27 readImageSize,
28 saveDraft,
29 textLength,
30 type UploadItem,
31} from '@app/domains/chat/model'
32import { t } from '@app/i18n'
33import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
34
35const DRAFT_SAVE_DEBOUNCE_MS = 1_200
36/** Rows the composer's own draft load must not clobber. */
37const DRAFT_MAX_LEN = MAX_MESSAGE_LEN
38
39export interface UseComposerOptions {
40 roomId: string
41 /** Drafts are per thread when one is open. */
42 threadId?: string | null
43 /** Freezes every request (voice channel, no access, outage gate). */
44 enabled: boolean
45 /** Message being edited: the draft machinery is bypassed entirely. */
46 editingId?: string | null
47 /** Seed text for the edit box. */
48 editingContent?: string
49 /** Called after a successful send / edit so the caller can clear reply targets. */
50 onSubmitted: (content: string, attachments: Attachment[]) => Promise<boolean> | boolean
51 /** Notify the socket that typing started (throttled by the client). */
52 onTyping?: () => void
53}
54
55export interface ComposerState {
56 text: string
57 setText: (value: string) => void
58 /** Attachments that finished all three upload steps and may be sent. */
59 readyAttachments: Attachment[]
60 uploads: UploadItem[]
61 addFiles: (files: File[]) => void
62 removeUpload: (localId: string) => void
63 retryUpload: (localId: string) => void
64 clearUploads: () => void
65 /** True when a file is still moving: send is deferred until it lands. */
66 uploading: boolean
67 blocked: string | null
68 overLength: boolean
69 remaining: number
70 canSend: boolean
71 submit: () => Promise<boolean>
72 /** Cancel an edit: clears the box and the draft machinery. */
73 reset: () => void
74 draftNotice: string | null
75}
76
77let uploadSeq = 0
78
79export function useComposer({
80 roomId,
81 threadId = null,
82 enabled,
83 editingId = null,
84 editingContent = '',
85 onSubmitted,
86 onTyping,
87}: UseComposerOptions): ComposerState {
88 const [text, setTextState] = useState(editingId ? editingContent.slice(0, DRAFT_MAX_LEN) : '')
89 const [uploads, setUploads] = useState<UploadItem[]>([])
90 const [draftNotice, setDraftNotice] = useState<string | null>(null)
91 const [loadedDraft, setLoadedDraft] = useState(false)
92 const touched = useRef(false)
93 const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
94 // Entering or leaving an edit is a scope change too: the box is handed to the
95 // message being corrected and comes back empty afterwards (the room's stored
96 // draft is then re-read, so nothing typed before the detour is lost).
97 const draftScope = `${roomId}\u0000${threadId ?? ''}`
98 const scopeKey = `${draftScope}\u0000edit:${editingId ?? ''}`
99 const urls = useRef<Set<string>>(new Set())
100 // Mirror of `uploads` for callbacks that must reason about the queue without
101 // writing new state from inside a state updater. React may invoke an updater
102 // more than once (StrictMode) and drops all but one result, so ids collected
103 // inside it can desync from the committed state.
104 const uploadsRef = useRef<UploadItem[]>([])
105 /** The single writer of the queue: keeps the ref and the state in lockstep. */
106 const commitUploads = useCallback((update: (current: UploadItem[]) => UploadItem[]) => {
107 const next = update(uploadsRef.current)
108 uploadsRef.current = next
109 setUploads(next)
110 }, [])
111
112 // A new scope starts with an empty box; the previous room's text belongs to
113 // that room's draft, never to this one.
114 const lastScope = useRef(scopeKey)
115 const lastDraftScope = useRef(draftScope)
116 useEffect(() => {
117 if (lastScope.current === scopeKey) return
118 const [previousRoom, previousThread] = lastDraftScope.current.split('\u0000')
119 lastScope.current = scopeKey
120 lastDraftScope.current = draftScope
121 // The debounce below fires on a timer, so switching rooms inside that window
122 // would otherwise either lose the text or write it into the *new* room's
123 // draft: flush it to the scope it was typed in first.
124 if (saveTimer.current) {
125 clearTimeout(saveTimer.current)
126 saveTimer.current = undefined
127 }
128 if (touched.current && text) {
129 saveDraft(previousRoom ?? roomId, text, previousThread || undefined).catch(() => undefined)
130 }
131 touched.current = false
132 setTextState('')
133 setLoadedDraft(false)
134 commitUploads((current) => {
135 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
136 return []
137 })
138 urls.current.clear()
139 // `commitUploads` is a stable `useCallback`: listing it is what keeps the
140 // queue write and this reset on the same writer.
141 }, [draftScope, editingId, roomId, scopeKey, text, threadId, commitUploads])
142
143 /* --------------------------------------------------------------- draft */
144
145 useEffect(() => {
146 if (!enabled || editingId || touched.current) return
147 let active = true
148 getDraft(roomId, threadId ?? undefined).then(
149 (draft) => {
150 // Never overwrite what the user already started typing while this was in
151 // flight (a fast click into the room and straight into the box).
152 if (active && draft?.content && !touched.current) {
153 setTextState(draft.content.slice(0, DRAFT_MAX_LEN))
154 touched.current = true
155 }
156 if (active) setLoadedDraft(true)
157 },
158 () => active && setLoadedDraft(true),
159 )
160 return () => {
161 active = false
162 }
163 }, [enabled, editingId, roomId, threadId])
164
165 const persistDraft = useCallback(
166 (value: string) => {
167 if (!enabled || editingId) return
168 // An empty write deletes the draft server-side, which is what "the user
169 // erased the box" means; skipping it would resurrect the text next visit.
170 saveDraft(roomId, value, threadId ?? undefined).catch(() => {
171 setDraftNotice(t('chat.composer.draftSaveFailed'))
172 })
173 },
174 [enabled, editingId, roomId, threadId],
175 )
176
177 useEffect(() => {
178 if (!loadedDraft || editingId) return
179 if (saveTimer.current) clearTimeout(saveTimer.current)
180 saveTimer.current = setTimeout(() => {
181 saveTimer.current = undefined
182 // A scope switch flushes the pending write itself, so a timer that outlived
183 // the room it belongs to must never write into the new one.
184 if (lastScope.current !== scopeKey) return
185 persistDraft(text)
186 }, DRAFT_SAVE_DEBOUNCE_MS)
187 return () => {
188 if (saveTimer.current) clearTimeout(saveTimer.current)
189 }
190 // The timer must not restart on every render, only on text/scope changes.
191 // oxlint-disable-next-line react-hooks/exhaustive-deps -- 定时器只跟随 text/scope 重启;其余依赖变化会各自处理
192 }, [text, roomId, threadId, loadedDraft, editingId])
193
194 useEffect(
195 () => () => {
196 for (const url of urls.current) URL.revokeObjectURL(url)
197 urls.current.clear()
198 },
199 [],
200 )
201
202 const setText = useCallback(
203 (value: string) => {
204 touched.current = true
205 setDraftNotice(null)
206 setTextState(value)
207 if (value) onTyping?.()
208 },
209 [onTyping],
210 )
211
212 const reset = useCallback(() => {
213 touched.current = false
214 setTextState('')
215 commitUploads((current) => {
216 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
217 return []
218 })
219 urls.current.clear()
220 if (enabled && !editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined)
221 }, [commitUploads, editingId, enabled, roomId, threadId])
222
223 /* ----------------------------------------------------------- attachments */
224
225 const patch = useCallback(
226 (localId: string, changes: Partial<UploadItem>) => {
227 commitUploads((current) => current.map((item) => (item.localId === localId ? { ...item, ...changes } : item)))
228 },
229 [commitUploads],
230 )
231
232 const runUpload = useCallback(
233 async (item: UploadItem) => {
234 patch(item.localId, { state: 'checking', error: undefined, progress: 0 })
235 try {
236 const intent = await createAttachment({
237 filename: item.file.name,
238 content_type: item.file.type || 'application/octet-stream',
239 size: item.file.size,
240 })
241 patch(item.localId, { attachmentId: intent.id, state: 'uploading', progress: 0.25 })
242 await putUpload(intent.upload_url, item.file)
243 patch(item.localId, { state: 'finalizing', progress: 0.8 })
244 const size = item.width ? { width: item.width, height: item.height } : await readImageSize(item.file)
245 const meta = await finalizeAttachment(intent.id, size ?? undefined)
246 patch(item.localId, {
247 state: 'ready',
248 progress: 1,
249 attachment: meta,
250 width: meta.width ?? undefined,
251 height: meta.height ?? undefined,
252 })
253 } catch (error) {
254 patch(item.localId, { state: 'failed', error: describeFailure(error), progress: 0 })
255 }
256 },
257 [patch],
258 )
259
260 const addFiles = useCallback(
261 (files: File[]) => {
262 if (!enabled || files.length === 0) return
263 // The batch is decided before state is touched: the ids handed to
264 // `runUpload` must be exactly the ids that reach the queue, otherwise
265 // every progress patch misses and the row spins on「上传中…」forever.
266 const accepted: UploadItem[] = []
267 const queued = uploadsRef.current.length
268 let overflow = false
269 for (const file of files) {
270 if (queued + accepted.length >= MAX_ATTACHMENTS) {
271 overflow = true
272 break
273 }
274 const localId = `upload:${Date.now().toString(36)}:${(uploadSeq += 1)}`
275 if (file.size > MAX_UPLOAD_BYTES) {
276 accepted.push({
277 localId,
278 file,
279 progress: 0,
280 state: 'too_big',
281 error: t('chat.composer.overLimit', { mb: Math.round(MAX_UPLOAD_BYTES / 1024 / 1024) }),
282 })
283 continue
284 }
285 const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined
286 if (previewUrl) urls.current.add(previewUrl)
287 accepted.push({
288 localId,
289 file,
290 previewUrl,
291 progress: 0.05,
292 state: 'checking',
293 })
294 }
295 if (overflow) setDraftNotice(t('chat.composer.attachmentLimit', { max: MAX_ATTACHMENTS }))
296 if (accepted.length === 0) return
297 commitUploads((current) => [...current, ...accepted])
298 for (const item of accepted) void runUpload(item)
299 },
300 [commitUploads, enabled, runUpload],
301 )
302
303 const removeUpload = useCallback(
304 (localId: string) => {
305 commitUploads((current) => {
306 const item = current.find((candidate) => candidate.localId === localId)
307 if (item?.previewUrl) {
308 URL.revokeObjectURL(item.previewUrl)
309 urls.current.delete(item.previewUrl)
310 }
311 return current.filter((candidate) => candidate.localId !== localId)
312 })
313 },
314 [commitUploads],
315 )
316
317 const retryUpload = useCallback(
318 (localId: string) => {
319 const item = uploads.find((candidate) => candidate.localId === localId)
320 if (item && item.state !== 'too_big') void runUpload(item)
321 },
322 [runUpload, uploads],
323 )
324
325 const clearUploads = useCallback(() => {
326 commitUploads((current) => {
327 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
328 return []
329 })
330 }, [commitUploads])
331
332 /* ----------------------------------------------------------------- send */
333
334 const readyAttachments = useMemo(
335 () =>
336 uploads
337 .filter((item) => item.state === 'ready' && item.attachment)
338 .map((item) => item.attachment as unknown as Attachment),
339 [uploads],
340 )
341
342 const uploading = uploads.some(
343 (item) => item.state === 'checking' || item.state === 'uploading' || item.state === 'finalizing',
344 )
345 const failedUploads = uploads.filter((item) => item.state === 'failed' || item.state === 'too_big')
346 const blocked = uploading
347 ? t('chat.composer.blockedUploading')
348 : failedUploads.length > 0
349 ? t('chat.composer.blockedFailed')
350 : null
351 const length = textLength(text)
352 const overLength = length > DRAFT_MAX_LEN
353 const canSend = enabled && !overLength && !blocked && (Boolean(text.trim()) || readyAttachments.length > 0)
354
355 const submit = useCallback(async () => {
356 const body = text.replace(/\s+$/, '')
357 if (!canSend) return false
358 const attachments = readyAttachments
359 // Clear first: the box must not keep the text if the send is slow, and the
360 // server-side draft is dropped by the same rule the eraser uses.
361 touched.current = false
362 setTextState('')
363 commitUploads((current) => {
364 for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl)
365 return []
366 })
367 urls.current.clear()
368 if (!editingId) void clearDraft(roomId, threadId ?? undefined).catch(() => undefined)
369 const ok = await onSubmitted(body, attachments)
370 if (!ok) {
371 // Nothing was sent: give the text back rather than losing the message.
372 setTextState(body)
373 touched.current = Boolean(body)
374 return false
375 }
376 return true
377 }, [canSend, commitUploads, editingId, onSubmitted, readyAttachments, roomId, text, threadId])
378
379 return {
380 text,
381 setText,
382 readyAttachments,
383 uploads,
384 addFiles,
385 removeUpload,
386 retryUpload,
387 clearUploads,
388 uploading,
389 blocked,
390 overLength,
391 remaining: Math.max(0, DRAFT_MAX_LEN - length),
392 canSend,
393 submit,
394 reset,
395 draftNotice,
396 }
397}
Added view/app/src/domains/chat/model/use-room-data.ts view/app/src/domains/chat/model/use-room-data.ts +152 −0 View view/app/src/domains/chat/model/use-room-data.ts
@@ -0,0 +1,152 @@
1/**
2 * Everything a room needs besides its transcript: the authoritative room row,
3 * the member directory (names, avatars, @-mention targets), online presence,
4 * the pin list, the thread index and the space's custom emojis.
5 *
6 * These are five independent reads with different failure modes, so each one
7 * keeps its own state and the room stays usable when any of them is missing:
8 * a private room whose member list is forbidden still chats, an unreachable
9 * presence endpoint just leaves every dot grey.
10 */
11
12import {
13 type ChatMessage,
14 type CustomEmoji,
15 getRoom,
16 listPins,
17 listRoomMembers,
18 listRoomThreads,
19 listSpaceEmojis,
20 listSpacePresences,
21 type Presence,
22 type RoomBrief,
23 type RoomMember,
24 type ThreadBrief,
25} from '@app/domains/chat/model'
26import { t } from '@app/i18n'
27import { type AsyncState, roomKey, spaceEmojisKey, useAsync } from '@app/lib'
28import { useWsUserEvents } from '@app/services/realtime'
29import { useCallback, useEffect, useMemo, useState } from 'react'
30
31export interface RoomData {
32 /** `GET /chat/rooms/{id}` — the room as the server sees it right now. */
33 room: AsyncState<RoomBrief | undefined>
34 members: AsyncState<RoomMember[]>
35 pins: AsyncState<ChatMessage[]>
36 threads: AsyncState<ThreadBrief[]>
37 /** Empty outside a space (the personal `@me` scope has no presence list). */
38 presences: AsyncState<Presence[]>
39 emojis: AsyncState<CustomEmoji[]>
40 reload: () => void
41 /** Thread indexed by its root message, so a row knows its own topic. */
42 threadOf: (messageId: string) => ThreadBrief | undefined
43 /** Display name for a user id, resolved from the room's member list. */
44 nameOf: (userId: number | null | undefined) => string
45}
46
47export interface UseRoomDataOptions {
48 roomId: string
49 /** Personal scope (`@me`) has no space-scoped presence/emoji endpoints. */
50 spaceName?: string
51 enabled?: boolean
52 /** Bumped by live `room_updated` / `thread_updated` traffic. */
53 version?: number
54}
55
56export function useRoomData({ roomId, spaceName, enabled = true, version = 0 }: UseRoomDataOptions): RoomData {
57 const [reloadKey, setReloadKey] = useState(0)
58 const gate = enabled && Boolean(roomId)
59 // `version` rides along with the dependency list: live traffic that changes
60 // room state (a pin by someone else, a new thread) refreshes the side data
61 // without the user having to reopen the panel. Every read is local-first:
62 // reopening a room paints the stored payload while the refresh runs.
63 const deps = [gate ? roomId : '', gate, version, reloadKey]
64 const room = useAsync(() => (gate ? getRoom(roomId) : Promise.resolve(undefined)), deps, {
65 cacheKey: gate ? roomKey(roomId, 'room') : null,
66 })
67 const members = useAsync(() => (gate ? listRoomMembers(roomId).catch(() => []) : Promise.resolve([])), deps, {
68 cacheKey: gate ? roomKey(roomId, 'members') : null,
69 })
70 const pins = useAsync(() => (gate ? listPins(roomId).catch(() => []) : Promise.resolve([])), deps, {
71 cacheKey: gate ? roomKey(roomId, 'pins') : null,
72 })
73 const threads = useAsync(() => (gate ? listRoomThreads(roomId).catch(() => []) : Promise.resolve([])), deps, {
74 cacheKey: gate ? roomKey(roomId, 'threads') : null,
75 })
76 // Presence is live-only state: a stale online dot is worse than no dot.
77 // 队友的在线变化没有广播通道(`presence` 事件只回发给本人),所以在标签页
78 // 可见时每 30s 重拉一次,切回前台立刻补一次;隐藏时停表,不空转网络。
79 const [presenceTick, setPresenceTick] = useState(0)
80 const livePresence = gate && Boolean(spaceName) && spaceName !== '@me'
81 useEffect(() => {
82 if (!livePresence) return
83 const tick = () => setPresenceTick((value) => value + 1)
84 let timer: ReturnType<typeof setInterval> | undefined
85 const start = () => {
86 if (timer === undefined) timer = setInterval(tick, 30_000)
87 }
88 const stop = () => {
89 if (timer !== undefined) {
90 clearInterval(timer)
91 timer = undefined
92 }
93 }
94 const onVisibility = () => {
95 if (document.visibilityState === 'visible') {
96 tick()
97 start()
98 } else {
99 stop()
100 }
101 }
102 if (document.visibilityState === 'visible') start()
103 document.addEventListener('visibilitychange', onVisibility)
104 return () => {
105 stop()
106 document.removeEventListener('visibilitychange', onVisibility)
107 }
108 }, [livePresence])
109 // 自己上线/改状态(`presence` 事件只回发给本人)时立即补拉一次:硬刷新直接
110 // 进房间时,挂载那一刻 socket 可能还没连上,首屏的在线状态会短暂缺失。
111 useWsUserEvents((event) => {
112 if (event.kind === 'presence') setPresenceTick((value) => value + 1)
113 })
114 const presences = useAsync(
115 () =>
116 gate && spaceName && spaceName !== '@me' ? listSpacePresences(spaceName).catch(() => []) : Promise.resolve([]),
117 [gate, spaceName, reloadKey, presenceTick],
118 )
119 const emojis = useAsync(
120 () => (gate && spaceName && spaceName !== '@me' ? listSpaceEmojis(spaceName).catch(() => []) : Promise.resolve([])),
121 [gate, spaceName, reloadKey],
122 { cacheKey: gate && spaceName && spaceName !== '@me' ? spaceEmojisKey(spaceName) : null },
123 )
124
125 const reload = useCallback(() => setReloadKey((key) => key + 1), [])
126
127 const byRoot = useMemo(() => {
128 const map = new Map<string, ThreadBrief>()
129 for (const thread of threads.data ?? []) map.set(thread.root_message_id, thread)
130 return map
131 }, [threads.data])
132
133 const users = useMemo(() => {
134 const map = new Map<number, string>()
135 for (const member of members.data ?? []) {
136 map.set(member.user.user_id, member.nick || member.user.display_name || member.user.username)
137 }
138 return map
139 }, [members.data])
140
141 const threadOf = useCallback((messageId: string) => byRoot.get(messageId), [byRoot])
142
143 const nameOf = useCallback(
144 (userId: number | null | undefined) => {
145 if (!userId) return t('common.state.unknownUser')
146 return users.get(userId) ?? t('shell.chat.userFallback', { id: String(userId).slice(0, 8) })
147 },
148 [users],
149 )
150
151 return { room, members, pins, threads, presences, emojis, reload, threadOf, nameOf }
152}
Added view/app/src/domains/chat/model/use-room-stream.ts view/app/src/domains/chat/model/use-room-stream.ts +742 −0 View view/app/src/domains/chat/model/use-room-stream.ts
@@ -0,0 +1,742 @@
1/**
2 * The chat room's data engine: one hook that owns the transcript of a scope (the
3 * room's main stream, or one thread inside it) and keeps it correct across HTTP
4 * pagination, WebSocket live traffic, reconnects and the local user's own
5 * optimistic posts.
6 *
7 * Division of labour, and why:
8 * - **HTTP** seeds the window and pages it (`before_seq` / `after_seq`), because
9 * the socket only ever walks *forward* from a cursor.
10 * - **The socket** (`@/shared/ws`) is the live source: `subscribe` answers with a
11 * `history` snapshot that is merged in, then `event` frames stream updates. The
12 * client resolves `gap` and `resync_required` itself; the cases it cannot
13 * (lost cursor, truncated walk) fall back to an HTTP re-seed here.
14 * - **The reducer** (`./transcript`) is the only writer of list state, so dedupe,
15 * ordering and the viewer's own reaction flags stay in exactly one place.
16 *
17 * Sends use the backend's `nonce` idempotency key: retrying after a timeout
18 * returns the original message instead of posting a duplicate.
19 */
20
21import {
22 type Attachment,
23 type ChatMessage,
24 clientNonce,
25 deleteMessage as deleteMessageApi,
26 describeFailure,
27 editMessage as editMessageApi,
28 failureReason,
29 findRetryAfterSeconds,
30 getMessage,
31 getReadState,
32 listMessages,
33 MESSAGE_PAGE_SIZE,
34 type MessagePage,
35 markRead as markReadApi,
36 type PendingMessage,
37 sendMessage,
38 setMessagePin,
39 setReaction,
40} from '@app/domains/chat/model'
41import { t } from '@app/i18n'
42import { chatSeedKey } from '@app/lib'
43import { useRoomWatch, useTypingIndicators, useWsFrames, useWsIssues, useWsUserEvents } from '@app/services/realtime'
44import { readCache, writeCache } from '@app/services/storage'
45import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
46import {
47 belongsToScope,
48 createTranscriptState,
49 findEchoedPending,
50 type TranscriptState,
51 transcriptReducer,
52} from './transcript'
53
54export interface UseRoomStreamOptions {
55 roomId: string
56 /** Set to read one thread instead of the room's main stream. */
57 threadId?: string | null
58 /** Local user id: identifies your own posts and ignores your typing echo. */
59 viewerId?: number
60 /** Stops all traffic (no room selected, voice channel, outage gate open …). */
61 enabled?: boolean
62 /** The unread cursor moved: the shell refreshes its sidebar badges. */
63 onUnreadChanged?: () => void
64 /** Room's `rate_limit_per_user`, mirrored so the countdown starts *before* the
65 * server has to refuse a second post. */
66 slowModeSeconds?: number
67 /** Side-channel room events (typing excluded — that is the registry's job). */
68 onRoomEvent?: (kind: string) => void
69}
70
71export interface SendInput {
72 content: string
73 attachments?: Attachment[]
74 replyToId?: string | null
75 /** Thread the post goes into; the main stream when omitted. */
76 threadId?: string | null
77 /** Also surface a thread reply in the main stream (`thread_broadcast`). */
78 threadBroadcast?: boolean
79}
80
81export interface RoomStream {
82 state: TranscriptState
83 /** Connection lifecycle of the shared socket. */
84 status: ReturnType<typeof useRoomWatch>
85 /** Ids currently typing in this scope (your own echo already filtered out). */
86 typingUserIds: number[]
87 /** Seq the viewer last read; the「新消息」divider sits right after it. */
88 lastReadSeq: number
89 /** True while the viewport is pinned to the newest row. */
90 atBottom: boolean
91 /** Rows that arrived while the viewport was scrolled away from the tail. */
92 missedCount: number
93 /** Seconds still blocked by the room's slow mode (0 when free to send). */
94 slowModeRemaining: number
95 /** Transient action failure (edit / delete / react / pin), shown above the composer. */
96 notice: string | null
97 dismissNotice: () => void
98 listRef: React.RefObject<HTMLDivElement | null>
99 reportAtBottom: (atBottom: boolean) => void
100 scrollToBottom: (options?: { smooth?: boolean }) => void
101 loadOlder: () => void
102 jumpTo: (messageId: string) => void
103 /** Back to the newest page: replaces the window instead of leaving a hole. */
104 returnToLatest: () => void
105 send: (input: SendInput) => Promise<boolean>
106 retryPending: (localId: string) => void
107 dismissPending: (localId: string) => void
108 editMessage: (messageId: string, content: string) => Promise<boolean>
109 deleteMessage: (messageId: string) => Promise<boolean>
110 toggleReaction: (messageId: string, emoji: string, on: boolean) => Promise<void>
111 togglePin: (messageId: string, pinned: boolean) => Promise<ChatMessage | undefined>
112 refresh: () => void
113}
114
115/** Coalesces the read-marker write: scrolling must not become a request storm. */
116const MARK_READ_DEBOUNCE_MS = 800
117
118export function useRoomStream({
119 roomId,
120 threadId = null,
121 viewerId,
122 enabled = true,
123 onUnreadChanged,
124 slowModeSeconds = 0,
125 onRoomEvent,
126}: UseRoomStreamOptions): RoomStream {
127 const [state, dispatch] = useReducer(transcriptReducer, { roomId, threadId }, (scope) =>
128 createTranscriptState(scope.roomId, scope.threadId),
129 )
130 const status = useRoomWatch(enabled ? roomId : null)
131 const scopeKey = `${roomId}\u0000${threadId ?? ''}`
132 const listRef = useRef<HTMLDivElement | null>(null)
133 // Scope-tagged state: a room/thread switch needs no reset effect, because a
134 // value stamped with the previous scope simply does not read as anything.
135 const [bottom, setBottom] = useState<{ scope: string; value: boolean; baseline: number }>({
136 scope: scopeKey,
137 value: true,
138 baseline: 0,
139 })
140 const [read, setRead] = useState<{ scope: string; seq: number }>({ scope: scopeKey, seq: 0 })
141 const [slowModeRemaining, setSlowModeRemaining] = useState(0)
142 const [notice, setNotice] = useState<string | null>(null)
143 const [reloadKey, setReloadKey] = useState(0)
144 const typing = useTypingIndicators(enabled ? roomId : null, threadId)
145 const atBottom = bottom.scope === scopeKey ? bottom.value : true
146 /** Tip captured when the reader left the tail; everything above it is seen. */
147 const missedBaseline = bottom.scope === scopeKey ? bottom.baseline : 0
148 const lastReadSeq = read.scope === scopeKey ? read.seq : 0
149 const readCursor = useRef(0)
150 const atBottomRef = useRef(true)
151 /** Newest row the transcript holds, mirrored for the debounced read marker. */
152 const tipRef = useRef(0)
153 const markReadTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
154 const inFlight = useRef<Set<string>>(new Set())
155 const pendingById = useRef<Record<string, PendingMessage>>({})
156 const slowTimer = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
157
158 /* ------------------------------------------------------------ seeding */
159
160 useEffect(() => {
161 if (!enabled || !roomId) return
162 dispatch({ type: 'reset', roomId, threadId })
163 readCursor.current = 0
164 tipRef.current = 0
165 atBottomRef.current = true
166 let active = true
167 // A *successful* network seed outranks a slower disk read; a failed one
168 // must not block it — offline, the stored page is the whole transcript.
169 let seedSucceeded = false
170 dispatch({ type: 'seedStart' })
171 const seedKey = chatSeedKey(roomId, threadId ?? null)
172 // Local-first prefill: paint the stored tail page while the fetch runs.
173 void readCache<MessagePage>(seedKey).then((hit) => {
174 if (!active || seedSucceeded || !hit) return
175 dispatch({
176 type: 'seedCached',
177 items: hit.data.items,
178 hasMore: hit.data.has_more,
179 oldestSeq: hit.data.oldest_seq ?? null,
180 newestSeq: hit.data.newest_seq ?? null,
181 })
182 })
183 listMessages(roomId, { threadId: threadId ?? undefined, limit: MESSAGE_PAGE_SIZE }).then(
184 (page) => {
185 if (!active) return
186 seedSucceeded = true
187 // The tail page is what the next visit prefills from.
188 writeCache(seedKey, page)
189 dispatch({
190 type: 'seedDone',
191 items: page.items,
192 hasMore: page.has_more,
193 oldestSeq: page.oldest_seq ?? null,
194 newestSeq: page.newest_seq ?? null,
195 })
196 },
197 (error: unknown) => {
198 if (active) dispatch({ type: 'seedError', message: describeFailure(error) })
199 },
200 )
201 return () => {
202 active = false
203 }
204 }, [enabled, roomId, threadId, reloadKey])
205
206 /**
207 * The unread cursor is room-level state (`thread_id` is not part of it), so a
208 * thread panel never touches it. The response also reports the room tip, which
209 * is what stops the first render from marking a room as read before the reader
210 * has actually seen anything.
211 */
212 useEffect(() => {
213 if (!enabled || !roomId || threadId) return
214 let active = true
215 getReadState(roomId).then(
216 (current) => {
217 if (!active) return
218 readCursor.current = current.last_read_seq
219 setRead({ scope: scopeKey, seq: current.last_read_seq })
220 },
221 () => {
222 // No read state yet means nothing was ever read: the divider will sit at
223 // the very first row, which is the honest rendering of that.
224 },
225 )
226 return () => {
227 active = false
228 }
229 }, [enabled, roomId, threadId, reloadKey, scopeKey])
230
231 /* -------------------------------------------------------- live traffic */
232
233 useWsFrames((frame) => {
234 if (!enabled) return
235 if (!('room_id' in frame) || frame.room_id !== roomId) return
236 if (frame.type === 'history') {
237 const items = frame.messages.filter((message) => belongsToScope(message, threadId))
238 if (items.length > 0) dispatch({ type: 'liveHistory', items })
239 return
240 }
241 if (frame.type === 'event') {
242 const event = frame.event
243 if (!event) return
244 switch (event.kind) {
245 case 'message_published':
246 if (belongsToScope(event.message, threadId)) {
247 dispatch({ type: 'published', message: event.message })
248 }
249 return
250 case 'message_edited':
251 if (belongsToScope(event.message, threadId)) {
252 dispatch({ type: 'edited', message: event.message })
253 }
254 return
255 case 'message_deleted':
256 dispatch({ type: 'deleted', messageIds: [event.message_id] })
257 return
258 case 'messages_deleted':
259 dispatch({ type: 'deleted', messageIds: event.message_ids })
260 return
261 case 'message_pinned':
262 dispatch({ type: 'pinned', messageId: event.message_id, pinned: event.pinned })
263 return
264 case 'reaction_updated':
265 dispatch({ type: 'reactions', messageId: event.message_id, reactions: event.reactions })
266 return
267 default:
268 // room_updated / member_* / thread_updated / typing: panels consume
269 // these; the transcript only needs to know the room moved.
270 onRoomEvent?.(event.kind)
271 dispatch({ type: 'liveTick' })
272 }
273 return
274 }
275 if (frame.type === 'resync_required' && frame.reason === 'access revoked') {
276 dispatch({ type: 'accessRevoked' })
277 }
278 })
279
280 /** Your own read marker, mirrored from another tab. */
281 useWsUserEvents((event) => {
282 if (event.kind !== 'read_marker' || event.room_id !== roomId || threadId) return
283 const seq = Number.isFinite(event.last_read_seq) ? event.last_read_seq : 0
284 if (seq <= readCursor.current) return
285 readCursor.current = seq
286 setRead({ scope: scopeKey, seq })
287 })
288
289 /**
290 * Conditions the socket cannot resolve on its own. A gap or a lagged resync it
291 * already backfills; these two mean the local window is no longer trustworthy,
292 * so re-seed it over HTTP instead of showing a transcript with a hole in it.
293 */
294 useWsIssues((issue) => {
295 if (!enabled) return
296 if ('room_id' in issue && issue.room_id !== undefined && issue.room_id !== roomId) return
297 if (issue.kind === 'server_error' && issue.error === 'forbidden') {
298 dispatch({ type: 'accessRevoked' })
299 setNotice(t('chat.room.accessDenied'))
300 return
301 }
302 if (
303 issue.kind === 'cursor_lost' ||
304 issue.kind === 'history_truncated' ||
305 (issue.kind === 'resync_required' && !issue.dropped)
306 ) {
307 setReloadKey((key) => key + 1)
308 }
309 })
310
311 /* --------------------------------------------------------- read marker */
312
313 const flushRead = useCallback(() => {
314 if (!enabled || threadId) return
315 const target = atBottomRef.current ? tipRef.current : readCursor.current
316 if (target <= readCursor.current) return
317 markReadApi(roomId, { seq: target }).then(
318 (current) => {
319 if (current.last_read_seq > readCursor.current) {
320 readCursor.current = current.last_read_seq
321 setRead({ scope: scopeKey, seq: current.last_read_seq })
322 onUnreadChanged?.()
323 }
324 },
325 () => {
326 // A lost read marker is cosmetic and self-heals on the next scroll.
327 },
328 )
329 }, [enabled, onUnreadChanged, roomId, scopeKey, threadId])
330
331 const scheduleRead = useCallback(() => {
332 if (markReadTimer.current) clearTimeout(markReadTimer.current)
333 markReadTimer.current = setTimeout(flushRead, MARK_READ_DEBOUNCE_MS)
334 }, [flushRead])
335
336 useEffect(
337 () => () => {
338 if (markReadTimer.current) clearTimeout(markReadTimer.current)
339 },
340 [],
341 )
342
343 // The tail is what the reader has seen. Mirroring it into a ref keeps the
344 // debounced marker honest without a second render, and the marker itself is
345 // only ever scheduled while the viewport is pinned there.
346 useEffect(() => {
347 if (state.newestSeq !== null && state.newestSeq > tipRef.current) {
348 tipRef.current = state.newestSeq
349 }
350 if (atBottom && tipRef.current > readCursor.current) scheduleRead()
351 }, [atBottom, scheduleRead, state.newestSeq])
352
353 // Leaving the room (navigating to another channel) must not lose what was read
354 // while sitting at the tail, so the pending marker is flushed on unmount.
355 const flushRef = useRef(flushRead)
356 useEffect(() => {
357 flushRef.current = flushRead
358 })
359 useEffect(
360 () => () => {
361 if (atBottomRef.current) flushRef.current()
362 },
363 [],
364 )
365
366 /* ------------------------------------------------------------ slow mode */
367
368 const startSlowMode = useCallback((seconds: number) => {
369 const until = Date.now() + seconds * 1_000
370 if (slowTimer.current) clearInterval(slowTimer.current)
371 slowTimer.current = setInterval(() => {
372 const remaining = Math.max(0, Math.ceil((until - Date.now()) / 1_000))
373 setSlowModeRemaining(remaining)
374 if (remaining === 0 && slowTimer.current) {
375 clearInterval(slowTimer.current)
376 slowTimer.current = undefined
377 }
378 }, 1_000)
379 setSlowModeRemaining(seconds)
380 }, [])
381
382 useEffect(
383 () => () => {
384 if (slowTimer.current) clearInterval(slowTimer.current)
385 },
386 [],
387 )
388
389 /* ------------------------------------------------------------ sending */
390
391 const deliver = useCallback(
392 async (item: PendingMessage): Promise<boolean> => {
393 if (inFlight.current.has(item.localId)) return false
394 inFlight.current.add(item.localId)
395 try {
396 const message = await sendMessage(item.room_id, {
397 content: item.content,
398 nonce: item.nonce,
399 reply_to_id: item.reply_to_id ?? null,
400 thread_id: item.thread_id ?? null,
401 thread_broadcast: item.thread_broadcast ?? false,
402 attachments: item.attachments.map((attachment) => attachment.id),
403 })
404 delete pendingById.current[item.localId]
405 dispatch({ type: 'pendingSent', localId: item.localId, message })
406 // The server answers with the room's own slow-mode failure; starting the
407 // countdown here keeps a fast typist from collecting 429s one by one.
408 if (slowModeSeconds > 0) startSlowMode(slowModeSeconds)
409 return true
410 } catch (error) {
411 const retry = findRetryAfterSeconds(error)
412 if (retry) startSlowMode(retry)
413 dispatch({ type: 'pendingFail', localId: item.localId, error: failureReason(error) })
414 return false
415 } finally {
416 inFlight.current.delete(item.localId)
417 }
418 },
419 [slowModeSeconds, startSlowMode],
420 )
421
422 const send = useCallback(
423 ({ content, attachments = [], replyToId, threadId: target, threadBroadcast }: SendInput) => {
424 const body = content.replace(/\s+$/, '')
425 if (!enabled || !roomId || (!body && attachments.length === 0)) return Promise.resolve(false)
426 const nonce = clientNonce()
427 const item: PendingMessage = {
428 localId: `local:${nonce}`,
429 nonce,
430 room_id: roomId,
431 author_id: viewerId ?? 0,
432 content: body,
433 created_at: new Date().toISOString(),
434 reply_to_id: replyToId ?? null,
435 thread_id: target ?? threadId ?? null,
436 thread_broadcast: threadBroadcast ?? false,
437 attachments,
438 state: 'sending',
439 sending: true,
440 }
441 pendingById.current[item.localId] = item
442 dispatch({ type: 'pendingAdd', message: item })
443 // The author must see their own post: pin the viewport back to the tail.
444 atBottomRef.current = true
445 setBottom({ scope: scopeKey, value: true, baseline: 0 })
446 return deliver(item)
447 },
448 [deliver, enabled, roomId, scopeKey, threadId, viewerId],
449 )
450
451 const retryPending = useCallback(
452 (localId: string) => {
453 const item = pendingById.current[localId]
454 if (!item) return
455 dispatch({ type: 'pendingRetry', localId })
456 void deliver(item)
457 },
458 [deliver],
459 )
460
461 const dismissPending = useCallback((localId: string) => {
462 delete pendingById.current[localId]
463 dispatch({ type: 'pendingDrop', localId })
464 }, [])
465
466 /** Reconcile a failed row whose message actually reached the server. */
467 useEffect(() => {
468 if (state.pending.length === 0) return
469 for (const row of state.rows) {
470 const echo = findEchoedPending(state.pending, row, viewerId)
471 if (echo) {
472 delete pendingById.current[echo.localId]
473 dispatch({ type: 'pendingDrop', localId: echo.localId })
474 }
475 }
476 }, [state.pending, state.rows, viewerId])
477
478 /* ---------------------------------------------------- message actions */
479
480 const reportFailure = useCallback((error: unknown) => {
481 setNotice(describeFailure(error))
482 if (typeof window !== 'undefined') {
483 window.setTimeout(() => setNotice(null), 6_000)
484 }
485 }, [])
486
487 const editMessage = useCallback(
488 async (messageId: string, content: string) => {
489 try {
490 dispatch({ type: 'edited', message: await editMessageApi(roomId, messageId, content) })
491 return true
492 } catch (error) {
493 reportFailure(error)
494 return false
495 }
496 },
497 [reportFailure, roomId],
498 )
499
500 const deleteMessage = useCallback(
501 async (messageId: string) => {
502 try {
503 await deleteMessageApi(roomId, messageId)
504 dispatch({ type: 'deleted', messageIds: [messageId] })
505 return true
506 } catch (error) {
507 reportFailure(error)
508 return false
509 }
510 },
511 [reportFailure, roomId],
512 )
513
514 const toggleReaction = useCallback(
515 async (messageId: string, emoji: string, on: boolean) => {
516 const local = state.rows.find((row) => row.id === messageId)
517 if (!local) return
518 const reactions = local.reactions ?? []
519 const without = reactions.filter((reaction) => reaction.emoji !== emoji)
520 // Optimistic, keyed off the *previous* aggregate so a concurrent live
521 // update can be out by one but never loses the viewer's own flag.
522 const current = reactions.find((reaction) => reaction.emoji === emoji)
523 const next = on ? [...without, { emoji, count: (current?.count ?? 0) + 1, me: true }] : without
524 dispatch({ type: 'reactions', messageId, reactions: next })
525 try {
526 await setReaction(roomId, messageId, emoji, on)
527 } catch (error) {
528 dispatch({ type: 'reactions', messageId, reactions })
529 reportFailure(error)
530 }
531 },
532 [reportFailure, roomId, state.rows],
533 )
534
535 const togglePin = useCallback(
536 async (messageId: string, pinned: boolean) => {
537 try {
538 const message = await setMessagePin(roomId, messageId, pinned)
539 dispatch({ type: 'pinned', messageId, pinned })
540 return message
541 } catch (error) {
542 reportFailure(error)
543 return undefined
544 }
545 },
546 [reportFailure, roomId],
547 )
548
549 /* --------------------------------------------------------- pagination */
550
551 const loadOlder = useCallback(() => {
552 if (!enabled || state.loadingOlder || !state.hasOlder) return
553 const anchor = state.oldestSeq ?? state.rows[0]?.seq
554 if (anchor === undefined || anchor <= 1) return
555 dispatch({ type: 'olderStart' })
556 listMessages(roomId, {
557 beforeSeq: anchor,
558 limit: MESSAGE_PAGE_SIZE,
559 threadId: threadId ?? undefined,
560 }).then(
561 (page) =>
562 dispatch({
563 type: 'olderDone',
564 items: page.items,
565 hasMore: page.has_more,
566 oldestSeq: page.oldest_seq ?? null,
567 }),
568 () => dispatch({ type: 'olderDone', items: [], hasMore: false, oldestSeq: null }),
569 )
570 }, [enabled, roomId, state.hasOlder, state.loadingOlder, state.oldestSeq, state.rows, threadId])
571
572 /**
573 * Return to the newest page after a backwards jump. The window is *replaced*,
574 * not extended: rows between where the reader was and the tail were never
575 * fetched, and merging would show a transcript with an invisible hole in it.
576 */
577 const returnToLatest = useCallback(() => {
578 if (!enabled || !roomId) return
579 dispatch({ type: 'newerStart' })
580 listMessages(roomId, { limit: MESSAGE_PAGE_SIZE, threadId: threadId ?? undefined }).then(
581 (page) => {
582 // Back at the tail: this is exactly the window the next visit prefills
583 // from, so the stored seed follows the reader.
584 writeCache(chatSeedKey(roomId, threadId ?? null), page)
585 dispatch({
586 type: 'tailReplaced',
587 items: page.items,
588 hasMore: page.has_more,
589 oldestSeq: page.oldest_seq ?? null,
590 newestSeq: page.newest_seq ?? null,
591 })
592 tipRef.current = Math.max(tipRef.current, page.newest_seq ?? 0)
593 atBottomRef.current = true
594 setBottom({ scope: scopeKey, value: true, baseline: tipRef.current })
595 },
596 () => dispatch({ type: 'seedError', message: t('chat.transcript.seedFailed') }),
597 )
598 }, [enabled, roomId, scopeKey, threadId])
599
600 /**
601 * Open the window around a row that is not loaded — a pin, a search hit, a
602 * reply target. `get_message` yields its seq first, because a page can only be
603 * anchored on a cursor, and one call cannot straddle a row.
604 */
605 const jumpTo = useCallback(
606 (messageId: string) => {
607 if (!enabled || !roomId) return
608 dispatch({ type: 'highlight', messageId })
609 if (state.rows.some((row) => row.id === messageId)) return
610 void (async () => {
611 try {
612 const target = await getMessage(roomId, messageId)
613 const page = await listMessages(roomId, {
614 beforeSeq: target.seq + 1,
615 limit: MESSAGE_PAGE_SIZE,
616 threadId: threadId ?? undefined,
617 })
618 dispatch({
619 type: 'seedDone',
620 items: page.items,
621 hasMore: page.has_more,
622 oldestSeq: page.oldest_seq ?? null,
623 newestSeq: page.newest_seq ?? null,
624 })
625 // "Missed" restarts at the row the reader asked for, not at the tail.
626 tipRef.current = Math.max(tipRef.current, page.newest_seq ?? target.seq)
627 atBottomRef.current = false
628 setBottom({ scope: scopeKey, value: false, baseline: target.seq })
629 dispatch({ type: 'highlight', messageId })
630 } catch (error) {
631 reportFailure(error)
632 }
633 })()
634 },
635 [enabled, reportFailure, roomId, scopeKey, state.rows, threadId],
636 )
637
638 /* ------------------------------------------------------- reply lookup */
639
640 const missingRefs = useMemo(
641 () =>
642 state.rows
643 .map((row) => row.reply_to_id)
644 .filter((id): id is string => Boolean(id))
645 .filter((id) => !state.refs[id] && !state.rows.some((row) => row.id === id)),
646 [state.refs, state.rows],
647 )
648
649 useEffect(() => {
650 if (missingRefs.length === 0 || !enabled) return
651 let active = true
652 for (const id of missingRefs) {
653 dispatch({ type: 'refStart', messageId: id })
654 getMessage(roomId, id).then(
655 (message) => active && dispatch({ type: 'refDone', messageId: id, message }),
656 () => active && dispatch({ type: 'refDone', messageId: id, message: null }),
657 )
658 }
659 return () => {
660 active = false
661 }
662 }, [enabled, missingRefs, roomId])
663
664 /* -------------------------------------------------------- scroll glue */
665
666 const reportAtBottom = useCallback(
667 (value: boolean) => {
668 if (atBottomRef.current === value) return
669 atBottomRef.current = value
670 // The baseline is captured at the moment the reader leaves the tail, which
671 // is what makes the「N 条新消息」pill count only what they have not seen.
672 // The baseline is captured at the moment the reader leaves the tail, which
673 // is what makes the「N 条新消息」pill count only what they have not seen.
674 setBottom({ scope: scopeKey, value, baseline: value ? 0 : tipRef.current })
675 },
676 [scopeKey],
677 )
678
679 const scrollToBottom = useCallback(
680 (options?: { smooth?: boolean }) => {
681 const node = listRef.current
682 if (node) {
683 node.scrollTo({
684 top: node.scrollHeight,
685 behavior: options?.smooth ? 'smooth' : 'auto',
686 })
687 }
688 reportAtBottom(true)
689 },
690 [reportAtBottom],
691 )
692
693 // Pinned means "follow the tail": only a *change of the last row* scrolls, so
694 // prepending an older page never moves the viewport out from under the reader.
695 const lastTail = useRef<string | null>(null)
696 useEffect(() => {
697 const tail = state.rows[state.rows.length - 1]?.id ?? ''
698 const pendingTail = state.pending[state.pending.length - 1]?.localId ?? ''
699 const signature = `${tail}|${pendingTail}`
700 const changed = lastTail.current !== signature
701 lastTail.current = signature
702 if (!changed || !atBottomRef.current) return
703 const frame = requestAnimationFrame(() => {
704 const node = listRef.current
705 if (node) node.scrollTop = node.scrollHeight
706 })
707 return () => cancelAnimationFrame(frame)
708 }, [state.rows, state.pending])
709
710 const missedCount = useMemo(() => {
711 if (atBottom) return 0
712 let count = 0
713 for (const row of state.rows) if (row.seq > missedBaseline) count += 1
714 return count
715 }, [atBottom, missedBaseline, state.rows])
716
717 return {
718 state,
719 status,
720 typingUserIds: typing.map((indicator) => Number(indicator.user_id)),
721 lastReadSeq,
722 atBottom,
723 missedCount,
724 slowModeRemaining,
725 notice,
726 dismissNotice: () => setNotice(null),
727 listRef,
728 reportAtBottom,
729 scrollToBottom,
730 loadOlder,
731 jumpTo,
732 returnToLatest,
733 send,
734 retryPending,
735 dismissPending,
736 editMessage,
737 deleteMessage,
738 toggleReaction,
739 togglePin,
740 refresh: () => setReloadKey((key) => key + 1),
741 }
742}
Added view/app/src/domains/chat/model/view-state.ts view/app/src/domains/chat/model/view-state.ts +23 −0 View view/app/src/domains/chat/model/view-state.ts
@@ -0,0 +1,23 @@
1/**
2 * The chat column's view state, as the page passes it down.
3 *
4 * It is deliberately a plain shape: the page derives it from the URL (see
5 * `pages/workspace/model/chat-location`) and hands back a patcher, so the
6 * feature never imports react-router and never learns what a query string is.
7 * That is also what keeps every panel, thread and jump deep-linkable.
8 */
9export type ChatPanelKind = 'thread' | 'pins' | 'threads' | 'members' | null
10
11export interface ChatViewState {
12 /** Which right-hand column is open; `null` leaves just the transcript. */
13 panel: ChatPanelKind
14 /** Set with `panel: 'thread'`; the thread's own id. */
15 threadId?: string
16 /** One-shot targets: the view consumes them and clears the URL entry. */
17 replyToId?: string
18 editId?: string
19 jumpId?: string
20}
21
22/** Defaults are omitted from the URL, so a plain room reads `/app/x/chat/<id>`. */
23export const CHAT_VIEW_DEFAULTS: ChatViewState = { panel: null }
Added view/app/src/domains/chat/model/voice-api.ts view/app/src/domains/chat/model/voice-api.ts +132 −0 View view/app/src/domains/chat/model/voice-api.ts
@@ -0,0 +1,132 @@
1/**
2 * Voice REST surface (LiveKit). Wraps the generated Alova definitions so
3 * consumers deal in domain types instead of the envelope namespace; every
4 * call resolves through \`unwrapApi\`, so failures surface as \`ApiFailure\`.
5 */
6
7import { unwrapApi } from '@app/lib'
8import type {
9 EnvelopeHandDto,
10 EnvelopeVecVoiceParticipantDto,
11 EnvelopeVoiceConfigDto,
12 EnvelopeVoiceHandsDto,
13 EnvelopeVoiceModerationDto,
14 EnvelopeVoiceSpaceStatesDto,
15 EnvelopeVoiceTokenDto,
16 HandState,
17 VoiceTileDto,
18} from '@app/services/api'
19import Apis from '@app/services/api'
20
21export type VoiceConfigDto = NonNullable<EnvelopeVoiceConfigDto['data']>
22export type VoiceToken = NonNullable<EnvelopeVoiceTokenDto['data']>
23export type VoiceHand = NonNullable<EnvelopeHandDto['data']>
24export type VoiceParticipant = NonNullable<EnvelopeVecVoiceParticipantDto['data']>[number]
25export type VoiceHands = NonNullable<EnvelopeVoiceHandsDto['data']>
26export type VoiceModeration = NonNullable<EnvelopeVoiceModerationDto['data']>
27export type VoiceSpaceStates = NonNullable<EnvelopeVoiceSpaceStatesDto['data']>
28export type VoiceTile = VoiceTileDto
29/** Shared layout as stored on the server: every tile carries its write stamp. */
30export type VoiceLayoutTiles = Record<string, VoiceTileDto>
31/**
32 * What a client may write: geometry only, the stamp is server-owned. `h` is
33 * optional on the wire so an older client's write stays valid — the server then
34 * leaves the height unset and readers fall back to the classic 3:4 shape.
35 */
36export interface VoiceTileWrite {
37 x: number
38 y: number
39 w: number
40 h?: number
41}
42export type VoiceHandState = HandState
43
44export function getVoiceConfig(): Promise<VoiceConfigDto> {
45 return unwrapApi(Apis.chat.voice_config({}))
46}
47
48export function requestVoiceToken(roomId: string): Promise<VoiceToken> {
49 return unwrapApi(Apis.chat.voice_token({ pathParams: { room_id: roomId } }))
50}
51
52export function listVoiceParticipants(roomId: string): Promise<VoiceParticipant[]> {
53 return unwrapApi(Apis.chat.voice_participants({ pathParams: { room_id: roomId } }))
54}
55
56export function getVoiceSpaceStates(spaceName: string): Promise<VoiceSpaceStates> {
57 return unwrapApi(Apis.chat.voice_space_states({ pathParams: { space_name: spaceName } }))
58}
59
60export function listVoiceHands(roomId: string): Promise<VoiceHands> {
61 return unwrapApi(Apis.chat.list_hands({ pathParams: { room_id: roomId } }))
62}
63
64export function raiseVoiceHand(roomId: string): Promise<VoiceHand> {
65 return unwrapApi(Apis.chat.raise_hand({ pathParams: { room_id: roomId } }))
66}
67
68export function cancelVoiceHand(roomId: string): Promise<void> {
69 return unwrapApi(Apis.chat.cancel_hand({ pathParams: { room_id: roomId } })).then(() => undefined)
70}
71
72export function approveVoiceHand(roomId: string, userId: number): Promise<VoiceModeration> {
73 return unwrapApi(Apis.chat.approve_hand({ pathParams: { room_id: roomId, user_id: userId } }))
74}
75
76export function denyVoiceHand(roomId: string, userId: number): Promise<VoiceModeration> {
77 return unwrapApi(Apis.chat.deny_hand({ pathParams: { room_id: roomId, user_id: userId } }))
78}
79
80export function revokeVoiceHand(roomId: string, userId: number): Promise<VoiceModeration> {
81 return unwrapApi(Apis.chat.revoke_hand({ pathParams: { room_id: roomId, user_id: userId } }))
82}
83
84export function moderateVoiceMute(roomId: string, userId: number, muted: boolean): Promise<VoiceModeration> {
85 return unwrapApi(
86 Apis.chat.moderate_mute({
87 pathParams: { room_id: roomId, user_id: userId },
88 params: { muted },
89 }),
90 )
91}
92
93export function kickVoiceParticipant(roomId: string, userId: number): Promise<VoiceModeration> {
94 return unwrapApi(Apis.chat.kick_participant({ pathParams: { room_id: roomId, user_id: userId } }))
95}
96
97/**
98 * Shared participant-window layout. Coordinates are normalized against the
99 * free canvas span, so the same layout lands inside the bounds on every screen.
100 *
101 * Writes are per-key patches: a client only ever submits the windows it moved,
102 * so two members dragging different windows cannot erase each other's work. The
103 * returned stamp is the server's write time, which lets the caller tell a fresh
104 * value from a GET that was already in flight when it dragged.
105 */
106export function getVoiceLayout(roomId: string): Promise<VoiceLayoutTiles> {
107 return unwrapApi<{ tiles?: VoiceLayoutTiles }>(Apis.chat.get_voice_layout({ pathParams: { room_id: roomId } })).then(
108 (data) => data?.tiles ?? {},
109 )
110}
111
112/** Upsert the given windows; resolves to the server write stamp (seconds). */
113export async function saveVoiceLayoutTiles(roomId: string, tiles: Record<string, VoiceTileWrite>): Promise<number> {
114 const data = await unwrapApi<{ updated_at?: number }>(
115 Apis.chat.save_voice_layout({
116 pathParams: { room_id: roomId },
117 data: { tiles, removed: [] },
118 }),
119 )
120 return data?.updated_at ?? 0
121}
122
123/** Drop the given windows back into the automatic grid. */
124export async function removeVoiceLayoutTiles(roomId: string, keys: string[]): Promise<number> {
125 const data = await unwrapApi<{ updated_at?: number }>(
126 Apis.chat.save_voice_layout({
127 pathParams: { room_id: roomId },
128 data: { tiles: {}, removed: keys },
129 }),
130 )
131 return data?.updated_at ?? 0
132}
Added view/app/src/domains/chat/model/voice-connection.ts view/app/src/domains/chat/model/voice-connection.ts +92 −0 View view/app/src/domains/chat/model/voice-connection.ts
@@ -0,0 +1,92 @@
1/**
2 * The wiring seam between the tab-wide voice session (`@/shared/voice`) and the
3 * chat API (`@/entities/chat`).
4 *
5 * `shared/voice` must stay free of entities imports, so the credential minting
6 * lives here in the feature that owns both sides. Everything the session needs
7 * to survive - a refresh restore, a rejoin with a *fresh* token after a signal
8 * drop, the pre-join capability gate - funnels through this module.
9 */
10import { getVoiceConfig, requestVoiceToken } from '@app/domains/chat/model'
11import { storedVoiceSession, type VoiceCredentials, voiceSession } from '@app/services/voice'
12
13let installed = false
14
15/** Mint a LiveKit access token + endpoint for one voice channel. */
16export async function voiceCredentials(roomId: string): Promise<VoiceCredentials> {
17 const token = await requestVoiceToken(roomId)
18 return {
19 url: token.url,
20 token: token.token,
21 canPublish: token.can_publish,
22 isStage: token.is_stage,
23 }
24}
25
26/**
27 * Restores an in-progress call after a page refresh.
28 *
29 * The connection cannot survive a reload, so the room id lives in
30 * sessionStorage and the tab re-joins silently - **muted**, because a browser
31 * blocks remote audio until a gesture anyway and rejoining with an open
32 * microphone would be worse than one extra click. The call bar then offers to
33 * restore sound.
34 */
35async function restoreStoredSession(): Promise<void> {
36 const stored = storedVoiceSession()
37 if (!stored) return
38 try {
39 const credentials = await voiceCredentials(stored.roomId)
40 await voiceSession.join({
41 roomId: stored.roomId,
42 roomLabel: stored.roomLabel,
43 spaceName: stored.spaceName,
44 ...credentials,
45 startWithMic: false,
46 })
47 } catch {
48 // A stale room (deleted channel, revoked access) simply does not restore;
49 // leaving clears the stored id so the next load stays quiet.
50 await voiceSession.leave()
51 }
52}
53
54/**
55 * Install the session's credential source and kick off the refresh restore.
56 * Called once from the app layer; idempotent, so a double render (StrictMode)
57 * cannot tear down and rebuild the connection the first pass just made.
58 */
59export function installVoiceSession(): void {
60 if (installed) return
61 installed = true
62 voiceSession.setCredentialSource(voiceCredentials)
63 void restoreStoredSession()
64}
65
66export interface JoinVoiceRoomOptions {
67 roomId: string
68 roomLabel?: string
69 spaceName?: string
70 /** Voice channels open the mic; Stage audiences join in listen-only mode. */
71 startWithMic: boolean
72 audioDeviceId?: string | null
73 videoDeviceId?: string | null
74 audioOutputDeviceId?: string | null
75}
76
77/** Capability gate + credential minting + connect, as one awaitable action. */
78export async function joinVoiceRoom(options: JoinVoiceRoomOptions): Promise<void> {
79 const config = await getVoiceConfig()
80 if (!config.enabled) throw new Error('livekit is not enabled')
81 const credentials = await voiceCredentials(options.roomId)
82 await voiceSession.join({
83 roomId: options.roomId,
84 roomLabel: options.roomLabel,
85 spaceName: options.spaceName,
86 ...credentials,
87 startWithMic: options.startWithMic,
88 audioDeviceId: options.audioDeviceId,
89 videoDeviceId: options.videoDeviceId,
90 audioOutputDeviceId: options.audioOutputDeviceId,
91 })
92}
Added view/app/src/domains/chat/ui/voice/voice-avatar-tile.tsx view/app/src/domains/chat/ui/voice/voice-avatar-tile.tsx +107 −0 View view/app/src/domains/chat/ui/voice/voice-avatar-tile.tsx
@@ -0,0 +1,107 @@
1/**
2 * The window shown for a member whose camera is off: their avatar inside a ring
3 * that reacts to their voice.
4 *
5 * The official `ParticipantAudioTile` fills the whole window with a seven-bar
6 * level meter, which is the wrong shape for a person — a full-height meter
7 * carries no identity, so an audio-only member read as a broken window rather
8 * than as a person. The avatar is what says *who* is talking, so the meter is
9 * reduced to the ring around it: one band of the same analyser the official bars
10 * used, with no extra audio plumbing.
11 */
12
13import { useT } from '@app/i18n'
14import {
15 isTrackReference,
16 type TrackReferenceOrPlaceholder,
17 useIsMuted,
18 useIsSpeaking,
19 useTrackVolume,
20} from '@app/vendor/livekit'
21import { Track } from 'livekit-client'
22import { MicOff } from 'lucide-react'
23import { type CSSProperties, useMemo } from 'react'
24import { UserAvatar } from '../parts'
25import { initials } from './voice-format'
26
27/**
28 * The analyser reports RMS frequency energy, which sits around 0.4–0.6 for an
29 * open microphone in a quiet room — a straight multiply pins the ring at its
30 * maximum and it reads as a static border. The band below is the room floor and
31 * the band above is a raised voice, so the ring only grows for actual speech.
32 */
33const LEVEL_FLOOR = 0.4
34const LEVEL_CEILING = 0.8
35/** Ring spread at full level: enough to be seen, small enough to stay a ring. */
36const LEVEL_SPREAD = 0.24
37
38/** Voice activity in 0…1, with the room floor subtracted and the rest stretched. */
39function spreadOf(level: number): number {
40 const trimmed = (level - LEVEL_FLOOR) / (LEVEL_CEILING - LEVEL_FLOOR)
41 return Math.min(1, Math.max(0, trimmed))
42}
43
44export function VoiceAvatarTile({
45 trackRef,
46 micRef,
47 avatarUrl,
48}: {
49 trackRef: TrackReferenceOrPlaceholder
50 /** The member's microphone: what the ring level is measured from. */
51 micRef?: TrackReferenceOrPlaceholder
52 avatarUrl?: string | null
53}) {
54 const { t } = useT()
55 const participant = trackRef.participant
56 const name = participant.name || initials(participant.identity)
57 const speaking = useIsSpeaking(participant)
58 /**
59 * A window with no camera still belongs to a member whose microphone *is* the
60 * thing producing sound, so the level and the mute flag are read from the
61 * microphone reference. Falling back to a constructed reference keeps the
62 * hooks unconditional when the caller has no entry for this identity.
63 */
64 const mic = useMemo<TrackReferenceOrPlaceholder>(
65 () =>
66 micRef ?? {
67 participant,
68 source: Track.Source.Microphone,
69 publication: participant.getTrackPublication(Track.Source.Microphone),
70 },
71 [micRef, participant],
72 )
73 const muted = useIsMuted(mic)
74 /* The volume analyser needs a live publication; an unsubscribed (placeholder)
75 microphone has no audio to measure, so the ring rests at its minimum. */
76 const liveMic = isTrackReference(mic) ? mic : undefined
77 const level = useTrackVolume(liveMic)
78 const style = useMemo(
79 () =>
80 ({
81 '--voice-level': String(spreadOf(level) * LEVEL_SPREAD),
82 }) as CSSProperties,
83 [level],
84 )
85
86 return (
87 <div
88 className="voice-avatar-tile lk-participant-tile"
89 data-lk-speaking={speaking}
90 data-lk-audio-muted={muted}
91 style={style}
92 title={speaking ? t('chat.voice.tileSpeaking', { name }) : name}
93 >
94 <span className="voice-avatar-ring" aria-hidden />
95 {/* Sized by CSS against the window, which the caller can resize. */}
96 <UserAvatar
97 user={{ display_name: name, username: name, avatar_url: avatarUrl ?? '' }}
98 id={Number(participant.identity) || 0}
99 className="voice-avatar-face"
100 />
101 <span className="voice-avatar-name">
102 {muted ? <MicOff className="h-3 w-3 shrink-0" aria-hidden /> : null}
103 <span className="truncate">{name}</span>
104 </span>
105 </div>
106 )
107}
Added view/app/src/domains/chat/ui/voice/voice-call-bar.tsx view/app/src/domains/chat/ui/voice/voice-call-bar.tsx +253 −0 View view/app/src/domains/chat/ui/voice/voice-call-bar.tsx
@@ -0,0 +1,253 @@
1/**
2 * The persistent call bar. It renders in every workspace mode (chat, board,
3 * repository) because the call outlives the route, and it is the only place a
4 * browser-blocked remote-audio stream can be resumed with a user gesture.
5 *
6 * It floats and is draggable: the composer, the file tree and the board all
7 * want the bottom of the screen, so the bar stays where the user parked it.
8 */
9
10import { useT } from '@app/i18n'
11import { useVoiceSession, voiceSession } from '@app/services/voice'
12import { ConnectionQualityIndicator } from '@app/vendor/livekit'
13import {
14 AlertCircle,
15 GripVertical,
16 Loader2,
17 Mic,
18 MicOff,
19 MonitorUp,
20 PhoneOff,
21 Video,
22 VideoOff,
23 Volume2,
24} from 'lucide-react'
25import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from 'react'
26import { Link, useLocation } from 'react-router-dom'
27
28const POSITION_KEY = 'gitdata.voice.callbar.position'
29const VIEWPORT_MARGIN = 12
30
31interface Point {
32 x: number
33 y: number
34}
35
36function readStoredPosition(): Point | null {
37 try {
38 const raw = localStorage.getItem(POSITION_KEY)
39 if (!raw) return null
40 const parsed = JSON.parse(raw) as Partial<Point>
41 return typeof parsed.x === 'number' && typeof parsed.y === 'number' ? { x: parsed.x, y: parsed.y } : null
42 } catch {
43 return null
44 }
45}
46
47/** Keep a parked bar reachable no matter how small the window becomes. */
48function clampToViewport(point: Point, el: HTMLDivElement | null): Point {
49 const width = el?.offsetWidth ?? 320
50 const height = el?.offsetHeight ?? 46
51 return {
52 x: Math.min(
53 Math.max(VIEWPORT_MARGIN, point.x),
54 Math.max(VIEWPORT_MARGIN, window.innerWidth - width - VIEWPORT_MARGIN),
55 ),
56 y: Math.min(
57 Math.max(VIEWPORT_MARGIN, point.y),
58 Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN),
59 ),
60 }
61}
62
63function isInteractive(target: EventTarget | null): boolean {
64 return target instanceof Element && target.closest('button, a') !== null
65}
66
67/** The voice room the current route shows, if it is one. */
68function roomIdInPath(pathname: string): string | null {
69 const match = /^\/app\/[^/]+\/chat\/([^/]+)/.exec(pathname)
70 return match ? decodeURIComponent(match[1]) : null
71}
72
73export function VoiceCallBar() {
74 const { t } = useT()
75 const session = useVoiceSession()
76 const location = useLocation()
77 const barRef = useRef<HTMLDivElement | null>(null)
78 const dragRef = useRef<{ offsetX: number; offsetY: number } | null>(null)
79 const [position, setPosition] = useState<Point | null>(readStoredPosition)
80 const positionRef = useRef<Point | null>(position)
81 const [dragging, setDragging] = useState(false)
82
83 useEffect(() => {
84 const onResize = () => setPosition((current) => (current ? clampToViewport(current, barRef.current) : current))
85 window.addEventListener('resize', onResize)
86 return () => window.removeEventListener('resize', onResize)
87 }, [])
88
89 const moveTo = (next: Point) => {
90 const clamped = clampToViewport(next, barRef.current)
91 positionRef.current = clamped
92 setPosition(clamped)
93 }
94
95 const resetPosition = () => {
96 positionRef.current = null
97 setPosition(null)
98 try {
99 localStorage.removeItem(POSITION_KEY)
100 } catch {
101 // Storage is an convenience here, never a requirement.
102 }
103 }
104
105 const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
106 const el = barRef.current
107 if (!el || event.button !== 0 || isInteractive(event.target)) return
108 const rect = el.getBoundingClientRect()
109 dragRef.current = { offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top }
110 el.setPointerCapture(event.pointerId)
111 setDragging(true)
112 }
113
114 const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
115 const drag = dragRef.current
116 if (!drag) return
117 moveTo({ x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY })
118 }
119
120 const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
121 if (!dragRef.current) return
122 dragRef.current = null
123 setDragging(false)
124 try {
125 barRef.current?.releasePointerCapture(event.pointerId)
126 } catch {
127 // The pointer may already be gone; nothing to release.
128 }
129 const parked = positionRef.current
130 if (!parked) return
131 try {
132 localStorage.setItem(POSITION_KEY, JSON.stringify(parked))
133 } catch {
134 // Same: a full storage quota must not break the call.
135 }
136 }
137
138 const active = session.roomId !== null && session.status !== 'idle'
139 // Inside the room the call belongs to, the room's own control bar carries the
140 // same actions - and its right-hand corner belongs to the settings menu, so a
141 // floating pill there would only overlap it.
142 const inOwnRoom = session.roomId !== null && roomIdInPath(location.pathname) === session.roomId
143 if (!active || inOwnRoom) return null
144
145 // The label and the route are captured at join time; a call keeps working
146 // from any page, so the bar never depends on the current route params.
147 const roomLabel = session.roomLabel ?? t('chat.voice.channelFallback')
148 const href =
149 session.roomId && session.spaceName
150 ? '/app/' + encodeURIComponent(session.spaceName) + '/chat/' + encodeURIComponent(session.roomId)
151 : undefined
152
153 const connecting = session.status === 'connecting' || session.status === 'reconnecting'
154 const room = voiceSession.getRoom()
155
156 return (
157 <div
158 ref={barRef}
159 className={['voice-call-bar', position ? 'is-moved' : '', dragging ? 'is-dragging' : '']
160 .filter(Boolean)
161 .join(' ')}
162 style={position ? { left: position.x, top: position.y } : undefined}
163 role="region"
164 aria-label={t('chat.voice.callAria')}
165 title={t('chat.voice.barDragHint')}
166 onPointerDown={onPointerDown}
167 onPointerMove={onPointerMove}
168 onPointerUp={endDrag}
169 onPointerCancel={endDrag}
170 onDoubleClick={(event) => {
171 if (!isInteractive(event.target)) resetPosition()
172 }}
173 >
174 <GripVertical className="voice-call-grip" aria-hidden />
175 <span className="voice-call-status">
176 {connecting ? (
177 <Loader2 className="h-3.5 w-3.5 animate-spin" />
178 ) : (
179 <span className="voice-call-dot" aria-hidden />
180 )}
181 <span className="voice-call-label">{roomLabel}</span>
182 {/* The official indicator reads the local participant's own quality, so
183 the bar says "your uplink is bad" instead of pretending all is well. */}
184 {room ? (
185 <ConnectionQualityIndicator participant={room.localParticipant} className="voice-call-quality" />
186 ) : null}
187 </span>
188
189 {!session.audioPlaybackAllowed ? (
190 <button type="button" className="voice-call-resume" onClick={() => void voiceSession.resumeAudio()}>
191 <AlertCircle className="h-3.5 w-3.5" />
192 {t('chat.voice.restore')}
193 </button>
194 ) : null}
195
196 {session.notice ? (
197 <span className="voice-call-notice truncate" title={session.notice}>
198 {session.notice}
199 </span>
200 ) : null}
201
202 {session.needsManualRejoin ? (
203 <button type="button" className="voice-call-resume" onClick={() => void voiceSession.rejoin()}>
204 <PhoneOff className="h-3.5 w-3.5" />
205 {t('chat.voice.rejoin')}
206 </button>
207 ) : null}
208
209 <span className="voice-call-actions">
210 <button
211 type="button"
212 className={session.micEnabled ? 'is-on' : 'is-off'}
213 disabled={!session.canPublish}
214 title={session.canPublish ? t('chat.voice.mic') : t('chat.voice.noPublish')}
215 onClick={() => void voiceSession.setMicrophone(!session.micEnabled)}
216 >
217 {session.micEnabled ? <Mic className="h-4 w-4" /> : <MicOff className="h-4 w-4" />}
218 </button>
219 <button
220 type="button"
221 className={session.cameraEnabled ? 'is-on' : 'is-off'}
222 disabled={!session.canPublish}
223 title={t('chat.voice.camera')}
224 onClick={() => void voiceSession.setCamera(!session.cameraEnabled)}
225 >
226 {session.cameraEnabled ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
227 </button>
228 <button
229 type="button"
230 className={session.screenShareEnabled ? 'is-on' : 'is-off'}
231 disabled={!session.canPublish}
232 title={t('chat.voice.screenShare')}
233 onClick={() => void voiceSession.setScreenShare(!session.screenShareEnabled)}
234 >
235 <MonitorUp className="h-4 w-4" />
236 </button>
237 {href ? (
238 <Link className="voice-call-return" to={href} title={t('chat.voice.return')}>
239 <Volume2 className="h-4 w-4" />
240 </Link>
241 ) : null}
242 <button
243 type="button"
244 className="is-hangup"
245 title={t('chat.voice.hangUp')}
246 onClick={() => void voiceSession.leave()}
247 >
248 <PhoneOff className="h-4 w-4" />
249 </button>
250 </span>
251 </div>
252 )
253}
Added view/app/src/domains/chat/ui/voice/voice-danmaku-history.tsx view/app/src/domains/chat/ui/voice/voice-danmaku-history.tsx +102 −0 View view/app/src/domains/chat/ui/voice/voice-danmaku-history.tsx
@@ -0,0 +1,102 @@
1/**
2 * The bullet-comment archive: a small window over the call that lists what the
3 * room already said (newest first, paging backwards on demand) and carries the
4 * only switch that controls whether bullets fly across the screen at all.
5 */
6
7import type { ChatMessage } from '@app/domains/chat/model'
8import { useT } from '@app/i18n'
9import { fmtRelative } from '@app/lib/format'
10import { X } from 'lucide-react'
11import { useEffect, useRef } from 'react'
12
13export interface VoiceDanmakuHistoryProps {
14 messages: ChatMessage[]
15 nameOf: (userId: number) => string
16 hasOlder: boolean
17 onLoadOlder: () => void
18 flying: boolean
19 onFlyingChange: (next: boolean) => void
20 onClose: () => void
21}
22
23function isBullet(message: ChatMessage): boolean {
24 if (message.deleted_at) return false
25 const type = message.message_type
26 return (type === 'Default' || type === 'Reply') && message.content.trim() !== ''
27}
28
29export function VoiceDanmakuHistory({
30 messages,
31 nameOf,
32 hasOlder,
33 onLoadOlder,
34 flying,
35 onFlyingChange,
36 onClose,
37}: VoiceDanmakuHistoryProps) {
38 const { t } = useT()
39 const ref = useRef<HTMLDivElement | null>(null)
40
41 // Clicking anywhere else, or Escape, dismisses it. The toggle button is
42 // exempt: it handles its own close, and closing here first would make the
43 // following click re-open the window.
44 useEffect(() => {
45 const onPointerDown = (event: PointerEvent) => {
46 const target = event.target
47 if (!(target instanceof Element)) return
48 if (target.closest('[data-danmaku-toggle]')) return
49 if (ref.current?.contains(target)) return
50 onClose()
51 }
52 const onKeyDown = (event: KeyboardEvent) => {
53 if (event.key === 'Escape') onClose()
54 }
55 document.addEventListener('pointerdown', onPointerDown)
56 document.addEventListener('keydown', onKeyDown)
57 return () => {
58 document.removeEventListener('pointerdown', onPointerDown)
59 document.removeEventListener('keydown', onKeyDown)
60 }
61 }, [onClose])
62
63 const items = messages.filter(isBullet).slice().reverse()
64
65 return (
66 <div ref={ref} className="voice-danmaku-history" role="dialog" aria-label={t('chat.danmaku.title')}>
67 <div className="voice-danmaku-history-head">
68 <span className="voice-danmaku-history-title">{t('chat.danmaku.title')}</span>
69 <span className="voice-badge">{t('chat.danmaku.count', { count: items.length })}</span>
70 <button
71 type="button"
72 className="voice-danmaku-history-close"
73 title={t('common.action.close')}
74 onClick={onClose}
75 >
76 <X className="h-3.5 w-3.5" />
77 </button>
78 </div>
79 {hasOlder ? (
80 <button type="button" className="voice-danmaku-history-more" onClick={onLoadOlder}>
81 {t('chat.danmaku.loadOlder')}
82 </button>
83 ) : null}
84 <div className="voice-danmaku-history-list">
85 {items.length === 0 ? <p className="voice-hands-empty">{t('chat.danmaku.empty')}</p> : null}
86 {items.map((message) => (
87 <div key={String(message.id)} className="voice-danmaku-history-row">
88 <span className="voice-danmaku-history-author">
89 {message.author?.display_name || nameOf(message.author_id)}
90 </span>
91 <span className="voice-danmaku-history-text">{message.content.trim()}</span>
92 <time className="voice-danmaku-history-time">{fmtRelative(message.created_at)}</time>
93 </div>
94 ))}
95 </div>
96 <label className="voice-danmaku-history-foot">
97 <input type="checkbox" checked={flying} onChange={() => onFlyingChange(!flying)} />
98 {t('chat.danmaku.flying')}
99 </label>
100 </div>
101 )
102}
Added view/app/src/domains/chat/ui/voice/voice-danmaku-input.tsx view/app/src/domains/chat/ui/voice/voice-danmaku-input.tsx +71 −0 View view/app/src/domains/chat/ui/voice/voice-danmaku-input.tsx
@@ -0,0 +1,71 @@
1/**
2 * The bullet-comment entry. Deliberately minimal: it parks as a slim, faded
3 * pill until it is focused, it is single-line, and it carries no attachments,
4 * no emoji picker and no keyboard hints — a voice channel is for talking, so the
5 * text box must never compete with the call for attention.
6 */
7
8import { MAX_MESSAGE_LEN } from '@app/domains/chat/model'
9import { useT } from '@app/i18n'
10import { MessageSquare, Send } from 'lucide-react'
11import { type KeyboardEvent as ReactKeyboardEvent, useState } from 'react'
12
13export interface VoiceDanmakuInputProps {
14 value: string
15 onChange: (next: string) => void
16 onSend: () => void
17 disabled?: boolean
18 /** Only real failures surface: slow mode, a blocked draft. */
19 notice?: string | null
20}
21
22export function VoiceDanmakuInput({
23 value,
24 onChange,
25 onSend,
26 disabled = false,
27 notice = null,
28}: VoiceDanmakuInputProps) {
29 const { t } = useT()
30 const [focused, setFocused] = useState(false)
31 const open = focused || value.length > 0
32 const sendable = value.trim().length > 0 && !disabled
33
34 const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
35 // Confirming a Chinese candidate with Enter must never fire a half-written
36 // bullet, so the composing flag is checked before anything else.
37 if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
38 event.preventDefault()
39 if (sendable) onSend()
40 }
41
42 return (
43 <div className={['voice-danmaku-input', open ? 'is-open' : ''].filter(Boolean).join(' ')}>
44 <MessageSquare className="voice-danmaku-input-icon" aria-hidden />
45 <input
46 className="voice-danmaku-input-field"
47 value={value}
48 disabled={disabled}
49 maxLength={MAX_MESSAGE_LEN}
50 placeholder={t('chat.danmaku.placeholder')}
51 aria-label={t('chat.danmaku.label')}
52 onChange={(event) => onChange(event.target.value)}
53 onFocus={() => setFocused(true)}
54 onBlur={() => setFocused(false)}
55 onKeyDown={onKeyDown}
56 />
57 {open && notice ? <span className="voice-danmaku-input-notice">{notice}</span> : null}
58 <button
59 type="button"
60 className="voice-danmaku-input-send"
61 disabled={!sendable}
62 title={t('chat.composer.send')}
63 onClick={() => {
64 if (sendable) onSend()
65 }}
66 >
67 <Send className="h-4 w-4" />
68 </button>
69 </div>
70 )
71}
Added view/app/src/domains/chat/ui/voice/voice-danmaku.tsx view/app/src/domains/chat/ui/voice/voice-danmaku.tsx +148 −0 View view/app/src/domains/chat/ui/voice/voice-danmaku.tsx
@@ -0,0 +1,148 @@
1/**
2 * Bullet comments for voice channels: text posted to the room flies across the
3 * call surface instead of being read as a transcript.
4 *
5 * Two details make it bearable:
6 * - only traffic that arrives after the transcript has been seeded is replayed,
7 * so opening a channel never throws its whole history at you;
8 * - the layer is click-through and every bullet keeps a lane derived from its
9 * own id, so tiles stay usable and nothing jumps sideways when a neighbour
10 * expires.
11 */
12
13import type { ChatMessage } from '@app/domains/chat/model'
14import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
15
16/** Seconds a bullet needs to cross the stage; keep in sync with the CSS. */
17const CROSS_MS = 11_000
18const LANE_HEIGHT = 40
19
20export interface VoiceDanmakuProps {
21 messages: ChatMessage[]
22 /** True once the first page loaded: everything up to it is history. */
23 seeded: boolean
24 nameOf: (userId: number) => string
25}
26
27interface Bullet {
28 id: string
29 author: string
30 text: string
31 lane: number
32}
33
34function isHumanText(message: ChatMessage): boolean {
35 if (message.deleted_at) return false
36 const type = message.message_type
37 return (type === 'Default' || type === 'Reply') && message.content.trim() !== ''
38}
39
40/** Server stamps carry a `+00:00:00` offset that Date cannot parse. */
41function stampOf(value: string): number {
42 const parsed = Date.parse(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
43 return Number.isNaN(parsed) ? Number.NaN : parsed
44}
45
46/**
47 * Bullets are a live surface: one only flies when the message is genuinely new,
48 * so a late joiner never watches the room's past. Three guards, because the
49 * transcript can hand history over in several shapes (seed, resync, prefetch).
50 */
51function isLive(message: ChatMessage, born: number, threshold: number, now: number): boolean {
52 if (born === 0) return false
53 if ((message.seq ?? 0) <= threshold) return false
54 const stamped = stampOf(message.created_at)
55 if (!Number.isNaN(stamped) && now - stamped > CROSS_MS) return false
56 return true
57}
58
59function laneFor(id: string, lanes: number): number {
60 let hash = 0
61 for (let index = 0; index < id.length; index += 1) hash = (hash * 31 + id.charCodeAt(index)) % 9973
62 return hash % lanes
63}
64
65export function VoiceDanmaku({ messages, seeded, nameOf }: VoiceDanmakuProps) {
66 const layerRef = useRef<HTMLDivElement | null>(null)
67 const [lanes, setLanes] = useState(3)
68 const [distance, setDistance] = useState(0)
69 const historyUntil = useRef<number | null>(null)
70 const bornAt = useRef<Map<string, number>>(new Map())
71 const [, setTick] = useState(0)
72
73 useLayoutEffect(() => {
74 const el = layerRef.current
75 if (!el) return
76 const measure = () => {
77 setLanes(Math.max(1, Math.floor(el.clientHeight / LANE_HEIGHT)))
78 setDistance(el.clientWidth)
79 }
80 measure()
81 const observer = new ResizeObserver(measure)
82 observer.observe(el)
83 return () => observer.disconnect()
84 }, [])
85
86 // Freeze the seq the seed ended on: a larger seq can only be live traffic.
87 if (seeded && historyUntil.current === null) {
88 let newest = 0
89 for (const message of messages) newest = Math.max(newest, message.seq ?? 0)
90 historyUntil.current = newest
91 }
92 const threshold = historyUntil.current
93
94 const now = Date.now()
95 // Live traffic arrives one or two messages at a time; anything that lands as
96 // a page (seed refresh, resync, local-first prefetch) is history.
97 const unseen = messages.filter((message) => isHumanText(message) && !bornAt.current.has(String(message.id)))
98 const pageIsHistory = unseen.length > 3
99 for (const message of unseen) {
100 bornAt.current.set(String(message.id), pageIsHistory ? 0 : now)
101 }
102
103 const bullets: Bullet[] = []
104 for (const message of messages) {
105 if (!isHumanText(message)) continue
106 const id = String(message.id)
107 const born = bornAt.current.get(id) ?? 0
108 if (!isLive(message, born, threshold ?? -1, now)) continue
109 if (now - born >= CROSS_MS) continue
110 bullets.push({
111 id,
112 author: message.author?.display_name || nameOf(message.author_id),
113 text: message.content.trim(),
114 lane: laneFor(id, lanes),
115 })
116 }
117 if (bornAt.current.size > 300) {
118 for (const [id, born] of bornAt.current) if (born !== 0 && now - born >= CROSS_MS) bornAt.current.delete(id)
119 }
120
121 // Bullets expire on their own clock, not only when a new message renders.
122 useEffect(() => {
123 if (bullets.length === 0) return
124 const timer = setInterval(() => setTick((value) => value + 1), 1000)
125 return () => clearInterval(timer)
126 // eslint-disable-next-line react-hooks/exhaustive-deps
127 }, [bullets.length])
128
129 return (
130 <div ref={layerRef} className="voice-danmaku" aria-hidden>
131 {bullets.map((bullet) => (
132 <span
133 key={bullet.id}
134 className="voice-danmaku-item"
135 style={
136 {
137 top: bullet.lane * LANE_HEIGHT + 4,
138 '--voice-danmaku-distance': distance + 'px',
139 } as CSSProperties
140 }
141 >
142 <span className="voice-danmaku-author">{bullet.author}</span>
143 <span className="voice-danmaku-text">{bullet.text}</span>
144 </span>
145 ))}
146 </div>
147 )
148}
Added view/app/src/domains/chat/ui/voice/voice-device-sheet.tsx view/app/src/domains/chat/ui/voice/voice-device-sheet.tsx +229 −0 View view/app/src/domains/chat/ui/voice/voice-device-sheet.tsx
@@ -0,0 +1,229 @@
1/**
2 * Pre-join device sheet.
3 *
4 * Deliberately not the official full-screen `PreJoin`: a GitData voice channel
5 * is opened from the sidebar and the member should stay inside the channel. The
6 * sheet still uses the official building blocks - `usePreviewTracks`,
7 * `useMediaDevices` and the same `usePersistentUserChoices` store that the
8 * in-call device menus write to - so there is exactly one remembered device set.
9 *
10 * Enumerating devices *with labels* needs a capture permission, so every picker
11 * is its own component: a Stage audience only sees the loudspeaker, and joining
12 * to listen never opens a microphone prompt.
13 */
14
15import { useT } from '@app/i18n'
16import { useMediaDevices, usePersistentUserChoices, usePreviewTracks, useTrackVolume } from '@app/vendor/livekit'
17import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
18import { Loader2, Mic, Video, Volume2 } from 'lucide-react'
19import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
20import { readOutputDevice, rememberOutputDevice } from './voice-devices'
21
22export interface VoiceJoinChoice {
23 /** Voice channels open the mic; Stage audiences join in listen-only mode. */
24 startWithMic: boolean
25 audioDeviceId: string | null
26 videoDeviceId: string | null
27 audioOutputDeviceId: string | null
28}
29
30export interface VoiceDeviceSheetProps {
31 open: boolean
32 busy: boolean
33 /** Stage channels cannot open the mic on join, so no capture is requested. */
34 allowMicrophone: boolean
35 /** What the "join with microphone" checkbox starts from (the footer toggle). */
36 defaultMicOn?: boolean
37 error: string | null
38 onCancel: () => void
39 onConfirm: (choice: VoiceJoinChoice) => void
40}
41
42/** The official store keeps `default` for "whatever the browser prefers". */
43const normalize = (value: string | undefined): string | null => (!value || value === 'default' ? null : value)
44
45function DeviceSelect({
46 id,
47 kind,
48 icon,
49 label,
50 value,
51 emptyLabel,
52 onChange,
53}: {
54 id: string
55 kind: MediaDeviceKind
56 icon: ReactNode
57 label: string
58 value: string | null
59 emptyLabel: string
60 onChange: (deviceId: string | null) => void
61}) {
62 const { t } = useT()
63 const devices = useMediaDevices({ kind })
64 // A device that disappeared (headset unplugged between two calls) must not
65 // block the join, so the effective selection is derived, not stored.
66 const effective = value && devices.some((device) => device.deviceId === value) ? value : null
67 return (
68 <label className="voice-sheet-row" htmlFor={id}>
69 <span className="voice-sheet-caption">
70 {icon} {label}
71 </span>
72 <select id={id} value={effective ?? ''} onChange={(event) => onChange(event.target.value || null)}>
73 <option value="">{t('chat.voice.deviceSystemDefault')}</option>
74 {devices.map((device) => (
75 <option key={device.deviceId} value={device.deviceId}>
76 {device.label || device.deviceId}
77 </option>
78 ))}
79 {devices.length === 0 ? (
80 <option value="" disabled>
81 {emptyLabel}
82 </option>
83 ) : null}
84 </select>
85 </label>
86 )
87}
88
89export function VoiceDeviceSheet({
90 open,
91 busy,
92 allowMicrophone,
93 defaultMicOn = true,
94 error,
95 onCancel,
96 onConfirm,
97}: VoiceDeviceSheetProps) {
98 const { t } = useT()
99 const { userChoices, saveAudioInputDeviceId, saveVideoInputDeviceId } = usePersistentUserChoices()
100 const [audioDeviceId, setAudioDeviceId] = useState<string | null>(() => normalize(userChoices.audioDeviceId))
101 const [videoDeviceId, setVideoDeviceId] = useState<string | null>(() => normalize(userChoices.videoDeviceId))
102 const [outputDeviceId, setOutputDeviceId] = useState<string | null>(readOutputDevice)
103 const [micOn, setMicOn] = useState(allowMicrophone && defaultMicOn)
104 const [previewError, setPreviewError] = useState<string | null>(null)
105 const videoRef = useRef<HTMLVideoElement | null>(null)
106
107 const requestAudio = open && allowMicrophone && micOn
108 const requestVideo = open && allowMicrophone && Boolean(videoDeviceId)
109 const tracks = usePreviewTracks(
110 {
111 audio: requestAudio ? { deviceId: audioDeviceId ?? undefined } : false,
112 video: requestVideo ? { deviceId: videoDeviceId ?? undefined } : false,
113 },
114 (cause: Error) => setPreviewError(cause.message),
115 )
116
117 const micTrack = useMemo(
118 () => tracks?.find((track): track is LocalAudioTrack => track instanceof LocalAudioTrack),
119 [tracks],
120 )
121 const cameraTrack = useMemo(
122 () => tracks?.find((track): track is LocalVideoTrack => track instanceof LocalVideoTrack),
123 [tracks],
124 )
125 const level = useTrackVolume(micTrack)
126
127 useEffect(() => {
128 const element = videoRef.current
129 if (!element || !cameraTrack) return
130 cameraTrack.attach(element)
131 return () => {
132 cameraTrack.detach(element)
133 }
134 }, [cameraTrack])
135
136 if (!open) return null
137
138 const confirm = () => {
139 if (allowMicrophone) {
140 saveAudioInputDeviceId(audioDeviceId ?? 'default')
141 saveVideoInputDeviceId(videoDeviceId ?? 'default')
142 }
143 rememberOutputDevice(outputDeviceId)
144 onConfirm({
145 startWithMic: allowMicrophone && micOn,
146 audioDeviceId: allowMicrophone ? audioDeviceId : null,
147 videoDeviceId: allowMicrophone ? videoDeviceId : null,
148 audioOutputDeviceId: outputDeviceId,
149 })
150 }
151
152 const notice = previewError ?? error
153
154 return (
155 <div className="voice-sheet-backdrop" role="presentation" onClick={busy ? undefined : onCancel}>
156 <div
157 className="voice-sheet"
158 role="dialog"
159 aria-modal="true"
160 aria-label={t('chat.voice.deviceTitle')}
161 onClick={(event) => event.stopPropagation()}
162 >
163 <h3 className="voice-sheet-title">{t('chat.voice.deviceTitle')}</h3>
164 <p className="voice-sheet-hint">{t('chat.voice.deviceHint')}</p>
165
166 {allowMicrophone ? (
167 <>
168 <DeviceSelect
169 id="voice-sheet-mic"
170 kind="audioinput"
171 icon={<Mic className="h-3.5 w-3.5" aria-hidden />}
172 label={t('chat.voice.mic')}
173 value={audioDeviceId}
174 emptyLabel={t('chat.voice.deviceNoMic')}
175 onChange={setAudioDeviceId}
176 />
177 <div className="voice-sheet-level" aria-hidden>
178 <span className="voice-sheet-level-bar" style={{ transform: 'scaleX(' + Math.min(1, level * 3) + ')' }} />
179 </div>
180 <p className="voice-sheet-caption">{t('chat.voice.micTest')}</p>
181 <DeviceSelect
182 id="voice-sheet-camera"
183 kind="videoinput"
184 icon={<Video className="h-3.5 w-3.5" aria-hidden />}
185 label={t('chat.voice.camera')}
186 value={videoDeviceId}
187 emptyLabel={t('chat.voice.deviceNoCamera')}
188 onChange={setVideoDeviceId}
189 />
190 {cameraTrack ? <video ref={videoRef} className="voice-sheet-preview" autoPlay playsInline muted /> : null}
191 <label className="voice-sheet-check">
192 <input type="checkbox" checked={micOn} onChange={(event) => setMicOn(event.target.checked)} />
193 <span>{t('chat.voice.joinWithMic')}</span>
194 </label>
195 </>
196 ) : (
197 <p className="voice-sheet-caption">{t('chat.voice.noPublish')}</p>
198 )}
199
200 <DeviceSelect
201 id="voice-sheet-speaker"
202 kind="audiooutput"
203 icon={<Volume2 className="h-3.5 w-3.5" aria-hidden />}
204 label={t('chat.voice.speaker')}
205 value={outputDeviceId}
206 emptyLabel={t('chat.voice.deviceNoOutput')}
207 onChange={setOutputDeviceId}
208 />
209
210 {notice ? <p className="voice-sheet-notice">{notice}</p> : null}
211
212 <div className="voice-sheet-actions">
213 <button type="button" className="voice-ghost-button" onClick={onCancel} disabled={busy}>
214 {t('common.action.cancel')}
215 </button>
216 <button type="button" className="voice-primary-button" onClick={confirm} disabled={busy}>
217 {busy ? (
218 <>
219 <Loader2 className="h-4 w-4 animate-spin" /> {t('chat.voice.joining')}
220 </>
221 ) : (
222 t('chat.voice.deviceJoin')
223 )}
224 </button>
225 </div>
226 </div>
227 </div>
228 )
229}
Added view/app/src/domains/chat/ui/voice/voice-devices.ts view/app/src/domains/chat/ui/voice/voice-devices.ts +26 −0 View view/app/src/domains/chat/ui/voice/voice-devices.ts
@@ -0,0 +1,26 @@
1/**
2 * Output-device memory.
3 *
4 * Microphone/camera choices are persisted through the official
5 * `usePersistentUserChoices` store so the vendored `ControlBar` device menus and
6 * the pre-join sheet agree. The loudspeaker route is not part of that store
7 * (`LocalUserChoices` has no output field), so it gets its own key here.
8 */
9const OUTPUT_DEVICE_KEY = 'gitdata.voice.outputDeviceId'
10
11export function readOutputDevice(): string | null {
12 try {
13 return localStorage.getItem(OUTPUT_DEVICE_KEY)
14 } catch {
15 return null
16 }
17}
18
19export function rememberOutputDevice(deviceId: string | null): void {
20 try {
21 if (deviceId) localStorage.setItem(OUTPUT_DEVICE_KEY, deviceId)
22 else localStorage.removeItem(OUTPUT_DEVICE_KEY)
23 } catch {
24 // Storage is a convenience here, never a requirement.
25 }
26}
Added view/app/src/domains/chat/ui/voice/voice-format.ts view/app/src/domains/chat/ui/voice/voice-format.ts +8 −0 View view/app/src/domains/chat/ui/voice/voice-format.ts
@@ -0,0 +1,8 @@
1/** Small display helpers shared by the voice surfaces. */
2
3/** Two-letter monogram for a member who has no avatar and no camera. */
4export function initials(name: string | undefined | null): string {
5 const trimmed = (name ?? '').trim()
6 if (!trimmed) return '?'
7 return [...trimmed].slice(0, 2).join('').toUpperCase()
8}
Added view/app/src/domains/chat/ui/voice/voice-room-panel.tsx view/app/src/domains/chat/ui/voice/voice-room-panel.tsx +548 −0 View view/app/src/domains/chat/ui/voice/voice-room-panel.tsx
@@ -0,0 +1,548 @@
1/**
2 * The in-room voice surface.
3 *
4 * It is the orchestrator only: the windows come from `VoiceTileBoard`, the media
5 * controls from the vendored `ControlBar`, the device plumbing from
6 * `useVoiceTileLayout` + `VoiceDeviceSheet`. The connection itself belongs to
7 * `voiceSession`, so leaving this page can never hang up the call.
8 *
9 * Everything the old hand-rolled version had to guess about is read from the
10 * Room instead: who is speaking, whose mic is off, what is shared and by whom.
11 */
12
13import type { ChatMessage } from '@app/domains/chat/model'
14import {
15 approveVoiceHand,
16 cancelVoiceHand,
17 denyVoiceHand,
18 kickVoiceParticipant,
19 listVoiceHands,
20 moderateVoiceMute,
21 raiseVoiceHand,
22 revokeVoiceHand,
23 type VoiceHands,
24 type VoiceParticipant,
25} from '@app/domains/chat/model'
26import { useT } from '@app/i18n'
27import { useWsFrames } from '@app/services/realtime'
28import { useVoiceSession, VoiceProvider, voiceSession } from '@app/services/voice'
29import { ControlBar, MediaDeviceSelect, usePersistentUserChoices } from '@app/vendor/livekit'
30import { AlertCircle, Hand, Loader2, MessageSquare, PhoneCall, Settings2, ShieldCheck, Volume2 } from 'lucide-react'
31import { useCallback, useEffect, useMemo, useState } from 'react'
32import { joinVoiceRoom } from '../../model/voice-connection'
33import { VoiceDanmaku } from './voice-danmaku'
34import { VoiceDanmakuHistory } from './voice-danmaku-history'
35import { VoiceDeviceSheet, type VoiceJoinChoice } from './voice-device-sheet'
36import { readOutputDevice, rememberOutputDevice } from './voice-devices'
37import { useVoiceTileLayout } from './voice-tile-layout'
38import { VoiceTileBoard } from './voice-tiles'
39
40export interface VoiceRoomPanelProps {
41 roomId: string
42 roomName: string
43 spaceName: string
44 isStage: boolean
45 /** Server-published participants (webhook backed): presence for people who are
46 * not on this page, plus the moderator-mute flags. */
47 participants: VoiceParticipant[]
48 /** Room transcript, reused as the bullet-comment feed. */
49 danmakuMessages: ChatMessage[]
50 danmakuSeeded: boolean
51 danmakuHasOlder: boolean
52 onLoadOlderDanmaku: () => void
53 nameOf: (userId: number) => string
54 viewerId?: number
55 canModerate: boolean
56}
57
58const DANMAKU_KEY = 'gitdata.voice.danmaku'
59
60function readDanmakuPreference(): boolean {
61 try {
62 return localStorage.getItem(DANMAKU_KEY) !== 'off'
63 } catch {
64 return true
65 }
66}
67
68function toggleDanmakuPreference(next: boolean): void {
69 try {
70 localStorage.setItem(DANMAKU_KEY, next ? 'on' : 'off')
71 } catch {
72 // A preference that cannot persist is not worth failing the call over.
73 }
74}
75
76async function microphoneAlreadyGranted(): Promise<boolean> {
77 try {
78 const status = await navigator.permissions.query({ name: 'microphone' as PermissionName })
79 return status.state === 'granted'
80 } catch {
81 return false
82 }
83}
84
85/**
86 * Loudspeaker picker for the settings popover.
87 *
88 * Microphone and camera live in the official device menus attached to the
89 * control-bar toggles; the output route has no equivalent there. The select
90 * applies it through the room, which is what the audio elements owned by
91 * RoomAudioRenderer then follow.
92 */
93function VoiceOutputSelect() {
94 const { t } = useT()
95 return (
96 <div className="voice-settings">
97 <p className="voice-settings-caption">
98 <Volume2 className="h-3.5 w-3.5" aria-hidden /> {t('chat.voice.speaker')}
99 </p>
100 <MediaDeviceSelect kind="audiooutput" onActiveDeviceChange={(deviceId) => rememberOutputDevice(deviceId)} />
101 </div>
102 )
103}
104
105export function VoiceRoomPanel({
106 roomId,
107 roomName,
108 spaceName,
109 isStage,
110 participants,
111 danmakuMessages,
112 danmakuSeeded,
113 danmakuHasOlder,
114 onLoadOlderDanmaku,
115 nameOf,
116 viewerId,
117 canModerate,
118}: VoiceRoomPanelProps) {
119 const { t } = useT()
120 const session = useVoiceSession()
121 const joined = session.roomId === roomId && session.status !== 'idle'
122 /**
123 * A connection that gave up keeps the stored room (so the banner can offer a
124 * rejoin) but must fall back to the join card: an empty stage with nobody to
125 * hang up on reads as a broken page.
126 */
127 const inCall = joined && session.status !== 'failed'
128 const tileLayout = useVoiceTileLayout(roomId)
129 // The same store the control-bar device menus write to: one remembered set.
130 const { userChoices } = usePersistentUserChoices()
131 const [hands, setHands] = useState<VoiceHands | null>(null)
132 const [joining, setJoining] = useState(false)
133 const [error, setError] = useState<string | null>(null)
134 const [sheetOpen, setSheetOpen] = useState(false)
135 const [settingsOpen, setSettingsOpen] = useState(false)
136 const [danmakuOn, setDanmakuOn] = useState(readDanmakuPreference)
137 const [historyOpen, setHistoryOpen] = useState(false)
138
139 const refreshHands = useCallback(async () => {
140 if (!isStage) return
141 try {
142 const next = await listVoiceHands(roomId)
143 setHands(next)
144 voiceSession.markHandRaised(next.my_state === 'pending' || next.my_state === 'approved')
145 } catch {
146 // A failed hand list must not break the call surface.
147 }
148 }, [isStage, roomId])
149
150 // The queue and the shared layout are both pushed over the chat socket, so no
151 // timer is needed: one event, one refetch.
152 useWsFrames((frame) => {
153 if (!('room_id' in frame) || frame.room_id !== roomId) return
154 if (frame.type !== 'event' || !frame.event?.kind) return
155 if (frame.event.kind === 'voice_hands') void refreshHands()
156 if (frame.event.kind === 'voice_layout') void tileLayout.pullRemote()
157 })
158
159 useEffect(() => {
160 void refreshHands()
161 void tileLayout.pullRemote()
162 // The layout hook is stable per room; re-pull when the channel changes.
163 // eslint-disable-next-line react-hooks/exhaustive-deps
164 }, [refreshHands, roomId, session.status])
165
166 /** Avatar per user id: the presence snapshot is the only place carrying it. */
167 const avatarUrls = useMemo(
168 () => new Map(participants.map((participant) => [String(participant.user_id), participant.avatar_url ?? null])),
169 [participants],
170 )
171
172 const mutedIds = useMemo(
173 () =>
174 new Set(
175 participants
176 .filter((participant) => participant.moderator_muted)
177 .map((participant) => String(participant.user_id)),
178 ),
179 [participants],
180 )
181
182 const start = useCallback(
183 async (choice: VoiceJoinChoice) => {
184 setJoining(true)
185 setError(null)
186 try {
187 await joinVoiceRoom({
188 roomId,
189 roomLabel: roomName,
190 spaceName,
191 ...choice,
192 })
193 await voiceSession.resumeAudio()
194 await refreshHands()
195 setSheetOpen(false)
196 } catch (cause) {
197 setError(cause instanceof Error ? cause.message : t('chat.voice.joinFailed'))
198 setSheetOpen(false)
199 } finally {
200 setJoining(false)
201 }
202 },
203 [roomId, roomName, spaceName, refreshHands, t],
204 )
205
206 /**
207 * Fast path: a member who has joined before and already granted the
208 * microphone goes straight in. Otherwise the sheet runs first - it is the one
209 * place the browser is asked for permission, and its preview is what makes
210 * the browser reveal its local media candidates before the connect.
211 */
212 const onJoinClick = useCallback(async () => {
213 const asRemembered = (value: string | undefined) => (!value || value === 'default' ? null : value)
214 // First call in this browser, a Stage channel, or a browser that has never
215 // seen the microphone: the sheet runs first, so permission and the local
216 // media candidates are settled before the connect.
217 if (isStage || !userChoices.audioDeviceId || !(await microphoneAlreadyGranted())) {
218 setSheetOpen(true)
219 return
220 }
221 await start({
222 // The footer's microphone toggle is the remembered default; the sheet is
223 // where an explicit per-join choice is made.
224 startWithMic: !session.micMuted,
225 audioDeviceId: asRemembered(userChoices.audioDeviceId),
226 videoDeviceId: asRemembered(userChoices.videoDeviceId),
227 audioOutputDeviceId: readOutputDevice(),
228 })
229 }, [isStage, session.micMuted, start, userChoices.audioDeviceId, userChoices.videoDeviceId])
230 const onRaiseHand = useCallback(async () => {
231 setError(null)
232 try {
233 await raiseVoiceHand(roomId)
234 await refreshHands()
235 } catch (cause) {
236 setError(cause instanceof Error ? cause.message : t('chat.voice.handFailed'))
237 }
238 }, [roomId, refreshHands, t])
239
240 const onCancelHand = useCallback(async () => {
241 setError(null)
242 try {
243 await cancelVoiceHand(roomId)
244 await refreshHands()
245 } catch (cause) {
246 setError(cause instanceof Error ? cause.message : t('chat.voice.handCancelFailed'))
247 }
248 }, [roomId, refreshHands, t])
249
250 const decide = useCallback(
251 async (userId: number, action: 'approve' | 'deny' | 'revoke') => {
252 setError(null)
253 try {
254 if (action === 'approve') await approveVoiceHand(roomId, userId)
255 else if (action === 'deny') await denyVoiceHand(roomId, userId)
256 else await revokeVoiceHand(roomId, userId)
257 await refreshHands()
258 } catch (cause) {
259 setError(cause instanceof Error ? cause.message : t('chat.voice.handDecideFailed'))
260 }
261 },
262 [roomId, refreshHands, t],
263 )
264
265 const onMute = useCallback(
266 (userId: number, muted: boolean) => {
267 setError(null)
268 void moderateVoiceMute(roomId, userId, muted)
269 .then(() => refreshHands())
270 .catch((cause) => setError(cause instanceof Error ? cause.message : t('chat.voice.handDecideFailed')))
271 },
272 [roomId, refreshHands, t],
273 )
274
275 const onKick = useCallback(
276 (userId: number) => {
277 setError(null)
278 void kickVoiceParticipant(roomId, userId).catch((cause) =>
279 setError(cause instanceof Error ? cause.message : t('chat.voice.handDecideFailed')),
280 )
281 },
282 [roomId, t],
283 )
284
285 const myHand = hands?.my_state
286 const pendingHands = (hands?.hands ?? []).filter((hand) => hand.state === 'pending')
287 const approvedHands = (hands?.hands ?? []).filter((hand) => hand.state === 'approved')
288 // An empty moderation queue is not a panel worth reserving space for; the
289 // audience keeps theirs because it holds the raise-hand action itself.
290 const showHands = isStage && (!canModerate || pendingHands.length > 0 || approvedHands.length > 0)
291
292 // Keyboard: M / V toggle the local mic and camera while focus is not inside a
293 // control, so a member can mute without hunting for the button.
294 useEffect(() => {
295 if (!inCall) return
296 const onKey = (event: KeyboardEvent) => {
297 if (event.metaKey || event.ctrlKey || event.altKey) return
298 const target = event.target
299 if (target instanceof HTMLElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
300 if (event.key === 'm' || event.key === 'M') void voiceSession.setMicrophone(!session.micEnabled)
301 if (event.key === 'v' || event.key === 'V') void voiceSession.setCamera(!session.cameraEnabled)
302 if (event.key === 'Escape') {
303 setSheetOpen(false)
304 setSettingsOpen(false)
305 }
306 }
307 window.addEventListener('keydown', onKey)
308 return () => window.removeEventListener('keydown', onKey)
309 }, [inCall, session.micEnabled, session.cameraEnabled])
310
311 return (
312 <div className="voice-room flex min-h-0 flex-1 flex-col" data-lk-theme="default">
313 {session.notice || error ? (
314 <div className="voice-banners">
315 {session.notice ? (
316 <div className="voice-banner" role="status">
317 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
318 <span className="min-w-0 truncate">{session.notice}</span>
319 {session.needsManualRejoin ? (
320 <button type="button" onClick={() => void voiceSession.rejoin()}>
321 {t('chat.voice.rejoin')}
322 </button>
323 ) : null}
324 <button type="button" onClick={() => voiceSession.clearNotice()}>
325 {t('common.action.ignore')}
326 </button>
327 </div>
328 ) : null}
329 {error ? (
330 <div className="voice-banner" role="alert">
331 <AlertCircle className="h-3.5 w-3.5 shrink-0" />
332 <span className="min-w-0 truncate">{error}</span>
333 <button type="button" onClick={() => setError(null)}>
334 {t('common.action.ignore')}
335 </button>
336 </div>
337 ) : null}
338 {session.noticeDetail ? (
339 <details className="voice-debug">
340 <summary>{t('chat.voice.debugDetails')}</summary>
341 <code>{session.noticeDetail}</code>
342 </details>
343 ) : null}
344 </div>
345 ) : null}
346 <div className="voice-call-body">
347 {danmakuOn ? <VoiceDanmaku messages={danmakuMessages} seeded={danmakuSeeded} nameOf={nameOf} /> : null}
348
349 {!inCall ? (
350 <div className="voice-join">
351 <div className="voice-join-icon">
352 <PhoneCall className="h-6 w-6" />
353 </div>
354 <p className="voice-join-title">{roomName}</p>
355 <p className="voice-join-hint">{isStage ? t('chat.voice.stageHint') : t('chat.voice.joinHint')}</p>
356 {participants.length > 0 ? (
357 <p className="voice-join-members">{t('chat.voice.membersCount', { count: participants.length })}</p>
358 ) : null}
359 {isStage && myHand === 'pending' ? (
360 <div className="flex flex-col items-center gap-2">
361 <span className="voice-hand-pending">
362 <Hand className="h-3.5 w-3.5" /> {t('chat.voice.handPending')}
363 </span>
364 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
365 {t('chat.voice.cancelHand')}
366 </button>
367 </div>
368 ) : null}
369 <button
370 type="button"
371 className="voice-primary-button"
372 disabled={joining || session.status === 'connecting'}
373 onClick={() => void onJoinClick()}
374 >
375 {joining || session.status === 'connecting' ? (
376 <>
377 <Loader2 className="h-4 w-4 animate-spin" /> {t('chat.voice.joining')}
378 </>
379 ) : session.status === 'failed' ? (
380 t('chat.voice.rejoin')
381 ) : (
382 t('chat.voice.join')
383 )}
384 </button>
385 <button type="button" className="voice-ghost-button" disabled={joining} onClick={() => setSheetOpen(true)}>
386 {t('chat.voice.deviceTitle')}
387 </button>
388 </div>
389 ) : (
390 <VoiceProvider>
391 <div className="voice-call-stack">
392 <VoiceTileBoard
393 roomId={roomId}
394 viewerId={viewerId}
395 canModerate={canModerate}
396 layout={tileLayout}
397 mutedIds={mutedIds}
398 avatarUrls={avatarUrls}
399 onMute={onMute}
400 onKick={onKick}
401 />
402
403 {showHands ? (
404 <div className="voice-hands">
405 <div className="voice-hands-head">
406 <Hand className="h-4 w-4" />
407 <span>{t('chat.voice.hands')}</span>
408 {!canModerate ? (
409 myHand === 'approved' ? (
410 <span className="voice-badge ok">
411 <ShieldCheck className="h-3 w-3" /> {t('chat.voice.canSpeak')}
412 </span>
413 ) : session.hasRaisedHand || myHand === 'pending' ? (
414 <button type="button" className="voice-ghost-button" onClick={onCancelHand}>
415 {t('chat.voice.cancelHand')}
416 </button>
417 ) : (
418 <button type="button" className="voice-ghost-button" onClick={onRaiseHand}>
419 {t('chat.voice.raiseHand')}
420 </button>
421 )
422 ) : (
423 <span className="voice-badge">
424 {t('chat.voice.pendingCount', { count: pendingHands.length })}
425 </span>
426 )}
427 </div>
428 {canModerate ? (
429 pendingHands.length === 0 ? (
430 <p className="voice-hands-empty">{t('chat.voice.noHands')}</p>
431 ) : (
432 <ul className="voice-hands-list">
433 {pendingHands.map((hand) => (
434 <li key={hand.user_id}>
435 <span className="truncate">{hand.display_name}</span>
436 <span className="voice-hands-actions">
437 <button
438 type="button"
439 className="voice-approve"
440 onClick={() => void decide(hand.user_id, 'approve')}
441 >
442 {t('common.action.approve')}
443 </button>
444 <button
445 type="button"
446 className="voice-deny"
447 onClick={() => void decide(hand.user_id, 'deny')}
448 >
449 {t('common.action.reject')}
450 </button>
451 </span>
452 </li>
453 ))}
454 </ul>
455 )
456 ) : null}
457 {canModerate && approvedHands.length > 0 ? (
458 <ul className="voice-hands-list">
459 {approvedHands.map((hand) => (
460 <li key={hand.user_id}>
461 <span className="truncate">{t('chat.voice.speaking', { name: hand.display_name })}</span>
462 <span className="voice-hands-actions">
463 <button
464 type="button"
465 className="voice-deny"
466 onClick={() => void decide(hand.user_id, 'revoke')}
467 >
468 {t('chat.voice.revoke')}
469 </button>
470 </span>
471 </li>
472 ))}
473 </ul>
474 ) : null}
475 </div>
476 ) : null}
477
478 {historyOpen ? (
479 <VoiceDanmakuHistory
480 messages={danmakuMessages}
481 nameOf={nameOf}
482 hasOlder={danmakuHasOlder}
483 onLoadOlder={onLoadOlderDanmaku}
484 flying={danmakuOn}
485 onFlyingChange={(next) => {
486 setDanmakuOn(next)
487 toggleDanmakuPreference(next)
488 }}
489 onClose={() => setHistoryOpen(false)}
490 />
491 ) : null}
492
493 {settingsOpen ? (
494 <div className="voice-settings-anchor">
495 <VoiceOutputSelect />
496 </div>
497 ) : null}
498
499 <ControlBar
500 className="voice-control-bar"
501 saveUserChoices={false}
502 controls={{
503 microphone: true,
504 camera: true,
505 screenShare: true,
506 chat: false,
507 settings: false,
508 leave: true,
509 }}
510 />
511 <div className="voice-controls">
512 <button
513 type="button"
514 className={['voice-control', settingsOpen ? 'is-active' : ''].join(' ')}
515 title={t('livekit.settings')}
516 aria-expanded={settingsOpen}
517 onClick={() => setSettingsOpen((value) => !value)}
518 >
519 <Settings2 className="h-4 w-4" />
520 </button>
521 <button
522 type="button"
523 data-danmaku-toggle="true"
524 className={['voice-control', historyOpen ? 'is-active' : ''].join(' ')}
525 title={t('chat.danmaku.title')}
526 aria-expanded={historyOpen}
527 onClick={() => setHistoryOpen((value) => !value)}
528 >
529 <MessageSquare className="h-4 w-4" />
530 </button>
531 </div>
532 </div>
533 </VoiceProvider>
534 )}
535
536 <VoiceDeviceSheet
537 open={sheetOpen}
538 busy={joining}
539 allowMicrophone={!isStage}
540 defaultMicOn={!session.micMuted}
541 error={error}
542 onCancel={() => setSheetOpen(false)}
543 onConfirm={(choice) => void start(choice)}
544 />
545 </div>
546 </div>
547 )
548}
Added view/app/src/domains/chat/ui/voice/voice-tile-layout.ts view/app/src/domains/chat/ui/voice/voice-tile-layout.ts +678 −0 View view/app/src/domains/chat/ui/voice/voice-tile-layout.ts
@@ -0,0 +1,678 @@
1/**
2 * Shared participant-window layout.
3 *
4 * Coordinates are normalized against the free span of the tile canvas (0..1)
5 * instead of pixels, so one placement stays inside the bounds for every member
6 * on every screen size. The layout is a *shared* artefact: it is stored per
7 * channel on the server and broadcast over the chat socket, so the room looks
8 * the same for everyone.
9 *
10 * Concurrency rules (this is what used to make windows jump under a second
11 * drag):
12 * - a write carries exactly one tile - the one the local user moved - and the
13 * server merges per key, so two people dragging different windows can never
14 * erase each other;
15 * - every tile stores the server's write stamp, and a snapshot only replaces a
16 * local tile when it is at least as new, which keeps an in-flight GET from
17 * rewinding a window the user just parked;
18 * - while a window is under the pointer it is frozen: no remote value is merged
19 * into the key being dragged;
20 * - a *remote* move is not drawn the moment it arrives: snapshots are collected
21 * and applied on a fixed cadence, and the window glides to each new placement
22 * (see REMOTE_APPLY_MS). Drawing every snapshot as it lands is what made a
23 * member watching somebody else drag see the window teleport once per push.
24 */
25
26import {
27 getVoiceLayout,
28 removeVoiceLayoutTiles,
29 saveVoiceLayoutTiles,
30 type VoiceLayoutTiles,
31 type VoiceTileWrite,
32} from '@app/domains/chat/model'
33import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'
34
35export interface TilePlacement {
36 /** 0 -> glued to the left edge, 1 -> glued to the right edge. */
37 x: number
38 /** 0 -> glued to the top edge, 1 -> glued to the bottom edge. */
39 y: number
40 /** 0 -> narrowest window, 1 -> widest window. */
41 w: number
42 /**
43 * 0 -> shortest window, 1 -> tallest window. Independent of `w`, so a window can
44 * be stretched on one axis without the other. `undefined` marks a record written
45 * before the height existed: those keep the classic 3:4 shape (see `heightFor`).
46 */
47 h?: number
48}
49
50export interface PlacedTile extends TilePlacement {
51 /** Server write stamp in seconds; 0 for a placement that was never pushed. */
52 updatedAt: number
53}
54
55interface TileBox {
56 left: number
57 top: number
58 width: number
59 height: number
60}
61
62interface TileGesture {
63 mode: 'move' | 'resize'
64 key: string
65 origin: TileBox
66 startX: number
67 startY: number
68 /** The box under the pointer, painted the moment it changes. */
69 target: TileBox
70 el: HTMLElement
71}
72
73interface CanvasBox {
74 width: number
75 height: number
76}
77
78const LAYOUT_PREFIX = 'gitdata.voice.tiles.'
79const TILE_MIN_WIDTH = 140
80const TILE_MAX_WIDTH = 640
81const TILE_MIN_HEIGHT = 140
82const TILE_MAX_HEIGHT = 760
83const TILE_SNAP = 4
84/**
85 * 没有 `h` 的旧布局沿用这个高度系数。旧代码里窗口的高度是 CSS 的
86 * `aspect-ratio: 3 / 4` 定出来的,也就是 width : height = 3 : 4 的竖屏,
87 * 所以 height = width * 4/3。
88 */
89const TILE_LEGACY_ASPECT = 4 / 3
90/** Mirrors the padding of the tile canvas: windows never cross it. */
91const TILE_PAD_X = 16
92const TILE_PAD_Y = 18
93/** Shared-layout write throttle, so a drag stays live without flooding. */
94const LAYOUT_PUSH_MS = 140
95/**
96 * Cadence for drawing *somebody else's* move. A drag reaches this tab as a burst
97 * of snapshots ~140ms apart, one per server write; applying each one would step
98 * the window across the canvas. Instead the newest snapshot is held and applied
99 * on this cadence, and the window is tweened across the gap (see
100 * [`REMOTE_GLIDE_MS`]) — the move then trails the person dragging it by about
101 * this much and travels continuously rather than in jumps.
102 */
103const REMOTE_APPLY_MS = 500
104/**
105 * Length of one remote glide. Interpolation lives here, in JavaScript, because
106 * the browser this runs in reports `prefers-reduced-motion: reduce` — which kills
107 * a CSS transition and left other people's windows hopping from sample to sample.
108 * Matching the apply cadence makes one update flow into the next.
109 */
110const REMOTE_GLIDE_MS = 460
111
112const clamp01 = (value: number) => (Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0)
113
114/**
115 * Paint one pixel box on the window element. The local gesture writes here
116 * instead of going through React state: the board is not re-rendered per pointer
117 * event, and the window stays glued to the cursor. Interpolation is reserved for
118 * *remote* moves (see REMOTE_APPLY_MS and the glide transition on
119 * `.voice-window.is-floating`), where the missing frames are the ones this tab
120 * never sees.
121 */
122function paintBox(el: HTMLElement, box: TileBox): void {
123 el.style.left = box.left + 'px'
124 el.style.top = box.top + 'px'
125 el.style.width = box.width + 'px'
126 el.style.height = box.height + 'px'
127}
128
129/**
130 * Per-axis spans of the canvas: the pixels a window may occupy on each axis, and
131 * the range its size is normalized against. The two axes are independent — a
132 * window is free to be wide and short or narrow and tall.
133 */
134function spansOf(box: CanvasBox) {
135 const usableW = Math.max(0, box.width - TILE_PAD_X * 2)
136 const usableH = Math.max(0, box.height - TILE_PAD_Y * 2)
137 const maxW = Math.max(0, Math.min(TILE_MAX_WIDTH, usableW))
138 const maxH = Math.max(0, Math.min(TILE_MAX_HEIGHT, usableH))
139 return {
140 usableW,
141 usableH,
142 maxW,
143 maxH,
144 minW: Math.min(TILE_MIN_WIDTH, maxW),
145 minH: Math.min(TILE_MIN_HEIGHT, maxH),
146 }
147}
148
149/**
150 * Normalized placement -> pixels. Clamping to the span makes overflow impossible,
151 * and both axes are derived from their own normalized value, so a resize that only
152 * changed `w` leaves the height exactly where it was.
153 *
154 * A placement with no `h` predates the field and keeps the classic 3:4 window. The
155 * two axes are normalized against different spans, so equal normalized values are
156 * *not* the old shape — reusing `w` directly would make every parked window jump
157 * taller. Deriving the height from the width in pixels and the old ratio is what
158 * leaves an existing layout looking exactly as its owner left it.
159 */
160export function pixelsFor(box: CanvasBox, placement: TilePlacement): TileBox | null {
161 const { usableW, usableH, maxW, maxH, minW, minH } = spansOf(box)
162 if (maxW <= 0 || maxH <= 0) return null
163 const width = Math.round(minW + clamp01(placement.w) * (maxW - minW))
164 const height =
165 placement.h === undefined
166 ? Math.min(maxH, Math.round(width * TILE_LEGACY_ASPECT))
167 : Math.round(minH + clamp01(placement.h) * (maxH - minH))
168 return {
169 left: Math.round(TILE_PAD_X + clamp01(placement.x) * Math.max(0, usableW - width)),
170 top: Math.round(TILE_PAD_Y + clamp01(placement.y) * Math.max(0, usableH - height)),
171 width,
172 height,
173 }
174}
175
176function normalizedFor(box: CanvasBox, tile: TileBox): TilePlacement {
177 const { usableW, usableH, maxW, maxH, minW, minH } = spansOf(box)
178 const width = Math.min(Math.max(minW, tile.width), maxW)
179 const height = Math.min(Math.max(minH, tile.height), maxH)
180 const spanW = Math.max(1, usableW - width)
181 const spanH = Math.max(1, usableH - height)
182 return {
183 x: clamp01((tile.left - TILE_PAD_X) / spanW),
184 y: clamp01((tile.top - TILE_PAD_Y) / spanH),
185 w: maxW <= minW ? 1 : clamp01((width - minW) / (maxW - minW)),
186 h: maxH <= minH ? 1 : clamp01((height - minH) / (maxH - minH)),
187 }
188}
189
190function readLocal(roomId: string): Record<string, PlacedTile> {
191 try {
192 const raw = localStorage.getItem(LAYOUT_PREFIX + roomId)
193 if (!raw) return {}
194 const parsed = JSON.parse(raw) as Record<string, Partial<PlacedTile>>
195 const out: Record<string, PlacedTile> = {}
196 for (const [key, value] of Object.entries(parsed)) {
197 if (typeof value?.x === 'number' && typeof value?.y === 'number' && typeof value?.w === 'number') {
198 out[key] = {
199 x: clamp01(value.x),
200 y: clamp01(value.y),
201 w: clamp01(value.w),
202 h: typeof value.h === 'number' ? clamp01(value.h) : undefined,
203 updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
204 }
205 }
206 }
207 return out
208 } catch {
209 return {}
210 }
211}
212
213function writeLocal(roomId: string, layout: Record<string, PlacedTile>): void {
214 try {
215 const key = LAYOUT_PREFIX + roomId
216 if (Object.keys(layout).length === 0) localStorage.removeItem(key)
217 else localStorage.setItem(key, JSON.stringify(layout))
218 } catch {
219 // A layout that cannot persist is not worth failing the call over.
220 }
221}
222
223function toTile(placement: PlacedTile): VoiceTileWrite {
224 // A legacy placement that was only moved (never resized) keeps no height, so the
225 // write stays in the older shape and the server leaves `h` unset.
226 return { x: placement.x, y: placement.y, w: placement.w, ...(placement.h === undefined ? {} : { h: placement.h }) }
227}
228
229export interface VoiceTileLayout {
230 layout: Record<string, PlacedTile>
231 /** Callback ref for the tile canvas: the observer follows it in and out. */
232 setCanvas: (element: HTMLDivElement | null) => void
233 canvasSize: CanvasBox
234 /** Key currently under the pointer, or null. Tiles lose their placement while focused. */
235 draggingKey: string | null
236 /** Pixels for a member's window, or null when it belongs to the automatic grid. */
237 boxOf: (key: string) => TileBox | null
238 beginGesture: (event: ReactPointerEvent<HTMLElement>, key: string, mode: 'move' | 'resize') => void
239 moveGesture: (event: ReactPointerEvent<HTMLElement>, key: string) => void
240 endGesture: (key: string) => void
241 releaseTile: (key: string) => void
242 /** True right after a window was dragged, so the click can be swallowed. */
243 didDrag: (key: string) => boolean
244 /** Pull the shared layout; call it on the socket event and when members change. */
245 pullRemote: () => Promise<void>
246}
247
248export function useVoiceTileLayout(roomId: string): VoiceTileLayout {
249 const [layout, setLayout] = useState<Record<string, PlacedTile>>(() => readLocal(roomId))
250 const layoutRef = useRef(layout)
251 const draggingRef = useRef<string | null>(null)
252 const gestureRef = useRef<TileGesture | null>(null)
253 /** Keys whose last gesture moved; consumed once by `didDrag`. */
254 const movedKeys = useRef(new Set<string>())
255 const [canvasEl, setCanvasEl] = useState<HTMLDivElement | null>(null)
256 const [canvasSize, setCanvasSize] = useState<CanvasBox>({ width: 0, height: 0 })
257 const setCanvas = useCallback((element: HTMLDivElement | null) => setCanvasEl(element), [])
258 const [draggingKey, setDraggingKey] = useState<string | null>(null)
259 /** Per-key push throttle: one in-flight timer, always for the key being dragged. */
260 const pushRef = useRef<{ key: string | null; last: number; timer: number }>({
261 key: null,
262 last: 0,
263 timer: 0,
264 })
265 /** Remote placements waiting for the next application slot, and that slot's timer. */
266 const remoteRef = useRef<{ pending: Record<string, PlacedTile>; timer: number }>({
267 pending: {},
268 timer: 0,
269 })
270 /** In-flight glides, one per window: the next sample for a key supersedes it. */
271 const tweensRef = useRef(new Map<string, { raf: number; start: number }>())
272 /** The room's layout has been read once: the first pull is hydration, not a move. */
273 const hydratedRef = useRef(false)
274
275 useEffect(() => {
276 layoutRef.current = layout
277 writeLocal(roomId, layout)
278 }, [roomId, layout])
279
280 // The canvas belongs to the board, which mounts after this hook, so the
281 // observed element arrives as state rather than being read off a ref.
282 useEffect(() => {
283 const el = canvasEl
284 const measure = () =>
285 setCanvasSize((previous) =>
286 !el || (previous.width === el.clientWidth && previous.height === el.clientHeight)
287 ? previous
288 : { width: el.clientWidth, height: el.clientHeight },
289 )
290 measure()
291 if (!el) return
292 const observer = new ResizeObserver(measure)
293 observer.observe(el)
294 return () => observer.disconnect()
295 }, [canvasEl])
296
297 const commit = useCallback((next: Record<string, PlacedTile>) => {
298 layoutRef.current = next
299 setLayout(next)
300 }, [])
301
302 /** Write the single tile that changed; the server merges it into the room map. */
303 const pushKey = useCallback(
304 (key: string, final: boolean) => {
305 const flush = () => {
306 const placement = layoutRef.current[key]
307 if (!placement) return
308 pushRef.current.last = Date.now()
309 void saveVoiceLayoutTiles(roomId, { [key]: toTile(placement) })
310 .then((stamp) => {
311 if (!stamp) return
312 const current = layoutRef.current[key]
313 if (!current) return
314 const next = { ...layoutRef.current, [key]: { ...current, updatedAt: stamp } }
315 layoutRef.current = next
316 setLayout(next)
317 })
318 .catch(() => undefined)
319 }
320 const push = pushRef.current
321 if (push.timer) {
322 clearTimeout(push.timer)
323 push.timer = 0
324 }
325 // Switching keys mid-drag cannot happen (one pointer), but a stale timer
326 // for a tile the user has let go of must still fire.
327 if (push.key && push.key !== key) flush()
328 push.key = key
329 const wait = LAYOUT_PUSH_MS - (Date.now() - push.last)
330 if (final || wait <= 0) flush()
331 else
332 push.timer = window.setTimeout(() => {
333 push.timer = 0
334 flush()
335 }, wait)
336 },
337 [roomId],
338 )
339
340 /**
341 * Placements from one snapshot that this tab should adopt, or null when the
342 * snapshot agrees with what is already on screen.
343 *
344 * Two values are deliberately left alone: the key under the local pointer,
345 * and any key this tab wrote more recently than the snapshot. Both stamps come
346 * from the same server clock, so a slow GET can never rewind a window the user
347 * has just parked.
348 */
349 const placementsFrom = useCallback((tiles: VoiceLayoutTiles): Record<string, PlacedTile> | null => {
350 const adopted: Record<string, PlacedTile> = {}
351 const frozen = draggingRef.current
352 for (const [key, tile] of Object.entries(tiles)) {
353 if (key === frozen) continue
354 const local = layoutRef.current[key]
355 const updatedAt = tile.updated_at ?? 0
356 if (local && local.updatedAt > updatedAt) continue
357 // A record written before height existed (or by an older client) keeps the
358 // classic shape: its height is resolved against the canvas it renders on.
359 const placement: PlacedTile = {
360 x: clamp01(tile.x),
361 y: clamp01(tile.y),
362 w: clamp01(tile.w),
363 h: typeof tile.h === 'number' ? clamp01(tile.h) : undefined,
364 updatedAt,
365 }
366 if (
367 local &&
368 local.x === placement.x &&
369 local.y === placement.y &&
370 local.w === placement.w &&
371 local.h === placement.h
372 ) {
373 continue
374 }
375 adopted[key] = placement
376 }
377 return Object.keys(adopted).length > 0 ? adopted : null
378 }, [])
379
380 /**
381 * Glide one window from the box currently painted on its element to the box an
382 * adopted placement renders at. Only the gesture and this tween ever write
383 * pixels; React is handed the placement once the motion has landed, which is
384 * what keeps its style write from restarting the movement.
385 */
386 const glideTile = useCallback(
387 (canvas: HTMLDivElement, key: string, placement: PlacedTile) => {
388 const el = canvas.querySelector<HTMLElement>('[data-tile="' + key + '"]')
389 const running = tweensRef.current.get(key)
390 if (running) {
391 window.cancelAnimationFrame(running.raf)
392 tweensRef.current.delete(key)
393 }
394 const to = pixelsFor(canvasSize, placement)
395 // A canvas that is not measured yet, or a window still in the automatic
396 // grid, has no absolute box to glide from: land it directly.
397 if (!to || !el || !el.classList.contains('is-floating')) {
398 commit({ ...layoutRef.current, [key]: placement })
399 return
400 }
401 const canvasRect = canvas.getBoundingClientRect()
402 const rect = el.getBoundingClientRect()
403 const from: TileBox = {
404 left: rect.left - canvasRect.left + canvas.scrollLeft,
405 top: rect.top - canvasRect.top + canvas.scrollTop,
406 width: rect.width,
407 height: rect.height,
408 }
409 const tween = { raf: 0, start: 0 }
410 const frame = (now: number) => {
411 if (!tween.start) tween.start = now
412 const t = Math.min(1, (now - tween.start) / REMOTE_GLIDE_MS)
413 const ease = 1 - (1 - t) ** 3
414 paintBox(el, {
415 left: from.left + (to.left - from.left) * ease,
416 top: from.top + (to.top - from.top) * ease,
417 width: from.width + (to.width - from.width) * ease,
418 height: from.height + (to.height - from.height) * ease,
419 })
420 if (t < 1) {
421 tween.raf = window.requestAnimationFrame(frame)
422 return
423 }
424 tweensRef.current.delete(key)
425 paintBox(el, to)
426 if (el.isConnected) commit({ ...layoutRef.current, [key]: placement })
427 }
428 tween.raf = window.requestAnimationFrame(frame)
429 tweensRef.current.set(key, tween)
430 },
431 [canvasSize, commit],
432 )
433
434 /**
435 * Draw whatever the last slot collected. Reading the pending map *inside* the
436 * timer is what makes the newest snapshot win: a burst of pushes collapses
437 * into one application, so the window lands on a position its owner really
438 * had rather than on every intermediate one.
439 */
440 const flushRemote = useCallback(() => {
441 const slot = remoteRef.current
442 slot.timer = 0
443 const pending = slot.pending
444 slot.pending = {}
445 // Re-check every entry on the way out: the pointer may have landed on one of
446 // these windows while the slot was waiting, and a snapshot can have been
447 // superseded by a newer own write in the meantime.
448 const adopted = placementsFrom(
449 Object.fromEntries(
450 Object.entries(pending).map(([key, placement]) => [
451 key,
452 {
453 x: placement.x,
454 y: placement.y,
455 w: placement.w,
456 h: placement.h,
457 updated_at: placement.updatedAt,
458 },
459 ]),
460 ),
461 )
462 if (!adopted) return
463 const canvas = canvasEl
464 if (!canvas) {
465 commit({ ...layoutRef.current, ...adopted })
466 return
467 }
468 for (const [key, placement] of Object.entries(adopted)) glideTile(canvas, key, placement)
469 }, [canvasEl, commit, glideTile, placementsFrom])
470
471 const pullRemote = useCallback(async () => {
472 let tiles: VoiceLayoutTiles
473 try {
474 tiles = await getVoiceLayout(roomId)
475 } catch {
476 // No shared layout yet simply means the automatic grid.
477 return
478 }
479 // The first read of a room is its state, not a move: it has to land at once,
480 // or joining a call would show every parked window sliding in from wherever
481 // this tab last saw it. The flag is cleared by the read itself rather than by
482 // finding something to adopt — a room whose windows all sit in the automatic
483 // grid answers the first pull with nothing, and the *next* pull is then the
484 // first real move and must still glide.
485 const hydrating = !hydratedRef.current
486 hydratedRef.current = true
487 const adopted = placementsFrom(tiles)
488 if (!adopted) return
489 if (hydrating) {
490 commit({ ...layoutRef.current, ...adopted })
491 return
492 }
493 const slot = remoteRef.current
494 Object.assign(slot.pending, adopted)
495 // A slot is already waiting: it will pick this snapshot up when it fires, so
496 // the move never waits twice and a stream of pushes cannot starve it.
497 if (slot.timer) return
498 slot.timer = window.setTimeout(flushRemote, REMOTE_APPLY_MS)
499 }, [roomId, placementsFrom, commit, flushRemote])
500
501 const beginGesture = useCallback(
502 (event: ReactPointerEvent<HTMLElement>, key: string, mode: 'move' | 'resize') => {
503 const canvas = canvasEl
504 if (!canvas || event.button !== 0) return
505 // The tile menu stays clickable: grabbing a button is not grabbing the
506 // window, and pointer capture on the tile would swallow the click.
507 if (event.target instanceof Element && event.target.closest('button')) return
508 const el = canvas.querySelector<HTMLElement>('[data-tile="' + key + '"]')
509 if (!el) return
510 const canvasRect = canvas.getBoundingClientRect()
511 const tileRect = el.getBoundingClientRect()
512 const rectBox: TileBox = {
513 left: tileRect.left - canvasRect.left + canvas.scrollLeft,
514 top: tileRect.top - canvasRect.top + canvas.scrollTop,
515 width: tileRect.width,
516 height: tileRect.height,
517 }
518 const parked = layoutRef.current[key]
519 // Grabbing a window in the middle of a remote glide: stop the glide and
520 // lift from what is painted, or the first pointer-down would snap it to a
521 // target its owner already moved past.
522 const gliding = tweensRef.current.get(key)
523 if (gliding) {
524 window.cancelAnimationFrame(gliding.raf)
525 tweensRef.current.delete(key)
526 }
527 const origin = gliding || !parked ? rectBox : (pixelsFor(canvasSize, parked) ?? rectBox)
528 movedKeys.current.delete(key)
529 gestureRef.current = {
530 mode,
531 key,
532 origin,
533 startX: event.clientX,
534 startY: event.clientY,
535 target: origin,
536 el,
537 }
538 draggingRef.current = key
539 setDraggingKey(key)
540 // A grid window (or one caught mid-glide) lifts out of the flow at exactly
541 // where it was drawn, so the first pointer-down never makes it jump.
542 if (!parked || gliding) {
543 commit({
544 ...layoutRef.current,
545 [key]: { ...normalizedFor(canvasSize, rectBox), updatedAt: parked?.updatedAt ?? 0 },
546 })
547 }
548 el.setPointerCapture(event.pointerId)
549 },
550 [canvasEl, canvasSize, commit],
551 )
552
553 const moveGesture = useCallback(
554 (event: ReactPointerEvent<HTMLElement>, key: string) => {
555 const active = gestureRef.current
556 if (!active || active.key !== key) return
557 const dx = event.clientX - active.startX
558 const dy = event.clientY - active.startY
559 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) movedKeys.current.add(key)
560 // Both axes follow their own delta: the handle is a free corner resize, not
561 // a proportional scale, so dragging straight down makes the window taller
562 // without touching its width. Nothing is snapped or smoothed here: the box
563 // under the pointer is what gets painted, so the window tracks the cursor
564 // exactly. Quantizing happens once, on release.
565 const next: TileBox =
566 active.mode === 'move'
567 ? {
568 left: active.origin.left + dx,
569 top: active.origin.top + dy,
570 width: active.origin.width,
571 height: active.origin.height,
572 }
573 : {
574 ...active.origin,
575 width: active.origin.width + dx,
576 height: active.origin.height + dy,
577 }
578 active.target = next
579 paintBox(active.el, next)
580 // Ref-only write: the throttled pushes and the final commit read this.
581 // Re-rendering the board per pointer event is what used to make the drag
582 // choppy, and a re-render is the only thing interpolation could smooth —
583 // the local window needs neither.
584 layoutRef.current = {
585 ...layoutRef.current,
586 [key]: { ...normalizedFor(canvasSize, next), updatedAt: 0 },
587 }
588 pushKey(key, false)
589 },
590 [canvasSize, pushKey],
591 )
592
593 const endGesture = useCallback(
594 (key: string) => {
595 const active = gestureRef.current
596 if (!active || active.key !== key) return
597 gestureRef.current = null
598 draggingRef.current = null
599 setDraggingKey(null)
600 const snap = (value: number) => Math.round(value / TILE_SNAP) * TILE_SNAP
601 const settled: TileBox = {
602 left: snap(active.target.left),
603 top: snap(active.target.top),
604 width: snap(active.target.width),
605 height: snap(active.target.height),
606 }
607 // Paint the snapped box before React takes the style back: the commit below
608 // re-renders from state, and a raw value there would twitch the window at
609 // the very end of the drag.
610 paintBox(active.el, settled)
611 const parked = layoutRef.current[key]
612 commit({
613 ...layoutRef.current,
614 [key]: { ...normalizedFor(canvasSize, settled), updatedAt: parked?.updatedAt ?? 0 },
615 })
616 pushKey(key, true)
617 },
618 [canvasSize, commit, pushKey],
619 )
620
621 /** One-shot: true the first time it is asked after that window was dragged. */
622 const didDrag = useCallback((key: string) => movedKeys.current.delete(key), [])
623
624 const releaseTile = useCallback(
625 (key: string) => {
626 const next = { ...layoutRef.current }
627 delete next[key]
628 commit(next)
629 const push = pushRef.current
630 if (push.timer) {
631 clearTimeout(push.timer)
632 push.timer = 0
633 }
634 // Dropping a window is a write too, otherwise it resurrects on the
635 // next pull and the member cannot get rid of a parked tile.
636 void removeVoiceLayoutTiles(roomId, [key]).catch(() => undefined)
637 },
638 [roomId, commit],
639 )
640
641 const boxOf = useCallback(
642 (key: string) => {
643 const placement = layout[key]
644 return placement ? pixelsFor(canvasSize, placement) : null
645 },
646 [layout, canvasSize],
647 )
648
649 useEffect(() => {
650 const push = pushRef.current
651 const remote = remoteRef.current
652 const tweens = tweensRef.current
653 return () => {
654 if (push.timer) clearTimeout(push.timer)
655 push.timer = 0
656 push.last = 0
657 if (remote.timer) clearTimeout(remote.timer)
658 remote.timer = 0
659 remote.pending = {}
660 for (const tween of tweens.values()) window.cancelAnimationFrame(tween.raf)
661 tweens.clear()
662 }
663 }, [])
664
665 return {
666 layout,
667 setCanvas,
668 canvasSize,
669 draggingKey,
670 boxOf,
671 beginGesture,
672 moveGesture,
673 endGesture,
674 releaseTile,
675 didDrag,
676 pullRemote,
677 }
678}
Added view/app/src/domains/chat/ui/voice/voice-tiles.tsx view/app/src/domains/chat/ui/voice/voice-tiles.tsx +479 −0 View view/app/src/domains/chat/ui/voice/voice-tiles.tsx
@@ -0,0 +1,479 @@
1/**
2 * The in-call board: one window per participant, built from the vendored
3 * LiveKit components.
4 *
5 * Every piece of media state is read from the Room itself, which is what fixes
6 * the class of bugs this view used to have: the speaking ring, the "their mic
7 * is off" flag and the connection quality come from the live participant
8 * objects, not from a webhook snapshot; a screen share is rendered exactly once
9 * (as the focused window), and a second sharer no longer loses to Map iteration
10 * order because the stage holds the newest subscribed share.
11 *
12 * The one thing the official layouts do not have is GitData's shared window
13 * placement, so a placed window is lifted out of the grid and positioned by
14 * `useVoiceTileLayout`; while any window is focused, placements are ignored.
15 */
16
17import { useT } from '@app/i18n'
18import { rememberedVolume, rememberVolume } from '@app/services/voice'
19import {
20 CarouselLayout,
21 FocusLayout,
22 FocusLayoutContainer,
23 GridLayout,
24 getTrackReferenceId,
25 isTrackReference,
26 LayoutContextProvider,
27 ParticipantTile,
28 TrackMutedIndicator,
29 type TrackReferenceOrPlaceholder,
30 useCreateLayoutContext,
31 useIsMuted,
32 useLayoutContext,
33 useMaybeTrackRefContext,
34 useParticipants,
35 usePinnedTracks,
36 useRoomContext,
37 useTracks,
38} from '@app/vendor/livekit'
39import { type RemoteParticipant, RoomEvent, Track } from 'livekit-client'
40import { LogOut, Maximize2, Mic, MicOff, Minimize2, Volume2 } from 'lucide-react'
41import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
42import { VoiceAvatarTile } from './voice-avatar-tile'
43import { initials } from './voice-format'
44import type { VoiceTileLayout } from './voice-tile-layout'
45
46interface BoardProps {
47 roomId: string
48 viewerId?: number
49 canModerate: boolean
50 layout: VoiceTileLayout
51 /** Server-side moderator mutes, keyed by user id (identity === user id). */
52 mutedIds: Set<string>
53 /** Avatar per user id, from the presence snapshot (identity === user id). */
54 avatarUrls: Map<string, string | null>
55 onMute: (userId: number, muted: boolean) => void
56 onKick: (userId: number) => void
57}
58
59/** Microphone reference per member, so a camera tile can still show "muted". */
60export type MicRefIndex = Map<string, TrackReferenceOrPlaceholder>
61
62/** 新窗口的入场方向:每个成员第一次出现时随机挑一个。 */
63const ENTER_DIRECTIONS = ['left', 'right', 'top', 'bottom'] as const
64type TileEnterDirection = (typeof ENTER_DIRECTIONS)[number]
65
66function isShare(ref: TrackReferenceOrPlaceholder): boolean {
67 return ref.source === Track.Source.ScreenShare
68}
69
70/** Stable key for a share, used to remember when this tab first saw it. */
71function shareKeyOf(ref: TrackReferenceOrPlaceholder): string {
72 return isTrackReference(ref) ? ref.publication.trackSid : ref.participant.identity + ':' + String(ref.source)
73}
74
75function displayNameOf(ref: TrackReferenceOrPlaceholder): string {
76 return ref.participant.name || initials(ref.participant.identity)
77}
78
79/** Per-window actions: stage focus, this member's volume, moderation. */
80function TileMenu({
81 trackRef,
82 roomId,
83 viewerId,
84 canModerate,
85 moderatorMuted,
86 onMute,
87 onKick,
88}: {
89 trackRef: TrackReferenceOrPlaceholder
90 roomId: string
91 viewerId?: number
92 canModerate: boolean
93 moderatorMuted: boolean
94 onMute: (userId: number, muted: boolean) => void
95 onKick: (userId: number) => void
96}) {
97 const { t } = useT()
98 const room = useRoomContext()
99 const layoutContext = useLayoutContext()
100 const identity = trackRef.participant.identity
101 const pinned = usePinnedTracks(layoutContext)
102 const isPinned = pinned.some((entry) => entry.participant.identity === identity)
103 const isSelf = identity === String(viewerId ?? '')
104 const remembered = useMemo(() => rememberedVolume(roomId, identity), [roomId, identity])
105 const [volume, setVolume] = useState(remembered)
106
107 const applyVolume = (next: number) => {
108 setVolume(next)
109 const participant = room.getParticipantByIdentity(identity)
110 if (participant instanceof Object && 'setVolume' in participant) {
111 ;(participant as RemoteParticipant).setVolume(next)
112 }
113 rememberVolume(roomId, identity, next)
114 }
115
116 const name = displayNameOf(trackRef)
117
118 return (
119 <div className="voice-tile-menu" onPointerDown={(event) => event.stopPropagation()}>
120 <button
121 type="button"
122 className={isPinned ? 'is-active' : ''}
123 title={isPinned ? t('chat.voice.clearFocus') : t('chat.voice.focus', { name })}
124 onClick={() =>
125 layoutContext.pin.dispatch?.(isPinned ? { msg: 'clear_pin' } : { msg: 'set_pin', trackReference: trackRef })
126 }
127 >
128 {isPinned ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
129 </button>
130 {!isSelf ? (
131 <label className="voice-tile-volume">
132 <Volume2 className="h-3.5 w-3.5" aria-hidden />
133 <input
134 type="range"
135 min={0}
136 max={1}
137 step={0.05}
138 value={volume}
139 aria-label={t('chat.voice.volume', { name })}
140 onChange={(event) => applyVolume(Number(event.target.value))}
141 />
142 </label>
143 ) : null}
144 {canModerate && !isSelf ? (
145 <>
146 <button
147 type="button"
148 title={moderatorMuted ? t('chat.voice.forceUnmute') : t('chat.voice.forceMute')}
149 onClick={() => onMute(Number(identity) || 0, !moderatorMuted)}
150 >
151 {moderatorMuted ? <Mic className="h-3.5 w-3.5" /> : <MicOff className="h-3.5 w-3.5" />}
152 </button>
153 <button type="button" title={t('chat.voice.kick')} onClick={() => onKick(Number(identity) || 0)}>
154 <LogOut className="h-3.5 w-3.5" />
155 </button>
156 </>
157 ) : null}
158 </div>
159 )
160}
161
162/**
163 * What a window shows: live video when there is any, and the member's avatar
164 * otherwise. "Live video" is a running camera *or* a screen share — a share must
165 * keep the official tile, since an avatar in its place would hide what is being
166 * presented.
167 *
168 * This is separate from `Window` because the choice is made by a hook and
169 * `Window` returns early when it has no track reference yet — a hook after that
170 * return would be called on some renders and skipped on others.
171 */
172function WindowMedia({
173 trackRef,
174 micRef,
175 avatarUrl,
176}: {
177 trackRef: TrackReferenceOrPlaceholder
178 micRef?: TrackReferenceOrPlaceholder
179 avatarUrl?: string | null
180}) {
181 /**
182 * A camera that is switched off is only *muted*: its publication keeps
183 * `source === 'camera'`. Judging by the source alone therefore kept the window
184 * on the official camera tile, which draws the generic grey placeholder — so a
185 * member looked like a missing person until they reconnected, and nothing ever
186 * brought the avatar back. The mute flag is what tells the two apart.
187 */
188 const cameraOff = useIsMuted(trackRef)
189 const showsVideo = isShare(trackRef) || (trackRef.source === Track.Source.Camera && !cameraOff)
190 if (showsVideo) {
191 return (
192 <>
193 <ParticipantTile trackRef={trackRef} className="voice-window-tile" />
194 {micRef ? (
195 /* The official tile only marks the source it renders, so a camera
196 window still needs this member's microphone state. */
197 <TrackMutedIndicator trackRef={micRef} show="muted" className="voice-window-mic" />
198 ) : null}
199 </>
200 )
201 }
202 return <VoiceAvatarTile trackRef={trackRef} micRef={micRef} avatarUrl={avatarUrl} />
203}
204
205/**
206 * One window. Inside `GridLayout`/`CarouselLayout` the track reference comes
207 * from the loop context; floated (placed) windows are rendered directly and get
208 * it as a property. The window itself owns the frame and its gestures; what goes
209 * inside is `WindowMedia`.
210 */
211function Window({
212 trackRef: explicitRef,
213 roomId,
214 viewerId,
215 canModerate,
216 mutedIds,
217 avatarUrls,
218 onMute,
219 onKick,
220 layout,
221 micRefs,
222 enterDirOf,
223}: Omit<BoardProps, 'layout'> & {
224 layout: VoiceTileLayout
225 trackRef?: TrackReferenceOrPlaceholder
226 micRefs: MicRefIndex
227 enterDirOf: (identity: string) => TileEnterDirection | null
228}): ReactElement | null {
229 const { t } = useT()
230 const contextRef = useMaybeTrackRefContext()
231 const trackRef = explicitRef ?? contextRef
232 const identity = trackRef?.participant.identity ?? ''
233 // 入场方向来自 Board 的幂等方向表;首次挂载取一次,上下文晚到也会在这里补上。
234 const [claimed] = useState<TileEnterDirection | null>(() => enterDirOf(identity))
235 const enter = claimed ?? enterDirOf(identity)
236 if (!trackRef) return null
237 const micRef = micRefs.get(identity)
238 const box = layout.boxOf(identity)
239 const dragging = layout.draggingKey === identity
240 return (
241 <div
242 data-tile={identity}
243 className={[
244 'voice-window',
245 enter ? `voice-enter-${enter}` : '',
246 box ? 'is-floating' : '',
247 dragging ? 'is-dragging' : '',
248 ]
249 .filter(Boolean)
250 .join(' ')}
251 style={box ? { left: box.left, top: box.top, width: box.width, height: box.height } : undefined}
252 title={box ? t('chat.voice.tilePlaced') : t('chat.voice.tileHint')}
253 onPointerDown={(event) => layout.beginGesture(event, identity, 'move')}
254 onPointerMove={(event) => layout.moveGesture(event, identity)}
255 onPointerUp={() => layout.endGesture(identity)}
256 onPointerCancel={() => layout.endGesture(identity)}
257 onClickCapture={(event) => {
258 // Swallow the click that closes a drag: the official tile pins on
259 // click, and nobody expects moving a window to change the stage.
260 if (!layout.didDrag(identity)) return
261 event.stopPropagation()
262 event.preventDefault()
263 }}
264 onDoubleClick={(event) => {
265 if (event.target instanceof Element && event.target.closest('button,input')) return
266 layout.releaseTile(identity)
267 }}
268 >
269 <WindowMedia trackRef={trackRef} micRef={micRef} avatarUrl={avatarUrls.get(identity)} />
270 {box ? (
271 <span
272 className="voice-window-resize"
273 aria-hidden
274 onPointerDown={(event) => {
275 event.stopPropagation()
276 layout.beginGesture(event, identity, 'resize')
277 }}
278 />
279 ) : null}
280 <TileMenu
281 trackRef={trackRef}
282 roomId={roomId}
283 viewerId={viewerId}
284 canModerate={canModerate}
285 moderatorMuted={mutedIds.has(identity)}
286 onMute={onMute}
287 onKick={onKick}
288 />
289 </div>
290 )
291}
292
293/** Join/leave chatter: the visual tile set cannot announce it to a screen reader. */
294function VoiceAnnouncements({ viewerId }: { viewerId?: number }) {
295 const { t } = useT()
296 const participants = useParticipants()
297 const known = useRef<Map<string, string> | null>(null)
298 const [message, setMessage] = useState('')
299
300 useEffect(() => {
301 const ids = participants.map((participant) => participant.identity)
302 if (known.current === null) {
303 known.current = new Map(participants.map((p) => [p.identity, p.name || p.identity]))
304 return
305 }
306 for (const participant of participants) {
307 if (participant.identity === String(viewerId ?? '')) continue
308 if (!known.current.has(participant.identity)) {
309 setMessage(t('chat.voice.aria.joined', { name: participant.name || participant.identity }))
310 }
311 }
312 for (const [identity, name] of known.current) {
313 if (!ids.includes(identity) && identity !== String(viewerId ?? '')) {
314 setMessage(t('chat.voice.aria.left', { name }))
315 }
316 }
317 known.current = new Map(participants.map((p) => [p.identity, p.name || p.identity]))
318 }, [participants, viewerId, t])
319
320 return (
321 <div className="voice-sr-only" role="status" aria-live="polite">
322 {message}
323 </div>
324 )
325}
326
327export function VoiceTileBoard(props: BoardProps) {
328 const { roomId, viewerId, canModerate, layout, mutedIds, avatarUrls, onMute, onKick } = props
329 const { t } = useT()
330 const layoutContext = useCreateLayoutContext()
331 const pinnedTracks = usePinnedTracks(layoutContext)
332
333 const tracks = useTracks(
334 [
335 { source: Track.Source.Camera, withPlaceholder: false },
336 { source: Track.Source.Microphone, withPlaceholder: true },
337 { source: Track.Source.ScreenShare, withPlaceholder: false },
338 ],
339 { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
340 )
341
342 // One window per member: a camera wins over the microphone placeholder, and
343 // shares are kept out of the face list so they can never render twice.
344 const faceRefs = useMemo(() => {
345 const byIdentity = new Map<string, TrackReferenceOrPlaceholder>()
346 for (const ref of tracks) {
347 if (isShare(ref)) continue
348 const current = byIdentity.get(ref.participant.identity)
349 const isCamera = ref.source === Track.Source.Camera
350 if (!current || (isCamera && current.source !== Track.Source.Camera)) {
351 byIdentity.set(ref.participant.identity, ref)
352 }
353 }
354 return [...byIdentity.values()]
355 }, [tracks])
356
357 const shareRefs = useMemo(() => tracks.filter((ref) => isShare(ref) && isTrackReference(ref)), [tracks])
358
359 const micRefs = useMemo(() => {
360 const index: MicRefIndex = new Map()
361 for (const ref of tracks) {
362 if (ref.source === Track.Source.Microphone) index.set(ref.participant.identity, ref)
363 }
364 return index
365 }, [tracks])
366 const boardRefs = useMemo(() => [...faceRefs, ...shareRefs], [faceRefs, shareRefs])
367
368 /**
369 * 成员 → 入场方向。用 Map 而不是一次性集合:StrictMode 会把渲染与初始化器
370 * 各跑两遍,幂等取值才能既保证方向唯一、又不会被第一次调用“吃掉”。
371 */
372 const enterDirsRef = useRef(new Map<string, TileEnterDirection>())
373 const enterDirOf = (identity: string): TileEnterDirection | null => {
374 if (!identity) return null
375 const dirs = enterDirsRef.current
376 const existing = dirs.get(identity)
377 if (existing) return existing
378 const dir = ENTER_DIRECTIONS[Math.floor(Math.random() * ENTER_DIRECTIONS.length)]
379 dirs.set(identity, dir)
380 return dir
381 }
382
383 const focused = pinnedTracks[0]
384 const autoFocused = useRef<TrackReferenceOrPlaceholder | null>(null)
385 /**
386 * When each share first reached this tab. The SDK exposes no publish timestamp
387 * on a track publication, so recency is observed here instead - which also
388 * makes the stage choice independent of server clock drift.
389 */
390 const shareSeenAt = useRef(new Map<string, number>())
391
392 // Nobody pinned anything: the newest share takes the stage and steps down when
393 // it ends. A manual unpin is respected until a different share appears.
394 useEffect(() => {
395 if (pinnedTracks.length > 0) return
396 const autoSid = isTrackReference(autoFocused.current) ? autoFocused.current.publication.trackSid : null
397 const autoStillLive =
398 autoSid !== null && shareRefs.some((ref) => isTrackReference(ref) && ref.publication.trackSid === autoSid)
399 if (autoStillLive) return
400 const now = Date.now()
401 const seen = shareSeenAt.current
402 const liveKeys = new Set(shareRefs.map(shareKeyOf))
403 for (const key of [...seen.keys()]) if (!liveKeys.has(key)) seen.delete(key)
404 for (const ref of shareRefs) {
405 const key = shareKeyOf(ref)
406 if (!seen.has(key)) seen.set(key, now)
407 }
408 const newest = [...shareRefs].sort((a, b) => (seen.get(shareKeyOf(b)) ?? 0) - (seen.get(shareKeyOf(a)) ?? 0))[0]
409 if (newest) {
410 autoFocused.current = newest
411 layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: newest })
412 } else if (autoFocused.current) {
413 autoFocused.current = null
414 layoutContext.pin.dispatch?.({ msg: 'clear_pin' })
415 }
416 }, [shareRefs, pinnedTracks.length, layoutContext])
417
418 // While somebody owns the stage, placements are ignored: the rail and the
419 // focused window are laid out by the official components instead.
420 const windowProps = {
421 roomId,
422 viewerId,
423 canModerate,
424 mutedIds,
425 avatarUrls,
426 onMute,
427 onKick,
428 micRefs,
429 enterDirOf,
430 layout: focused ? { ...layout, boxOf: () => null } : layout,
431 }
432
433 if (focused) {
434 const rest = boardRefs.filter(
435 (ref) => !(ref.participant.identity === focused.participant.identity && ref.source === focused.source),
436 )
437 return (
438 <LayoutContextProvider value={layoutContext}>
439 <div className="voice-board">
440 <FocusLayoutContainer>
441 <CarouselLayout tracks={rest}>
442 <Window {...windowProps} />
443 </CarouselLayout>
444 <FocusLayout trackRef={focused} />
445 </FocusLayoutContainer>
446 </div>
447 <VoiceAnnouncements viewerId={viewerId} />
448 </LayoutContextProvider>
449 )
450 }
451
452 // Callback ref, not a ref read: the board owns the canvas element, so the
453 // layout hook learns about it when React attaches it (see useVoiceTileLayout).
454 const setCanvas = layout.setCanvas
455 const placedRefs = boardRefs.filter((ref) => layout.layout[ref.participant.identity])
456 const gridRefs = boardRefs.filter((ref) => !layout.layout[ref.participant.identity])
457
458 return (
459 <LayoutContextProvider value={layoutContext}>
460 {/* oxlint-disable-next-line react/refs -- setCanvas 是 useVoiceTileLayout 返回的回调 ref,不是 ref.current 读取 */}
461 <div className="voice-board" ref={setCanvas}>
462 <GridLayout tracks={gridRefs} className="voice-grid">
463 <Window {...windowProps} />
464 </GridLayout>
465 <div className="voice-floats">
466 {placedRefs.map((ref) => (
467 <Window key={getTrackReferenceId(ref)} {...windowProps} trackRef={ref} />
468 ))}
469 </div>
470 {boardRefs.length <= 1 ? (
471 <p className="voice-empty" aria-hidden={boardRefs.length > 1}>
472 {t('chat.voice.solo')}
473 </p>
474 ) : null}
475 </div>
476 <VoiceAnnouncements viewerId={viewerId} />
477 </LayoutContextProvider>
478 )
479}
Added view/app/src/domains/chat/ui/voice/voice.css view/app/src/domains/chat/ui/voice/voice.css +1116 −0 View view/app/src/domains/chat/ui/voice/voice.css
@@ -0,0 +1,1116 @@
1/* 语音频道:通话面板与全局通话条。只用 shell 设计令牌,深浅色自适应。 */
2
3.voice-room {
4 background: var(--shell-surface);
5}
6/* The call surface the bullets fly across: everything but the banners. */
7.voice-call-body {
8 position: relative;
9 display: flex;
10 flex: 1;
11 min-height: 0;
12 flex-direction: column;
13}
14
15/* 提示条 */
16.voice-banners {
17 display: flex;
18 flex-shrink: 0;
19 flex-wrap: wrap;
20 gap: 8px;
21 padding: 12px 16px 0;
22}
23.voice-banner {
24 display: flex;
25 align-items: center;
26 gap: 8px;
27 padding: 5px 6px 5px 11px;
28 border: 1px solid color-mix(in srgb, var(--destructive) 22%, transparent);
29 border-radius: 999px;
30 background: color-mix(in srgb, var(--destructive) 7%, var(--shell-surface));
31 color: var(--error-foreground);
32 font-size: 12px;
33}
34.voice-banner button {
35 flex-shrink: 0;
36 padding: 2px 9px;
37 border-radius: 999px;
38 color: inherit;
39 font-size: 11.5px;
40 opacity: 0.7;
41 transition:
42 opacity 150ms,
43 background 150ms;
44}
45.voice-banner button:hover {
46 opacity: 1;
47 background: color-mix(in srgb, var(--destructive) 12%, transparent);
48}
49
50/* 未加入时的引导 */
51.voice-join {
52 display: flex;
53 flex: 1;
54 min-height: 0;
55 flex-direction: column;
56 align-items: center;
57 justify-content: center;
58 gap: 12px;
59 padding: 28px;
60 text-align: center;
61}
62.voice-join-icon {
63 display: grid;
64 width: 56px;
65 height: 56px;
66 place-items: center;
67 border-radius: 18px;
68 color: var(--shell-ink-muted);
69 background: var(--shell-field);
70 box-shadow: var(--shell-shadow);
71}
72.voice-join-title {
73 font-size: 15px;
74 font-weight: 600;
75}
76.voice-join-hint {
77 max-width: 400px;
78 color: var(--shell-ink-muted);
79 font-size: 12.5px;
80 line-height: 1.7;
81}
82.voice-primary-button {
83 display: inline-flex;
84 align-items: center;
85 gap: 6px;
86 margin-top: 6px;
87 padding: 10px 22px;
88 border-radius: 12px;
89 background: var(--shell-action);
90 color: #fff;
91 font-size: 13px;
92 font-weight: 600;
93 box-shadow: var(--shell-shadow);
94 transition:
95 filter 160ms,
96 transform 160ms;
97}
98.voice-primary-button:hover:not(:disabled) {
99 filter: brightness(1.08);
100 transform: translateY(-1px);
101}
102.voice-primary-button:active:not(:disabled) {
103 transform: none;
104}
105.voice-primary-button:disabled {
106 opacity: 0.55;
107}
108.voice-ghost-button {
109 padding: 6px 13px;
110 border: 1px solid var(--shell-hairline);
111 border-radius: 999px;
112 color: var(--shell-ink);
113 font-size: 12px;
114 transition:
115 background 160ms,
116 border-color 160ms;
117}
118.voice-ghost-button:hover {
119 background: var(--shell-active);
120 border-color: var(--shell-focus);
121}
122.voice-hand-pending {
123 display: inline-flex;
124 align-items: center;
125 gap: 5px;
126 padding: 5px 12px;
127 border-radius: 999px;
128 background: color-mix(in srgb, var(--warning-foreground) 14%, transparent);
129 color: var(--warning-foreground);
130 font-size: 12px;
131}
132
133/* 舞台发言申请 */
134.voice-hands {
135 flex-shrink: 0;
136 margin: 0 16px 12px;
137 padding: 12px 14px;
138 border: 1px solid var(--shell-hairline);
139 border-radius: 14px;
140 background: var(--shell-field);
141}
142.voice-hands-head {
143 display: flex;
144 align-items: center;
145 gap: 7px;
146 font-size: 12.5px;
147 font-weight: 600;
148}
149.voice-badge {
150 margin-left: auto;
151 padding: 2px 9px;
152 border-radius: 999px;
153 background: var(--shell-active);
154 color: var(--shell-ink-muted);
155 font-size: 11px;
156 font-weight: 500;
157}
158.voice-badge.ok {
159 display: inline-flex;
160 align-items: center;
161 gap: 4px;
162 background: color-mix(in srgb, var(--success-foreground) 12%, transparent);
163 color: var(--success-foreground);
164}
165.voice-hands-empty {
166 margin-top: 8px;
167 color: var(--shell-ink-muted);
168 font-size: 12px;
169}
170.voice-hands-list {
171 margin-top: 8px;
172 display: flex;
173 flex-direction: column;
174 gap: 2px;
175}
176.voice-hands-list li {
177 display: flex;
178 align-items: center;
179 gap: 8px;
180 padding: 4px 6px;
181 border-radius: 8px;
182 font-size: 12.5px;
183}
184.voice-hands-list li:hover {
185 background: var(--shell-active);
186}
187.voice-hands-actions {
188 display: flex;
189 margin-left: auto;
190 gap: 6px;
191}
192.voice-approve,
193.voice-deny {
194 padding: 4px 12px;
195 border-radius: 999px;
196 font-size: 12px;
197 font-weight: 500;
198 transition: filter 150ms;
199}
200.voice-approve {
201 background: color-mix(in srgb, var(--success-foreground) 14%, transparent);
202 color: var(--success-foreground);
203}
204.voice-deny {
205 background: color-mix(in srgb, var(--destructive) 14%, transparent);
206 color: var(--error-foreground);
207}
208.voice-approve:hover,
209.voice-deny:hover {
210 filter: brightness(0.95);
211}
212
213/* 控制条:居中的悬浮胶囊 */
214.voice-controls {
215 position: absolute;
216 right: 16px;
217 bottom: 70px;
218 z-index: 6;
219 display: flex;
220 flex-shrink: 0;
221 align-items: center;
222 gap: 4px;
223 margin: 0;
224 padding: 6px;
225 border: 1px solid var(--shell-hairline);
226 border-radius: 999px;
227 background: var(--shell-surface);
228 box-shadow: var(--shell-shadow);
229}
230.voice-control {
231 display: grid;
232 width: 38px;
233 height: 38px;
234 place-items: center;
235 border-radius: 50%;
236 color: var(--shell-ink);
237 transition:
238 background 150ms,
239 color 150ms;
240}
241.voice-control:hover:not(:disabled) {
242 background: var(--shell-active);
243}
244.voice-control:disabled {
245 opacity: 0.4;
246}
247.voice-control.is-off {
248 background: color-mix(in srgb, var(--destructive) 12%, transparent);
249 color: var(--error-foreground);
250}
251.voice-control-sep {
252 flex: none;
253 width: 1px;
254 height: 20px;
255 margin: 0 4px;
256 background: var(--shell-hairline);
257}
258.voice-control.danger {
259 margin-left: 2px;
260 background: color-mix(in srgb, var(--destructive) 14%, transparent);
261 color: var(--destructive);
262}
263.voice-control.danger:hover {
264 background: color-mix(in srgb, var(--destructive) 24%, transparent);
265}
266.voice-primary-button:focus-visible,
267.voice-ghost-button:focus-visible,
268.voice-control:focus-visible,
269.voice-call-actions > button:focus-visible,
270.voice-call-return:focus-visible {
271 outline: 2px solid var(--shell-focus);
272 outline-offset: 2px;
273}
274
275/* 弹幕:文字从通话画面上方飘过,点击穿透,不影响操作 */
276.voice-danmaku {
277 position: absolute;
278 inset: 10px 0 auto;
279 z-index: 3;
280 height: min(42%, 260px);
281 overflow: hidden;
282 pointer-events: none;
283}
284.voice-danmaku-item {
285 position: absolute;
286 left: 100%;
287 display: inline-flex;
288 align-items: baseline;
289 gap: 7px;
290 max-width: min(60vw, 520px);
291 padding: 4px 12px;
292 border-radius: 999px;
293 background: rgb(17 19 20 / 62%);
294 backdrop-filter: blur(4px);
295 color: #fff;
296 font-size: 12.5px;
297 line-height: 18px;
298 white-space: nowrap;
299 overflow: hidden;
300 animation-name: voice-danmaku-cross;
301 animation-duration: 11s;
302 animation-timing-function: linear;
303 animation-fill-mode: forwards;
304}
305.voice-danmaku-author {
306 flex: none;
307 color: color-mix(in srgb, var(--primary) 72%, #fff);
308 font-weight: 600;
309}
310.voice-danmaku-text {
311 min-width: 0;
312 overflow: hidden;
313 text-overflow: ellipsis;
314}
315@keyframes voice-danmaku-cross {
316 from {
317 transform: translateX(0);
318 }
319 to {
320 transform: translateX(calc(-100% - var(--voice-danmaku-distance, 100vw)));
321 }
322}
323
324/* 弹幕输入:默认收起成一条细胶囊,无附件、无表情、无键盘提示 */
325.voice-danmaku-input {
326 display: flex;
327 flex-shrink: 0;
328 align-items: center;
329 gap: 8px;
330 /* Fixed width: focusing must never make the bar jump or spill over. */
331 width: min(380px, calc(100% - 32px));
332 margin: 0 auto 14px;
333 padding: 0 5px 0 12px;
334 border: 1px solid var(--shell-hairline);
335 border-radius: 999px;
336 background: var(--shell-surface);
337 box-shadow: var(--shell-shadow);
338 opacity: 0.7;
339 transition:
340 opacity 160ms,
341 border-color 160ms;
342}
343.voice-danmaku-input:hover,
344.voice-danmaku-input.is-open {
345 opacity: 1;
346}
347.voice-danmaku-input.is-open {
348 border-color: var(--shell-focus);
349}
350.voice-danmaku-input-icon {
351 flex: none;
352 width: 15px;
353 height: 15px;
354 color: var(--shell-ink-muted);
355}
356.voice-danmaku-input-field {
357 min-width: 0;
358 flex: 1;
359 height: 34px;
360 border: 0;
361 background: transparent;
362 color: var(--shell-ink);
363 font-size: 13px;
364 outline: none;
365}
366.voice-danmaku-input-field:disabled {
367 opacity: 0.5;
368}
369.voice-danmaku-input-notice {
370 flex: none;
371 color: var(--error-foreground);
372 font-size: 11.5px;
373}
374.voice-danmaku-input-send {
375 display: grid;
376 flex: none;
377 width: 30px;
378 height: 30px;
379 place-items: center;
380 border-radius: 50%;
381 color: var(--shell-ink);
382 transition: background 150ms;
383}
384.voice-danmaku-input-send:hover:not(:disabled) {
385 background: var(--shell-active);
386}
387.voice-danmaku-input-send:disabled {
388 opacity: 0.35;
389}
390/* 弹幕记录小窗 */
391.voice-danmaku-history {
392 position: absolute;
393 bottom: 62px;
394 left: 50%;
395 z-index: 4;
396 display: flex;
397 flex-direction: column;
398 width: min(360px, calc(100% - 24px));
399 max-height: min(56%, 420px);
400 transform: translateX(-50%);
401 border: 1px solid var(--shell-hairline);
402 border-radius: 14px;
403 background: var(--shell-surface);
404 box-shadow: 0 18px 44px rgb(15 17 19 / 22%);
405 overflow: hidden;
406}
407.voice-danmaku-history-head {
408 display: flex;
409 align-items: center;
410 gap: 8px;
411 flex-shrink: 0;
412 padding: 9px 11px;
413 border-bottom: 1px solid var(--shell-hairline);
414}
415.voice-danmaku-history-title {
416 font-size: 12.5px;
417 font-weight: 600;
418}
419.voice-danmaku-history-head .voice-badge {
420 margin-left: 0;
421}
422.voice-danmaku-history-close {
423 display: grid;
424 margin-left: auto;
425 width: 24px;
426 height: 24px;
427 place-items: center;
428 border-radius: 7px;
429 color: var(--shell-ink-muted);
430}
431.voice-danmaku-history-close:hover {
432 background: var(--shell-active);
433 color: var(--shell-ink);
434}
435.voice-danmaku-history-more {
436 flex-shrink: 0;
437 padding: 6px;
438 color: var(--shell-ink-muted);
439 font-size: 11.5px;
440 border-bottom: 1px solid var(--shell-hairline);
441 transition:
442 background 150ms,
443 color 150ms;
444}
445.voice-danmaku-history-more:hover {
446 background: var(--shell-field);
447 color: var(--shell-ink);
448}
449.voice-danmaku-history-list {
450 min-height: 0;
451 flex: 1;
452 padding: 4px 0;
453 overflow-y: auto;
454}
455.voice-danmaku-history-row {
456 display: grid;
457 grid-template-columns: 68px minmax(0, 1fr) auto;
458 gap: 8px;
459 align-items: baseline;
460 padding: 5px 11px;
461 font-size: 12.5px;
462}
463.voice-danmaku-history-row:hover {
464 background: var(--shell-field);
465}
466.voice-danmaku-history-author {
467 overflow: hidden;
468 color: var(--shell-ink-muted);
469 font-weight: 500;
470 text-overflow: ellipsis;
471 white-space: nowrap;
472}
473.voice-danmaku-history-text {
474 color: var(--shell-ink);
475 overflow-wrap: anywhere;
476 white-space: pre-wrap;
477}
478.voice-danmaku-history-time {
479 flex: none;
480 color: var(--shell-ink-muted);
481 font-size: 11px;
482}
483.voice-danmaku-history-foot {
484 display: flex;
485 flex-shrink: 0;
486 align-items: center;
487 gap: 6px;
488 padding: 8px 11px;
489 border-top: 1px solid var(--shell-hairline);
490 color: var(--shell-ink-muted);
491 font-size: 12px;
492 cursor: pointer;
493}
494.voice-danmaku-history-foot input {
495 accent-color: var(--primary);
496}
497.voice-control.is-active {
498 background: var(--shell-tile);
499 color: var(--shell-ink);
500}
501/* 全局通话条:可拖动的悬浮胶囊 */
502.voice-call-bar {
503 position: fixed;
504 z-index: 35;
505 right: 18px;
506 bottom: 18px;
507 display: flex;
508 align-items: center;
509 gap: 8px;
510 min-height: 44px;
511 max-width: calc(100vw - 24px);
512 padding: 0 10px 0 6px;
513 border: 1px solid var(--shell-hairline);
514 border-radius: 999px;
515 background: color-mix(in srgb, var(--shell-surface) 90%, transparent);
516 backdrop-filter: blur(12px);
517 box-shadow: 0 10px 30px rgb(15 17 19 / 14%);
518 cursor: grab;
519 touch-action: none;
520 user-select: none;
521}
522.voice-call-bar.is-moved {
523 right: auto;
524 bottom: auto;
525}
526.voice-call-bar.is-dragging {
527 cursor: grabbing;
528 box-shadow: 0 16px 40px rgb(15 17 19 / 22%);
529}
530.voice-call-grip {
531 flex: none;
532 width: 12px;
533 height: 16px;
534 color: var(--shell-ink-muted);
535 opacity: 0.5;
536}
537.voice-call-status {
538 display: flex;
539 min-width: 0;
540 align-items: center;
541 gap: 7px;
542 font-size: 12.5px;
543 font-weight: 600;
544}
545.voice-call-dot {
546 flex: none;
547 width: 7px;
548 height: 7px;
549 border-radius: 50%;
550 background: var(--primary);
551 box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 20%, transparent);
552}
553.voice-call-label {
554 min-width: 0;
555 overflow: hidden;
556 text-overflow: ellipsis;
557 white-space: nowrap;
558}
559.voice-call-notice {
560 min-width: 0;
561 flex: 1;
562 max-width: 240px;
563 color: var(--shell-ink-muted);
564 font-size: 12px;
565}
566.voice-call-actions {
567 display: flex;
568 margin-left: auto;
569 align-items: center;
570 gap: 2px;
571}
572.voice-call-actions > button,
573.voice-call-return {
574 display: grid;
575 width: 32px;
576 height: 32px;
577 place-items: center;
578 border-radius: 50%;
579 color: var(--shell-ink);
580 transition:
581 background 150ms,
582 color 150ms;
583}
584.voice-call-actions > button:hover,
585.voice-call-return:hover {
586 background: var(--shell-active);
587}
588.voice-call-actions > button:disabled {
589 opacity: 0.4;
590}
591.voice-call-actions > button.is-off {
592 color: var(--shell-ink-muted);
593}
594.voice-call-actions > button.is-hangup {
595 color: var(--destructive);
596}
597.voice-call-actions > button.is-hangup:hover {
598 background: color-mix(in srgb, var(--destructive) 14%, transparent);
599}
600.voice-call-resume {
601 display: inline-flex;
602 align-items: center;
603 gap: 5px;
604 padding: 5px 12px;
605 border-radius: 999px;
606 background: color-mix(in srgb, var(--warning-foreground) 16%, transparent);
607 color: var(--warning-foreground);
608 font-size: 12px;
609}
610
611@media (max-width: 760px) {
612 .voice-tiles {
613 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
614 gap: 10px;
615 padding: 12px 10px;
616 }
617 .voice-stage {
618 height: min(36vh, 300px);
619 margin: 10px 10px 0;
620 }
621 .voice-hands {
622 margin: 0 10px 10px;
623 }
624 .voice-banners {
625 padding: 10px 10px 0;
626 }
627 .voice-controls {
628 margin-bottom: 12px;
629 }
630 .voice-call-bar {
631 right: 10px;
632 bottom: 10px;
633 padding: 0 8px 0 5px;
634 }
635 .voice-call-notice {
636 display: none;
637 }
638 .voice-danmaku {
639 height: min(34%, 180px);
640 }
641 .voice-danmaku-item {
642 font-size: 12px;
643 }
644}
645
646@media (prefers-reduced-motion: reduce) {
647 .voice-danmaku-item {
648 left: 12px;
649 animation: none;
650 }
651}
652
653/* ---------------------------------------------- LiveKit 组件主题对齐 shell --
654 * vendored 的 @livekit/components-* 用自带的编译样式(在 app/index.tsx 引入),
655 * 并以 [data-lk-theme] 为作用域。下面只重映射设计令牌,让官方布局看起来是本
656 * 应用的一部分;结构性覆盖一律按类名写,不用 !important。
657 */
658.voice-room[data-lk-theme],
659.voice-host[data-lk-theme] {
660 color-scheme: light dark;
661 --lk-bg: var(--shell-surface);
662 --lk-bg2: var(--shell-tile);
663 --lk-bg3: var(--shell-active);
664 --lk-bg4: var(--shell-active);
665 --lk-bg5: var(--shell-focus);
666 --lk-fg: var(--shell-ink);
667 --lk-fg2: var(--shell-ink);
668 --lk-fg3: var(--shell-ink-muted);
669 --lk-fg4: var(--shell-ink-muted);
670 --lk-fg5: var(--shell-ink-muted);
671 --lk-border-color: var(--shell-hairline);
672 --lk-accent-fg: var(--primary-foreground);
673 --lk-accent-bg: var(--primary);
674 --lk-danger: var(--destructive);
675 --lk-danger2: var(--error-foreground);
676 --lk-success-fg: var(--success-foreground);
677 --lk-success: var(--success-foreground);
678 --lk-control-fg: var(--shell-ink);
679 --lk-control-bg: var(--shell-field);
680 --lk-control-hover-bg: var(--shell-active);
681 --lk-font-family: inherit;
682 --lk-font-size: 13px;
683 --lk-border-radius: 12px;
684 --lk-grid-gap: 10px;
685 --lk-control-bar-height: 58px;
686 --lk-box-shadow: var(--shell-shadow);
687}
688
689/* 通话栈:官方布局铺满剩余高度,控制条与话轮面板浮在其上。 */
690.voice-call-stack {
691 position: relative;
692 display: flex;
693 min-height: 0;
694 flex: 1;
695 flex-direction: column;
696}
697.voice-board {
698 position: relative;
699 flex: 1;
700 min-height: 0;
701 padding: 6px 14px;
702 overflow: hidden;
703}
704.voice-board .lk-grid-layout,
705.voice-board .lk-focus-layout {
706 width: 100%;
707 height: 100%;
708}
709.voice-board .lk-carousel {
710 background: transparent;
711}
712.voice-board .lk-participant-tile {
713 border: 1px solid var(--shell-hairline);
714}
715.voice-board .lk-participant-tile[data-lk-speaking="true"] {
716 border-color: color-mix(in srgb, var(--success-foreground) 55%, transparent);
717 box-shadow: 0 0 0 1px color-mix(in srgb, var(--success-foreground) 30%, transparent);
718}
719.voice-board .lk-video-track,
720.voice-board .lk-audio-bar-visualizer {
721 border-radius: 11px;
722}
723
724/*
725 * 无摄像头成员:头像 + 随声音胀缩的光环。
726 *
727 * 窗口可以被拖动缩放(140px … 640px),所以尺寸一律用容器单位:写死像素会让
728 * 大窗口里的头像小得像个图标。光环不读 --lk-va-* 变量,因为那些是官方条形电平
729 * 表的尺寸;电平由 React 写进 --voice-level(0…1 的比例)。
730 */
731.voice-avatar-tile {
732 position: relative;
733 display: flex;
734 height: 100%;
735 align-items: center;
736 justify-content: center;
737 background: var(--shell-field);
738 container-type: inline-size;
739}
740.voice-avatar-ring {
741 position: absolute;
742 left: 50%;
743 top: 50%;
744 transform: translate(-50%, -50%);
745 width: calc(44cqw * (1 + var(--voice-level, 0)));
746 height: calc(44cqw * (1 + var(--voice-level, 0)));
747 border-radius: 50%;
748 border: max(1px, 1.2cqw) solid color-mix(in srgb, var(--shell-ink-muted) 45%, transparent);
749 opacity: 0.5;
750 transition:
751 width 90ms linear,
752 height 90ms linear,
753 border-color 160ms,
754 opacity 160ms;
755}
756.voice-avatar-tile[data-lk-speaking="true"] .voice-avatar-ring {
757 border-color: color-mix(in srgb, var(--success-foreground) 70%, transparent);
758 box-shadow: 0 0 0 6px color-mix(in srgb, var(--success-foreground) 18%, transparent);
759 opacity: 0.95;
760}
761.voice-avatar-face {
762 width: 40cqw;
763 height: 40cqw;
764 font-size: 15cqw;
765 box-shadow: 0 6px 18px rgb(24 28 31 / 12%);
766}
767.voice-avatar-name {
768 position: absolute;
769 bottom: 10px;
770 left: 50%;
771 display: flex;
772 max-width: calc(100% - 20px);
773 align-items: center;
774 gap: 4px;
775 padding: max(2px, 0.5cqw) max(7px, 2.4cqw);
776 border-radius: 999px;
777 background: color-mix(in srgb, var(--shell-surface) 88%, transparent);
778 color: var(--shell-ink);
779 font-size: clamp(11px, 3.2cqw, 15px);
780 transform: translateX(-50%);
781}
782
783/* 参与者窗口:网格里的一个,或被摆位后浮出来的一个。 */
784.voice-window {
785 position: relative;
786 min-height: 0;
787 user-select: none;
788 touch-action: none;
789}
790.voice-window .lk-participant-tile {
791 height: 100%;
792}
793/*
794 * 浮出来的窗口长宽都由内联样式给出(两个轴独立,可以只拉宽或只拉高),所以这里
795 * 不能再留 aspect-ratio:它会反过来把高度重新绑到宽度上,垂直拖动就没有效果。
796 * 网格里的窗口仍由官方布局决定尺寸。
797 */
798.voice-window.is-floating {
799 position: absolute;
800 z-index: 2;
801 cursor: grab;
802 pointer-events: auto;
803}
804.voice-window.is-dragging {
805 z-index: 3;
806 cursor: grabbing;
807}
808/*
809 * 新成员入场:第一次出现的窗口从随机方向滑入,而不是凭空出现。方向类只在
810 * 成员首次出场时挂上(voice-tiles.tsx 的认领表),重排/重挂载不会重播;
811 * 全局的 prefers-reduced-motion 规则会把动画时长清零。
812 */
813@keyframes voice-window-enter-left {
814 from {
815 opacity: 0;
816 transform: translateX(-26px);
817 }
818 to {
819 opacity: 1;
820 transform: translateX(0);
821 }
822}
823@keyframes voice-window-enter-right {
824 from {
825 opacity: 0;
826 transform: translateX(26px);
827 }
828 to {
829 opacity: 1;
830 transform: translateX(0);
831 }
832}
833@keyframes voice-window-enter-top {
834 from {
835 opacity: 0;
836 transform: translateY(-22px);
837 }
838 to {
839 opacity: 1;
840 transform: translateY(0);
841 }
842}
843@keyframes voice-window-enter-bottom {
844 from {
845 opacity: 0;
846 transform: translateY(22px);
847 }
848 to {
849 opacity: 1;
850 transform: translateY(0);
851 }
852}
853.voice-enter-left {
854 animation: voice-window-enter-left 280ms cubic-bezier(0, 0, 0.2, 1) both;
855}
856.voice-enter-right {
857 animation: voice-window-enter-right 280ms cubic-bezier(0, 0, 0.2, 1) both;
858}
859.voice-enter-top {
860 animation: voice-window-enter-top 280ms cubic-bezier(0, 0, 0.2, 1) both;
861}
862.voice-enter-bottom {
863 animation: voice-window-enter-bottom 280ms cubic-bezier(0, 0, 0.2, 1) both;
864}
865/*
866 * 别人的移动由 JS 逐帧补间(voice-tile-layout 的 REMOTE_GLIDE_MS):这里刻意
867 * 不留 CSS 过渡,一是避免两层动画叠在一起,二是本机 prefers-reduced-motion 会
868 * 把过渡时长清零,补间必须走 JS 才在哪儿都生效。自己的拖拽则完全不过渡。
869 */
870/* 摄像头窗口上的麦克风状态:官方指示器只标注它渲染的那一路。 */
871.voice-call-quality {
872 width: 14px;
873 height: 14px;
874 margin-left: 6px;
875}
876.voice-call-quality .lk-connection-quality {
877 width: 100%;
878 height: 100%;
879}
880
881.voice-window-mic {
882 position: absolute;
883 bottom: 9px;
884 left: 30px;
885 z-index: 1;
886 color: #ff9c86;
887}
888
889.voice-window-resize {
890 position: absolute;
891 right: 4px;
892 bottom: 4px;
893 width: 16px;
894 height: 16px;
895 border-right: 2px solid var(--shell-ink-muted);
896 border-bottom: 2px solid var(--shell-ink-muted);
897 border-radius: 0 0 6px 0;
898 opacity: 0;
899 cursor: nwse-resize;
900 transition: opacity 160ms;
901}
902.voice-window:hover .voice-window-resize {
903 opacity: 0.85;
904}
905
906/* 悬停菜单:聚焦 / 该成员音量 / 主持人操作。 */
907.voice-tile-menu {
908 position: absolute;
909 top: 8px;
910 right: 8px;
911 display: flex;
912 align-items: center;
913 gap: 4px;
914 padding: 4px;
915 border-radius: 10px;
916 background: color-mix(in srgb, var(--shell-surface) 82%, transparent);
917 box-shadow: var(--shell-shadow);
918 opacity: 0;
919 transition: opacity 160ms;
920 pointer-events: auto;
921}
922.voice-window:hover .voice-tile-menu,
923.voice-window:focus-within .voice-tile-menu {
924 opacity: 1;
925}
926.voice-tile-menu button {
927 display: grid;
928 place-items: center;
929 width: 26px;
930 height: 26px;
931 border: 0;
932 border-radius: 7px;
933 background: transparent;
934 color: var(--shell-ink);
935 cursor: pointer;
936}
937.voice-tile-menu button:hover {
938 background: var(--shell-active);
939}
940.voice-tile-menu button.is-active {
941 color: var(--primary);
942}
943.voice-tile-volume {
944 display: flex;
945 align-items: center;
946 gap: 4px;
947 color: var(--shell-ink-muted);
948}
949.voice-tile-volume input[type="range"] {
950 width: 68px;
951 accent-color: var(--primary);
952 cursor: pointer;
953}
954
955/*
956 * 会中设备弹层。右下角那一列从 70px 起、高 52px(.voice-controls 的圆钮),
957 * 旧的 78px 让弹层正好压在圆钮上;留到 132px 让整块菜单停在圆钮上方。
958 */
959.voice-settings-anchor {
960 position: absolute;
961 right: 16px;
962 bottom: 132px;
963 z-index: 5;
964}
965.voice-settings {
966 display: flex;
967 flex-direction: column;
968 gap: 6px;
969 min-width: 244px;
970 padding: 12px;
971 border: 1px solid var(--shell-hairline);
972 border-radius: 14px;
973 background: var(--shell-surface);
974 box-shadow: var(--shell-shadow);
975}
976.voice-settings-caption {
977 color: var(--shell-ink-muted);
978 font-size: 11.5px;
979 font-weight: 600;
980}
981.voice-settings .lk-input-form-control,
982.voice-settings select {
983 width: 100%;
984 padding: 6px 8px;
985 border: 1px solid var(--shell-hairline);
986 border-radius: 9px;
987 background: var(--shell-field);
988 color: var(--shell-ink);
989 font-size: 12.5px;
990}
991
992/* 加入前的设备与预览层。 */
993.voice-sheet-backdrop {
994 position: fixed;
995 inset: 0;
996 z-index: 60;
997 display: grid;
998 place-items: center;
999 padding: 24px;
1000 background: rgb(17 17 17 / 42%);
1001}
1002.voice-sheet {
1003 display: flex;
1004 flex-direction: column;
1005 gap: 10px;
1006 width: min(420px, 100%);
1007 padding: 20px;
1008 border: 1px solid var(--shell-hairline);
1009 border-radius: 18px;
1010 background: var(--shell-surface);
1011 box-shadow: var(--shell-shadow);
1012 color: var(--shell-ink);
1013}
1014.voice-sheet-title {
1015 font-size: 15px;
1016 font-weight: 600;
1017}
1018.voice-sheet-hint,
1019.voice-sheet-caption {
1020 color: var(--shell-ink-muted);
1021 font-size: 12px;
1022 line-height: 1.6;
1023}
1024.voice-sheet-row {
1025 display: flex;
1026 flex-direction: column;
1027 gap: 4px;
1028 font-size: 12px;
1029}
1030.voice-sheet-row select {
1031 padding: 7px 9px;
1032 border: 1px solid var(--shell-hairline);
1033 border-radius: 10px;
1034 background: var(--shell-field);
1035 color: var(--shell-ink);
1036 font-size: 12.5px;
1037}
1038.voice-sheet-level {
1039 height: 6px;
1040 border-radius: 999px;
1041 background: var(--shell-active);
1042 overflow: hidden;
1043}
1044.voice-sheet-level-bar {
1045 display: block;
1046 height: 100%;
1047 background: var(--primary);
1048 transform-origin: left center;
1049 transition: transform 60ms linear;
1050}
1051.voice-sheet-preview {
1052 width: 100%;
1053 max-height: 168px;
1054 border-radius: 12px;
1055 background: #000;
1056 object-fit: cover;
1057}
1058.voice-sheet-check {
1059 display: flex;
1060 align-items: center;
1061 gap: 8px;
1062 font-size: 12.5px;
1063}
1064.voice-sheet-notice {
1065 color: var(--error-foreground);
1066 font-size: 12px;
1067}
1068.voice-sheet-actions {
1069 display: flex;
1070 justify-content: flex-end;
1071 gap: 8px;
1072 margin-top: 4px;
1073}
1074
1075/* 折叠的连接细节:把 SDK 原文留给排查,不透给普通成员。 */
1076.voice-debug {
1077 width: 100%;
1078 color: var(--shell-ink-muted);
1079 font-size: 11.5px;
1080}
1081.voice-debug summary {
1082 cursor: pointer;
1083}
1084.voice-debug code {
1085 display: block;
1086 margin-top: 4px;
1087 overflow-x: auto;
1088 font-family: var(--font-mono, ui-monospace, monospace);
1089 font-size: 11px;
1090}
1091
1092.voice-join-members {
1093 color: var(--shell-ink-muted);
1094 font-size: 12px;
1095}
1096
1097/* 只有自己在频道里:把邀请提示留在画布中央。 */
1098.voice-empty {
1099 position: absolute;
1100 inset: 0;
1101 display: grid;
1102 place-items: center;
1103 pointer-events: none;
1104 color: var(--shell-ink-muted);
1105 font-size: 12.5px;
1106}
1107
1108/* 键盘/读屏:加入离开播报只进辅助技术。 */
1109.voice-sr-only {
1110 position: absolute;
1111 width: 1px;
1112 height: 1px;
1113 overflow: hidden;
1114 clip-path: inset(50%);
1115 white-space: nowrap;
1116}
Added view/app/src/domains/chat/ui/chat-composer.tsx view/app/src/domains/chat/ui/chat-composer.tsx +475 −0 View view/app/src/domains/chat/ui/chat-composer.tsx
@@ -0,0 +1,475 @@
1/**
2 * The composer band: message entry, @-mention completion, emoji insertion,
3 * attachment staging, and the reply / edit contexts.
4 *
5 * Details that are easy to get wrong and are handled here:
6 * - **Enter sends, Shift+Enter breaks a line — unless an IME is composing.**
7 * `e.isComposing` is checked first: confirming a Chinese candidate with Enter
8 * must never fire a half-finished message.
9 * - The mention list matches the backend's own token grammar (`@name`, trailing
10 * punctuation stripped), and an unresolvable name simply posts as plain text
11 * rather than being rejected.
12 * - Attachments stage in the strip above the box; the send button explains *why*
13 * it is unavailable (uploading, failed file, too long, slow mode) instead of
14 * silently ignoring the click.
15 * - Paste and drag-and-drop both feed the same queue as the file button.
16 */
17
18import {
19 type ChatMessage,
20 type CustomEmoji,
21 formatSize,
22 MAX_MESSAGE_LEN,
23 type RoomMember,
24 textLength,
25} from '@app/domains/chat/model'
26import { useT } from '@app/i18n'
27import { AtSign, FileText, Loader2, Paperclip, RefreshCw, Send, Smile, TriangleAlert, X } from 'lucide-react'
28import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
29import type { ComposerState } from '../model/use-composer'
30import { EmojiPicker } from './emoji-picker'
31import { Popover } from './parts'
32
33const MAX_BOX_HEIGHT = 224
34
35export interface ComposerProps {
36 composer: ComposerState
37 roomLabel: string
38 /** Override the entry hint (voice channels post bullet comments). */
39 placeholder?: string
40 /** Thread scope changes the placeholder and the reply affordance. */
41 threadRoot?: ChatMessage
42 /** Quote-reply target, kept out of the text itself. */
43 replyTo?: ChatMessage | null
44 onCancelReply?: () => void
45 editing?: ChatMessage | null
46 onCancelEdit?: () => void
47 /** Room's `rate_limit_per_user`, mirrored so the box can explain itself. */
48 slowModeSeconds?: number
49 slowModeRemaining?: number
50 members: RoomMember[]
51 customEmojis?: CustomEmoji[]
52 spaceName?: string
53 /** Voice channel, deleted room, read-only …: replaces the box with a note. */
54 disabledNote?: string | null
55 onTyping?: () => void
56 onSubmit?: () => void
57}
58
59export function ChatComposer({
60 composer,
61 roomLabel,
62 placeholder,
63 replyTo,
64 onCancelReply,
65 editing,
66 onCancelEdit,
67 slowModeSeconds = 0,
68 slowModeRemaining = 0,
69 members,
70 customEmojis = [],
71 spaceName,
72 disabledNote,
73 onTyping,
74 onSubmit,
75}: ComposerProps) {
76 const { t } = useT()
77 const boxRef = useRef<HTMLTextAreaElement | null>(null)
78 const helpId = useId()
79 const [emojiOpen, setEmojiOpen] = useState(false)
80 const [mention, setMention] = useState<{ query: string; start: number } | null>(null)
81 const [active, setActive] = useState(0)
82 const { text, setText } = composer
83
84 // Grow with the content up to a limit, then scroll inside the box.
85 useEffect(() => {
86 const node = boxRef.current
87 if (!node) return
88 node.style.height = 'auto'
89 node.style.height = `${Math.min(node.scrollHeight, MAX_BOX_HEIGHT)}px`
90 }, [text])
91
92 useEffect(() => {
93 if (!disabledNote) boxRef.current?.focus()
94 }, [disabledNote, roomLabel])
95
96 const candidates = useMemo(() => {
97 if (!mention) return []
98 const query = mention.query.toLowerCase()
99 return members
100 .filter((member) => {
101 const name = member.user.username.toLowerCase()
102 const display = (member.nick || member.user.display_name || '').toLowerCase()
103 return !query || name.startsWith(query) || display.includes(query)
104 })
105 .slice(0, 8)
106 }, [members, mention])
107
108 // Clamped during render instead of reset in an effect: the highlight must
109 // follow a shrinking candidate list without a second render pass.
110 const activeIndex = candidates.length > 0 ? Math.min(active, candidates.length - 1) : 0
111
112 const syncMention = useCallback((value: string, caret: number) => {
113 const upto = value.slice(0, caret)
114 const start = upto.lastIndexOf('@')
115 if (start < 0) return setMention(null)
116 // A space before the @ (or the very start) is what makes it a mention and
117 // not an e-mail address typed mid-sentence.
118 const boundary = start === 0 || /\s/.test(upto[start - 1] ?? '')
119 const token = upto.slice(start + 1)
120 if (!boundary || /\s/.test(token) || token.length > 40) return setMention(null)
121 setMention({ query: token, start })
122 }, [])
123
124 const insertMention = useCallback(
125 (member: RoomMember) => {
126 const node = boxRef.current
127 if (!mention || !node) return
128 const caret = node.selectionStart ?? text.length
129 const before = text.slice(0, mention.start)
130 const after = text.slice(caret)
131 const token = `@${member.user.username}`
132 const next = `${before}${token} ${after}`
133 setText(next.slice(0, MAX_MESSAGE_LEN + 200))
134 setMention(null)
135 requestAnimationFrame(() => {
136 node.focus()
137 const position = before.length + token.length + 1
138 node.setSelectionRange(position, position)
139 })
140 },
141 [mention, setText, text],
142 )
143
144 const insertEmoji = useCallback(
145 (emoji: string) => {
146 const node = boxRef.current
147 const caret = node?.selectionStart ?? text.length
148 const next = `${text.slice(0, caret)}${emoji}${text.slice(caret)}`
149 setText(next)
150 setEmojiOpen(false)
151 requestAnimationFrame(() => {
152 if (!node) return
153 node.focus()
154 const position = caret + emoji.length
155 node.setSelectionRange(position, position)
156 })
157 },
158 [setText, text],
159 )
160
161 const submit = useCallback(() => {
162 if (!composer.canSend || slowModeRemaining > 0) return
163 setMention(null)
164 onSubmit?.()
165 }, [composer.canSend, onSubmit, slowModeRemaining])
166
167 const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
168 // IME first: Enter belongs to the candidate window while it is open.
169 if (event.nativeEvent.isComposing || event.keyCode === 229) return
170 if (mention && candidates.length > 0) {
171 if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
172 event.preventDefault()
173 setActive((index) => {
174 const current = Math.min(index, candidates.length - 1)
175 return (current + (event.key === 'ArrowDown' ? 1 : candidates.length - 1)) % candidates.length
176 })
177 return
178 }
179 if (event.key === 'Enter' || event.key === 'Tab') {
180 event.preventDefault()
181 const picked = candidates[activeIndex]
182 if (picked) insertMention(picked)
183 return
184 }
185 if (event.key === 'Escape') {
186 event.preventDefault()
187 setMention(null)
188 return
189 }
190 }
191 if (event.key === 'Enter' && !event.shiftKey) {
192 event.preventDefault()
193 submit()
194 return
195 }
196 if (event.key === 'Escape') {
197 if (editing) onCancelEdit?.()
198 else if (replyTo) onCancelReply?.()
199 }
200 }
201
202 const onPaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
203 const files = [...event.clipboardData.files]
204 if (files.length > 0) {
205 event.preventDefault()
206 composer.addFiles(files)
207 }
208 }
209
210 const onDrop = (event: React.DragEvent<HTMLDivElement>) => {
211 const files = [...event.dataTransfer.files]
212 if (files.length === 0) return
213 event.preventDefault()
214 composer.addFiles(files)
215 }
216
217 if (disabledNote) {
218 return (
219 <div className="flex h-11 items-center justify-center rounded-lg bg-shell-field px-3 text-[13px] text-shell-ink-muted">
220 {disabledNote}
221 </div>
222 )
223 }
224
225 const length = textLength(text)
226 const over = length > MAX_MESSAGE_LEN
227 const blocked = composer.blocked
228 const canSend = composer.canSend && slowModeRemaining === 0
229
230 return (
231 <div onDragOver={(event) => event.preventDefault()} onDrop={onDrop} className="chat-composer">
232 {editing ? (
233 <ContextBar
234 icon={<span className="text-[11px]">✎</span>}
235 label={t('chat.composer.editLabel')}
236 preview={editing.content}
237 hint={t('chat.composer.escCancel')}
238 onCancel={onCancelEdit}
239 />
240 ) : replyTo ? (
241 <ContextBar
242 icon={<AtSign className="h-3.5 w-3.5" />}
243 label={t('chat.composer.replyLabel', {
244 author: replyTo.author?.display_name || replyTo.author?.username || t('chat.composer.someMessage'),
245 })}
246 preview={replyTo.content}
247 hint={t('chat.composer.escCancel')}
248 onCancel={onCancelReply}
249 />
250 ) : null}
251
252 {composer.uploads.length > 0 ? (
253 <div className="flex flex-wrap gap-2 px-2.5 pt-2.5">
254 {composer.uploads.map((item) => (
255 <div
256 key={item.localId}
257 className="relative flex h-14 w-32 items-center gap-1.5 overflow-hidden rounded-md border border-shell-hairline bg-shell-surface p-1.5"
258 >
259 {item.previewUrl ? (
260 <img src={item.previewUrl} alt="" className="h-10 w-10 shrink-0 rounded object-cover" />
261 ) : (
262 <FileText className="h-5 w-5 shrink-0 text-shell-ink-muted" />
263 )}
264 <div className="min-w-0 flex-1">
265 <p className="truncate text-[11px] font-medium">{item.file.name}</p>
266 <p className="truncate text-[10px] text-shell-ink-muted">
267 {item.state === 'ready'
268 ? formatSize(item.file.size)
269 : item.state === 'failed' || item.state === 'too_big'
270 ? (item.error ?? t('chat.composer.uploadFailedShort'))
271 : t('chat.composer.uploading')}
272 </p>
273 </div>
274 {item.state === 'failed' ? (
275 <button
276 type="button"
277 aria-label={t('chat.composer.retryUpload')}
278 onClick={() => composer.retryUpload(item.localId)}
279 className="grid h-5 w-5 place-content-center rounded bg-[#e5484d]/12 text-[#e5484d]"
280 >
281 <RefreshCw className="h-3 w-3" />
282 </button>
283 ) : item.state === 'ready' ? (
284 <span className="grid h-5 w-5 place-content-center rounded bg-[#3ba55d]/15 text-[#3ba55d]">✓</span>
285 ) : item.state === 'too_big' ? null : (
286 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-shell-ink-muted" />
287 )}
288 <button
289 type="button"
290 aria-label={t('chat.composer.removeFile', { name: item.file.name })}
291 onClick={() => composer.removeUpload(item.localId)}
292 className="absolute right-0.5 top-0.5 grid h-4 w-4 place-content-center rounded bg-shell-surface/90 text-shell-ink-muted hover:text-shell-ink"
293 >
294 <X className="h-3 w-3" />
295 </button>
296 </div>
297 ))}
298 </div>
299 ) : null}
300
301 <div className="chat-composer-input">
302 <div className="relative min-w-0 flex-1">
303 <textarea
304 ref={boxRef}
305 value={text}
306 rows={1}
307 placeholder={placeholder ?? t('chat.composer.entryHint', { room: roomLabel })}
308 aria-label={t('chat.composer.inputAria')}
309 aria-describedby={helpId}
310 spellCheck
311 onChange={(event) => {
312 setText(event.target.value)
313 syncMention(event.target.value, event.target.selectionStart ?? 0)
314 onTyping?.()
315 }}
316 onClick={(event) => syncMention(text, event.currentTarget.selectionStart ?? 0)}
317 onBlur={() => setMention(null)}
318 onKeyDown={onKeyDown}
319 onPaste={onPaste}
320 className="max-h-56 w-full resize-none bg-transparent py-1.5 text-[15px] leading-[1.5] outline-none placeholder:text-shell-ink-muted"
321 />
322 <Popover
323 open={Boolean(mention) && candidates.length > 0}
324 onClose={() => setMention(null)}
325 className="w-64 p-1"
326 >
327 <p className="px-2 py-1 text-[10.5px] font-bold uppercase tracking-wide text-shell-ink-muted">
328 {t('chat.composer.mentionMembers')}
329 </p>
330 {candidates.map((member, index) => (
331 <button
332 key={member.user.user_id}
333 type="button"
334 onMouseEnter={() => setActive(index)}
335 onMouseDown={(event) => {
336 // mousedown, not click: the textarea's blur would otherwise
337 // close the list before the pick is registered.
338 event.preventDefault()
339 insertMention(member)
340 }}
341 className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[12.5px] ${
342 index === activeIndex ? 'bg-shell-active' : 'hover:bg-shell-field'
343 }`}
344 >
345 <span className="truncate font-medium">
346 {member.nick || member.user.display_name || member.user.username}
347 </span>
348 <span className="min-w-0 flex-1 truncate text-shell-ink-muted">@{member.user.username}</span>
349 {member.role !== 'member' ? (
350 <span className="shrink-0 rounded bg-shell-field px-1 text-[10px]">{member.role}</span>
351 ) : null}
352 </button>
353 ))}
354 </Popover>
355 </div>
356 </div>
357 <div className="chat-composer-toolbar">
358 <label className="chat-composer-tool" title={t('chat.composer.addAttachmentHint')}>
359 <input
360 type="file"
361 multiple
362 aria-label={t('chat.composer.addAttachment')}
363 className="sr-only"
364 onChange={(event) => {
365 const files = [...(event.target.files ?? [])]
366 composer.addFiles(files)
367 event.target.value = ''
368 }}
369 />
370 <Paperclip className="h-[18px] w-[18px]" />
371 </label>
372 <div className="relative shrink-0">
373 <button
374 type="button"
375 aria-label={t('chat.composer.insertEmoji')}
376 title={t('chat.composer.insertEmoji')}
377 aria-expanded={emojiOpen}
378 onClick={() => setEmojiOpen((value) => !value)}
379 className="chat-composer-tool"
380 >
381 <Smile className="h-[18px] w-[18px]" />
382 </button>
383 <Popover open={emojiOpen} onClose={() => setEmojiOpen(false)} className="w-80">
384 <EmojiPicker custom={customEmojis} spaceName={spaceName} onPick={insertEmoji} />
385 </Popover>
386 </div>
387 <p id={helpId} className="chat-composer-help">
388 <span>
389 <kbd>Enter</kbd> {t('chat.composer.enterSends')}
390 </span>
391 <span>
392 <kbd>Shift + Enter</kbd> {t('chat.composer.shiftEnterNewline')}
393 </span>
394 </p>
395 <button
396 type="button"
397 aria-label={t('chat.composer.sendAria')}
398 title={
399 slowModeRemaining > 0
400 ? t('chat.composer.slowModeWait', { seconds: slowModeRemaining })
401 : blocked
402 ? blocked
403 : over
404 ? t('chat.composer.tooLongBy', { count: length - MAX_MESSAGE_LEN })
405 : t('chat.composer.sendWithEnter')
406 }
407 disabled={!canSend}
408 onClick={submit}
409 className="chat-send-button"
410 >
411 {slowModeRemaining > 0 ? (
412 <span className="text-[11px] font-bold tabular-nums">{slowModeRemaining}</span>
413 ) : (
414 <Send className="h-4 w-4" />
415 )}
416 <span>{editing ? t('chat.composer.save') : t('chat.composer.sendAria')}</span>
417 </button>
418 </div>
419
420 {(over || blocked || composer.draftNotice || (slowModeSeconds > 0 && length === 0)) && (
421 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pb-2 text-[11.5px]">
422 {over ? (
423 <span className="flex items-center gap-1 text-[#e5484d]">
424 <TriangleAlert className="h-3 w-3" />
425 {t('chat.composer.tooLongDetail', { over: length - MAX_MESSAGE_LEN, max: MAX_MESSAGE_LEN })}
426 </span>
427 ) : null}
428 {blocked ? <span className="text-shell-ink-muted">{blocked}</span> : null}
429 {composer.draftNotice ? <span className="text-shell-ink-muted">{composer.draftNotice}</span> : null}
430 {slowModeSeconds > 0 && length === 0 ? (
431 <span className="text-shell-ink-muted">
432 {t('chat.composer.slowModeDetail', { seconds: slowModeSeconds })}
433 </span>
434 ) : null}
435 </div>
436 )}
437 </div>
438 )
439}
440
441function ContextBar({
442 icon,
443 label,
444 preview,
445 hint,
446 onCancel,
447}: {
448 icon: React.ReactNode
449 label: string
450 preview: string
451 hint: string
452 onCancel?: () => void
453}) {
454 const { t } = useT()
455 return (
456 <div className="flex items-center gap-2 rounded-t-lg border-b border-shell-hairline bg-shell-surface px-3 py-1.5">
457 {icon}
458 <span className="shrink-0 text-[12px] font-semibold">{label}</span>
459 <span className="min-w-0 flex-1 truncate text-[12px] text-shell-ink-muted">
460 {preview || t('chat.composer.attachmentPlaceholder')}
461 </span>
462 <span className="hidden shrink-0 text-[11px] text-shell-ink-muted lg:inline">{hint}</span>
463 {onCancel ? (
464 <button
465 type="button"
466 aria-label={t('common.action.cancel')}
467 onClick={onCancel}
468 className="shrink-0 text-shell-ink-muted hover:text-shell-ink"
469 >
470 <X className="h-3.5 w-3.5" />
471 </button>
472 ) : null}
473 </div>
474 )
475}
Added view/app/src/domains/chat/ui/chat-polish.css view/app/src/domains/chat/ui/chat-polish.css +498 −0 View view/app/src/domains/chat/ui/chat-polish.css
@@ -0,0 +1,498 @@
1/* Conversation surfaces favour reading rhythm and clear, quiet affordances. */
2.chat-room {
3 background: var(--shell-surface);
4}
5.chat-room.chat-panel-resizing,
6.chat-room.chat-panel-resizing * {
7 cursor: col-resize !important;
8 user-select: none;
9}
10.chat-main {
11 position: relative;
12 min-width: 0;
13 container-type: inline-size;
14}
15.chat-header {
16 display: flex;
17 min-height: 76px;
18 align-items: center;
19 gap: 12px;
20 /* 13px(而不是 14px):标题块 48 + 上下留白 26 + 底部 1px 线恰好 75,由
21 min-height 收到 76,与右侧面板头的 76 精确对齐。 */
22 padding: 13px 24px;
23 border-bottom: 1px solid var(--shell-hairline);
24 background: color-mix(in srgb, var(--shell-surface) 94%, var(--shell-field));
25}
26/* 纯字形:无底色无边框,`#` 用主墨色(浅色主题下渲染为黑色)。38px 的占位
27 保持与两行标题的垂直对齐。 */
28.chat-channel-icon {
29 display: grid;
30 width: 38px;
31 height: 38px;
32 flex: 0 0 auto;
33 place-items: center;
34 border-radius: 12px;
35 color: var(--shell-ink);
36 background: transparent;
37}
38.chat-heading {
39 min-width: 0;
40 flex: 1;
41}
42.chat-topic {
43 margin-top: 2px;
44 overflow: hidden;
45 color: var(--shell-ink-muted);
46 font-size: 12px;
47 line-height: 1.5;
48 text-overflow: ellipsis;
49 white-space: nowrap;
50}
51.chat-header-actions {
52 display: flex;
53 flex: 0 0 auto;
54 align-items: center;
55 gap: 4px;
56}
57.chat-header-actions > button,
58.chat-panel-close {
59 display: inline-grid;
60 width: 36px;
61 height: 36px;
62 place-items: center;
63 border-radius: 10px;
64 color: var(--shell-ink-muted);
65 transition:
66 color 160ms,
67 background 160ms;
68}
69.chat-header-actions > button:hover,
70.chat-panel-close:hover {
71 color: var(--shell-ink);
72 background: var(--shell-active);
73}
74.chat-action-label {
75 display: none;
76}
77.chat-message-list {
78 padding: 8px 12px 16px;
79 scroll-padding-block: 20px;
80}
81.chat-history-start {
82 margin: 20px 8px 8px;
83 color: var(--shell-ink-muted);
84 font-size: 12px;
85 text-align: center;
86}
87.chat-day-divider {
88 margin-top: 10px;
89 color: var(--shell-ink-muted);
90 font-size: 11px;
91 font-weight: 600;
92 letter-spacing: 0.03em;
93}
94.chat-message-row {
95 margin: 1px 0;
96 border-radius: 10px;
97}
98.chat-message-row:hover {
99 background: color-mix(in srgb, var(--shell-field) 72%, transparent);
100}
101.chat-message-row.chat-message-grouped {
102 min-height: 24px;
103}
104.chat-message-body {
105 max-width: 820px;
106 line-height: 1.65;
107 overflow-wrap: anywhere;
108}
109.chat-message-toolbar {
110 position: absolute;
111 z-index: 20; /* above the jump-to-latest pill, so its popover is never buried */
112 right: 10px;
113 top: -15px;
114 display: flex;
115 align-items: center;
116 gap: 2px;
117 padding: 3px;
118 border: 1px solid var(--shell-hairline);
119 border-radius: 10px;
120 background: var(--shell-surface);
121 box-shadow: var(--shell-shadow);
122 opacity: 0;
123 pointer-events: none; /* hidden, but it still covered the row above and ate its clicks */
124 /*
125 * Fade only. A `transform` here would make the toolbar the containing block
126 * for the reaction picker and the more-menu, which are `position: fixed`
127 * children placed with viewport coordinates — they would fly off-screen and
128 * the buttons would look dead.
129 */
130 transition: opacity 150ms;
131}
132.chat-message-row:hover .chat-message-toolbar,
133.chat-message-toolbar:focus-within,
134.chat-message-toolbar:has([aria-pressed="true"]) {
135 opacity: 1;
136 pointer-events: auto;
137}
138/* Square icon buttons are the toolbar's own actions, never the popovers it hosts. */
139.chat-message-toolbar > button,
140.chat-message-toolbar > div > button {
141 display: grid;
142 width: 30px;
143 height: 30px;
144 place-items: center;
145 border-radius: 7px;
146 color: var(--shell-ink-muted);
147}
148.chat-message-toolbar > button:hover,
149.chat-message-toolbar > div > button:hover {
150 color: var(--shell-ink);
151 background: var(--shell-field);
152}
153.chat-unread-marker {
154 margin: 8px 0;
155}
156.chat-transcript-notice {
157 min-height: 280px;
158}
159.chat-notice-icon {
160 display: grid;
161 width: 48px;
162 height: 48px;
163 place-items: center;
164 border: 1px solid var(--shell-hairline);
165 border-radius: 16px;
166 color: var(--shell-ink-muted);
167 background: var(--shell-field);
168}
169.chat-notice-title {
170 font-size: 18px;
171 font-weight: 600;
172}
173.chat-notice-description {
174 max-width: 360px;
175 color: var(--shell-ink-muted);
176 font-size: 14px;
177 line-height: 1.7;
178}
179.chat-composer-band {
180 flex: 0 0 auto;
181 padding: 12px 20px 18px;
182 border-top: 1px solid var(--shell-hairline);
183 background: var(--shell-surface);
184}
185.chat-composer {
186 overflow: hidden;
187 border: 1px solid var(--shell-hairline);
188 border-radius: 14px;
189 background: var(--shell-field);
190 box-shadow: 0 2px 8px rgb(24 28 31 / 3%);
191}
192.chat-composer:focus-within {
193 border-color: var(--shell-tile);
194}
195.chat-composer-input {
196 display: flex;
197 min-height: 46px;
198 padding: 5px 12px 0;
199}
200.chat-composer-toolbar {
201 display: flex;
202 align-items: center;
203 gap: 4px;
204 min-height: 42px;
205 padding: 4px 8px 8px;
206}
207.chat-composer-tool {
208 display: grid;
209 width: 34px;
210 height: 34px;
211 place-items: center;
212 border-radius: 9px;
213 color: var(--shell-ink-muted);
214 transition:
215 background 160ms,
216 color 160ms;
217}
218.chat-composer-tool:hover {
219 color: var(--shell-ink);
220 background: var(--shell-active);
221}
222.chat-composer-help {
223 display: flex;
224 gap: 12px;
225 margin-left: 6px;
226 color: var(--shell-ink-muted);
227 font-size: 11px;
228}
229.chat-composer-help kbd {
230 padding: 1px 4px;
231 border: 1px solid var(--shell-hairline);
232 border-radius: 4px;
233 background: var(--shell-surface);
234 font-family: inherit;
235}
236.chat-send-button {
237 display: inline-flex;
238 min-width: 70px;
239 height: 34px;
240 align-items: center;
241 justify-content: center;
242 gap: 6px;
243 margin-left: auto;
244 padding: 0 11px;
245 border-radius: 9px;
246 color: white;
247 background: var(--shell-action);
248 font-size: 12px;
249 font-weight: 600;
250 transition:
251 filter 160ms,
252 transform 160ms;
253}
254.chat-send-button:hover:not(:disabled) {
255 filter: brightness(1.08);
256}
257.chat-send-button:active:not(:disabled) {
258 transform: scale(0.97);
259}
260.chat-send-button:disabled {
261 cursor: not-allowed;
262 opacity: 0.45;
263}
264.chat-side-panel {
265 position: relative;
266 width: min(var(--chat-panel-width, 380px), calc(100% - 100px));
267 max-width: calc(100% - 100px);
268 box-shadow: -10px 0 24px rgb(24 28 31 / 3%);
269}
270.chat-panel-resize-handle {
271 position: absolute;
272 z-index: 30;
273 top: 0;
274 bottom: 0;
275 left: -6px;
276 width: 12px;
277 cursor: col-resize;
278 touch-action: none;
279 outline: none;
280}
281.chat-panel-resize-handle::after {
282 position: absolute;
283 top: 50%;
284 left: 5px;
285 width: 2px;
286 height: 44px;
287 border-radius: 999px;
288 background: var(--shell-focus);
289 content: "";
290 opacity: 0;
291 transform: translateY(-50%);
292 transition: opacity 150ms;
293}
294.chat-panel-resize-handle:hover::after,
295.chat-panel-resize-handle:focus-visible::after,
296.chat-panel-resizing .chat-panel-resize-handle::after {
297 opacity: 1;
298}
299.chat-panel-focus-scrim {
300 position: absolute;
301 z-index: 25;
302 inset: 0;
303 background: color-mix(in srgb, var(--shell-surface) 38%, transparent);
304 backdrop-filter: blur(8px) saturate(0.86);
305 -webkit-backdrop-filter: blur(8px) saturate(0.86);
306}
307.chat-panel-header {
308 min-height: 76px;
309 padding: 0 16px;
310}
311.chat-panel-empty {
312 display: flex;
313 flex-direction: column;
314 align-items: center;
315 gap: 10px;
316 padding: 40px 24px;
317 text-align: center;
318}
319.chat-panel-empty-icon {
320 display: grid;
321 width: 44px;
322 height: 44px;
323 place-items: center;
324 border-radius: 15px;
325 color: var(--shell-ink-muted);
326 background: var(--shell-field);
327}
328.chat-popover {
329 box-shadow: 0 14px 40px rgb(24 28 31 / 16%);
330}
331.chat-popover button {
332 cursor: pointer;
333}
334
335/* The picker ships its own palette; re-point its variables at the shell tokens
336 * so search, category tabs and hover states read as part of the same surface. */
337.chat-emoji-picker .EmojiPickerReact {
338 --epr-bg-color: var(--shell-surface);
339 --epr-picker-border-color: var(--shell-hairline);
340 --epr-picker-border-radius: 10px;
341 --epr-text-color: var(--shell-ink-muted);
342 --epr-search-input-bg-color: var(--shell-field);
343 --epr-search-input-bg-color-active: var(--shell-field);
344 --epr-search-border-color: var(--shell-hairline);
345 --epr-search-border-color-active: var(--shell-focus);
346 --epr-hover-bg-color: var(--shell-active);
347 --epr-focus-bg-color: var(--shell-active);
348 --epr-category-label-bg-color: color-mix(in srgb, var(--shell-surface) 90%, transparent);
349 --epr-category-icon-active-color: var(--shell-action);
350 --epr-category-icon-inactive-color: var(--shell-ink-muted);
351}
352/* The dark theme re-points every variable at an --epr-dark-* twin, so the
353 * tokens have to be overridden there instead. */
354.chat-emoji-picker .epr-dark-theme {
355 --epr-dark-bg-color: var(--shell-surface);
356 --epr-dark-picker-border-color: var(--shell-hairline);
357 --epr-dark-text-color: var(--shell-ink-muted);
358 --epr-dark-search-input-bg-color: var(--shell-field);
359 --epr-dark-search-input-bg-color-active: var(--shell-field);
360 --epr-dark-hover-bg-color: var(--shell-active);
361 --epr-dark-focus-bg-color: var(--shell-active);
362 --epr-dark-category-label-bg-color: color-mix(in srgb, var(--shell-surface) 90%, transparent);
363 --epr-dark-category-icon-active-color: var(--shell-ink);
364 --epr-dark-category-icon-inactive-color: var(--shell-ink-muted);
365}
366.chat-emoji-picker .epr-main * {
367 font-family: inherit;
368}
369/* The picker scrolls its own body, so the popover must not clip it. */
370.chat-popover:has(.chat-emoji-picker) {
371 padding: 4px;
372}
373
374/* A reader can intentionally leave the chat column very narrow while widening
375 * a detail pane. Keep the header compact and horizontal at every width rather
376 * than allowing its title and topic to wrap into an unusable column. */
377@container (max-width: 480px) {
378 .chat-main > .chat-header {
379 min-height: 60px;
380 padding: 10px 12px;
381 gap: 8px;
382 }
383 .chat-main .chat-topic {
384 display: none;
385 }
386 .chat-main .chat-header-actions {
387 gap: 0;
388 }
389 .chat-main .chat-header-actions > button {
390 width: 32px;
391 height: 32px;
392 }
393 .chat-main .chat-header-actions > button:nth-child(n + 4) {
394 display: none;
395 }
396}
397@container (max-width: 300px) {
398 .chat-main .chat-heading {
399 display: none;
400 }
401 .chat-main .chat-header-actions > button:nth-child(n + 3) {
402 display: none;
403 }
404}
405@container (max-width: 180px) {
406 .chat-main .chat-header-actions {
407 display: none;
408 }
409}
410
411.chat-panel-scrim {
412 display: none;
413}
414@media (max-width: 1200px) {
415 .chat-panel-scrim {
416 display: block;
417 position: fixed;
418 inset: 0;
419 z-index: 24;
420 background: rgb(18 20 22 / 40%);
421 }
422 .chat-side-panel {
423 position: fixed;
424 z-index: 25;
425 right: 0;
426 top: 0;
427 bottom: 0;
428 width: min(380px, 88vw);
429 max-width: none;
430 box-shadow: -18px 0 48px rgb(24 28 31 / 18%);
431 }
432 .chat-panel-resize-handle,
433 .chat-panel-focus-scrim {
434 display: none;
435 }
436}
437@media (max-width: 760px) {
438 .chat-header {
439 min-height: 68px;
440 padding: 12px 16px;
441 }
442 .chat-panel-header {
443 min-height: 68px;
444 }
445 .chat-channel-icon {
446 width: 34px;
447 height: 34px;
448 border-radius: 10px;
449 }
450 .chat-header-actions {
451 gap: 1px;
452 }
453 .chat-header-actions > button {
454 width: 32px;
455 height: 32px;
456 }
457 .chat-message-list {
458 padding: 6px 8px 12px;
459 }
460 .chat-message-row {
461 gap: 10px;
462 padding-left: 8px;
463 padding-right: 8px;
464 }
465 .chat-message-toolbar {
466 right: 8px;
467 opacity: 1;
468 pointer-events: auto;
469 }
470 .chat-message-toolbar > :nth-child(n + 4) {
471 display: none;
472 }
473 .chat-composer-band {
474 padding: 10px 12px 12px;
475 }
476 .chat-composer-help {
477 display: none;
478 }
479 .chat-side-panel {
480 width: 100%;
481 }
482}
483@media (max-width: 420px) {
484 .chat-header {
485 gap: 8px;
486 padding: 10px 12px;
487 }
488 .chat-header-actions > button {
489 width: 30px;
490 height: 30px;
491 }
492}
493@media (prefers-reduced-motion: reduce) {
494 .chat-message-toolbar,
495 .chat-send-button {
496 transition: none;
497 }
498}
Added view/app/src/domains/chat/ui/chat-room.tsx view/app/src/domains/chat/ui/chat-room.tsx +1026 −0 View view/app/src/domains/chat/ui/chat-room.tsx
@@ -0,0 +1,1026 @@
1/**
2 * The 聊天室 column: header, connection banners, transcript, composer and the
3 * right-hand panel. It is a *view* — the page owns the URL, this owns the room —
4 * and every piece of it survives a missing backend answer:
5 *
6 * - the header paints from the sidebar's own row while `GET /rooms/{id}` is in
7 * flight, so a channel switch never flashes a placeholder title;
8 * - the socket status is a banner, never a blocker: reading and posting keep
9 * working over HTTP while realtime is reconnecting;
10 * - a room that is gone (deleted, or access revoked mid-read) replaces the column
11 * with the reason instead of an empty transcript that looks like "no messages";
12 * - voice channels keep the call surface on top and reuse the same text API:
13 * posts fly over the call as bullet comments, and the header switches to the
14 * full transcript whenever someone wants to read it in order.
15 */
16
17import {
18 type ChatMessage,
19 closeDm as closeDmApi,
20 createThread as createThreadApi,
21 deleteRoom as deleteRoomApi,
22 failureCode,
23 failureReason,
24 getMessage as getMessageApi,
25 getThread,
26 isDmKind,
27 isVoiceRoom,
28 listVoiceParticipants,
29 openDm as openDmApi,
30 type RoomBrief,
31 type RoomKindName,
32 type ThreadBrief,
33 type UserInfo,
34 updateRoom,
35 updateRoomSetting,
36 type VoiceParticipant,
37} from '@app/domains/chat/model'
38import { FormModal } from '@app/domains/shared'
39import { useT } from '@app/i18n'
40import { useAsync } from '@app/lib'
41import { getRealtimeHub, sendTypingSignal } from '@app/services/realtime'
42import { useVoiceSession } from '@app/services/voice'
43import {
44 Bell,
45 BellOff,
46 CalendarX,
47 Hash,
48 Lock,
49 LogOut,
50 MessageSquare,
51 Phone,
52 Pin,
53 RefreshCw,
54 Settings2,
55 Star,
56 Users,
57 WifiOff,
58} from 'lucide-react'
59import {
60 type CSSProperties,
61 type KeyboardEvent as ReactKeyboardEvent,
62 type ReactNode,
63 type PointerEvent as ReactPointerEvent,
64 useCallback,
65 useEffect,
66 useMemo,
67 useRef,
68 useState,
69} from 'react'
70import { useComposer } from '../model/use-composer'
71import { useRoomData } from '../model/use-room-data'
72import { useRoomStream } from '../model/use-room-stream'
73import type { ChatViewState } from '../model/view-state'
74import { ChatComposer } from './chat-composer'
75import { IconAction, Popover, UserAvatar } from './parts'
76import { MembersPanel, PanelHeader, PinsPanel, ThreadPanel, ThreadsPanel } from './side-panel'
77import { type MessageInteractions, Transcript } from './transcript'
78import { VoiceDanmakuInput } from './voice/voice-danmaku-input'
79import { VoiceRoomPanel } from './voice/voice-room-panel'
80import './chat-polish.css'
81import './voice/voice.css'
82
83/** The detail pane stays useful while always leaving a visible chat column. */
84const PANEL_MIN_WIDTH = 280
85const CHAT_MIN_WIDTH = 100
86const PANEL_RESIZE_STEP = 24
87const PANEL_DEFAULT_WIDTH = 380
88
89export interface ChatRoomProps {
90 spaceName: string
91 roomId: string
92 viewerId?: number
93 /** Sidebar row, used only until the authoritative room row arrives. */
94 hint?: { name?: string; topic?: string | null; kind?: RoomKindName; peer?: UserInfo | null }
95 /** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
96 titleMenu?: (title: string) => ReactNode
97 /** Space-level `manage_channels`: channel settings and deletion. */
98 canManageSpace?: boolean
99 state: ChatViewState
100 onStateChange: (patch: Partial<ChatViewState>) => void
101 /** Unread cursor moved: the shell refetches its badges. */
102 onUnreadChanged?: () => void
103 /** The room was deleted or left: the page navigates away. */
104 onRoomGone: (reason: 'deleted' | 'left' | 'dm_closed') => void
105 /** A DM room was opened for a roster member: the page owns the route change. */
106 onDmOpened: (roomId: string) => void
107}
108
109export function ChatRoom({
110 spaceName,
111 roomId,
112 viewerId,
113 hint,
114 titleMenu,
115 canManageSpace = false,
116 state,
117 onStateChange,
118 onUnreadChanged,
119 onRoomGone,
120 onDmOpened,
121}: ChatRoomProps) {
122 const { t } = useT()
123 const [version, setVersion] = useState(0)
124 const [flash, setFlash] = useState<string | null>(null)
125 useEffect(() => {
126 if (!flash) return
127 const timer = setTimeout(() => setFlash(null), 6_000)
128 return () => clearTimeout(timer)
129 }, [flash])
130 const data = useRoomData({ roomId, spaceName, version })
131 const room = data.room.data
132 const kind = room?.kind ?? hint?.kind
133 const voice = isVoiceRoom(kind)
134 // Which half of a voice channel the column shows: the call (with bullet
135 // comments) or the plain transcript. The call keeps running either way.
136 const [voicePane, setVoicePane] = useState<'call' | 'text'>('call')
137 const dm = isDmKind(kind) || spaceName === '@me'
138 const memberCount = room?.member_count ?? 0
139 const slowModeSeconds = room?.rate_limit_per_user ?? 0
140 const isOwner = Boolean(viewerId && room && room.owner_id === viewerId)
141 const canModerateRoom = canManageSpace || isOwner
142 const chatRoomRef = useRef<HTMLDivElement>(null)
143 const resizeRef = useRef<{ pointerId: number; startX: number; moved: boolean } | null>(null)
144 const [panelWidth, setPanelWidth] = useState(PANEL_DEFAULT_WIDTH)
145 const [resizingPanel, setResizingPanel] = useState(false)
146 const [panelHasCustomWidth, setPanelHasCustomWidth] = useState(false)
147 const [focusedPanelKey, setFocusedPanelKey] = useState<string | null>(null)
148 const activePanelKey = state.panel === 'thread' ? `thread:${state.threadId ?? ''}` : state.panel
149 const panelFocused = activePanelKey !== null && focusedPanelKey === activePanelKey
150 const panelResized = Boolean(state.panel && panelHasCustomWidth)
151
152 const clampPanelWidth = useCallback((width: number) => {
153 const frame = chatRoomRef.current?.getBoundingClientRect()
154 const maxWidth = frame
155 ? Math.max(PANEL_MIN_WIDTH, Math.floor(frame.width - CHAT_MIN_WIDTH))
156 : Math.max(PANEL_MIN_WIDTH, Math.round(width))
157 return Math.min(maxWidth, Math.max(PANEL_MIN_WIDTH, Math.round(width)))
158 }, [])
159
160 const beginPanelResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
161 if (event.button !== 0) return
162 resizeRef.current = { pointerId: event.pointerId, startX: event.clientX, moved: false }
163 event.currentTarget.focus()
164 event.currentTarget.setPointerCapture(event.pointerId)
165 setResizingPanel(true)
166 event.preventDefault()
167 }, [])
168
169 const movePanelResize = useCallback(
170 (event: ReactPointerEvent<HTMLDivElement>) => {
171 const resize = resizeRef.current
172 const frame = chatRoomRef.current?.getBoundingClientRect()
173 if (!resize || resize.pointerId !== event.pointerId || !frame) return
174 if (!resize.moved && Math.abs(event.clientX - resize.startX) > 2) {
175 resize.moved = true
176 setPanelHasCustomWidth(true)
177 if (activePanelKey) setFocusedPanelKey(activePanelKey)
178 }
179 setPanelWidth(clampPanelWidth(frame.right - event.clientX))
180 },
181 [activePanelKey, clampPanelWidth],
182 )
183
184 const finishPanelResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
185 if (resizeRef.current?.pointerId !== event.pointerId) return
186 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
187 event.currentTarget.releasePointerCapture(event.pointerId)
188 }
189 resizeRef.current = null
190 setResizingPanel(false)
191 }, [])
192
193 const resizePanelWithKeyboard = useCallback(
194 (event: ReactKeyboardEvent<HTMLDivElement>) => {
195 let next: number | null = null
196 if (event.key === 'ArrowLeft') next = panelWidth + PANEL_RESIZE_STEP
197 if (event.key === 'ArrowRight') next = panelWidth - PANEL_RESIZE_STEP
198 if (event.key === 'Home') next = PANEL_MIN_WIDTH
199 if (event.key === 'End') next = clampPanelWidth(Number.MAX_SAFE_INTEGER)
200 if (next === null) return
201 event.preventDefault()
202 setPanelWidth(clampPanelWidth(next))
203 setPanelHasCustomWidth(true)
204 if (activePanelKey) setFocusedPanelKey(activePanelKey)
205 },
206 [activePanelKey, clampPanelWidth, panelWidth],
207 )
208
209 // Voice rooms replace the transcript with the call surface; the participant
210 // list is webhook-backed, so the room re-reads it whenever a chat event says
211 // the room changed while a call is live.
212 const voiceState = useVoiceSession()
213 // 弹幕只在通话中发送:未加入(或连接失败)时,输入栏不该出现。
214 const inVoiceCall = voiceState.roomId === roomId && voiceState.status !== 'idle' && voiceState.status !== 'failed'
215 const voiceParticipants = useAsync(
216 () =>
217 voice
218 ? listVoiceParticipants(roomId).catch(() => [] as VoiceParticipant[])
219 : Promise.resolve([] as VoiceParticipant[]),
220 // The call status is a dependency: joining or leaving changes who is in
221 // the room, and neither produces a chat event on its own. The raw status is
222 // used (not a joined/not-joined flag) so the refetch also re-runs on
223 // connecting -> connected, when the participant actually appears.
224 [voice, roomId, version, room?.kind, voiceState.status],
225 )
226
227 const stream = useRoomStream({
228 roomId,
229 viewerId,
230 slowModeSeconds,
231 onUnreadChanged,
232 onRoomEvent: (kind) => {
233 if (kind === 'room_updated' || kind === 'thread_updated' || kind === 'message_pinned') {
234 setVersion((value) => value + 1)
235 }
236 if (kind === 'member_joined' || kind === 'member_left') setVersion((value) => value + 1)
237 if (kind === 'voice_presence' || kind === 'voice_moderation') setVersion((value) => value + 1)
238 },
239 })
240
241 // Quote-reply and edit targets are *URL state resolved against the loaded
242 // rows* rather than local mirrors: a row that is deleted or paged out of the
243 // window therefore drops the context by itself, and reloading the address
244 // restores the composer in the same state it was left in.
245 const replyTo = state.replyToId ? findRow(stream.state.rows, state.replyToId) : undefined
246 const editing = state.editId ? findRow(stream.state.rows, state.editId) : undefined
247 const composer = useComposer({
248 roomId,
249 enabled: !stream.state.accessRevoked,
250 editingId: editing?.id ?? null,
251 editingContent: editing?.content ?? '',
252 onSubmitted: (content, attachments) => stream.send({ content, attachments, replyToId: replyTo?.id ?? null }),
253 onTyping: () => sendTypingSignal(roomId),
254 })
255
256 // A deep `?jump=` is applied once per id: the transcript fetches its own window
257 // around the row, which is the only way to reach a message that was never
258 // loaded, and clearing the parameter would erase a link worth keeping. A jump
259 // inside an open thread belongs to the thread panel's own transcript.
260 const jumpingInThread = state.panel === 'thread' && Boolean(state.threadId)
261 useEffect(() => {
262 const id = state.jumpId
263 if (!id || jumped.current === id) return
264 // 话题内的跳转由 ThreadPanel 自己完成;这里只把它标记成已处理,
265 // 免得关掉话题面板后同一条深链又到主时间线里再跳一次。
266 jumped.current = id
267 if (jumpingInThread) return
268 stream.jumpTo(id)
269 }, [state.jumpId, jumpingInThread, stream])
270
271 const jumped = useRef<string | null>(null)
272
273 /* -------------------------------------------------------- interactions */
274
275 const nameOf = useCallback(
276 (userId: number | null | undefined) => {
277 if (!userId) return t('common.state.unknownUser')
278 if (userId === viewerId) return t('shell.chat.me')
279 const member = (data.members.data ?? []).find((row) => row.user.user_id === userId)
280 if (member) return member.nick || member.user.display_name || member.user.username
281 const author = stream.state.rows.find((row) => row.author_id === userId)?.author
282 return (
283 author?.display_name || author?.username || t('shell.chat.userFallback', { id: String(userId).slice(0, 8) })
284 )
285 },
286 [data.members.data, stream.state.rows, t, viewerId],
287 )
288
289 const openThread = useCallback(
290 async (message: ChatMessage) => {
291 const existing = data.threadOf(message.id)
292 if (existing) {
293 setFocusedPanelKey(null)
294 onStateChange({ panel: 'thread', threadId: existing.id })
295 return
296 }
297 try {
298 const thread = await createThreadApi(roomId, message.id)
299 setVersion((value) => value + 1)
300 setFocusedPanelKey(null)
301 onStateChange({ panel: 'thread', threadId: thread.id })
302 } catch (error) {
303 // A root that cannot be threaded (locked room, missing message) must say
304 // so instead of silently doing nothing.
305 setFlash(failureReason(error))
306 }
307 },
308 [data, onStateChange, roomId],
309 )
310
311 const interactions = useMemo<MessageInteractions>(
312 () => ({
313 onReply: (message) => onStateChange({ replyToId: message.id }),
314 onOpenThread: (message) => void openThread(message),
315 onEdit: (message) => onStateChange({ editId: message.id }),
316 onDelete: (message) => void stream.deleteMessage(message.id),
317 onTogglePin: (message) => void stream.togglePin(message.id, !message.pinned),
318 onReact: (message, emoji, on) => void stream.toggleReaction(message.id, emoji, on),
319 // Jumping always goes through the URL, so the address bar and the window the
320 // transcript ended up showing stay one and the same thing.
321 onJump: (messageId) => onStateChange({ jumpId: messageId }),
322 canModerate: (message) => canModerateRoom || Boolean(viewerId && message.author_id === viewerId),
323 canParticipate: !stream.state.accessRevoked,
324 threadOf: (messageId) => data.threadOf(messageId),
325 nameOf,
326 }),
327 [canModerateRoom, data, nameOf, onStateChange, openThread, stream, viewerId],
328 )
329
330 /**
331 * A dead socket is *not* a dead room: HTTP keeps working, so "reconnect" only
332 * restarts the transport (a terminal auth_required/failed state needs an
333 * explicit start) and re-pulls whatever the gap window missed.
334 */
335 const reconnect = useCallback(() => {
336 getRealtimeHub().retry()
337 stream.refresh()
338 }, [stream])
339
340 /* --------------------------------------------------------------- header */
341
342 const title = dm
343 ? hint?.peer?.display_name || hint?.peer?.username || room?.name || t('shell.chat.dm')
344 : room?.name || hint?.name || t('shell.chat.channel')
345 const topic = room?.topic ?? hint?.topic ?? undefined
346 // The room itself is unreachable: anything below it would be an empty
347 // transcript pretending to be a quiet channel, so replace the column instead.
348 const roomErrorCode = data.room.error && !data.room.data ? failureCode(data.room.error) : undefined
349 const roomGone = roomErrorCode === 404 || roomErrorCode === 403 || roomErrorCode === 401
350
351 // The global search trigger lives above the workspace shell, outside this
352 // flex row. Publish the live panel width so it remains aligned with the
353 // chat column's right edge while the user resizes the detail pane.
354 useEffect(() => {
355 if (!state.panel || roomGone) return
356 const root = document.documentElement
357 root.style.setProperty('--global-chat-panel-width', `${panelWidth}px`)
358 return () => {
359 root.style.removeProperty('--global-chat-panel-width')
360 }
361 }, [panelWidth, roomGone, state.panel])
362
363 const [settingsOpen, setSettingsOpen] = useState(false)
364 const [menu, setMenu] = useState(false)
365 // Opening a DM is a write that can fail (the peer left, the quota is spent),
366 // so it is reported here rather than swallowed by the page's navigation.
367 const [dmBusy, setDmBusy] = useState(false)
368 /**
369 * Room writes are fire-and-forget from the menu's point of view, so every one
370 * them report through the same banner: a silent 403 on 「离开频道」 would
371 * otherwise read as success, because the row simply stays where it was.
372 */
373 const write = useCallback(
374 (action: () => Promise<unknown>) => {
375 setMenu(false)
376 void action().then(
377 () => {
378 // Success needs no banner: the refetched room row flips the menu label
379 // (静音 → 取消静音), which is the confirmation the reader can verify.
380 setVersion((value) => value + 1)
381 onUnreadChanged?.()
382 setFlash(null)
383 },
384 (error) => setFlash(failureReason(error)),
385 )
386 },
387 [onUnreadChanged],
388 )
389 const openDmWith = useCallback(
390 (user: UserInfo) => {
391 // One in flight at a time: a double click would open (and report) twice.
392 if (user.user_id === viewerId || dmBusy) return
393 setDmBusy(true)
394 openDmApi(user.user_id).then(
395 (row) => {
396 setDmBusy(false)
397 onDmOpened(row.id)
398 },
399 (error) => {
400 setDmBusy(false)
401 setFlash(failureReason(error))
402 },
403 )
404 },
405 [dmBusy, onDmOpened, viewerId],
406 )
407
408 // Narrow screens turn the panel into a full-height overlay; a scrim is the
409 // only affordance that lets a tap outside dismiss it again.
410 const closePanel = useCallback(() => {
411 setFocusedPanelKey(null)
412 setPanelHasCustomWidth(false)
413 onStateChange({ panel: null, threadId: undefined })
414 }, [onStateChange])
415 const togglePanel = useCallback(
416 (panel: 'pins' | 'threads' | 'members') => {
417 if (state.panel === panel) {
418 closePanel()
419 return
420 }
421 setFocusedPanelKey(null)
422 onStateChange({ panel })
423 },
424 [closePanel, onStateChange, state.panel],
425 )
426
427 useEffect(() => {
428 if (!state.panel) return
429 const onKey = (event: KeyboardEvent) => {
430 if (event.key === 'Escape') closePanel()
431 }
432 window.addEventListener('keydown', onKey)
433 return () => window.removeEventListener('keydown', onKey)
434 }, [state.panel, closePanel])
435
436 const header = (
437 <header className="chat-header">
438 <span className="chat-channel-icon" aria-hidden={!dm}>
439 {dm ? <UserAvatarOrInitial hint={hint} /> : <Hash className="h-5 w-5" />}
440 </span>
441 <div className="chat-heading">
442 <div className="flex min-w-0 items-center gap-2">
443 {titleMenu ? (
444 titleMenu(title)
445 ) : (
446 <h1 className="truncate text-[16px] font-semibold" title={title}>
447 {title}
448 </h1>
449 )}
450 {room?.is_private ? <Lock className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" /> : null}
451 {slowModeSeconds > 0 ? (
452 <span
453 title={t('chat.room.slowModeTitle', { seconds: slowModeSeconds })}
454 className="shrink-0 rounded bg-shell-field px-1.5 py-px text-[10.5px] text-shell-ink-muted"
455 >
456 {slowModeSeconds}s
457 </span>
458 ) : null}
459 </div>
460 <p className="chat-topic" title={topic || undefined}>
461 {topic || (dm ? t('chat.room.dmTopicPlaceholder') : t('chat.room.topicPlaceholder'))}
462 </p>
463 </div>
464 <div className="chat-header-actions">
465 {voice ? (
466 <IconAction
467 label={voicePane === 'call' ? t('chat.room.transcriptPane') : t('chat.room.callPane')}
468 active={voicePane === 'text'}
469 onClick={() => setVoicePane((value) => (value === 'call' ? 'text' : 'call'))}
470 >
471 {voicePane === 'call' ? (
472 <MessageSquare className="h-[17px] w-[17px]" />
473 ) : (
474 <Phone className="h-[17px] w-[17px]" />
475 )}
476 <span className="chat-action-label">
477 {voicePane === 'call' ? t('chat.room.transcriptPane') : t('chat.room.callPane')}
478 </span>
479 </IconAction>
480 ) : null}
481 <IconAction label={t('chat.room.pins')} active={state.panel === 'pins'} onClick={() => togglePanel('pins')}>
482 <Pin className="h-[17px] w-[17px]" />
483 </IconAction>
484 <IconAction
485 label={t('chat.room.threads')}
486 active={state.panel === 'threads'}
487 onClick={() => togglePanel('threads')}
488 >
489 <MessageSquare className="h-[17px] w-[17px]" />
490 </IconAction>
491 <IconAction
492 label={memberCount > 0 ? t('chat.room.membersWithCount', { count: memberCount }) : t('chat.room.members')}
493 active={state.panel === 'members'}
494 onClick={() => togglePanel('members')}
495 >
496 <Users className="h-[17px] w-[17px]" />
497 </IconAction>
498 <div className="relative">
499 <IconAction label={t('chat.room.options')} active={menu} onClick={() => setMenu((value) => !value)}>
500 <Settings2 className="h-[17px] w-[17px]" />
501 </IconAction>
502 <Popover open={menu} onClose={() => setMenu(false)} align="end" side="bottom" className="w-52 py-1">
503 <MenuRow
504 icon={room?.muted ? <BellOff className="h-3.5 w-3.5" /> : <Bell className="h-3.5 w-3.5" />}
505 label={room?.muted ? t('chat.room.unmute') : t('chat.room.mute')}
506 onClick={() => write(() => updateRoomSetting(roomId, { muted: !room?.muted }))}
507 />
508 <MenuRow
509 icon={<Star className="h-3.5 w-3.5" />}
510 label={room?.favorite ? t('chat.room.unfavorite') : t('chat.room.favorite')}
511 onClick={() => write(() => updateRoomSetting(roomId, { favorite: !room?.favorite }))}
512 />
513 <MenuRow
514 icon={<CalendarX className="h-3.5 w-3.5" />}
515 label={t('chat.room.clearDraft')}
516 onClick={() => {
517 setMenu(false)
518 // The composer owns the draft write: resetting it also deletes
519 // the server-side row, so one call is enough.
520 composer.reset()
521 }}
522 />
523 <div className="my-1 h-px bg-shell-hairline" />
524 {dm ? (
525 <MenuRow
526 icon={<LogOut className="h-3.5 w-3.5" />}
527 label={t('chat.room.closeDm')}
528 danger
529 onClick={() => write(() => closeDmApi(roomId).then(() => onRoomGone('dm_closed')))}
530 />
531 ) : (
532 <>
533 <MenuRow
534 icon={<Settings2 className="h-3.5 w-3.5" />}
535 label={canModerateRoom ? t('chat.room.settings') : t('chat.room.info')}
536 onClick={() => {
537 setMenu(false)
538 setSettingsOpen(true)
539 }}
540 />
541 </>
542 )}
543 </Popover>
544 </div>
545 </div>
546 </header>
547 )
548
549 /* ---------------------------------------------------------------- body */
550
551 const body = (
552 <div className="flex min-h-0 flex-1 flex-col">
553 <ConnectionBanner status={stream.status} onRetry={reconnect} />
554 {stream.notice || flash ? (
555 <div
556 role="status"
557 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]"
558 >
559 <span className="min-w-0 flex-1 truncate">{stream.notice ?? flash}</span>
560 <button
561 type="button"
562 onClick={() => {
563 stream.dismissNotice()
564 setFlash(null)
565 }}
566 className="shrink-0 underline"
567 >
568 {t('common.action.ignore')}
569 </button>
570 </div>
571 ) : null}
572 {voice && voicePane === 'call' ? (
573 <>
574 <VoiceRoomPanel
575 key={roomId}
576 roomId={roomId}
577 roomName={title}
578 spaceName={spaceName}
579 isStage={kind === 'Stage'}
580 participants={voiceParticipants.data ?? []}
581 danmakuMessages={stream.state.rows}
582 danmakuSeeded={stream.state.seeded}
583 danmakuHasOlder={stream.state.hasOlder}
584 onLoadOlderDanmaku={stream.loadOlder}
585 nameOf={nameOf}
586 viewerId={viewerId}
587 canModerate={canModerateRoom}
588 />
589 {inVoiceCall ? (
590 <VoiceDanmakuInput
591 value={composer.text}
592 onChange={composer.setText}
593 disabled={stream.state.accessRevoked}
594 notice={
595 composer.blocked ??
596 (stream.slowModeRemaining > 0
597 ? t('chat.room.slowModeShort', { seconds: stream.slowModeRemaining })
598 : null)
599 }
600 onSend={() => {
601 void composer.submit().then((ok) => {
602 if (ok) onStateChange({ replyToId: undefined, editId: undefined })
603 })
604 }}
605 />
606 ) : null}
607 </>
608 ) : (
609 <Transcript
610 state={stream.state}
611 viewerId={viewerId}
612 lastReadSeq={stream.lastReadSeq}
613 missedCount={stream.missedCount}
614 listRef={stream.listRef}
615 interactions={interactions}
616 editingId={editing?.id ?? null}
617 onSaveEdit={async (messageId, content) => {
618 const ok = await stream.editMessage(messageId, content)
619 if (ok) onStateChange({ editId: undefined })
620 return ok
621 }}
622 onCancelEdit={() => onStateChange({ editId: undefined })}
623 onLoadOlder={stream.loadOlder}
624 onRetrySeed={stream.refresh}
625 onAtBottom={stream.reportAtBottom}
626 onReturnToLatest={stream.returnToLatest}
627 atBottom={stream.atBottom}
628 retryPending={stream.retryPending}
629 dismissPending={stream.dismissPending}
630 typingNames={stream.typingUserIds.map(nameOf)}
631 roomLabel={title}
632 customEmojis={data.emojis.data ?? []}
633 spaceName={spaceName}
634 />
635 )}
636 {voice && voicePane === 'call' ? null : (
637 <div className="chat-composer-band">
638 <ChatComposer
639 composer={composer}
640 roomLabel={title}
641 replyTo={replyTo}
642 onCancelReply={() => {
643 onStateChange({ replyToId: undefined })
644 stream.scrollToBottom()
645 }}
646 editing={editing}
647 onCancelEdit={() => onStateChange({ editId: undefined })}
648 slowModeSeconds={slowModeSeconds}
649 slowModeRemaining={stream.slowModeRemaining}
650 members={data.members.data ?? []}
651 customEmojis={data.emojis.data ?? []}
652 spaceName={spaceName}
653 onTyping={() => sendTypingSignal(roomId)}
654 onSubmit={() => {
655 void composer.submit().then((ok) => {
656 if (ok) onStateChange({ replyToId: undefined, editId: undefined })
657 })
658 }}
659 />
660 </div>
661 )}
662 </div>
663 )
664
665 // `?thread=` is a deep link: the id may belong to a topic older than the
666 // first page of `list_threads`, so ask for that one row before giving up.
667 const listed = state.panel === 'thread' ? findThread(data.threads.data ?? [], state.threadId) : undefined
668 const needLookup = state.panel === 'thread' && !listed && Boolean(state.threadId)
669 const lookedUp = useAsync(
670 () => (needLookup ? getThread(state.threadId ?? '').catch(() => undefined) : Promise.resolve(undefined)),
671 [needLookup, state.threadId],
672 )
673 const thread = listed ?? lookedUp.data
674 // A thread's root is usually already on screen; when it is older than the
675 // loaded window it is fetched, so the panel never opens without context.
676 const rootInWindow = thread ? findRow(stream.state.rows, thread.root_message_id) : undefined
677 const rootFetch = useAsync(
678 () =>
679 thread && !rootInWindow
680 ? getMessageApi(roomId, thread.root_message_id).catch(() => undefined)
681 : Promise.resolve(undefined),
682 [roomId, rootInWindow?.id, thread?.root_message_id],
683 )
684 const threadRoot = rootInWindow ?? rootFetch.data
685
686 return (
687 <div
688 ref={chatRoomRef}
689 className={`chat-room flex h-full min-h-0 min-w-0 flex-1${resizingPanel ? ' chat-panel-resizing' : ''}${panelResized ? ' chat-panel-resized' : ''}`}
690 >
691 <section className="chat-main flex min-w-0 flex-1 flex-col bg-shell-surface" aria-label={title}>
692 {header}
693 {roomGone ? <RoomGone code={roomErrorCode} /> : body}
694 {state.panel && !roomGone && panelFocused ? (
695 <button
696 type="button"
697 className="chat-panel-focus-scrim"
698 aria-label={t('chat.panel.clearFocus')}
699 onClick={() => setFocusedPanelKey(null)}
700 ></button>
701 ) : null}
702 </section>
703 {state.panel && !roomGone ? (
704 <>
705 <button
706 type="button"
707 className="chat-panel-scrim"
708 aria-label={t('chat.panel.close')}
709 tabIndex={-1}
710 onClick={closePanel}
711 />
712 <aside
713 className="chat-side-panel flex shrink-0 flex-col border-l border-shell-hairline bg-shell-sidebar"
714 style={{ '--chat-panel-width': `${panelWidth}px` } as CSSProperties}
715 aria-label={t('chat.room.details')}
716 >
717 <div
718 role="separator"
719 aria-orientation="vertical"
720 aria-label={t('chat.panel.resize')}
721 aria-valuemin={PANEL_MIN_WIDTH}
722 aria-valuenow={panelWidth}
723 tabIndex={0}
724 className="chat-panel-resize-handle"
725 onPointerDown={beginPanelResize}
726 onPointerMove={movePanelResize}
727 onPointerUp={finishPanelResize}
728 onPointerCancel={finishPanelResize}
729 onLostPointerCapture={finishPanelResize}
730 onKeyDown={resizePanelWithKeyboard}
731 />
732 {state.panel === 'thread' ? (
733 thread ? (
734 <ThreadPanel
735 roomId={roomId}
736 thread={thread}
737 root={threadRoot}
738 viewerId={viewerId}
739 spaceName={spaceName}
740 members={data.members.data ?? []}
741 customEmojis={data.emojis.data ?? []}
742 nameOf={nameOf}
743 interactions={interactions}
744 jumpId={state.jumpId}
745 onClose={closePanel}
746 onChanged={() => setVersion((value) => value + 1)}
747 />
748 ) : (
749 <ThreadMissing onClose={closePanel} />
750 )
751 ) : state.panel === 'pins' ? (
752 <PinsPanel
753 pins={data.pins.data ?? []}
754 loading={data.pins.loading}
755 nameOf={nameOf}
756 onJump={(messageId) => stream.jumpTo(messageId)}
757 onUnpin={(message) => void stream.togglePin(message.id, false)}
758 onClose={closePanel}
759 />
760 ) : state.panel === 'threads' ? (
761 <ThreadsPanel
762 threads={data.threads.data ?? []}
763 loading={data.threads.loading}
764 nameOf={nameOf}
765 onOpen={(row) => {
766 setFocusedPanelKey(null)
767 onStateChange({ panel: 'thread', threadId: row.id })
768 }}
769 onClose={closePanel}
770 />
771 ) : state.panel === 'members' ? (
772 <MembersPanel
773 memberCount={memberCount}
774 members={data.members.data ?? []}
775 loading={data.members.loading}
776 error={data.members.error}
777 presences={data.presences.data ?? []}
778 viewerId={viewerId}
779 onDm={openDmWith}
780 onClose={closePanel}
781 />
782 ) : null}
783 </aside>
784 </>
785 ) : null}
786
787 <RoomSettingsModal
788 open={settingsOpen}
789 room={room}
790 hintName={title}
791 hintTopic={topic ?? null}
792 dm={dm}
793 canManage={canModerateRoom}
794 onClose={() => setSettingsOpen(false)}
795 onChanged={() => {
796 setVersion((value) => value + 1)
797 stream.refresh()
798 }}
799 onDeleted={() => {
800 setSettingsOpen(false)
801 onRoomGone('deleted')
802 }}
803 />
804 </div>
805 )
806}
807
808/* ------------------------------------------------------------------ pieces */
809
810/** Rows are addressed by id; a target that is not loaded has no context to show. */
811function findRow(rows: ChatMessage[], id: string | undefined): ChatMessage | undefined {
812 return id ? rows.find((row) => row.id === id) : undefined
813}
814
815function findThread(threads: ThreadBrief[], threadId: string | undefined): ThreadBrief | undefined {
816 if (!threadId) return undefined
817 return threads.find((candidate) => candidate.id === threadId)
818}
819
820function UserAvatarOrInitial({ hint }: { hint?: { peer?: UserInfo | null } }) {
821 if (!hint?.peer) return <Users className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />
822 return <UserAvatar user={hint.peer} id={hint.peer.user_id} size={38} />
823}
824
825function ConnectionBanner({ status, onRetry }: { status: string; onRetry: () => void }) {
826 const { t } = useT()
827 if (status === 'open') return null
828 const text =
829 status === 'auth_required'
830 ? t('chat.connection.authRequired')
831 : status === 'failed'
832 ? t('chat.connection.stopped')
833 : status === 'connecting' || status === 'reconnecting'
834 ? t('chat.connection.connecting')
835 : t('chat.connection.closed')
836 return (
837 <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]">
838 <WifiOff className="h-3.5 w-3.5 shrink-0" />
839 <span className="min-w-0 flex-1 truncate">{text}</span>
840 <button
841 type="button"
842 onClick={onRetry}
843 className="flex shrink-0 items-center gap-1 rounded border border-[#804200]/30 px-1.5 py-px hover:bg-[#f0b232]/25"
844 >
845 <RefreshCw className="h-3 w-3" />
846 {t('chat.connection.retry')}
847 </button>
848 </div>
849 )
850}
851
852function RoomGone({ code }: { code?: number }) {
853 const { t } = useT()
854 return (
855 <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
856 <Lock className="h-6 w-6 text-shell-ink-muted" />
857 <p className="text-[14px] font-semibold">{code === 403 ? t('chat.room.accessDenied') : t('chat.room.gone')}</p>
858 <p className="max-w-sm text-[12.5px] leading-[1.6] text-shell-ink-muted">{t('chat.room.goneHint')}</p>
859 </div>
860 )
861}
862
863function ThreadMissing({ onClose }: { onClose: () => void }) {
864 const { t } = useT()
865 return (
866 <>
867 <PanelHeader title={t('chat.thread.title')} subtitle={t('chat.thread.missing')} onClose={onClose} />
868 <div className="p-4 text-[12.5px] leading-[1.6] text-shell-ink-muted">{t('chat.thread.missingHint')}</div>
869 </>
870 )
871}
872
873function MenuRow({
874 icon,
875 label,
876 onClick,
877 danger = false,
878 disabled = false,
879}: {
880 icon: React.ReactNode
881 label: string
882 onClick: () => void
883 danger?: boolean
884 disabled?: boolean
885}) {
886 return (
887 <button
888 type="button"
889 disabled={disabled}
890 onClick={onClick}
891 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 ${
892 danger ? 'text-[#e5484d]' : ''
893 }`}
894 >
895 {icon}
896 {label}
897 </button>
898 )
899}
900
901/** Channel settings: the writable subset of `PATCH /chat/rooms/{id}`. */
902function RoomSettingsModal({
903 open,
904 room,
905 hintName,
906 hintTopic,
907 dm,
908 canManage,
909 onClose,
910 onChanged,
911 onDeleted,
912}: {
913 open: boolean
914 room?: RoomBrief
915 hintName: string
916 hintTopic: string | null
917 dm: boolean
918 canManage: boolean
919 onClose: () => void
920 onChanged: () => void
921 onDeleted: () => void
922}) {
923 const { t } = useT()
924 const [armed, setArmed] = useState(false)
925 // Gated on `open` rather than reset by an effect: closing the dialog must be
926 // enough to disarm the destructive step.
927 const confirmDelete = open && armed
928 return (
929 <FormModal
930 open={open}
931 title={dm ? t('chat.room.dmSettings') : t('chat.room.settings')}
932 description={
933 dm
934 ? t('chat.room.settingsNoteDm')
935 : canManage
936 ? t('chat.room.settingsNoteManage')
937 : t('chat.room.settingsNoteView')
938 }
939 wide
940 confirmLabel={canManage ? t('common.action.save') : t('common.action.close')}
941 fields={[
942 {
943 key: 'name',
944 label: t('chat.rooms.name'),
945 kind: 'text',
946 optional: false,
947 initialValue: room?.name ?? hintName,
948 maxLength: 128,
949 hint: t('chat.room.nameHint'),
950 },
951 {
952 key: 'topic',
953 label: t('chat.rooms.topic'),
954 kind: 'textarea',
955 optional: true,
956 initialValue: room?.topic ?? hintTopic ?? '',
957 maxLength: 1024,
958 hint: t('chat.room.topicHint'),
959 rows: 3,
960 },
961 {
962 key: 'is_private',
963 label: t('chat.room.private'),
964 kind: 'toggle',
965 initialValue: room?.is_private ?? false,
966 },
967 {
968 key: 'rate_limit',
969 label: t('chat.room.rateLimit'),
970 kind: 'number',
971 optional: true,
972 initialValue: String(room?.rate_limit_per_user ?? 0),
973 hint: t('chat.room.rateLimitHint'),
974 },
975 ]}
976 onSubmit={async (values) => {
977 if (!canManage) return undefined
978 const name = String(values.name ?? '').trim()
979 const topic = String(values.topic ?? '').trim()
980 const rate = Number.parseInt(String(values.rate_limit ?? '0'), 10)
981 await updateRoom(room?.id ?? '', {
982 name: name || undefined,
983 topic,
984 is_private: Boolean(values.is_private),
985 rate_limit_per_user: Number.isFinite(rate) && rate > 0 ? rate : null,
986 })
987 onChanged()
988 return t('chat.room.saved')
989 }}
990 onClose={onClose}
991 extra={
992 !dm && canManage ? (
993 confirmDelete ? (
994 <div className="rounded-md border-2 border-[#e5484d]/40 bg-[#e5484d]/8 p-2.5 text-[12px] leading-[1.6]">
995 {t('chat.room.deleteNote')}
996 <div className="mt-2 flex gap-2">
997 <button
998 type="button"
999 onClick={() => void deleteRoomApi(room?.id ?? '').then(() => onDeleted())}
1000 className="rounded bg-[#e5484d] px-2.5 py-1 text-[12px] font-semibold text-white"
1001 >
1002 {t('chat.room.deleteConfirm')}
1003 </button>
1004 <button
1005 type="button"
1006 onClick={() => setArmed(false)}
1007 className="rounded px-2 py-1 text-[12px] hover:bg-shell-field"
1008 >
1009 {t('common.action.cancel')}
1010 </button>
1011 </div>
1012 </div>
1013 ) : (
1014 <button
1015 type="button"
1016 onClick={() => setArmed(true)}
1017 className="rounded-md border-2 border-shell-hairline px-2.5 py-1 text-[12px] text-[#e5484d] hover:border-[#e5484d]/50"
1018 >
1019 {t('chat.room.deleteAction')}
1020 </button>
1021 )
1022 ) : null
1023 }
1024 />
1025 )
1026}
Added view/app/src/domains/chat/ui/emoji-picker.tsx view/app/src/domains/chat/ui/emoji-picker.tsx +136 −0 View view/app/src/domains/chat/ui/emoji-picker.tsx
@@ -0,0 +1,136 @@
1/**
2 * Emoji picker for reactions and the composer.
3 *
4 * `emoji-picker-react` owns the grid, the search box, the skin tones and the
5 * category navigation; this wrapper only adapts it to the product: the shell
6 * palette, the emoji names of the active locale, and the space's custom set.
7 *
8 * Reactions are stored as plain text (up to `chat.max_reaction_emoji_len`), so a
9 * Unicode pick travels to the server verbatim. A custom emoji is an uploaded
10 * image referenced by its name, so it travels as `:name:`.
11 */
12
13import { type CustomEmoji, emojiImageUrl } from '@app/domains/chat/model'
14import { type Locale, useLocale, useT } from '@app/i18n'
15import { useTheme } from '@app/lib'
16import EmojiPickerReact, {
17 Categories,
18 type CategoryConfig,
19 type EmojiClickData,
20 EmojiStyle,
21 Theme,
22} from 'emoji-picker-react'
23import { useEffect, useState } from 'react'
24
25/** Names and categories for one language, read off the library's own modules. */
26type EmojiDataset = typeof import('emoji-picker-react/dist/data/emojis-zh.js').default
27
28/**
29 * Resolved name tables, kept for the session: each is a few hundred kB, so it
30 * is fetched only when a picker first opens, and a reopen repaints from here
31 * instead of re-running the loader. `null` records a failed load, which falls
32 * back to the library's bundled English table rather than a permanent skeleton.
33 */
34const datasets = new Map<Locale, EmojiDataset | null>()
35const LOADERS: Record<Locale, () => Promise<{ default: EmojiDataset }>> = {
36 'zh-CN': () => import('emoji-picker-react/dist/data/emojis-zh.js'),
37 en: () => import('emoji-picker-react/dist/data/emojis-en.js'),
38}
39
40function useDataset(locale: Locale): EmojiDataset | null | undefined {
41 const [, bump] = useState(0)
42 const loaded = datasets.get(locale)
43 useEffect(() => {
44 if (loaded !== undefined) return
45 let active = true
46 LOADERS[locale]().then(
47 (module) => {
48 datasets.set(locale, module.default)
49 if (active) bump((value) => value + 1)
50 },
51 () => {
52 datasets.set(locale, null)
53 if (active) bump((value) => value + 1)
54 },
55 )
56 return () => {
57 active = false
58 }
59 }, [locale, loaded])
60 return loaded
61}
62
63/** Stock sections in reading order; suggestions and space emojis lead. */
64const CATEGORY_ORDER: readonly Categories[] = [
65 Categories.SMILEYS_PEOPLE,
66 Categories.ANIMALS_NATURE,
67 Categories.FOOD_DRINK,
68 Categories.TRAVEL_PLACES,
69 Categories.ACTIVITIES,
70 Categories.OBJECTS,
71 Categories.SYMBOLS,
72 Categories.FLAGS,
73]
74
75export function EmojiPicker({
76 onPick,
77 custom = [],
78 spaceName,
79}: {
80 onPick: (emoji: string) => void
81 custom?: CustomEmoji[]
82 spaceName?: string
83}) {
84 const { t } = useT()
85 const locale = useLocale()
86 const theme = useTheme()
87 const dataset = useDataset(locale)
88 // Section labels are localized by the name table itself.
89 const sectionName = (category: Categories) => dataset?.categories?.[category]?.name ?? t('chat.emoji.common')
90 // A disabled emoji has been retired from the space: keep it out of the grid.
91 const usable = custom.filter((item) => !item.disabled)
92 const nameById = new Map(usable.map((item) => [item.id.toLowerCase(), item.name]))
93 const spaceEmojis =
94 spaceName && usable.length > 0
95 ? usable.map((item) => ({
96 id: item.id,
97 names: [item.name],
98 imgUrl: emojiImageUrl(spaceName, item.id),
99 }))
100 : []
101 const categories: CategoryConfig[] = [
102 { category: Categories.SUGGESTED, name: sectionName(Categories.SUGGESTED) },
103 ...(spaceEmojis.length > 0 ? [{ category: Categories.CUSTOM, name: t('chat.emoji.custom') }] : []),
104 ...CATEGORY_ORDER.map((category) => ({ category, name: sectionName(category) })),
105 ]
106 // Purposeful delay: mounting with the library's built-in table would paint
107 // one frame of English category labels before the localized names arrive.
108 if (dataset === undefined) {
109 return <div className="chat-emoji-picker h-[352px] w-full bg-shell-field" />
110 }
111 return (
112 <div className="chat-emoji-picker w-full">
113 <EmojiPickerReact
114 width="100%"
115 height={352}
116 theme={theme === 'dark' ? Theme.DARK : Theme.LIGHT}
117 emojiStyle={EmojiStyle.NATIVE}
118 emojiData={dataset ?? undefined}
119 categories={categories}
120 customEmojis={spaceEmojis}
121 previewConfig={{ showPreview: false }}
122 searchPlaceholder={t('chat.emoji.search')}
123 searchClearButtonLabel={t('chat.emoji.searchClear')}
124 onEmojiClick={(clicked: EmojiClickData) =>
125 onPick(
126 clicked.isCustom
127 ? // The library returns the lower-cased id as `unified`; the stored
128 // short code keeps the name the space author chose.
129 `:${nameById.get(clicked.unified.toLowerCase()) ?? clicked.names[0] ?? clicked.unified}:`
130 : clicked.emoji,
131 )
132 }
133 />
134 </div>
135 )
136}
Added view/app/src/domains/chat/ui/parts.tsx view/app/src/domains/chat/ui/parts.tsx +707 −0 View view/app/src/domains/chat/ui/parts.tsx
@@ -0,0 +1,707 @@
1/**
2 * Render primitives shared by the transcript, the thread panel and the side
3 * panels. Nothing here talks to the API: the content renderer is the one piece
4 * with real logic, and it is deliberately React-node based — user text is never
5 * injected as HTML, so a crafted message cannot execute anything.
6 */
7
8import {
9 type Attachment,
10 attachmentKind,
11 attachmentUrl,
12 type ChatMessage,
13 clockStamp,
14 EVERYONE_TOKENS,
15 formatSize,
16 initialsOf,
17 type UserInfo,
18} from '@app/domains/chat/model'
19import { useT } from '@app/i18n'
20import { useSpaLinkClick } from '@app/lib'
21import DOMPurify from 'dompurify'
22import { Download, FileText, Maximize2, X } from 'lucide-react'
23import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
24
25const avatarPalette = ['#b77945', '#448675', '#b6638d', '#6871c9', '#b96369', '#4f8a90', '#9272bb']
26
27/** Deterministic tile colour, so a person looks the same in every row. */
28function colorFor(id: string): string {
29 let hash = 0
30 for (let i = 0; i < id.length; i += 1) hash = (hash * 31 + id.charCodeAt(i)) | 0
31 return avatarPalette[Math.abs(hash) % avatarPalette.length]
32}
33
34/**
35 * Avatar with no layout surprises: images keep their aspect ratio, and a missing
36 * or broken image degrades to the coloured initial instead of an empty box.
37 */
38export function UserAvatar({
39 user,
40 id,
41 size = 40,
42 className = '',
43}: {
44 user?: Pick<UserInfo, 'avatar_url' | 'display_name' | 'username'> | null
45 id: number
46 size?: number
47 className?: string
48}) {
49 // "this exact URL failed to load" is the state, so a new avatar needs no
50 // effect to clear a previous failure.
51 const [failedUrl, setFailedUrl] = useState<string | null>(null)
52 const label = user?.display_name || user?.username || String(id)
53 const src = user?.avatar_url && failedUrl !== user.avatar_url ? user.avatar_url : null
54 if (src) {
55 return (
56 <img
57 src={src}
58 alt={label}
59 title={label}
60 loading="lazy"
61 onError={() => setFailedUrl(src)}
62 className={`shrink-0 rounded-full bg-shell-tile object-cover ${className}`}
63 /* Passing `className` hands the size to CSS (a resizable window cannot
64 use a fixed pixel avatar); `size` still covers every other caller. */
65 style={className ? undefined : { width: size, height: size }}
66 />
67 )
68 }
69 return (
70 <span
71 title={label}
72 className={`grid shrink-0 place-content-center rounded-full font-semibold text-white ${className}`}
73 style={
74 className
75 ? { backgroundColor: colorFor(String(id || 0)) }
76 : { width: size, height: size, backgroundColor: colorFor(String(id || 0)), fontSize: size * 0.4 }
77 }
78 >
79 {initialsOf(user)}
80 </span>
81 )
82}
83
84export function IconAction({
85 label,
86 onClick,
87 children,
88 active = false,
89 disabled = false,
90 className = '',
91}: {
92 label: string
93 onClick?: () => void
94 children: ReactNode
95 active?: boolean
96 disabled?: boolean
97 className?: string
98}) {
99 return (
100 <button
101 type="button"
102 title={label}
103 aria-label={label}
104 aria-pressed={active || undefined}
105 disabled={disabled}
106 onClick={onClick}
107 className={`chat-icon-action grid h-7 w-7 place-content-center rounded-md text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink disabled:opacity-40 ${
108 active ? 'bg-shell-active text-shell-ink' : ''
109 } ${className}`}
110 >
111 {children}
112 </button>
113 )
114}
115
116/**
117 * Small click-outside popover used by the reaction picker, the message menu and
118 * the mention list. Escape and any outside pointer press close it; it renders
119 * inline with viewport positioning so menus remain visible near screen edges.
120 */
121export function Popover({
122 open,
123 onClose,
124 align = 'start',
125 side = 'top',
126 className = '',
127 children,
128}: {
129 open: boolean
130 onClose: () => void
131 align?: 'start' | 'end'
132 side?: 'top' | 'bottom'
133 className?: string
134 children: ReactNode
135}) {
136 const host = useRef<HTMLDivElement | null>(null)
137 const [position, setPosition] = useState({ left: 0, top: 0 })
138 useLayoutEffect(() => {
139 if (!open) return
140 const place = () => {
141 const node = host.current
142 const anchor = node?.parentElement
143 if (!node || !anchor) return
144 const rect = anchor.getBoundingClientRect()
145 const width = node.offsetWidth
146 const height = node.offsetHeight
147 const below = rect.bottom + 8
148 const above = rect.top - height - 8
149 const preferred = side === 'bottom' ? below : above
150 const alternate = side === 'bottom' ? above : below
151 const top = preferred >= 12 && preferred + height <= window.innerHeight - 12 ? preferred : alternate
152 setPosition({
153 left: Math.max(12, Math.min(align === 'end' ? rect.right - width : rect.left, window.innerWidth - width - 12)),
154 top: Math.max(12, Math.min(top, window.innerHeight - height - 12)),
155 })
156 }
157 place()
158 window.addEventListener('resize', place)
159 window.addEventListener('scroll', place, true)
160 return () => {
161 window.removeEventListener('resize', place)
162 window.removeEventListener('scroll', place, true)
163 }
164 }, [open, align, side, children])
165 useEffect(() => {
166 if (!open) return
167 const dismiss = (event: MouseEvent | TouchEvent) => {
168 if (!host.current?.parentElement?.contains(event.target as Node)) onClose()
169 }
170 const onKey = (event: KeyboardEvent) => {
171 if (event.key === 'Escape') onClose()
172 }
173 document.addEventListener('mousedown', dismiss)
174 document.addEventListener('touchstart', dismiss)
175 window.addEventListener('keydown', onKey)
176 return () => {
177 document.removeEventListener('mousedown', dismiss)
178 document.removeEventListener('touchstart', dismiss)
179 window.removeEventListener('keydown', onKey)
180 }
181 }, [open, onClose])
182 if (!open) return null
183 return (
184 <div
185 ref={host}
186 style={position}
187 className={`chat-popover fixed z-30 rounded-lg border border-shell-hairline bg-shell-surface p-1.5 shadow-lg ${className}`}
188 >
189 {children}
190 </div>
191 )
192}
193
194export function DayDivider({ value }: { value: string }) {
195 return (
196 <div className="chat-day-divider flex items-center gap-3 px-3 pb-1 pt-5">
197 <div className="h-px flex-1 bg-shell-hairline" />
198 <span className="shrink-0 text-[11px] font-bold text-shell-ink-muted">{value}</span>
199 <div className="h-px flex-1 bg-shell-hairline" />
200 </div>
201 )
202}
203
204/** The「未读」rule: everything under it is what the viewer has not seen yet. */
205export function UnreadDivider({ count }: { count: number }) {
206 const { t } = useT()
207 return (
208 <div className="flex items-center gap-3 px-3 py-2">
209 <div className="h-px flex-1 bg-[#e5484d]/45" />
210 <span className="shrink-0 rounded-full bg-[#e5484d] px-2 py-px text-[10.5px] font-bold text-white">
211 {count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
212 </span>
213 <div className="h-px flex-1 bg-[#e5484d]/45" />
214 </div>
215 )
216}
217
218/* ------------------------------------------------------------ rich content */
219
220interface Token {
221 type: 'code' | 'text'
222 value: string
223 lang?: string
224}
225
226/** Split a message into fenced blocks and text; unterminated fences are treated
227 * as code, which is what a reader expects from a half-pasted snippet. */
228function splitFences(content: string): Token[] {
229 const tokens: Token[] = []
230 const lines = content.split('\n')
231 let buffer: string[] = []
232 let code: string[] | null = null
233 let lang: string | undefined
234 const flushText = () => {
235 if (buffer.length > 0) tokens.push({ type: 'text', value: buffer.join('\n') })
236 buffer = []
237 }
238 for (const line of lines) {
239 const fence = /^```(.*)$/.exec(line.trim())
240 if (fence && code === null) {
241 flushText()
242 code = []
243 lang = fence[1]?.trim() || undefined
244 continue
245 }
246 if (fence && code !== null) {
247 tokens.push({ type: 'code', value: code.join('\n'), lang })
248 code = null
249 lang = undefined
250 continue
251 }
252 if (code !== null) code.push(line)
253 else buffer.push(line)
254 }
255 if (code !== null) tokens.push({ type: 'code', value: code.join('\n'), lang })
256 flushText()
257 return tokens
258}
259
260// 链接里允许成对括号(`.../Rust_(programming_language)`),所以右括号不直接排除,
261// 由 trimUrlTail 按配对情况决定是否截断。
262const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(\bhttps?:\/\/[^\s<>'"]+)/g
263
264/**
265 * 裁掉链接末尾不属于 URL 的标点。右括号只有在比左括号多的时候才算句尾
266 * (`(见 https://a/b)` 的 `)` 要去掉,`.../Rust_(x)` 的要留着)。
267 */
268function trimUrlTail(value: string): string {
269 let end = value.length
270 const counts = (text: string, open: string, close: string) => {
271 let depth = 0
272 for (const ch of text) {
273 if (ch === open) depth += 1
274 else if (ch === close) depth -= 1
275 }
276 return depth
277 }
278 while (end > 0) {
279 const ch = value[end - 1]
280 const head = value.slice(0, end)
281 const drop =
282 ch === ')' || ch === '\uff09'
283 ? counts(head, '(', ')') + counts(head, '\uff08', '\uff09') < 0
284 : ch === ']' || ch === '\uff3d'
285 ? counts(head, '[', ']') + counts(head, '\uff3b', '\uff3d') < 0
286 : ch === '}' || ch === '\uff5d'
287 ? counts(head, '{', '}') + counts(head, '\uff5b', '\uff5d') < 0
288 : '.,;!?>\u3002\u3001\uff0c\uff01\uff1f\u300b\u300d\u300f\u3011\u3017'.includes(ch)
289 if (!drop) break
290 end -= 1
291 }
292 return value.slice(0, end)
293}
294
295function linkTarget(value: string): string {
296 return value.startsWith('http') ? value : `https://${value}`
297}
298
299/** Inline markdown + @mention chips. Returns plain strings where nothing matched. */
300function renderInline(
301 text: string,
302 mentionIds: Set<number>,
303 isSelf: boolean,
304 embeds: Map<string, MessageEmbed> = new Map(),
305): ReactNode[] {
306 const nodes: ReactNode[] = []
307 let last = 0
308 let key = 0
309 for (const match of text.matchAll(INLINE)) {
310 const index = match.index ?? 0
311 if (index > last) nodes.push(text.slice(last, index))
312 const value = match[0]
313 if (value.startsWith('`')) {
314 nodes.push(
315 <code
316 key={`i${(key += 1)}`}
317 className="rounded bg-shell-field px-1 py-px font-mono text-[0.9em] text-shell-ink"
318 >
319 {value.slice(1, -1)}
320 </code>,
321 )
322 } else if (value.startsWith('**')) {
323 nodes.push(
324 <strong key={`i${(key += 1)}`} className="font-semibold">
325 {value.slice(2, -2)}
326 </strong>,
327 )
328 } else if (value.startsWith('~~')) {
329 nodes.push(
330 <s key={`i${(key += 1)}`} className="text-shell-ink-muted">
331 {value.slice(2, -2)}
332 </s>,
333 )
334 } else if (value.startsWith('*')) {
335 nodes.push(<em key={`i${(key += 1)}`}>{value.slice(1, -1)}</em>)
336 } else if (/^https?:\/\//.test(value)) {
337 // 句尾标点不属于链接:留着会让 embed 匹配不上,也会让点开的地址 404。
338 const url = trimUrlTail(value)
339 const tail = value.slice(url.length)
340 const embed = embeds.get(url) ?? embeds.get(embedKey(url))
341 if (embed) nodes.push(<EmbedView key={`i${(key += 1)}`} embed={embed} />)
342 else nodes.push(<InlineLink key={`i${(key += 1)}`} url={url} />)
343 if (tail) nodes.push(tail)
344 } else {
345 nodes.push(value)
346 }
347 last = index + value.length
348 }
349 if (last < text.length) nodes.push(text.slice(last))
350 return mentionChips(nodes, mentionIds, isSelf)
351}
352
353/** Opening a link must not touch the SPA router: `window.open` in a new tab. */
354function InlineLink({ url, children }: { url: string; children?: ReactNode }) {
355 return (
356 <button
357 type="button"
358 title={url}
359 onClick={() => window.open(linkTarget(url), '_blank', 'noopener,noreferrer')}
360 className="max-w-full truncate text-shell-ink underline decoration-shell-hairline underline-offset-2 hover:decoration-shell-ink-muted"
361 >
362 {children ?? url}
363 </button>
364 )
365}
366
367/* --------------------------------------------------------------- embeds */
368
369/**
370 * 服务端 `lib/social` 生成的一条 embed。平台命中时带 `html`(iframe/video/
371 * audio/img/link card),未命中平台的普通链接只有 OpenGraph 字段。
372 */
373export type MessageEmbed = import('@app/services/api').MessageEmbed
374
375const EMBED_TAGS = ['iframe', 'video', 'audio', 'source', 'img', 'figure', 'figcaption']
376const EMBED_ATTRS = [
377 'allow',
378 'allowfullscreen',
379 'frameborder',
380 'referrerpolicy',
381 'sandbox',
382 'loading',
383 'controls',
384 'playsinline',
385 'preload',
386 'poster',
387 'autoplay',
388 'loop',
389 'muted',
390 'data-platform',
391 'data-category',
392]
393
394/** 服务端 HTML 同样属于不可信输入,注入前一律清洗。 */
395function sanitizeEmbed(html: string): string {
396 return DOMPurify.sanitize(html, {
397 ADD_TAGS: EMBED_TAGS,
398 ADD_ATTR: EMBED_ATTRS,
399 FORBID_TAGS: ['script', 'style', 'object', 'embed', 'form', 'input', 'textarea', 'button', 'base', 'link'],
400 })
401}
402
403function embedHost(url: string): string {
404 try {
405 return new URL(url).hostname.replace(/^www\./, '')
406 } catch {
407 return url
408 }
409}
410
411/** 句尾标点/尾斜杠不参与匹配:`...see https://youtu.be/x.` 也要能命中。 */
412function embedKey(url: string): string {
413 return url.replace(/[.,;:!?]+$/, '').replace(/\/+$/, '')
414}
415
416/** 平台 embed:直接展示服务端生成的 HTML;普通链接退化为 OpenGraph 卡片。 */
417export function EmbedView({ embed }: { embed: MessageEmbed }) {
418 const html = useMemo(() => (embed.html ? sanitizeEmbed(embed.html) : ''), [embed.html])
419 // embed HTML 里的链接同样不允许直接跳转:站内走 react-router,站外开新标签页。
420 const onClick = useSpaLinkClick()
421 if (html) {
422 return (
423 <span
424 className="chat-embed my-1.5 block min-w-0 max-w-full select-text"
425 data-embed-platform={embed.platform ?? ''}
426 data-embed-kind={embed.kind ?? ''}
427 onClick={onClick}
428 dangerouslySetInnerHTML={{ __html: html }}
429 />
430 )
431 }
432 // 无 HTML 且 OpenGraph 字段全空时不能吞掉链接,退回普通链接展示。
433 if (!embed.title && !embed.description && !embed.image_url && !embed.site_name) {
434 return <InlineLink url={embed.url ?? ''} />
435 }
436 return (
437 <button
438 type="button"
439 onClick={() => window.open(linkTarget(embed.url ?? ''), '_blank', 'noopener,noreferrer')}
440 className="chat-embed-card my-1.5 flex w-full max-w-[480px] gap-2.5 overflow-hidden rounded-lg border border-shell-hairline bg-shell-surface text-left hover:border-shell-ink-muted"
441 >
442 {embed.image_url ? (
443 <img src={embed.image_url} alt="" loading="lazy" className="h-20 w-20 shrink-0 object-cover" />
444 ) : null}
445 <span className="min-w-0 flex-1 p-2 pr-3">
446 <span className="block truncate text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
447 {embed.site_name || embedHost(embed.url ?? '')}
448 </span>
449 {embed.title ? (
450 <span className="mt-0.5 line-clamp-2 block text-[13px] font-semibold text-shell-ink">{embed.title}</span>
451 ) : null}
452 {embed.description ? (
453 <span className="mt-0.5 line-clamp-2 block text-[12px] leading-[1.4] text-shell-ink-muted">
454 {embed.description}
455 </span>
456 ) : null}
457 </span>
458 </button>
459 )
460}
461
462const MENTION = /@[\w.\-\u4e00-\u9fa5]+/g
463
464/** Replace bare @tokens inside text nodes with highlighted chips. */
465function mentionChips(nodes: ReactNode[], mentionIds: Set<number>, isSelf: boolean): ReactNode[] {
466 const out: ReactNode[] = []
467 let key = 0
468 for (const node of nodes) {
469 if (typeof node !== 'string') {
470 out.push(node)
471 continue
472 }
473 let last = 0
474 for (const match of node.matchAll(MENTION)) {
475 const index = match.index ?? 0
476 if (index > last) out.push(node.slice(last, index))
477 const token = match[0]
478 const everyone = EVERYONE_TOKENS.some((name) => token.toLowerCase() === name)
479 const hit = everyone ? isSelf : mentionIds.size > 0
480 out.push(
481 <span
482 key={`m${(key += 1)}`}
483 className={hit ? 'rounded bg-shell-active px-0.5 font-semibold text-shell-ink' : 'font-medium text-shell-ink'}
484 >
485 {token}
486 </span>,
487 )
488 last = index + token.length
489 }
490 if (last === 0) out.push(node)
491 else if (last < node.length) out.push(node.slice(last))
492 }
493 return out
494}
495
496/**
497 * A message body: fenced code, inline markdown, links and @mentions, with the
498 * author's own mention set deciding whether a chip is highlighted.
499 */
500export function MessageBody({
501 message,
502 viewerId,
503 compact = false,
504 suffix = null,
505}: {
506 message: ChatMessage
507 viewerId?: number
508 compact?: boolean
509 /** 追加在正文最后一段文本末尾的行内内容(“已编辑”等标记)。 */
510 suffix?: ReactNode
511}) {
512 const mentionIds = useMemo(() => new Set(message.mentions ?? []), [message.mentions])
513 const isSelf = Boolean(viewerId && mentionIds.has(viewerId)) || message.mention_everyone
514 const tokens = useMemo(() => splitFences(message.content), [message.content])
515 const embeds = useMemo(() => {
516 const list: MessageEmbed[] = message.embeds ?? []
517 const map = new Map<string, MessageEmbed>()
518 for (const item of list) {
519 if (!item || typeof item.url !== 'string') continue
520 map.set(item.url, item)
521 map.set(embedKey(item.url), item)
522 }
523 return map
524 }, [message])
525 const lastTextIndex = tokens.reduce((acc, token, index) => (token.type === 'text' ? index : acc), -1)
526 return (
527 <div className={`chat-message-body min-w-0 break-words ${compact ? 'text-[13px]' : 'text-[15px]'} leading-[1.5]`}>
528 {tokens.map((token, index) =>
529 token.type === 'code' ? (
530 <pre
531 key={`c${index}`}
532 className="my-1.5 max-h-80 overflow-auto rounded-md border border-shell-hairline bg-shell-field p-2.5 font-mono text-[12.5px] leading-[1.45]"
533 >
534 {token.lang ? (
535 <span className="mb-1 block text-[10px] uppercase text-shell-ink-muted">{token.lang}</span>
536 ) : null}
537 <code>{token.value}</code>
538 </pre>
539 ) : (
540 <div key={`t${index}`} className="whitespace-pre-wrap break-words">
541 {renderInline(token.value, mentionIds, isSelf, embeds)}
542 {suffix && index === lastTextIndex ? <span className="ml-1.5">{suffix}</span> : null}
543 </div>
544 ),
545 )}
546 {suffix && lastTextIndex < 0 ? <span className="ml-1.5">{suffix}</span> : null}
547 </div>
548 )
549}
550
551/* ------------------------------------------------------------ attachments */
552
553export function AttachmentGrid({
554 attachments,
555 onOpen,
556}: {
557 attachments: Attachment[]
558 onOpen: (attachment: Attachment) => void
559}) {
560 if (attachments.length === 0) return null
561 const images = attachments.filter((item) => attachmentKind(item) === 'image')
562 const rest = attachments.filter((item) => attachmentKind(item) !== 'image')
563 return (
564 <div className="mt-1.5 space-y-1.5">
565 {images.length > 0 ? (
566 <div className={`flex flex-wrap gap-2 ${images.length > 1 ? 'max-w-[420px]' : ''}`}>
567 {images.map((image) => (
568 <button
569 key={image.id}
570 type="button"
571 onClick={() => onOpen(image)}
572 title={image.filename}
573 className="group relative overflow-hidden rounded-lg border border-shell-hairline bg-shell-field"
574 >
575 {/* The grid loads the compressed derivative; clicking opens the
576 lightbox, which streams the original. */}
577 <img
578 src={attachmentUrl(image.id, 'preview')}
579 alt={image.filename}
580 loading="lazy"
581 className="max-h-64 max-w-full object-contain transition-transform group-hover:scale-[1.01] sm:max-w-[320px]"
582 />
583 <span className="absolute right-1.5 top-1.5 hidden rounded bg-black/55 p-1 text-white group-hover:block">
584 <Maximize2 className="h-3.5 w-3.5" />
585 </span>
586 </button>
587 ))}
588 </div>
589 ) : null}
590 {rest.map((item) => (
591 <AttachmentCard key={item.id} attachment={item} onOpen={onOpen} />
592 ))}
593 </div>
594 )
595}
596
597function AttachmentCard({ attachment, onOpen }: { attachment: Attachment; onOpen: (attachment: Attachment) => void }) {
598 const { t } = useT()
599 const kind = attachmentKind(attachment)
600 if (kind === 'video' || kind === 'audio') {
601 return (
602 <video
603 controls
604 preload="none"
605 src={attachmentUrl(attachment.id)}
606 className="max-h-72 w-full max-w-[420px] rounded-lg border border-shell-hairline bg-black"
607 />
608 )
609 }
610 return (
611 <div className="flex max-w-[420px] items-center gap-2.5 rounded-lg border border-shell-hairline bg-shell-field p-2.5">
612 <FileText className="h-5 w-5 shrink-0 text-shell-ink-muted" />
613 <div className="min-w-0 flex-1">
614 <p className="truncate text-[13px] font-medium">{attachment.filename}</p>
615 <p className="text-[11px] text-shell-ink-muted">
616 {formatSize(attachment.size)} · {attachment.content_type || t('chat.attachment.unknownType')}
617 </p>
618 </div>
619 <IconAction label={t('chat.attachment.download')} onClick={() => downloadAttachment(attachment)}>
620 <Download className="h-4 w-4" />
621 </IconAction>
622 <IconAction label={t('chat.attachment.preview')} onClick={() => onOpen(attachment)}>
623 <Maximize2 className="h-4 w-4" />
624 </IconAction>
625 </div>
626 )
627}
628
629/** Streaming endpoint is cookie-authed, so a new tab is a real download. */
630function downloadAttachment(attachment: Attachment): void {
631 window.open(attachmentUrl(attachment.id), '_blank', 'noopener,noreferrer')
632}
633
634export function Lightbox({ attachment, onClose }: { attachment: Attachment | null; onClose: () => void }) {
635 const { t } = useT()
636 useEffect(() => {
637 if (!attachment) return
638 const onKey = (event: KeyboardEvent) => {
639 if (event.key === 'Escape') onClose()
640 }
641 window.addEventListener('keydown', onKey)
642 return () => window.removeEventListener('keydown', onKey)
643 }, [attachment, onClose])
644 if (!attachment) return null
645 const kind = attachmentKind(attachment)
646 return (
647 <div
648 className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/80 p-6"
649 onClick={onClose}
650 >
651 <div className="flex w-full max-w-4xl items-center gap-3 text-white/85">
652 <span className="min-w-0 flex-1 truncate text-[13px]">{attachment.filename}</span>
653 <span className="shrink-0 text-[12px] text-white/60">{formatSize(attachment.size)}</span>
654 <button
655 type="button"
656 aria-label={t('chat.attachment.closePreview')}
657 onClick={onClose}
658 className="shrink-0 rounded p-1 hover:bg-white/15"
659 >
660 <X className="h-5 w-5" />
661 </button>
662 </div>
663 <div
664 className="flex max-h-[80vh] w-full max-w-4xl items-center justify-center"
665 onClick={(e) => e.stopPropagation()}
666 >
667 {kind === 'image' ? (
668 <img
669 src={attachmentUrl(attachment.id)}
670 alt={attachment.filename}
671 className="max-h-[74vh] max-w-full rounded-lg object-contain"
672 />
673 ) : kind === 'video' ? (
674 <video src={attachmentUrl(attachment.id)} controls autoPlay className="max-h-[74vh] w-full rounded-lg" />
675 ) : kind === 'audio' ? (
676 <audio src={attachmentUrl(attachment.id)} controls className="w-full" />
677 ) : (
678 <iframe
679 title={attachment.filename}
680 src={attachmentUrl(attachment.id)}
681 sandbox=""
682 className="h-[74vh] w-full rounded-lg bg-white"
683 />
684 )}
685 </div>
686 <button
687 type="button"
688 onClick={(event) => {
689 event.stopPropagation()
690 downloadAttachment(attachment)
691 }}
692 className="rounded-md bg-white/15 px-3 py-1.5 text-[13px] text-white hover:bg-white/25"
693 >
694 {t('common.action.download')}
695 </button>
696 </div>
697 )
698}
699
700/** Compact timestamp gutter used by grouped rows. */
701export function GutterTime({ value }: { value: string }) {
702 return (
703 <span className="w-10 shrink-0 select-none pt-1 text-right text-[10px] text-shell-ink-muted">
704 {clockStamp(value)}
705 </span>
706 )
707}
Added view/app/src/domains/chat/ui/room-watcher.tsx view/app/src/domains/chat/ui/room-watcher.tsx +56 −0 View view/app/src/domains/chat/ui/room-watcher.tsx
@@ -0,0 +1,56 @@
1import { useEffect, useMemo } from 'react'
2import { type RoomMeta, useMessagePipeline, useRoomMeta } from '../model/message-pipeline'
3
4const MAX_WATCHED = 24
5
6export interface WatchedRoom {
7 id: string
8 name: string
9}
10
11/**
12 * 为侧栏里的房间建立后台订阅:不在当前房间的消息也能进 pipeline,
13 * 并携带名称与跳转地址供 toast 使用。
14 */
15export function RoomWatcher({
16 spaceName,
17 rooms,
18 dms,
19 activeRoomId,
20}: {
21 spaceName: string
22 rooms: WatchedRoom[]
23 dms: WatchedRoom[]
24 activeRoomId: string | null
25}) {
26 const api = useMessagePipeline()
27
28 const meta = useMemo(() => {
29 const out: Record<string, RoomMeta> = {}
30 for (const room of rooms)
31 out[room.id] = {
32 name: room.name,
33 href: spaceName ? `/app/${spaceName}/chat/${room.id}` : `/app/@me/chat/${room.id}`,
34 }
35 for (const dm of dms) out[dm.id] = { name: dm.name, href: `/app/@me/chat/${dm.id}` }
36 return out
37 }, [rooms, dms, spaceName])
38
39 useRoomMeta(meta)
40
41 const ids = useMemo(() => [...rooms, ...dms].slice(0, MAX_WATCHED).map((room) => room.id), [rooms, dms])
42
43 useEffect(() => {
44 const releases = ids.map((id) => api.watch(id))
45 return () => {
46 for (const release of releases) release()
47 }
48 }, [api, ids])
49
50 useEffect(() => {
51 api.activate(activeRoomId)
52 return () => api.activate(null)
53 }, [api, activeRoomId])
54
55 return null
56}
Added view/app/src/domains/chat/ui/side-panel.tsx view/app/src/domains/chat/ui/side-panel.tsx +623 −0 View view/app/src/domains/chat/ui/side-panel.tsx
@@ -0,0 +1,623 @@
1/**
2 * The right-hand column: one thread, the thread list, the pin list, or the
3 * member roster. Only one is mounted at a time and the choice lives in the URL
4 * (see `pages/workspace/model/chat-location`), so every panel is deep-linkable
5 * and survives a reload without a document navigation.
6 *
7 * A private room whose member list is forbidden says so instead of showing an
8 * empty roster.
9 */
10
11import {
12 type ChatMessage,
13 type CustomEmoji,
14 clockStamp,
15 describeFailure,
16 followThread,
17 fullStamp,
18 listMyThreads,
19 type Presence,
20 type RoomMember,
21 type ThreadBrief,
22 type UserInfo,
23 unfollowThread,
24 updateThread,
25} from '@app/domains/chat/model'
26import { useT } from '@app/i18n'
27import { useAsync } from '@app/lib'
28import { sendTypingSignal } from '@app/services/realtime'
29import { Bell, BellOff, Clock, Crosshair, Loader2, Lock, MessageSquare, Pin, Users, X } from 'lucide-react'
30import { useEffect, useMemo, useRef, useState } from 'react'
31import { useComposer } from '../model/use-composer'
32import { useRoomStream } from '../model/use-room-stream'
33import { ChatComposer } from './chat-composer'
34import { AttachmentGrid, MessageBody, UserAvatar } from './parts'
35import { type MessageInteractions, Transcript } from './transcript'
36
37export function PanelHeader({
38 title,
39 subtitle,
40 onClose,
41 actions,
42}: {
43 title: string
44 subtitle?: string
45 onClose: () => void
46 actions?: React.ReactNode
47}) {
48 const { t } = useT()
49 return (
50 <div className="chat-panel-header flex shrink-0 items-center gap-2 border-b border-shell-hairline px-3">
51 <div className="min-w-0 flex-1">
52 <p className="truncate text-[13.5px] font-semibold">{title}</p>
53 {subtitle ? <p className="truncate text-[11px] text-shell-ink-muted">{subtitle}</p> : null}
54 </div>
55 {actions}
56 <button
57 type="button"
58 aria-label={t('chat.panel.close')}
59 onClick={onClose}
60 className="chat-panel-close shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
61 >
62 <X className="h-4 w-4" />
63 </button>
64 </div>
65 )
66}
67
68/* ------------------------------------------------------------------ thread */
69
70export interface ThreadPanelProps {
71 roomId: string
72 thread: ThreadBrief
73 /** Root message, when it is part of the loaded window (or fetched for the panel). */
74 root?: ChatMessage
75 viewerId?: number
76 spaceName?: string
77 members: RoomMember[]
78 customEmojis?: CustomEmoji[]
79 nameOf: (userId: number | null | undefined) => string
80 interactions: MessageInteractions
81 /** A message to reveal inside this thread (deep link from global search). */
82 jumpId?: string
83 onClose: () => void
84 /** Thread list / reply counts changed: the room view refetches them. */
85 onChanged: () => void
86}
87
88export function ThreadPanel({
89 roomId,
90 thread,
91 root,
92 viewerId,
93 spaceName,
94 members,
95 customEmojis,
96 nameOf,
97 interactions,
98 jumpId,
99 onClose,
100 onChanged,
101}: ThreadPanelProps) {
102 const { t } = useT()
103 const readOnly = thread.locked
104 ? t('chat.thread.lockedNotice')
105 : thread.archived
106 ? t('chat.thread.archivedReadOnly')
107 : null
108 const stream = useRoomStream({
109 roomId,
110 threadId: thread.id,
111 viewerId,
112 onRoomEvent: (kind) => {
113 if (kind === 'thread_updated') onChanged()
114 },
115 })
116 const composer = useComposer({
117 roomId,
118 threadId: thread.id,
119 enabled: readOnly === null,
120 onSubmitted: (content, attachments) => stream.send({ content, attachments }),
121 onTyping: () => sendTypingSignal(roomId, thread.id),
122 })
123 // Deep-linked reply: same one-shot rule as the room transcript, so a reload
124 // keeps the link and a second render does not re-fetch the window.
125 const jumped = useRef<string | null>(null)
126 useEffect(() => {
127 if (!jumpId || jumped.current === jumpId) return
128 jumped.current = jumpId
129 stream.jumpTo(jumpId)
130 }, [jumpId, stream])
131 // Replying inside a thread must never open another thread or quote a reply.
132 const threadInteractions = useMemo<MessageInteractions>(
133 () => ({
134 ...interactions,
135 onOpenThread: () => undefined,
136 onReply: () => undefined,
137 }),
138 [interactions],
139 )
140
141 return (
142 <>
143 <PanelHeader
144 title={thread.name || t('chat.thread.byOwner', { name: nameOf(thread.owner_id) })}
145 subtitle={t('chat.thread.summary', {
146 replies: thread.reply_count,
147 participants: thread.participant_count,
148 })}
149 onClose={onClose}
150 actions={
151 <ThreadTools
152 thread={thread}
153 viewerId={viewerId}
154 canManage={thread.owner_id === viewerId}
155 onChanged={onChanged}
156 />
157 }
158 />
159 {root ? (
160 <div className="shrink-0 border-b border-shell-hairline bg-shell-board/70 px-3 py-2">
161 <div className="flex items-baseline gap-2">
162 <UserAvatar user={root.author} id={root.author_id} size={22} />
163 <span className="truncate text-[12.5px] font-semibold">
164 {root.author?.display_name || root.author?.username || nameOf(root.author_id)}
165 </span>
166 <span className="shrink-0 text-[10.5px] text-shell-ink-muted" title={fullStamp(root.created_at)}>
167 {clockStamp(root.created_at)}
168 </span>
169 </div>
170 <div className="pl-8">
171 <MessageBody message={root} viewerId={viewerId} compact />
172 {root.attachments.length > 0 ? (
173 <AttachmentGrid attachments={root.attachments} onOpen={() => undefined} />
174 ) : null}
175 </div>
176 </div>
177 ) : null}
178 <Transcript
179 state={stream.state}
180 viewerId={viewerId}
181 lastReadSeq={0}
182 missedCount={stream.missedCount}
183 listRef={stream.listRef}
184 interactions={threadInteractions}
185 editingId={null}
186 onSaveEdit={stream.editMessage}
187 onCancelEdit={() => undefined}
188 onLoadOlder={stream.loadOlder}
189 onRetrySeed={stream.refresh}
190 onAtBottom={stream.reportAtBottom}
191 onReturnToLatest={stream.returnToLatest}
192 atBottom={stream.atBottom}
193 retryPending={stream.retryPending}
194 dismissPending={stream.dismissPending}
195 typingNames={stream.typingUserIds.map(nameOf)}
196 roomLabel={t('chat.thread.title')}
197 customEmojis={customEmojis}
198 spaceName={spaceName}
199 />
200 <div className="shrink-0 px-3 pb-3">
201 <ChatComposer
202 composer={composer}
203 roomLabel={t('chat.thread.title')}
204 threadRoot={root}
205 members={members}
206 customEmojis={customEmojis}
207 spaceName={spaceName}
208 disabledNote={readOnly}
209 onSubmit={() => {
210 void composer.submit().then((ok) => ok && onChanged())
211 }}
212 />
213 </div>
214 </>
215 )
216}
217
218function ThreadTools({
219 thread,
220 viewerId,
221 canManage,
222 onChanged,
223}: {
224 thread: ThreadBrief
225 viewerId?: number
226 canManage: boolean
227 onChanged: () => void
228}) {
229 const { t } = useT()
230 const [busy, setBusy] = useState(false)
231 const [error, setError] = useState<string | null>(null)
232 const [followOverride, setFollowOverride] = useState<{ threadId: string; following: boolean } | null>(null)
233 // A membership is the server's source of truth for reply notifications. The
234 // override keeps the label responsive after a toggle, before a later panel
235 // refresh has fetched the updated membership list.
236 const memberships = useAsync(listMyThreads, [thread.id])
237 const following =
238 followOverride?.threadId === thread.id
239 ? followOverride.following
240 : (memberships.data?.some((item) => item.id === thread.id) ?? thread.owner_id === viewerId)
241 const run = (action: () => Promise<unknown>, onSuccess?: () => void) => {
242 if (busy) return
243 setBusy(true)
244 setError(null)
245 void action().then(
246 () => {
247 setBusy(false)
248 onSuccess?.()
249 onChanged()
250 },
251 (reason) => {
252 setBusy(false)
253 setError(describeFailure(reason))
254 },
255 )
256 }
257 const toggleFollow = () => {
258 const next = !following
259 run(
260 () => (next ? followThread(thread.id, 'All') : unfollowThread(thread.id)),
261 () => setFollowOverride({ threadId: thread.id, following: next }),
262 )
263 }
264 return (
265 <div className="flex shrink-0 items-center gap-1 text-shell-ink-muted">
266 {error ? (
267 <span title={error} className="max-w-24 truncate text-[10.5px] text-[#e5484d]">
268 {error}
269 </span>
270 ) : null}
271 <button
272 type="button"
273 aria-pressed={following}
274 title={following ? t('chat.thread.followingTitle') : t('chat.thread.followTitle')}
275 onClick={toggleFollow}
276 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 ${
277 following ? 'border-shell-tile bg-shell-active text-shell-ink' : 'border-shell-hairline text-shell-ink-muted'
278 }`}
279 disabled={busy}
280 >
281 {following ? <Bell className="h-3 w-3" aria-hidden /> : <BellOff className="h-3 w-3" aria-hidden />}
282 {following ? t('chat.thread.following') : t('chat.thread.follow')}
283 </button>
284 {canManage ? (
285 <button
286 type="button"
287 title={thread.locked ? t('chat.thread.unlock') : t('chat.thread.lock')}
288 onClick={() => run(() => updateThread(thread.id, { locked: !thread.locked }))}
289 className={`grid h-6 w-6 place-content-center rounded hover:bg-shell-field ${
290 thread.locked ? 'text-[#b26a00]' : ''
291 }`}
292 >
293 <Lock className="h-3.5 w-3.5" />
294 </button>
295 ) : null}
296 {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
297 </div>
298 )
299}
300
301/* -------------------------------------------------------------------- pins */
302
303export function PinsPanel({
304 pins,
305 loading,
306 nameOf,
307 onJump,
308 onUnpin,
309 onClose,
310}: {
311 pins: ChatMessage[]
312 loading: boolean
313 nameOf: (userId: number | null | undefined) => string
314 onJump: (messageId: string) => void
315 onUnpin: (message: ChatMessage) => void
316 onClose: () => void
317}) {
318 const { t } = useT()
319 return (
320 <>
321 <PanelHeader
322 title={t('chat.room.pins')}
323 subtitle={t('chat.pins.count', { count: pins.length })}
324 onClose={onClose}
325 />
326 <div className="min-h-0 flex-1 overflow-y-auto p-2">
327 {loading ? (
328 <PanelBusy />
329 ) : pins.length === 0 ? (
330 <PanelEmpty icon={<Pin className="h-[18px] w-[18px]" />}>{t('chat.pins.empty')}</PanelEmpty>
331 ) : (
332 <ul className="space-y-0.5">
333 {pins.map((message) => (
334 <li
335 key={message.id}
336 className="group flex items-start gap-2.5 rounded-[10px] p-2.5 transition-colors hover:bg-shell-field"
337 >
338 <UserAvatar user={message.author} id={message.author_id} size={26} />
339 <button
340 type="button"
341 onClick={() => onJump(message.id)}
342 title={t('chat.pins.jump')}
343 className="min-w-0 flex-1 text-left"
344 >
345 <span className="flex items-baseline gap-1.5 text-[12px]">
346 <span className="truncate font-semibold">{nameOf(message.author_id)}</span>
347 <span className="shrink-0 text-[10.5px] text-shell-ink-muted">
348 {clockStamp(message.created_at)}
349 </span>
350 </span>
351 <span className="mt-0.5 line-clamp-3 block whitespace-pre-wrap break-words text-[12.5px]">
352 {message.deleted_at
353 ? t('chat.transcript.deletedShort')
354 : message.content || t('chat.composer.attachmentPlaceholder')}
355 </span>
356 </button>
357 <div className="flex shrink-0 flex-col gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
358 <button
359 type="button"
360 title={t('chat.pins.jumpShort')}
361 onClick={() => onJump(message.id)}
362 className="grid h-6 w-6 place-content-center rounded-md hover:bg-shell-active"
363 >
364 <Crosshair className="h-3.5 w-3.5 text-shell-ink-muted" />
365 </button>
366 <button
367 type="button"
368 title={t('chat.message.unpin')}
369 onClick={() => onUnpin(message)}
370 className="grid h-6 w-6 place-content-center rounded-md hover:bg-shell-active"
371 >
372 <Pin className="h-3.5 w-3.5 text-shell-ink-muted" />
373 </button>
374 </div>
375 </li>
376 ))}
377 </ul>
378 )}
379 </div>
380 </>
381 )
382}
383
384/* ----------------------------------------------------------------- members */
385
386export function MembersPanel({
387 memberCount,
388 members,
389 loading,
390 error,
391 presences,
392 viewerId,
393 onDm,
394 onClose,
395}: {
396 memberCount: number
397 members: RoomMember[]
398 loading: boolean
399 error?: unknown
400 presences: Presence[]
401 viewerId?: number
402 onDm: (user: UserInfo) => void
403 onClose: () => void
404}) {
405 const { t, tc } = useT()
406 const online = useMemo(() => {
407 const map = new Map<number, Presence>()
408 for (const presence of presences) map.set(presence.user_id, presence)
409 return map
410 }, [presences])
411 const sorted = useMemo(
412 () =>
413 [...members].sort(
414 (a, b) =>
415 presenceRank(online.get(a.user.user_id)) - presenceRank(online.get(b.user.user_id)) ||
416 a.user.username.localeCompare(b.user.username),
417 ),
418 [members, online],
419 )
420 const onlineMembers = useMemo(
421 () => sorted.filter((member) => online.get(member.user.user_id)?.online),
422 [sorted, online],
423 )
424 const offlineMembers = useMemo(
425 () => sorted.filter((member) => !online.get(member.user.user_id)?.online),
426 [sorted, online],
427 )
428 const renderMember = (member: RoomMember) => {
429 const presence = online.get(member.user.user_id)
430 const self = member.user.user_id === viewerId
431 return (
432 <li key={member.user.user_id}>
433 <button
434 type="button"
435 disabled={self}
436 onClick={() => onDm(member.user)}
437 title={self ? t('chat.member.self') : t('chat.member.dmTitle', { username: member.user.username })}
438 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"
439 >
440 <span className="relative shrink-0">
441 <UserAvatar user={member.user} id={member.user.user_id} size={32} />
442 {presence?.online ? (
443 <span
444 aria-hidden
445 title={presence.status}
446 className="absolute -bottom-0.5 -right-0.5 h-[11px] w-[11px] rounded-full border-2 border-shell-surface"
447 style={{
448 backgroundColor:
449 presence.status === 'Active' ? '#3ba55d' : presence.status === 'Away' ? '#f0b232' : '#da3e4c',
450 }}
451 />
452 ) : null}
453 </span>
454 <span className="min-w-0 flex-1">
455 <span className="block truncate text-[13px] font-medium">
456 {member.nick || member.user.display_name || member.user.username}
457 </span>
458 <span className="block truncate text-[11px] text-shell-ink-muted">@{member.user.username}</span>
459 </span>
460 {member.role !== 'member' ? (
461 <span className="shrink-0 rounded-full bg-shell-active px-1.5 py-px text-[10px] font-semibold text-shell-ink-muted">
462 {member.role === 'owner' ? t('chat.member.owner') : member.role}
463 </span>
464 ) : null}
465 </button>
466 </li>
467 )
468 }
469
470 return (
471 <>
472 <PanelHeader
473 title={t('chat.member.list')}
474 subtitle={tc('chat.member.count', memberCount || members.length)}
475 onClose={onClose}
476 />
477 <div className="min-h-0 flex-1 overflow-y-auto p-1.5">
478 {error && members.length === 0 ? (
479 <PanelNote>
480 {Number((error as { code?: number }).code ?? 0) === 403
481 ? t('chat.member.forbidden')
482 : t('chat.member.loadFailed')}
483 </PanelNote>
484 ) : loading && members.length === 0 ? (
485 <PanelBusy />
486 ) : (
487 <div className="space-y-1">
488 {onlineMembers.length > 0 ? (
489 <section>
490 <p className="px-2.5 pb-0.5 pt-2 text-[11px] font-semibold text-shell-ink-muted">
491 {t('label.presence.online')} · {onlineMembers.length}
492 </p>
493 <ul className="space-y-0.5">{onlineMembers.map(renderMember)}</ul>
494 </section>
495 ) : null}
496 {offlineMembers.length > 0 ? (
497 <section>
498 <p className="px-2.5 pb-0.5 pt-2 text-[11px] font-semibold text-shell-ink-muted">
499 {t('label.presence.offline')} · {offlineMembers.length}
500 </p>
501 <ul className="space-y-0.5">{offlineMembers.map(renderMember)}</ul>
502 </section>
503 ) : null}
504 </div>
505 )}
506 </div>
507 </>
508 )
509}
510
511function presenceRank(presence: Presence | undefined): number {
512 if (!presence?.online) return 3
513 if (presence.status === 'Active') return 0
514 if (presence.status === 'Dnd') return 1
515 return 2
516}
517
518/* ----------------------------------------------------------------- threads */
519
520export function ThreadsPanel({
521 threads,
522 loading,
523 nameOf,
524 onOpen,
525 onClose,
526}: {
527 threads: ThreadBrief[]
528 loading: boolean
529 nameOf: (userId: number | null | undefined) => string
530 onOpen: (thread: ThreadBrief) => void
531 onClose: () => void
532}) {
533 const { t, tc } = useT()
534 return (
535 <>
536 <PanelHeader
537 title={t('chat.thread.title')}
538 subtitle={t('chat.thread.count', { count: threads.length })}
539 onClose={onClose}
540 />
541 <div className="min-h-0 flex-1 overflow-y-auto p-2">
542 {loading ? (
543 <PanelBusy />
544 ) : threads.length === 0 ? (
545 <PanelEmpty icon={<MessageSquare className="h-[18px] w-[18px]" />}>{t('chat.thread.emptyHint')}</PanelEmpty>
546 ) : (
547 <ul className="space-y-0.5">
548 {threads.map((thread) => (
549 <li key={thread.id}>
550 <button
551 type="button"
552 onClick={() => onOpen(thread)}
553 className="flex w-full items-center gap-2.5 rounded-[10px] p-2.5 text-left transition-colors hover:bg-shell-field"
554 >
555 {thread.owner ? (
556 <UserAvatar user={thread.owner} id={thread.owner_id} size={34} />
557 ) : (
558 <span className="grid h-[34px] w-[34px] shrink-0 place-items-center rounded-full bg-shell-field text-shell-ink-muted">
559 <MessageSquare className="h-4 w-4" />
560 </span>
561 )}
562 <span className="min-w-0 flex-1">
563 <span className="block truncate text-[13px] font-semibold">
564 {thread.name || t('chat.thread.fromOwner', { name: nameOf(thread.owner_id) })}
565 </span>
566 <span className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-shell-ink-muted">
567 <span className="flex items-center gap-1">
568 <MessageSquare className="h-3 w-3" />
569 {t('chat.thread.replies', { count: thread.reply_count })}
570 </span>
571 <span className="flex items-center gap-1">
572 <Users className="h-3 w-3" />
573 {tc('chat.thread.participants', thread.participant_count)}
574 </span>
575 {thread.last_reply_at ? (
576 <span className="flex items-center gap-1">
577 <Clock className="h-3 w-3" />
578 {clockStamp(thread.last_reply_at)}
579 </span>
580 ) : null}
581 {thread.archived ? <span>{t('chat.thread.archived')}</span> : null}
582 {thread.locked ? (
583 <span className="flex items-center gap-1">
584 <Lock className="h-3 w-3" />
585 {t('chat.thread.locked')}
586 </span>
587 ) : null}
588 </span>
589 </span>
590 </button>
591 </li>
592 ))}
593 </ul>
594 )}
595 </div>
596 </>
597 )
598}
599
600/* ------------------------------------------------------------------- parts */
601
602function PanelBusy({ label }: { label?: string }) {
603 const { t } = useT()
604 return (
605 <p className="py-6 text-center text-[12px] text-shell-ink-muted">
606 <Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
607 {label ?? t('common.action.loading')}
608 </p>
609 )
610}
611
612function PanelEmpty({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
613 return (
614 <div className="chat-panel-empty">
615 {icon ? <span className="chat-panel-empty-icon">{icon}</span> : null}
616 <p className="text-[12.5px] leading-[1.65] text-shell-ink-muted">{children}</p>
617 </div>
618 )
619}
620
621function PanelNote({ children }: { children: React.ReactNode }) {
622 return <p className="px-2 py-4 text-[12px] leading-[1.6] text-shell-ink-muted">{children}</p>
623}
Added view/app/src/domains/chat/ui/system-lines.ts view/app/src/domains/chat/ui/system-lines.ts +41 −0 View view/app/src/domains/chat/ui/system-lines.ts
@@ -0,0 +1,41 @@
1/**
2 * One-line rendering for the non-`Default` message kinds the backend writes into
3 * a room (`ChatMessageType`). These are events, not posts, so they render as a
4 * compact grey line instead of a message row — and a webhook/bot post carries
5 * real content, which must not be swallowed by the system styling.
6 */
7import type { ChatMessage } from '@app/domains/chat/model'
8import { t } from '@app/i18n'
9
10export function systemLine(message: ChatMessage, nameOf: (userId: number | null | undefined) => string): string {
11 const who = nameOf(message.author_id)
12 if (message.author_type !== 'User') {
13 // Bots and webhooks post normal text; the label keeps it attributable.
14 const label = message.author?.display_name || message.author?.username || t('chat.system.integration')
15 return message.content
16 ? t('chat.system.postWithContent', { author: label, content: message.content })
17 : t('chat.system.post', { author: label })
18 }
19 switch (message.message_type) {
20 case 'ThreadCreated':
21 return t('chat.system.threadCreated', { author: who })
22 case 'ChannelJoin':
23 return t('chat.system.joined', { author: who })
24 case 'ChannelLeave':
25 return t('chat.system.left', { author: who })
26 case 'ChannelTopic':
27 return message.content
28 ? t('chat.system.topicChanged', { author: who, topic: message.content })
29 : t('chat.system.topicUpdated', { author: who })
30 case 'ChannelPinnedItem':
31 return t('chat.system.pinned', { author: who })
32 case 'Reminder':
33 return message.content
34 ? t('chat.system.reminder', { author: who, content: message.content })
35 : t('chat.system.reminderSet', { author: who })
36 case 'System':
37 return message.content || t('chat.system.message')
38 default:
39 return message.content || t('chat.system.sent', { author: who })
40 }
41}
Added view/app/src/domains/chat/ui/transcript.tsx view/app/src/domains/chat/ui/transcript.tsx +850 −0 View view/app/src/domains/chat/ui/transcript.tsx
@@ -0,0 +1,850 @@
1/**
2 * The message stream: grouping, date and unread dividers, live rows, optimistic
3 * rows, per-message actions, scroll anchoring, and the two navigation
4 * affordances ("加载更早" upward, "N 条新消息 / 回到最新" downward).
5 *
6 * Scroll rules, because this is where chat UIs usually break:
7 * - the viewport follows the tail **only** while it is pinned there, so a row
8 * that arrives while you read history never yanks the page;
9 * - prepending an older page keeps the pixel offset of what you were looking at
10 * (height delta applied in a layout effect, i.e. before paint);
11 * - "pinned to the tail" is also what the read cursor is derived from, so a
12 * scrolled-back window does not mark the room as read.
13 */
14
15import {
16 type Attachment,
17 type ChatMessage,
18 clockStamp,
19 dayKey,
20 dayLabel,
21 fullStamp,
22 isGrouped,
23 isSystemMessage,
24 MAX_MESSAGE_LEN,
25 type PendingMessage,
26 type ThreadBrief,
27 textLength,
28} from '@app/domains/chat/model'
29import { useT } from '@app/i18n'
30import {
31 ArrowDown,
32 Check,
33 Copy,
34 CornerUpLeft,
35 Loader2,
36 MessageSquare,
37 MoreHorizontal,
38 Pencil,
39 Pin,
40 RefreshCw,
41 SmilePlus,
42 Trash2,
43 TriangleAlert,
44 X,
45} from 'lucide-react'
46import { type ReactNode, type UIEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
47import type { TranscriptState } from '../model/transcript'
48import { EmojiPicker } from './emoji-picker'
49import { AttachmentGrid, DayDivider, IconAction, Lightbox, MessageBody, Popover, UserAvatar } from './parts'
50import { systemLine } from './system-lines'
51
52/** Distance from the bottom (px) that still counts as "reading the tail". */
53const STICK_THRESHOLD = 96
54/** Distance from the top that triggers a page fetch. */
55// Scroll lookahead for the older-messages page: fires early enough that the
56// next page is usually merged before the reader reaches the top.
57const LOAD_OLDER_THRESHOLD = 320
58
59export interface MessageInteractions {
60 onReply: (message: ChatMessage) => void
61 onOpenThread: (message: ChatMessage) => void
62 onEdit: (message: ChatMessage) => void
63 onDelete: (message: ChatMessage) => void
64 onTogglePin: (message: ChatMessage) => void
65 onReact: (message: ChatMessage, emoji: string, on: boolean) => void
66 onJump: (messageId: string) => void
67 /** Own post, or a channel manager: pin and delete are allowed. */
68 canModerate: (message: ChatMessage) => boolean
69 /** False for a read-only room: no reply/thread composer at all. */
70 canParticipate: boolean
71 threadOf: (messageId: string) => ThreadBrief | undefined
72 nameOf: (userId: number | null | undefined) => string
73}
74
75export interface TranscriptProps {
76 state: TranscriptState
77 viewerId?: number
78 lastReadSeq: number
79 missedCount: number
80 listRef: React.RefObject<HTMLDivElement | null>
81 interactions: MessageInteractions
82 editingId: string | null
83 onSaveEdit: (messageId: string, content: string) => Promise<boolean>
84 onCancelEdit: () => void
85 onLoadOlder: () => void
86 onRetrySeed: () => void
87 onAtBottom: (atBottom: boolean) => void
88 /** Back to the newest page; replaces the window when the reader jumped away. */
89 onReturnToLatest: () => void
90 atBottom: boolean
91 retryPending: (localId: string) => void
92 dismissPending: (localId: string) => void
93 typingNames: string[]
94 roomLabel: string
95 customEmojis?: Parameters<typeof EmojiPicker>[0]['custom']
96 spaceName?: string
97}
98
99export function Transcript({
100 state,
101 viewerId,
102 lastReadSeq,
103 missedCount,
104 listRef,
105 interactions,
106 editingId,
107 onSaveEdit,
108 onCancelEdit,
109 onLoadOlder,
110 onRetrySeed,
111 onAtBottom,
112 onReturnToLatest,
113 atBottom,
114 retryPending,
115 dismissPending,
116 typingNames,
117 roomLabel,
118 customEmojis,
119 spaceName,
120}: TranscriptProps) {
121 const { t, tc } = useT()
122 const anchorHeight = useRef<number | null>(null)
123 const [preview, setPreview] = useState<Attachment | null>(null)
124 const [flash, setFlash] = useState<string | null>(null)
125
126 // Rows are addressed through their `data-message-id` attribute instead of a
127 // ref map: a callback ref would be re-created (and re-attached) on every render
128 // just to look a row up once in a while.
129 const findRow = useCallback(
130 (id: string) =>
131 listRef.current?.querySelector<HTMLElement>(`[data-message-id="${id.replace(/[^\w-]/g, '')}"]`) ?? null,
132 [listRef],
133 )
134
135 const handleScroll = useCallback(
136 (event: UIEvent<HTMLDivElement>) => {
137 const node = event.currentTarget
138 const distance = node.scrollHeight - node.scrollTop - node.clientHeight
139 onAtBottom(distance <= STICK_THRESHOLD)
140 if (node.scrollTop < LOAD_OLDER_THRESHOLD && state.hasOlder && !state.loadingOlder) {
141 anchorHeight.current = node.scrollHeight
142 onLoadOlder()
143 }
144 },
145 [onAtBottom, onLoadOlder, state.hasOlder, state.loadingOlder],
146 )
147
148 // Keep the reader's position when an older page is inserted above them.
149 useLayoutEffect(() => {
150 const target = anchorHeight.current
151 if (target === null) return
152 anchorHeight.current = null
153 const node = listRef.current
154 if (!node) return
155 const delta = node.scrollHeight - target
156 if (delta > 0) node.scrollTop += delta
157 }, [listRef, state.rows])
158
159 // …and bring a jumped-to row into view, flashing it so it is easy to spot.
160 useEffect(() => {
161 const id = state.highlightId
162 if (!id) return
163 const frame = requestAnimationFrame(() => {
164 const node = findRow(id)
165 if (!node) return
166 node.scrollIntoView({ block: 'center', behavior: 'smooth' })
167 setFlash(id)
168 })
169 return () => cancelAnimationFrame(frame)
170 }, [findRow, state.highlightId, state.rows])
171
172 useEffect(() => {
173 if (!flash) return
174 const timer = setTimeout(() => setFlash(null), 2_400)
175 return () => clearTimeout(timer)
176 }, [flash])
177
178 if (state.accessRevoked) {
179 return (
180 <TranscriptNotice
181 icon={<TriangleAlert className="h-5 w-5" />}
182 title={t('chat.transcript.accessDenied')}
183 body={t('chat.transcript.accessDeniedHint')}
184 />
185 )
186 }
187
188 // The divider sits on the first row the viewer has not seen yet.
189 const unreadIndex = state.rows.findIndex((row) => row.seq > lastReadSeq)
190 const atError = state.error !== null && state.rows.length === 0
191
192 return (
193 <div className="chat-transcript relative flex min-h-0 flex-1 flex-col">
194 <div
195 ref={listRef}
196 onScroll={handleScroll}
197 aria-label={t('chat.transcript.aria')}
198 className="chat-message-list min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-3"
199 >
200 {state.loadingOlder ? (
201 <p className="py-2 text-center text-[11.5px] text-shell-ink-muted">
202 <Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
203 {t('chat.transcript.loadingOlder')}
204 </p>
205 ) : state.hasOlder ? null : state.rows.length > 0 ? (
206 <p className="chat-history-start">
207 {state.threadId ? t('chat.transcript.threadStart') : t('chat.transcript.start')}
208 </p>
209 ) : null}
210
211 {state.rows.map((message, index) => {
212 const previous = state.rows[index - 1]
213 const system = isSystemMessage(message)
214 const grouped = !system && !previous?.deleted_at && isGrouped(previous, message)
215 const newDay = !previous || dayKey(previous.created_at) !== dayKey(message.created_at)
216 return (
217 <div key={message.id} data-message-id={message.id}>
218 {newDay ? <DayDivider value={dayLabel(message.created_at)} /> : null}
219 {index === unreadIndex ? <UnreadMarker count={state.rows.length - index} /> : null}
220 {system ? (
221 <SystemRow message={message} nameOf={interactions.nameOf} />
222 ) : (
223 <MessageRow
224 message={message}
225 grouped={grouped}
226 viewerId={viewerId}
227 state={state}
228 interactions={interactions}
229 editing={editingId === message.id}
230 onSaveEdit={onSaveEdit}
231 onCancelEdit={onCancelEdit}
232 onPreview={setPreview}
233 flashed={flash === message.id}
234 customEmojis={customEmojis}
235 spaceName={spaceName}
236 />
237 )}
238 </div>
239 )
240 })}
241
242 {state.pending.map((item) => (
243 <div key={item.localId} data-message-id={item.localId}>
244 <PendingRow
245 item={item}
246 viewerId={viewerId}
247 onPreview={setPreview}
248 onRetry={retryPending}
249 onDismiss={dismissPending}
250 />
251 </div>
252 ))}
253
254 {state.loadingSeed && state.rows.length === 0 ? <SkeletonRows /> : null}
255
256 {atError ? (
257 <TranscriptNotice
258 icon={<TriangleAlert className="h-5 w-5" />}
259 title={t('chat.transcript.loadFailed')}
260 body={state.error ?? ''}
261 action={{ label: t('common.action.retry'), onClick: onRetrySeed }}
262 retry
263 />
264 ) : null}
265
266 {state.seeded && state.rows.length === 0 && state.pending.length === 0 && !state.loadingSeed && !state.error ? (
267 <TranscriptNotice
268 icon={<MessageSquare className="h-5 w-5" />}
269 title={
270 state.threadId
271 ? t('chat.transcript.threadEmptyTitle')
272 : t('chat.transcript.roomEmptyTitle', { room: roomLabel })
273 }
274 body={state.threadId ? t('chat.transcript.threadEmptyBody') : t('chat.transcript.roomEmptyBody')}
275 />
276 ) : null}
277
278 {typingNames.length > 0 ? <TypingRow names={typingNames} /> : null}
279 </div>
280
281 {/* Away from the tail for any reason — a jump, a scroll-up, new traffic —
282 gets the same way back, and the pill counts only what really arrived. */}
283 {!atBottom ? (
284 <button
285 type="button"
286 onClick={onReturnToLatest}
287 className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-full bg-shell-action px-3.5 py-1.5 text-[12.5px] font-semibold text-white shadow-lg transition-[filter] hover:brightness-95"
288 >
289 <ArrowDown className="h-3.5 w-3.5" />
290 {missedCount > 0 ? tc('chat.transcript.newMessages', missedCount) : t('chat.transcript.backToLatest')}
291 </button>
292 ) : null}
293
294 <Lightbox attachment={preview} onClose={() => setPreview(null)} />
295 </div>
296 )
297}
298
299function UnreadMarker({ count }: { count: number }) {
300 const { t } = useT()
301 return (
302 <div className="chat-unread-marker flex items-center gap-3 px-3 py-1.5">
303 <div className="h-px flex-1 bg-shell-tile" />
304 <span className="shrink-0 rounded-full bg-shell-active px-2 py-px text-[11px] font-semibold text-shell-ink-muted">
305 {count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
306 </span>
307 <div className="h-px flex-1 bg-shell-tile" />
308 </div>
309 )
310}
311
312function SystemRow({
313 message,
314 nameOf,
315}: {
316 message: ChatMessage
317 nameOf: (userId: number | null | undefined) => string
318}) {
319 return (
320 <div className="flex items-start gap-2 px-3 py-0.5 text-[12.5px] text-shell-ink-muted">
321 <span className="w-10 shrink-0 select-none pt-px text-right text-[10px]">{clockStamp(message.created_at)}</span>
322 <span className="min-w-0 break-words">{systemLine(message, nameOf)}</span>
323 </div>
324 )
325}
326
327function MessageRow({
328 message,
329 grouped,
330 viewerId,
331 state,
332 interactions,
333 editing,
334 onSaveEdit,
335 onCancelEdit,
336 onPreview,
337 flashed,
338 customEmojis,
339 spaceName,
340}: {
341 message: ChatMessage
342 grouped: boolean
343 viewerId?: number
344 state: TranscriptState
345 interactions: MessageInteractions
346 editing: boolean
347 onSaveEdit: (messageId: string, content: string) => Promise<boolean>
348 onCancelEdit: () => void
349 onPreview: (attachment: Attachment) => void
350 flashed: boolean
351 customEmojis?: Parameters<typeof EmojiPicker>[0]['custom']
352 spaceName?: string
353}) {
354 const { t } = useT()
355 const mine = Boolean(viewerId && message.author_id === viewerId)
356 const replyId = message.reply_to_id ?? null
357 const replyRow = replyId ? state.rows.find((row) => row.id === replyId) : undefined
358 const replyRef = replyId ? state.refs[replyId] : undefined
359 const replyMessage = replyRow ?? (replyRef?.state === 'ready' ? replyRef.message : undefined)
360 const thread = interactions.threadOf(message.id)
361 const deleted = Boolean(message.deleted_at)
362
363 return (
364 <div
365 className={`chat-message-row group relative flex gap-3 rounded-md px-3 transition-colors hover:bg-shell-board ${
366 grouped ? 'py-px' : 'pb-0.5 pt-2.5'
367 } ${grouped ? 'chat-message-grouped' : ''} ${flashed ? 'bg-shell-active ring-1 ring-inset ring-shell-tile' : ''}`}
368 >
369 {grouped ? (
370 <span className="w-10 shrink-0" aria-hidden="true" />
371 ) : (
372 <UserAvatar user={message.author} id={message.author_id} size={40} />
373 )}
374 <div className="min-w-0 flex-1">
375 {!grouped ? (
376 <div className="flex items-baseline gap-2">
377 <span className="min-w-0 truncate text-[15px] font-semibold">
378 {message.author?.display_name || message.author?.username || interactions.nameOf(message.author_id)}
379 </span>
380 {message.author_type !== 'User' ? (
381 <span className="shrink-0 rounded bg-shell-tile px-1 text-[9.5px] font-bold uppercase text-shell-ink">
382 {message.author_type === 'Webhook' ? 'webhook' : 'bot'}
383 </span>
384 ) : null}
385 <span className="shrink-0 text-[11px] text-shell-ink-muted" title={fullStamp(message.created_at)}>
386 {clockStamp(message.created_at)}
387 </span>
388 {message.pinned ? (
389 <span title={t('chat.transcript.pinned')} className="shrink-0 text-shell-ink-muted">
390 <Pin className="h-3 w-3" />
391 </span>
392 ) : null}
393 </div>
394 ) : null}
395
396 {replyMessage ? (
397 <ReplyQuote
398 message={replyMessage}
399 nameOf={interactions.nameOf}
400 onJump={() => replyId && interactions.onJump(replyId)}
401 />
402 ) : replyId && replyRef?.state === 'gone' ? (
403 <p className="text-[12px] text-shell-ink-muted">{t('chat.transcript.replyDeleted')}</p>
404 ) : null}
405
406 {editing ? (
407 <InlineEditor
408 initial={message.content}
409 onCancel={onCancelEdit}
410 onSave={async (content) => {
411 const saved = await onSaveEdit(message.id, content)
412 if (saved) onCancelEdit()
413 return saved
414 }}
415 />
416 ) : deleted ? (
417 <p className="text-[14px] italic text-shell-ink-muted">{t('chat.transcript.thisDeleted')}</p>
418 ) : (
419 <>
420 <MessageBody
421 message={message}
422 viewerId={viewerId}
423 suffix={
424 message.edited_at ? (
425 <span className="text-[10.5px] text-shell-ink-muted" title={fullStamp(message.edited_at)}>
426 {t('chat.message.edited')}
427 </span>
428 ) : null
429 }
430 />
431 {message.attachments.length > 0 ? (
432 <AttachmentGrid attachments={message.attachments} onOpen={onPreview} />
433 ) : null}
434 </>
435 )}
436
437 {message.reactions && message.reactions.length > 0 ? (
438 <div className="mt-1 flex flex-wrap gap-1">
439 {message.reactions.map((reaction) => (
440 <button
441 key={reaction.emoji}
442 type="button"
443 onClick={() => interactions.onReact(message, reaction.emoji, !reaction.me)}
444 aria-pressed={reaction.me}
445 aria-label={
446 reaction.me
447 ? t('chat.reaction.ariaMine', { emoji: reaction.emoji, count: reaction.count })
448 : t('chat.reaction.aria', { emoji: reaction.emoji, count: reaction.count })
449 }
450 title={t('chat.reaction.title', { count: reaction.count })}
451 className={`flex items-center gap-1 rounded-full border px-1.5 py-px text-[12px] transition-colors ${
452 reaction.me
453 ? 'border-shell-tile bg-shell-active text-shell-ink'
454 : 'border-shell-hairline bg-shell-field text-shell-ink hover:border-shell-ink-muted'
455 }`}
456 >
457 <span className="leading-none">{reaction.emoji}</span>
458 <span className="font-semibold tabular-nums">{reaction.count}</span>
459 </button>
460 ))}
461 </div>
462 ) : null}
463
464 {thread && thread.reply_count > 0 ? (
465 <button
466 type="button"
467 onClick={() => interactions.onOpenThread(message)}
468 className="mt-1 flex max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-[12.5px] font-medium text-shell-ink underline-offset-2 transition-colors hover:bg-shell-field hover:underline"
469 >
470 <MessageSquare className="h-3.5 w-3.5 shrink-0" />
471 <span className="shrink-0">{t('chat.thread.replies', { count: thread.reply_count })}</span>
472 {thread.last_reply_at ? (
473 <span className="min-w-0 truncate text-shell-ink-muted">
474 {t('chat.transcript.lastReply', { time: clockStamp(thread.last_reply_at) })}
475 </span>
476 ) : null}
477 {thread.locked ? <span className="shrink-0 text-[11px]">· {t('chat.thread.locked')}</span> : null}
478 </button>
479 ) : null}
480 </div>
481
482 {!editing && !deleted && !message.thread_id ? (
483 <MessageToolbar
484 message={message}
485 mine={mine}
486 canModerate={interactions.canModerate(message)}
487 canParticipate={interactions.canParticipate}
488 interactions={interactions}
489 customEmojis={customEmojis}
490 spaceName={spaceName}
491 />
492 ) : null}
493 </div>
494 )
495}
496
497function ReplyQuote({
498 message,
499 nameOf,
500 onJump,
501}: {
502 message: ChatMessage
503 nameOf: (userId: number | null | undefined) => string
504 onJump: () => void
505}) {
506 const { t } = useT()
507 return (
508 <button
509 type="button"
510 onClick={onJump}
511 title={t('chat.transcript.jumpToOriginal')}
512 className="mb-0.5 flex max-w-full items-center gap-1.5 text-[12px] text-shell-ink-muted transition-colors hover:text-shell-ink"
513 >
514 <CornerUpLeft className="h-3 w-3 shrink-0" />
515 <span className="shrink-0 font-medium">{nameOf(message.author_id)}</span>
516 <span className="min-w-0 truncate">
517 {message.deleted_at
518 ? t('chat.transcript.deletedShort')
519 : message.content || t('chat.composer.attachmentPlaceholder')}
520 </span>
521 </button>
522 )
523}
524
525function MessageToolbar({
526 message,
527 mine,
528 canModerate,
529 canParticipate,
530 interactions,
531 customEmojis,
532 spaceName,
533}: {
534 message: ChatMessage
535 mine: boolean
536 canModerate: boolean
537 canParticipate: boolean
538 interactions: MessageInteractions
539 customEmojis?: Parameters<typeof EmojiPicker>[0]['custom']
540 spaceName?: string
541}) {
542 const { t } = useT()
543 const [picker, setPicker] = useState(false)
544 const [menu, setMenu] = useState(false)
545 const [copied, setCopied] = useState(false)
546 const reacted = (message.reactions ?? []).filter((reaction) => reaction.me).map((r) => r.emoji)
547 const toggle = (emoji: string) => {
548 const on = !reacted.includes(emoji)
549 interactions.onReact(message, emoji, on)
550 }
551 return (
552 <div className="chat-message-toolbar" role="group" aria-label={t('chat.transcript.actions')}>
553 <div className="relative">
554 <IconAction label={t('chat.transcript.react')} onClick={() => setPicker((value) => !value)} active={picker}>
555 <SmilePlus className="h-4 w-4" />
556 </IconAction>
557 <Popover open={picker} onClose={() => setPicker(false)} align="end" className="w-80">
558 <EmojiPicker
559 custom={customEmojis}
560 spaceName={spaceName}
561 onPick={(emoji) => {
562 toggle(emoji)
563 setPicker(false)
564 }}
565 />
566 </Popover>
567 </div>
568 {canParticipate ? (
569 <>
570 <IconAction label={t('chat.transcript.replyInThread')} onClick={() => interactions.onOpenThread(message)}>
571 <MessageSquare className="h-4 w-4" />
572 </IconAction>
573 <IconAction label={t('chat.transcript.quoteReply')} onClick={() => interactions.onReply(message)}>
574 <CornerUpLeft className="h-4 w-4" />
575 </IconAction>
576 </>
577 ) : null}
578 <IconAction
579 label={copied ? t('chat.transcript.copied') : t('chat.transcript.copyText')}
580 onClick={() => {
581 void navigator.clipboard?.writeText(message.content).then(
582 () => setCopied(true),
583 () => setCopied(false),
584 )
585 }}
586 >
587 {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
588 </IconAction>
589 <div className="relative">
590 <IconAction label={t('chat.transcript.more')} onClick={() => setMenu((value) => !value)} active={menu}>
591 <MoreHorizontal className="h-4 w-4" />
592 </IconAction>
593 <Popover open={menu} onClose={() => setMenu(false)} align="end" className="w-40 py-1">
594 {mine ? (
595 <MenuItem
596 icon={<Pencil className="h-3.5 w-3.5" />}
597 label={t('common.action.edit')}
598 onClick={() => {
599 setMenu(false)
600 interactions.onEdit(message)
601 }}
602 />
603 ) : null}
604 <MenuItem
605 icon={<Pin className="h-3.5 w-3.5" />}
606 label={message.pinned ? t('chat.message.unpin') : t('chat.message.pin')}
607 disabled={!canModerate}
608 onClick={() => {
609 setMenu(false)
610 interactions.onTogglePin(message)
611 }}
612 />
613 {mine || canModerate ? (
614 <MenuItem
615 icon={<Trash2 className="h-3.5 w-3.5" />}
616 label={t('common.action.delete')}
617 danger
618 onClick={() => {
619 setMenu(false)
620 interactions.onDelete(message)
621 }}
622 />
623 ) : null}
624 </Popover>
625 </div>
626 </div>
627 )
628}
629
630function MenuItem({
631 icon,
632 label,
633 onClick,
634 danger = false,
635 disabled = false,
636}: {
637 icon: ReactNode
638 label: string
639 onClick: () => void
640 danger?: boolean
641 disabled?: boolean
642}) {
643 return (
644 <button
645 type="button"
646 disabled={disabled}
647 onClick={onClick}
648 className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-board disabled:pointer-events-none disabled:opacity-40 ${
649 danger ? 'text-[#e5484d]' : ''
650 }`}
651 >
652 {icon}
653 {label}
654 </button>
655 )
656}
657
658function PendingRow({
659 item,
660 viewerId,
661 onPreview,
662 onRetry,
663 onDismiss,
664}: {
665 item: PendingMessage
666 viewerId?: number
667 onPreview: (attachment: Attachment) => void
668 onRetry: (localId: string) => void
669 onDismiss: (localId: string) => void
670}) {
671 const { t } = useT()
672 const failed = item.state === 'failed'
673 return (
674 <div className="chat-pending-row flex gap-3 px-3 pb-0.5 pt-2.5">
675 <UserAvatar id={viewerId ?? 0} size={40} />
676 <div className="min-w-0 flex-1">
677 <div className="flex items-baseline gap-2">
678 <span className="text-[15px] font-semibold">{t('chat.transcript.mine')}</span>
679 <span className="text-[11px] text-shell-ink-muted">{clockStamp(item.created_at)}</span>
680 <span
681 className={`shrink-0 rounded px-1 text-[10px] font-semibold ${
682 failed ? 'bg-[#e5484d]/12 text-[#e5484d]' : 'bg-shell-field text-shell-ink-muted'
683 }`}
684 >
685 {failed ? t('chat.transcript.unsent') : t('chat.transcript.sending')}
686 </span>
687 </div>
688 <p className="whitespace-pre-wrap break-words text-[15px] leading-[1.5]">{item.content}</p>
689 {item.attachments.length > 0 ? <AttachmentGrid attachments={item.attachments} onOpen={onPreview} /> : null}
690 {failed ? (
691 <div className="mt-1 flex flex-wrap items-center gap-2 text-[12px] text-[#e5484d]">
692 <span className="min-w-0 truncate">{item.error ?? t('chat.transcript.sendFailed')}</span>
693 <button
694 type="button"
695 onClick={() => onRetry(item.localId)}
696 className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-px text-shell-ink hover:bg-shell-field"
697 >
698 <RefreshCw className="h-3 w-3" />
699 {t('common.action.retry')}
700 </button>
701 <button
702 type="button"
703 onClick={() => onDismiss(item.localId)}
704 className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-px text-shell-ink-muted hover:bg-shell-field"
705 >
706 <X className="h-3 w-3" />
707 {t('common.action.dismiss')}
708 </button>
709 </div>
710 ) : null}
711 </div>
712 </div>
713 )
714}
715
716function InlineEditor({
717 initial,
718 onSave,
719 onCancel,
720}: {
721 initial: string
722 onSave: (content: string) => Promise<unknown>
723 onCancel: () => void
724}) {
725 const { t } = useT()
726 const [value, setValue] = useState(initial)
727 const [busy, setBusy] = useState(false)
728 const over = textLength(value) > MAX_MESSAGE_LEN
729 const save = () => {
730 if (busy || over || !value.trim()) return
731 setBusy(true)
732 void Promise.resolve(onSave(value)).finally(() => setBusy(false))
733 }
734 return (
735 <div className="mt-1">
736 <textarea
737 autoFocus
738 aria-label={t('chat.message.edit')}
739 value={value}
740 onChange={(event) => setValue(event.target.value)}
741 onKeyDown={(event) => {
742 if (event.nativeEvent.isComposing || event.keyCode === 229) return
743 if (event.key === 'Escape') onCancel()
744 if (event.key === 'Enter' && !event.shiftKey) {
745 event.preventDefault()
746 save()
747 }
748 }}
749 rows={Math.min(10, value.split('\n').length + 1)}
750 className="w-full resize-none rounded-md border-2 border-shell-ink bg-shell-surface p-2 text-[14px] outline-none"
751 />
752 <div className="flex items-center gap-2 text-[11.5px] text-shell-ink-muted">
753 <button
754 type="button"
755 disabled={busy || over || !value.trim()}
756 onClick={save}
757 className="rounded bg-shell-action px-2.5 py-1 text-[12px] font-semibold text-white disabled:opacity-50"
758 >
759 {t('common.action.save')}
760 </button>
761 <button type="button" onClick={onCancel} className="rounded px-2 py-1 text-[12px] hover:bg-shell-field">
762 {t('common.action.cancel')}
763 </button>
764 <span>{t('chat.transcript.editHint')}</span>
765 {over ? (
766 <span className="text-[#e5484d]">
767 {t('chat.composer.tooLongBy', { count: textLength(value) - MAX_MESSAGE_LEN })}
768 </span>
769 ) : null}
770 </div>
771 </div>
772 )
773}
774
775function TypingRow({ names }: { names: string[] }) {
776 const { t } = useT()
777 const label =
778 names.length === 1
779 ? t('chat.transcript.typingOne', { name: names[0] })
780 : names.length === 2
781 ? t('chat.transcript.typingTwo', { first: names[0], second: names[1] })
782 : t('chat.transcript.typingMany', { names: names.slice(0, 3).join(', ') })
783 return (
784 <div className="flex items-center gap-2 px-6 pb-1 pt-1 text-[12px] text-shell-ink-muted">
785 <span className="flex gap-0.5" aria-hidden>
786 {[0, 1, 2].map((index) => (
787 <span
788 key={index}
789 className="h-1.5 w-1.5 animate-bounce rounded-full bg-shell-ink-muted"
790 style={{ animationDelay: `${index * 120}ms` }}
791 />
792 ))}
793 </span>
794 <span className="truncate">{label}</span>
795 </div>
796 )
797}
798
799function SkeletonRows() {
800 return (
801 <div className="space-y-4 px-3 pt-4">
802 {[520, 360, 460, 300].map((width) => (
803 <div key={width} className="flex gap-3">
804 <span className="h-10 w-10 shrink-0 rounded-full bg-shell-tile" />
805 <div className="flex-1 space-y-1.5 pt-1">
806 <span className="block h-3 w-24 rounded-full bg-shell-active" />
807 <span className="block h-3 max-w-full rounded-full bg-shell-active" style={{ width: width }} />
808 </div>
809 </div>
810 ))}
811 </div>
812 )
813}
814
815function TranscriptNotice({
816 icon,
817 title,
818 body,
819 action,
820 retry = false,
821}: {
822 icon: ReactNode
823 title: string
824 body: string
825 action?: { label: string; onClick: () => void }
826 retry?: boolean
827}) {
828 return (
829 <div
830 className="chat-transcript-notice flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 py-12 text-center"
831 role={retry ? 'status' : undefined}
832 >
833 <span className="chat-notice-icon">{icon}</span>
834 <p className="chat-notice-title">{title}</p>
835 <p className="chat-notice-description">{body}</p>
836 {action ? (
837 <button
838 type="button"
839 onClick={action.onClick}
840 className={`mt-1 rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-1.5 text-[12.5px] transition-colors hover:border-shell-ink ${
841 retry ? 'flex items-center gap-1.5' : ''
842 }`}
843 >
844 {retry ? <RefreshCw className="h-3.5 w-3.5" /> : null}
845 {action.label}
846 </button>
847 ) : null}
848 </div>
849 )
850}
Added view/app/src/domains/chat/index.ts view/app/src/domains/chat/index.ts +25 −0 View view/app/src/domains/chat/index.ts
@@ -0,0 +1,25 @@
1export {
2 MessagePipelineProvider,
3 useActiveRoom,
4 useMessagePipeline,
5 useRoomMessages,
6 useRoomMeta,
7 useWatchRoom,
8} from './model/message-pipeline'
9export { RoomWatcher, type WatchedRoom } from './ui/room-watcher'
10// Public API of features/chat — the 聊天室 column of the workspace page: the
11// transcript, the composer, the thread/pin/member panels and the room's
12// own dialogs. Realtime comes from the app-level socket in '@app/services/realtime'; message
13// rows and transport live in '@app/domains/chat/model'.
14// Consumers import from '@app/domains/chat', never from deeper files.
15
16export { prefetchRoomView } from './model/prefetch'
17export type { ChatPanelKind, ChatViewState } from './model/view-state'
18export {
19 installVoiceSession,
20 type JoinVoiceRoomOptions,
21 joinVoiceRoom,
22 voiceCredentials,
23} from './model/voice-connection'
24export { ChatRoom, type ChatRoomProps } from './ui/chat-room'
25export { VoiceCallBar } from './ui/voice/voice-call-bar'