Skip to content
Clone

feat(chat): resizable detail pane and a searchable emoji picker

The detail pane was fixed at 320px (380px from xl), so a thread or member
list squeezed the transcript for no reason. A pointer handle now drags it
between 280px and the room width minus a 100px reading column - default
380px, keyboard arrows in 24px steps, live width published as
--chat-panel-width - and a blur scrim lets the chat dim while a widened
pane has focus. Two chat-only affordances follow: grouped messages drop
the gutter timestamp (it repeated the header of the run) and the thread
pill only appears once a thread actually has replies.

The hand-rolled picker is replaced by emoji-picker-react, which brings
search, skin tones and category navigation; the wrapper keeps the product
surface - shell palette re-pointed through the --epr-* variables, emoji
names from the active locale, the space custom set as :name: picks. Locale
datasets are a few hundred kB, so they load lazily on first open and are
memoised per session; a failed load falls back to the bundled English
table instead of a permanent skeleton, and tsconfig maps the two dist/data
subpaths to their declarations so the library's own sources are not
compiled into this project.

Also fixes the custom emoji list being empty forever: the endpoint answers
with a bare array, not the paged { items } envelope the generated type
advertised, so unwrapApi<...>.items was always undefined. Message grouping
window widens from five to ten minutes to match how the runs actually read.
ZhenYi committed
parent 4c3ba81 commit 61a9be4 Browse files

13 files changed

+417 −133

File tree 13
Modified web/src/entities/chat/model/api.ts View web/src/entities/chat/model/api.ts
@@ -402,9 +402,12 @@
402402/* ------------------------------------------------------------------ emojis */
403403
404404export const listSpaceEmojis = (spaceName: string) =>
405 unwrapApi<{ items?: CustomEmoji[] } | null>(
405 // The endpoint answers with a bare array, not the paged { items } envelope
406 // the generated type advertises: reading .items silently returned nothing,
407 // so a space's custom emojis never reached the picker.
408 listOf<CustomEmoji>(
406409 Apis.chat.list_emojis({ pathParams: { space_name: spaceName }, params: { limit: 100 } }),
407 ).then(page => page?.items ?? [])
410 )
408411
409412/* -------------------------------------------------------------- attachments */
410413
Modified web/src/entities/chat/model/meta.ts View web/src/entities/chat/model/meta.ts
@@ -121,7 +121,7 @@
121121
122122/**
123123 * `true` when a message may join the one above it: same author, same thread,
124 * plain post, and within five minutes (Discord's grouping window).
124 * plain post, and within ten minutes.
125125 */
126126export function isGrouped(previous: ChatMessage | undefined, message: ChatMessage): boolean {
127127 if (!previous) return false
@@ -129,7 +129,7 @@
129129 if (isSystemMessage(previous) || isSystemMessage(message)) return false
130130 if ((previous.thread_id ?? null) !== (message.thread_id ?? null)) return false
131131 const gap = parseStamp(message.created_at) - parseStamp(previous.created_at)
132 return Number.isFinite(gap) && gap >= 0 && gap < 5 * 60_000
132 return Number.isFinite(gap) && gap >= 0 && gap < 10 * 60_000
133133}
134134
135135/**
Modified web/src/features/chat/ui/chat-composer.tsx View web/src/features/chat/ui/chat-composer.tsx
@@ -389,7 +389,7 @@
389389 >
390390 <Smile className="h-[18px] w-[18px]" />
391391 </button>
392 <Popover open={emojiOpen} onClose={() => setEmojiOpen(false)} className="w-72">
392 <Popover open={emojiOpen} onClose={() => setEmojiOpen(false)} className="w-80">
393393 <EmojiPicker custom={customEmojis} spaceName={spaceName} onPick={insertEmoji} />
394394 </Popover>
395395 </div>
Modified web/src/features/chat/ui/chat-polish.css View web/src/features/chat/ui/chat-polish.css
@@ -1,6 +1,7 @@
11/* Conversation surfaces favour reading rhythm and clear, quiet affordances. */
22.chat-room { background: var(--shell-surface); }
3.chat-main { min-width: 0; }
3.chat-room.chat-panel-resizing, .chat-room.chat-panel-resizing * { cursor: col-resize !important; user-select: none; }
4.chat-main { position: relative; min-width: 0; container-type: inline-size; }
45.chat-header {
56 display: flex;
67 min-height: 76px;
@@ -89,16 +90,74 @@
8990.chat-send-button:hover:not(:disabled) { filter: brightness(1.08); }
9091.chat-send-button:active:not(:disabled) { transform: scale(.97); }
9192.chat-send-button:disabled { cursor: not-allowed; opacity: .45; }
92.chat-side-panel { box-shadow: -10px 0 24px rgb(24 28 31 / 3%); }
93.chat-side-panel { position: relative; width: min(var(--chat-panel-width, 380px), calc(100% - 100px)); max-width: calc(100% - 100px); box-shadow: -10px 0 24px rgb(24 28 31 / 3%); }
94.chat-panel-resize-handle { position: absolute; z-index: 30; top: 0; bottom: 0; left: -6px; width: 12px; cursor: col-resize; touch-action: none; outline: none; }
95.chat-panel-resize-handle::after { position: absolute; top: 50%; left: 5px; width: 2px; height: 44px; border-radius: 999px; background: var(--shell-focus); content: ''; opacity: 0; transform: translateY(-50%); transition: opacity 150ms; }
96.chat-panel-resize-handle:hover::after, .chat-panel-resize-handle:focus-visible::after, .chat-panel-resizing .chat-panel-resize-handle::after { opacity: 1; }
97.chat-panel-focus-scrim { position: absolute; z-index: 25; inset: 0; background: color-mix(in srgb, var(--shell-surface) 38%, transparent); backdrop-filter: blur(8px) saturate(.86); -webkit-backdrop-filter: blur(8px) saturate(.86); }
9398.chat-panel-header { min-height: 60px; padding: 0 16px; }
9499.chat-panel-empty { padding: 32px 20px; }
95100.chat-popover { box-shadow: 0 14px 40px rgb(24 28 31 / 16%); }
96101.chat-popover button { cursor: pointer; }
97102
103/* The picker ships its own palette; re-point its variables at the shell tokens
104 * so search, category tabs and hover states read as part of the same surface. */
105.chat-emoji-picker .EmojiPickerReact {
106 --epr-bg-color: var(--shell-surface);
107 --epr-picker-border-color: var(--shell-hairline);
108 --epr-picker-border-radius: 10px;
109 --epr-text-color: var(--shell-ink-muted);
110 --epr-search-input-bg-color: var(--shell-field);
111 --epr-search-input-bg-color-active: var(--shell-field);
112 --epr-search-border-color: var(--shell-hairline);
113 --epr-search-border-color-active: var(--shell-focus);
114 --epr-hover-bg-color: var(--shell-active);
115 --epr-focus-bg-color: var(--shell-active);
116 --epr-category-label-bg-color: color-mix(in srgb, var(--shell-surface) 90%, transparent);
117 --epr-category-icon-active-color: var(--shell-action);
118 --epr-category-icon-inactive-color: var(--shell-ink-muted);
119}
120/* The dark theme re-points every variable at an --epr-dark-* twin, so the
121 * tokens have to be overridden there instead. */
122.chat-emoji-picker .epr-dark-theme {
123 --epr-dark-bg-color: var(--shell-surface);
124 --epr-dark-picker-border-color: var(--shell-hairline);
125 --epr-dark-text-color: var(--shell-ink-muted);
126 --epr-dark-search-input-bg-color: var(--shell-field);
127 --epr-dark-search-input-bg-color-active: var(--shell-field);
128 --epr-dark-hover-bg-color: var(--shell-active);
129 --epr-dark-focus-bg-color: var(--shell-active);
130 --epr-dark-category-label-bg-color: color-mix(in srgb, var(--shell-surface) 90%, transparent);
131 --epr-dark-category-icon-active-color: var(--shell-ink);
132 --epr-dark-category-icon-inactive-color: var(--shell-ink-muted);
133}
134.chat-emoji-picker .epr-main * { font-family: inherit; }
135/* The picker scrolls its own body, so the popover must not clip it. */
136.chat-popover:has(.chat-emoji-picker) { padding: 4px; }
137
138/* A reader can intentionally leave the chat column very narrow while widening
139 * a detail pane. Keep the header compact and horizontal at every width rather
140 * than allowing its title and topic to wrap into an unusable column. */
141@container (max-width: 480px) {
142 .chat-main > .chat-header { min-height: 60px; padding: 10px 12px; gap: 8px; }
143 .chat-main .chat-topic { display: none; }
144 .chat-main .chat-header-actions { gap: 0; }
145 .chat-main .chat-header-actions > button { width: 32px; height: 32px; }
146 .chat-main .chat-header-actions > button:nth-child(n + 4) { display: none; }
147}
148@container (max-width: 300px) {
149 .chat-main .chat-heading { display: none; }
150 .chat-main .chat-header-actions > button:nth-child(n + 3) { display: none; }
151}
152@container (max-width: 180px) {
153 .chat-main .chat-header-actions { display: none; }
154}
155
98156.chat-panel-scrim { display: none; }
99157@media (max-width: 1200px) {
100158 .chat-panel-scrim { display: block; position: fixed; inset: 0; z-index: 24; background: rgb(18 20 22 / 40%); }
101 .chat-side-panel { position: fixed; z-index: 25; right: 0; top: 0; bottom: 0; width: min(380px, 88vw); box-shadow: -18px 0 48px rgb(24 28 31 / 18%); }
159 .chat-side-panel { position: fixed; z-index: 25; right: 0; top: 0; bottom: 0; width: min(380px, 88vw); max-width: none; box-shadow: -18px 0 48px rgb(24 28 31 / 18%); }
160 .chat-panel-resize-handle, .chat-panel-focus-scrim { display: none; }
102161}
103162@media (max-width: 760px) {
104163 .chat-header { min-height: 68px; padding: 12px 16px; }
Modified web/src/features/chat/ui/chat-room.tsx View web/src/features/chat/ui/chat-room.tsx
@@ -13,8 +13,17 @@
1313 * posts fly over the call as bullet comments, and the header switches to the
1414 * full transcript whenever someone wants to read it in order.
1515 */
16import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
1716import {
17 useCallback,
18 useEffect,
19 useMemo,
20 useRef,
21 useState,
22 type CSSProperties,
23 type KeyboardEvent as ReactKeyboardEvent,
24 type PointerEvent as ReactPointerEvent,
25} from 'react'
26import {
1827 BellOff,
1928 Bell,
2029 CalendarX,
@@ -86,6 +95,12 @@
8695 hits: SearchHit[]
8796}
8897
98/** The detail pane stays useful while always leaving a visible chat column. */
99const PANEL_MIN_WIDTH = 280
100const CHAT_MIN_WIDTH = 100
101const PANEL_RESIZE_STEP = 24
102const PANEL_DEFAULT_WIDTH = 380
103
89104export interface ChatRoomProps {
90105 spaceName: string
91106 roomId: string
@@ -139,7 +154,73 @@
139154 const slowModeSeconds = room?.rate_limit_per_user ?? 0
140155 const isOwner = Boolean(viewerId && room && room.owner_id === viewerId)
141156 const canModerateRoom = canManageSpace || isOwner
157 const chatRoomRef = useRef<HTMLDivElement>(null)
158 const resizeRef = useRef<{ pointerId: number; startX: number; moved: boolean } | null>(null)
159 const [panelWidth, setPanelWidth] = useState(PANEL_DEFAULT_WIDTH)
160 const [resizingPanel, setResizingPanel] = useState(false)
161 const [panelHasCustomWidth, setPanelHasCustomWidth] = useState(false)
162 const [focusedPanelKey, setFocusedPanelKey] = useState<string | null>(null)
163 const activePanelKey = state.panel === 'thread' ? `thread:${state.threadId ?? ''}` : state.panel
164 const panelFocused = activePanelKey !== null && focusedPanelKey === activePanelKey
165 const panelResized = Boolean(state.panel && panelHasCustomWidth)
166
167 const clampPanelWidth = useCallback((width: number) => {
168 const frame = chatRoomRef.current?.getBoundingClientRect()
169 const maxWidth = frame
170 ? Math.max(PANEL_MIN_WIDTH, Math.floor(frame.width - CHAT_MIN_WIDTH))
171 : Math.max(PANEL_MIN_WIDTH, Math.round(width))
172 return Math.min(maxWidth, Math.max(PANEL_MIN_WIDTH, Math.round(width)))
173 }, [])
142174
175 const beginPanelResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
176 if (event.button !== 0) return
177 resizeRef.current = { pointerId: event.pointerId, startX: event.clientX, moved: false }
178 event.currentTarget.focus()
179 event.currentTarget.setPointerCapture(event.pointerId)
180 setResizingPanel(true)
181 event.preventDefault()
182 }, [])
183
184 const movePanelResize = useCallback(
185 (event: ReactPointerEvent<HTMLDivElement>) => {
186 const resize = resizeRef.current
187 const frame = chatRoomRef.current?.getBoundingClientRect()
188 if (!resize || resize.pointerId !== event.pointerId || !frame) return
189 if (!resize.moved && Math.abs(event.clientX - resize.startX) > 2) {
190 resize.moved = true
191 setPanelHasCustomWidth(true)
192 if (activePanelKey) setFocusedPanelKey(activePanelKey)
193 }
194 setPanelWidth(clampPanelWidth(frame.right - event.clientX))
195 },
196 [activePanelKey, clampPanelWidth],
197 )
198
199 const finishPanelResize = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
200 if (resizeRef.current?.pointerId !== event.pointerId) return
201 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
202 event.currentTarget.releasePointerCapture(event.pointerId)
203 }
204 resizeRef.current = null
205 setResizingPanel(false)
206 }, [])
207
208 const resizePanelWithKeyboard = useCallback(
209 (event: ReactKeyboardEvent<HTMLDivElement>) => {
210 let next: number | null = null
211 if (event.key === 'ArrowLeft') next = panelWidth + PANEL_RESIZE_STEP
212 if (event.key === 'ArrowRight') next = panelWidth - PANEL_RESIZE_STEP
213 if (event.key === 'Home') next = PANEL_MIN_WIDTH
214 if (event.key === 'End') next = clampPanelWidth(Number.MAX_SAFE_INTEGER)
215 if (next === null) return
216 event.preventDefault()
217 setPanelWidth(clampPanelWidth(next))
218 setPanelHasCustomWidth(true)
219 if (activePanelKey) setFocusedPanelKey(activePanelKey)
220 },
221 [activePanelKey, clampPanelWidth, panelWidth],
222 )
223
143224 // Voice rooms replace the transcript with the call surface; the participant
144225 // list is webhook-backed, so the room re-reads it whenever a chat event says
145226 // the room changed while a call is live.
@@ -253,12 +334,14 @@
253334 async (message: ChatMessage) => {
254335 const existing = data.threadOf(message.id)
255336 if (existing) {
337 setFocusedPanelKey(null)
256338 onStateChange({ panel: 'thread', threadId: existing.id })
257339 return
258340 }
259341 try {
260342 const thread = await createThreadApi(roomId, message.id)
261343 setVersion(value => value + 1)
344 setFocusedPanelKey(null)
262345 onStateChange({ panel: 'thread', threadId: thread.id })
263346 } catch (error) {
264347 // A root that cannot be threaded (locked room, missing message) must say
@@ -310,6 +393,18 @@
310393 const roomErrorCode = data.room.error && !data.room.data ? failureCode(data.room.error) : undefined
311394 const roomGone = roomErrorCode === 404 || roomErrorCode === 403 || roomErrorCode === 401
312395
396 // The global search trigger lives above the workspace shell, outside this
397 // flex row. Publish the live panel width so it remains aligned with the
398 // chat column's right edge while the user resizes the detail pane.
399 useEffect(() => {
400 if (!state.panel || roomGone) return
401 const root = document.documentElement
402 root.style.setProperty('--global-chat-panel-width', `${panelWidth}px`)
403 return () => {
404 root.style.removeProperty('--global-chat-panel-width')
405 }
406 }, [panelWidth, roomGone, state.panel])
407
313408 const [settingsOpen, setSettingsOpen] = useState(false)
314409 const [menu, setMenu] = useState(false)
315410 // Opening a DM is a write that can fail (the peer left, the quota is spent),
@@ -357,16 +452,31 @@
357452
358453 // Narrow screens turn the panel into a full-height overlay; a scrim is the
359454 // only affordance that lets a tap outside dismiss it again.
360 const closePanel = () => onStateChange({ panel: null, threadId: undefined, query: undefined })
455 const closePanel = useCallback(() => {
456 setFocusedPanelKey(null)
457 setPanelHasCustomWidth(false)
458 onStateChange({ panel: null, threadId: undefined, query: undefined })
459 }, [onStateChange])
460 const togglePanel = useCallback(
461 (panel: 'search' | 'pins' | 'threads' | 'members') => {
462 if (state.panel === panel) {
463 closePanel()
464 return
465 }
466 setFocusedPanelKey(null)
467 onStateChange({ panel })
468 },
469 [closePanel, onStateChange, state.panel],
470 )
361471
362472 useEffect(() => {
363473 if (!state.panel) return
364474 const onKey = (event: KeyboardEvent) => {
365 if (event.key === 'Escape') onStateChange({ panel: null, threadId: undefined, query: undefined })
475 if (event.key === 'Escape') closePanel()
366476 }
367477 window.addEventListener('keydown', onKey)
368478 return () => window.removeEventListener('keydown', onKey)
369 }, [state.panel, onStateChange])
479 }, [state.panel, closePanel])
370480
371481 const header = (
372482 <header className="chat-header">
@@ -411,7 +521,7 @@
411521 label={t('chat.search.placeholder')}
412522 className="chat-search-action"
413523 active={state.panel === 'search'}
414 onClick={() => onStateChange({ panel: state.panel === 'search' ? null : 'search' })}
524 onClick={() => togglePanel('search')}
415525 >
416526 <Search className="h-[17px] w-[17px]" />
417527 <span className="chat-action-label">{t('chat.search.placeholder')}</span>
@@ -419,21 +529,21 @@
419529 <IconAction
420530 label={t('chat.room.pins')}
421531 active={state.panel === 'pins'}
422 onClick={() => onStateChange({ panel: state.panel === 'pins' ? null : 'pins' })}
532 onClick={() => togglePanel('pins')}
423533 >
424534 <Pin className="h-[17px] w-[17px]" />
425535 </IconAction>
426536 <IconAction
427537 label={t('chat.room.threads')}
428538 active={state.panel === 'threads'}
429 onClick={() => onStateChange({ panel: state.panel === 'threads' ? null : 'threads' })}
539 onClick={() => togglePanel('threads')}
430540 >
431541 <MessageSquare className="h-[17px] w-[17px]" />
432542 </IconAction>
433543 <IconAction
434544 label={memberCount > 0 ? t('chat.room.membersWithCount', { count: memberCount }) : t('chat.room.members')}
435545 active={state.panel === 'members'}
436 onClick={() => onStateChange({ panel: state.panel === 'members' ? null : 'members' })}
546 onClick={() => togglePanel('members')}
437547 >
438548 <Users className="h-[17px] w-[17px]" />
439549 </IconAction>
@@ -638,7 +748,10 @@
638748 const threadRoot = rootInWindow ?? rootFetch.data
639749
640750 return (
641 <div className="chat-room flex h-full min-h-0 min-w-0 flex-1">
751 <div
752 ref={chatRoomRef}
753 className={`chat-room flex h-full min-h-0 min-w-0 flex-1${resizingPanel ? ' chat-panel-resizing' : ''}${panelResized ? ' chat-panel-resized' : ''}`}
754 >
642755 <section className="chat-main flex min-w-0 flex-1 flex-col bg-shell-surface" aria-label={title}>
643756 {header}
644757 {roomGone ? (
@@ -646,11 +759,39 @@
646759 ) : (
647760 body
648761 )}
762 {state.panel && !roomGone && panelFocused ? (
763 <button
764 type="button"
765 className="chat-panel-focus-scrim"
766 aria-label={t('chat.panel.clearFocus')}
767 onClick={() => setFocusedPanelKey(null)}
768 >
769 </button>
770 ) : null}
649771 </section>
650772 {state.panel && !roomGone ? (
651773 <>
652774 <button type="button" className="chat-panel-scrim" aria-label={t('chat.panel.close')} tabIndex={-1} onClick={closePanel} />
653 <aside className="chat-side-panel flex w-[320px] shrink-0 flex-col border-l border-shell-hairline bg-shell-sidebar xl:w-[380px]" aria-label={t('chat.room.details')}>
775 <aside
776 className="chat-side-panel flex shrink-0 flex-col border-l border-shell-hairline bg-shell-sidebar"
777 style={{ '--chat-panel-width': `${panelWidth}px` } as CSSProperties}
778 aria-label={t('chat.room.details')}
779 >
780 <div
781 role="separator"
782 aria-orientation="vertical"
783 aria-label={t('chat.panel.resize')}
784 aria-valuemin={PANEL_MIN_WIDTH}
785 aria-valuenow={panelWidth}
786 tabIndex={0}
787 className="chat-panel-resize-handle"
788 onPointerDown={beginPanelResize}
789 onPointerMove={movePanelResize}
790 onPointerUp={finishPanelResize}
791 onPointerCancel={finishPanelResize}
792 onLostPointerCapture={finishPanelResize}
793 onKeyDown={resizePanelWithKeyboard}
794 />
654795 {state.panel === 'thread' ? (
655796 thread ? (
656797 <ThreadPanel
@@ -664,11 +805,11 @@
664805 nameOf={nameOf}
665806 interactions={interactions}
666807 jumpId={state.jumpId}
667 onClose={() => onStateChange({ panel: null, threadId: undefined })}
808 onClose={closePanel}
668809 onChanged={() => setVersion(value => value + 1)}
669810 />
670811 ) : (
671 <ThreadMissing onClose={() => onStateChange({ panel: null, threadId: undefined })} />
812 <ThreadMissing onClose={closePanel} />
672813 )
673814 ) : state.panel === 'pins' ? (
674815 <PinsPanel
@@ -677,15 +818,18 @@
677818 nameOf={nameOf}
678819 onJump={messageId => stream.jumpTo(messageId)}
679820 onUnpin={message => void stream.togglePin(message.id, false)}
680 onClose={() => onStateChange({ panel: null })}
821 onClose={closePanel}
681822 />
682823 ) : state.panel === 'threads' ? (
683824 <ThreadsPanel
684825 threads={data.threads.data ?? []}
685826 loading={data.threads.loading}
686827 nameOf={nameOf}
687 onOpen={row => onStateChange({ panel: 'thread', threadId: row.id })}
688 onClose={() => onStateChange({ panel: null })}
828 onOpen={row => {
829 setFocusedPanelKey(null)
830 onStateChange({ panel: 'thread', threadId: row.id })
831 }}
832 onClose={closePanel}
689833 />
690834 ) : state.panel === 'members' ? (
691835 <MembersPanel
@@ -700,7 +844,7 @@
700844 canManage={canModerateRoom}
701845 onDm={openDmWith}
702846 onChanged={() => setVersion(value => value + 1)}
703 onClose={() => onStateChange({ panel: null })}
847 onClose={closePanel}
704848 />
705849 ) : (
706850 <SearchPanel
@@ -718,7 +862,7 @@
718862 if (hit.room_id === roomId) stream.jumpTo(hit.message_id)
719863 else onOpenRemoteMessage(hit.room_id, hit.message_id)
720864 }}
721 onClose={() => onStateChange({ panel: null, query: undefined })}
865 onClose={closePanel}
722866 nameOf={nameOf}
723867 roomLabel={title}
724868 />
Modified web/src/features/chat/ui/emoji-picker.tsx View web/src/features/chat/ui/emoji-picker.tsx
@@ -1,33 +1,73 @@
11/**
22 * Emoji picker for reactions and the composer.
33 *
4 * Reactions are stored as plain text (up to `chat.max_reaction_emoji_len`), so
5 * no shortcode resolution is needed: what you see is the string that goes to the
6 * server. Space custom emojis are offered separately because they render as
7 * images and are referenced by name.
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:`.
811 */
9import { useState } from 'react'
10import { QUICK_EMOJIS, type CustomEmoji } from '@/entities/chat'
11import { emojiImageUrl } from '@/entities/chat'
12import { t } from '@/shared/i18n'
12import { useEffect, useState } from 'react'
13import EmojiPickerReact, {
14 Categories,
15 EmojiStyle,
16 Theme,
17 type CategoryConfig,
18} from 'emoji-picker-react'
19import { emojiImageUrl, type CustomEmoji } from '@/entities/chat'
20import { useTheme } from '@/shared/lib'
21import { useLocale, useT, type Locale } from '@/shared/i18n'
22
23/** Names and categories for one language, read off the library's own modules. */
24type EmojiDataset = typeof import('emoji-picker-react/dist/data/emojis-zh.js').default
25
26/**
27 * Resolved name tables, kept for the session: each is a few hundred kB, so it
28 * is fetched only when a picker first opens, and a reopen repaints from here
29 * instead of re-running the loader. `null` records a failed load, which falls
30 * back to the library's bundled English table rather than a permanent skeleton.
31 */
32const datasets = new Map<Locale, EmojiDataset | null>()
33const LOADERS: Record<Locale, () => Promise<{ default: EmojiDataset }>> = {
34 'zh-CN': () => import('emoji-picker-react/dist/data/emojis-zh.js'),
35 en: () => import('emoji-picker-react/dist/data/emojis-en.js'),
36}
37
38function useDataset(locale: Locale): EmojiDataset | null | undefined {
39 const [, bump] = useState(0)
40 const loaded = datasets.get(locale)
41 useEffect(() => {
42 if (loaded !== undefined) return
43 let active = true
44 LOADERS[locale]().then(
45 module => {
46 datasets.set(locale, module.default)
47 if (active) bump(value => value + 1)
48 },
49 () => {
50 datasets.set(locale, null)
51 if (active) bump(value => value + 1)
52 },
53 )
54 return () => {
55 active = false
56 }
57 }, [locale, loaded])
58 return loaded
59}
1360
14const GROUPS: Array<{ label: string; emojis: string[] }> = [
15 { get label() { return t('chat.emoji.common') }, emojis: QUICK_EMOJIS },
16 {
17 get label() { return t('chat.composer.emoji') },
18 emojis: [
19 '😀', '😃', '😉', '😊', '😍', '🤔', '😎', '🥳', '😅', '😂', '🤣', '😇',
20 '🙂', '🙃', '😏', '😌', '😴', '🤯', '😱', '😭', '😡', '🥺', '😳', '🤗',
21 ],
22 },
23 {
24 get label() { return t('chat.emoji.gestures') },
25 emojis: ['👍', '👎', '👏', '🙌', '🤝', '🙏', '💪', '✍️', '👀', '🧠', '🫡', '✌️', '☝️', '🤞'],
26 },
27 {
28 get label() { return t('chat.emoji.symbols') },
29 emojis: ['🎉', '🔥', '✨', '⭐', '✅', '❌', '⚠️', '🚀', '💡', '🐛', '🔧', '📌', '📎', '☕'],
30 },
61/** Stock sections in reading order; suggestions and space emojis lead. */
62const CATEGORY_ORDER: readonly Categories[] = [
63 Categories.SMILEYS_PEOPLE,
64 Categories.ANIMALS_NATURE,
65 Categories.FOOD_DRINK,
66 Categories.TRAVEL_PLACES,
67 Categories.ACTIVITIES,
68 Categories.OBJECTS,
69 Categories.SYMBOLS,
70 Categories.FLAGS,
3171]
3272
3373export function EmojiPicker({
@@ -39,69 +79,59 @@
3979 custom?: CustomEmoji[]
4080 spaceName?: string
4181}) {
42 const [tab, setTab] = useState(0)
43 const isCustom = tab === GROUPS.length
44 if (isCustom && custom.length === 0) return null
82 const { t } = useT()
83 const locale = useLocale()
84 const theme = useTheme()
85 const dataset = useDataset(locale)
86 // Section labels are localized by the name table itself.
87 const sectionName = (category: Categories) =>
88 dataset?.categories?.[category]?.name ?? t('chat.emoji.common')
89 // A disabled emoji has been retired from the space: keep it out of the grid.
90 const usable = custom.filter(item => !item.disabled)
91 const nameById = new Map(usable.map(item => [item.id.toLowerCase(), item.name]))
92 const spaceEmojis =
93 spaceName && usable.length > 0
94 ? usable.map(item => ({
95 id: item.id,
96 names: [item.name],
97 imgUrl: emojiImageUrl(spaceName, item.id),
98 }))
99 : []
100 const categories: CategoryConfig[] = [
101 { category: Categories.SUGGESTED, name: sectionName(Categories.SUGGESTED) },
102 ...(spaceEmojis.length > 0
103 ? [{ category: Categories.CUSTOM, name: t('chat.emoji.custom') }]
104 : []),
105 ...CATEGORY_ORDER.map(category => ({ category, name: sectionName(category) })),
106 ]
107 // Purposeful delay: mounting with the library's built-in table would paint
108 // one frame of English category labels before the localized names arrive.
109 if (dataset === undefined) {
110 return <div className="chat-emoji-picker h-[352px] w-full bg-shell-field" />
111 }
45112 return (
46 <div className="chat-emoji-picker w-[268px] max-w-full">
47 <div className="mb-1.5 flex flex-wrap gap-1">
48 {GROUPS.map((group, index) => (
49 <button
50 key={group.label}
51 type="button"
52 aria-pressed={tab === index}
53 onClick={() => setTab(index)}
54 className={`rounded px-1.5 py-0.5 text-[11px] transition-colors ${
55 tab === index ? 'bg-shell-active text-shell-ink' : 'text-shell-ink-muted hover:bg-shell-field'
56 }`}
57 >
58 {group.label}
59 </button>
60 ))}
61 {custom.length > 0 && spaceName ? (
62 <button
63 type="button"
64 aria-pressed={isCustom}
65 onClick={() => setTab(GROUPS.length)}
66 className={`rounded px-1.5 py-0.5 text-[11px] transition-colors ${
67 isCustom ? 'bg-shell-active text-shell-ink' : 'text-shell-ink-muted hover:bg-shell-field'
68 }`}
69 >
70 {t('chat.emoji.custom')}
71 </button>
72 ) : null}
73 </div>
74 <div className="grid max-h-52 grid-cols-8 gap-0.5 overflow-y-auto">
75 {isCustom
76 ? custom.map(emoji => (
77 <button
78 key={emoji.id}
79 type="button"
80 disabled={emoji.disabled}
81 title={`:${emoji.name}:`}
82 onClick={() => onPick(`:${emoji.name}:`)}
83 className="grid h-7 w-7 place-content-center rounded hover:bg-shell-field disabled:opacity-40"
84 >
85 <img
86 src={emojiImageUrl(spaceName ?? '', emoji.id)}
87 alt={emoji.name}
88 className="h-5 w-5 object-contain"
89 loading="lazy"
90 />
91 </button>
92 ))
93 : GROUPS[tab].emojis.map(emoji => (
94 <button
95 key={emoji}
96 type="button"
97 title={emoji}
98 onClick={() => onPick(emoji)}
99 className="grid h-7 w-7 place-content-center rounded text-[16px] leading-none hover:bg-shell-field"
100 >
101 {emoji}
102 </button>
103 ))}
104 </div>
113 <div className="chat-emoji-picker w-full">
114 <EmojiPickerReact
115 width="100%"
116 height={352}
117 theme={theme === 'dark' ? Theme.DARK : Theme.LIGHT}
118 emojiStyle={EmojiStyle.NATIVE}
119 emojiData={dataset ?? undefined}
120 categories={categories}
121 customEmojis={spaceEmojis}
122 previewConfig={{ showPreview: false }}
123 searchPlaceholder={t('chat.emoji.search')}
124 searchClearButtonLabel={t('chat.emoji.searchClear')}
125 onEmojiClick={clicked =>
126 onPick(
127 clicked.isCustom
128 // The library returns the lower-cased id as `unified`; the stored
129 // short code keeps the name the space author chose.
130 ? `:${nameById.get(clicked.unified.toLowerCase()) ?? clicked.names[0] ?? clicked.unified}:`
131 : clicked.emoji,
132 )
133 }
134 />
105135 </div>
106136 )
107137}
Modified web/src/features/chat/ui/side-panel.tsx View web/src/features/chat/ui/side-panel.tsx
@@ -11,6 +11,8 @@
1111 */
1212import { useEffect, useMemo, useRef, useState } from 'react'
1313import {
14 Bell,
15 BellOff,
1416 Crosshair,
1517 Hash,
1618 Loader2,
@@ -26,6 +28,7 @@
2628 describeFailure,
2729 followThread,
2830 fullStamp,
31 listMyThreads,
2932 listSpaceMembers,
3033 unfollowThread,
3134 updateThread,
@@ -162,6 +165,7 @@
162165 actions={
163166 <ThreadTools
164167 thread={thread}
168 viewerId={viewerId}
165169 canManage={thread.owner_id === viewerId}
166170 onChanged={onChanged}
167171 />
@@ -228,23 +232,35 @@
228232
229233function ThreadTools({
230234 thread,
235 viewerId,
231236 canManage,
232237 onChanged,
233238}: {
234239 thread: ThreadBrief
240 viewerId?: string
235241 canManage: boolean
236242 onChanged: () => void
237243}) {
238244 const { t } = useT()
239245 const [busy, setBusy] = useState(false)
240246 const [error, setError] = useState<string | null>(null)
241 const run = (action: () => Promise<unknown>) => {
247 const [followOverride, setFollowOverride] = useState<{ threadId: string; following: boolean } | null>(null)
248 // A membership is the server's source of truth for reply notifications. The
249 // override keeps the label responsive after a toggle, before a later panel
250 // refresh has fetched the updated membership list.
251 const memberships = useAsync(listMyThreads, [thread.id])
252 const following =
253 followOverride?.threadId === thread.id
254 ? followOverride.following
255 : memberships.data?.some(item => item.id === thread.id) ?? thread.owner_id === viewerId
256 const run = (action: () => Promise<unknown>, onSuccess?: () => void) => {
242257 if (busy) return
243258 setBusy(true)
244259 setError(null)
245260 void action().then(
246261 () => {
247262 setBusy(false)
263 onSuccess?.()
248264 onChanged()
249265 },
250266 reason => {
@@ -253,6 +269,13 @@
253269 },
254270 )
255271 }
272 const toggleFollow = () => {
273 const next = !following
274 run(
275 () => (next ? followThread(thread.id, 'All') : unfollowThread(thread.id)),
276 () => setFollowOverride({ threadId: thread.id, following: next }),
277 )
278 }
256279 return (
257280 <div className="flex shrink-0 items-center gap-1 text-shell-ink-muted">
258281 {error ? (
@@ -262,19 +285,18 @@
262285 ) : null}
263286 <button
264287 type="button"
265 title={t('chat.thread.followTitle')}
266 onClick={() => run(() => followThread(thread.id, 'All'))}
267 className="rounded border border-shell-hairline px-1.5 py-0.5 text-[11px] hover:bg-shell-field"
288 aria-pressed={following}
289 title={following ? t('chat.thread.followingTitle') : t('chat.thread.followTitle')}
290 onClick={toggleFollow}
291 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 ${
292 following
293 ? 'border-shell-tile bg-shell-active text-shell-ink'
294 : 'border-shell-hairline text-shell-ink-muted'
295 }`}
296 disabled={busy}
268297 >
269 {t('chat.thread.follow')}
270 </button>
271 <button
272 type="button"
273 title={t('chat.thread.unfollowTitle')}
274 onClick={() => run(() => unfollowThread(thread.id))}
275 className="rounded border border-shell-hairline px-1.5 py-0.5 text-[11px] hover:bg-shell-field"
276 >
277 {t('chat.thread.unfollow')}
298 {following ? <Bell className="h-3 w-3" aria-hidden /> : <BellOff className="h-3 w-3" aria-hidden />}
299 {following ? t('chat.thread.following') : t('chat.thread.follow')}
278300 </button>
279301 {canManage ? (
280302 <button
Modified web/src/features/chat/ui/transcript.tsx View web/src/features/chat/ui/transcript.tsx
@@ -54,7 +54,6 @@
5454import {
5555 AttachmentGrid,
5656 DayDivider,
57 GutterTime,
5857 IconAction,
5958 Lightbox,
6059 MessageBody,
@@ -397,7 +396,7 @@
397396 } ${grouped ? 'chat-message-grouped' : ''} ${flashed ? 'bg-shell-active ring-1 ring-inset ring-shell-tile' : ''}`}
398397 >
399398 {grouped ? (
400 <GutterTime value={message.created_at} />
399 <span className="w-10 shrink-0" aria-hidden="true" />
401400 ) : (
402401 <UserAvatar user={message.author} id={message.author_id} size={40} />
403402 )}
@@ -489,7 +488,7 @@
489488 </div>
490489 ) : null}
491490
492 {thread ? (
491 {thread && thread.reply_count > 0 ? (
493492 <button
494493 type="button"
495494 onClick={() => interactions.onOpenThread(message)}
@@ -584,7 +583,7 @@
584583 <IconAction label={t('chat.transcript.react')} onClick={() => setPicker(value => !value)} active={picker}>
585584 <SmilePlus className="h-4 w-4" />
586585 </IconAction>
587 <Popover open={picker} onClose={() => setPicker(false)} align="end" className="w-72">
586 <Popover open={picker} onClose={() => setPicker(false)} align="end" className="w-80">
588587 <EmojiPicker
589588 custom={customEmojis}
590589 spaceName={spaceName}
Modified web/src/shared/i18n/messages/en/chat.ts View web/src/shared/i18n/messages/en/chat.ts
@@ -191,6 +191,8 @@
191191
192192 // Thread panels.
193193 'chat.panel.close': 'Close panel',
194 'chat.panel.resize': 'Resize side panel',
195 'chat.panel.clearFocus': 'Restore chat clarity',
194196 'chat.panel.loadedMembers': 'Loading space members…',
195197 'chat.panel.searching': 'Searching…',
196198 'chat.thread.lockedNotice': 'This thread is locked and cannot be replied to',
@@ -202,9 +204,11 @@
202204 'chat.thread.participants_one': '{count} participant',
203205 'chat.thread.participants_other': '{count} participants',
204206 'chat.thread.count': '{count} threads',
205 'chat.thread.follow': 'Follow',
207 'chat.thread.follow': 'Turn on alerts',
206208 'chat.thread.followTitle': 'Notify me about every new reply in this thread',
207 'chat.thread.unfollow': 'Unfollow',
209 'chat.thread.following': 'Alerts on',
210 'chat.thread.followingTitle': 'New replies will notify you; click to turn alerts off',
211 'chat.thread.unfollow': 'Turn off alerts',
208212 'chat.thread.unfollowTitle': 'Stop notifying me about this thread',
209213 'chat.thread.lock': 'Lock the thread (no new replies)',
210214 'chat.thread.unlock': 'Unlock the thread',
@@ -250,6 +254,8 @@
250254 'chat.emoji.gestures': 'Gestures',
251255 'chat.emoji.symbols': 'Symbols',
252256 'chat.emoji.custom': 'Space',
257 'chat.emoji.search': 'Search emoji',
258 'chat.emoji.searchClear': 'Clear search',
253259
254260 // Voice surface.
255261 'chat.voice.channelFallback': 'Voice channel',
Modified web/src/shared/i18n/messages/zh-CN/chat.ts View web/src/shared/i18n/messages/zh-CN/chat.ts
@@ -191,6 +191,8 @@
191191
192192 // Thread panels.
193193 'chat.panel.close': '关闭面板',
194 'chat.panel.resize': '调整侧栏宽度',
195 'chat.panel.clearFocus': '恢复聊天区清晰度',
194196 'chat.panel.loadedMembers': '加载社区成员…',
195197 'chat.panel.searching': '搜索中…',
196198 'chat.thread.lockedNotice': '这个话题已被锁定,无法继续回复',
@@ -202,9 +204,11 @@
202204 'chat.thread.participants_one': '{count} 人',
203205 'chat.thread.participants_other': '{count} 人',
204206 'chat.thread.count': '{count} 个',
205 'chat.thread.follow': '关注',
207 'chat.thread.follow': '开启提醒',
206208 'chat.thread.followTitle': '话题内每条新回复都提醒我',
207 'chat.thread.unfollow': '取关',
209 'chat.thread.following': '已开启提醒',
210 'chat.thread.followingTitle': '新回复会提醒你;点击可关闭提醒',
211 'chat.thread.unfollow': '关闭提醒',
208212 'chat.thread.unfollowTitle': '取消话题提醒',
209213 'chat.thread.lock': '锁定话题(停止新回复)',
210214 'chat.thread.unlock': '解锁话题',
@@ -250,6 +254,8 @@
250254 'chat.emoji.gestures': '手势',
251255 'chat.emoji.symbols': '符号',
252256 'chat.emoji.custom': '社区',
257 'chat.emoji.search': '搜索表情',
258 'chat.emoji.searchClear': '清除搜索',
253259
254260 // Voice surface.
255261 'chat.voice.channelFallback': '语音频道',
Modified web/bun.lock View web/bun.lock
@@ -12,6 +12,7 @@
1212 "alova": "^3.5.5",
1313 "clsx": "2.1.1",
1414 "dompurify": "^3.4.15",
15 "emoji-picker-react": "^4.22.2",
1516 "events": "^3.3.0",
1617 "livekit-client": "2.22.3",
1718 "loglevel": "^1.9.2",
@@ -314,6 +315,8 @@
314315
315316 "effector": ["effector@23.4.4", "", {}, "sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg=="],
316317
318 "emoji-picker-react": ["emoji-picker-react@4.22.2", "", { "dependencies": { "shipstyles": "1.0.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-xd0YRbuqkdfVYLAMEiDhM/xx98OQZ4FWu3KQJjVBkQO956EpMTJY0wW4jtF1VGqYBYK4E1KDXyDa2e8M4PQFkw=="],
319
317320 "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
318321
319322 "empathic": ["empathic@1.1.0", "", {}, "sha512-rsPft6CK3eHtrlp9Y5ALBb+hfK+DWnA4WFebbazxjWyx8vSm3rZeoM3z9irsjcqO3PYRzlfv27XIB4tz2DV7RA=="],
@@ -562,6 +565,8 @@
562565
563566 "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
564567
568 "shipstyles": ["shipstyles@1.0.0", "", {}, "sha512-1AcihfXo/mSxqeOdoKqq/5TNv+vSH0dBYPFq88PUX5FBkK5pRmY5TP9SuMNio85PQ+euUI67+KpSVk1cUVDCgA=="],
569
565570 "should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="],
566571
567572 "should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="],
Modified web/package.json View web/package.json
@@ -19,6 +19,7 @@
1919 "alova": "^3.5.5",
2020 "clsx": "2.1.1",
2121 "dompurify": "^3.4.15",
22 "emoji-picker-react": "^4.22.2",
2223 "events": "^3.3.0",
2324 "livekit-client": "2.22.3",
2425 "loglevel": "^1.9.2",
Modified web/tsconfig.app.json View web/tsconfig.app.json
@@ -24,6 +24,15 @@
2424 "@/features/*": ["./src/features/*"],
2525 "@/entities/*": ["./src/entities/*"],
2626 "@/shared/*": ["./src/shared/*"],
27 /* The picker ships its locale data as .ts source next to the .js build;
28 * resolve those two subpaths to their declarations so the library's own
29 * sources are never compiled as part of this project. */
30 "emoji-picker-react/dist/data/emojis-zh.js": [
31 "./node_modules/emoji-picker-react/dist/data/emojis-zh.d.ts"
32 ],
33 "emoji-picker-react/dist/data/emojis-en.js": [
34 "./node_modules/emoji-picker-react/dist/data/emojis-en.d.ts"
35 ],
2736 "@livekit/components-core": ["./src/shared/livekit/core/index.ts"],
2837 "@livekit/components-styles/*": ["./src/shared/livekit/styles/*"]
2938 },