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
Modified web/src/entities/chat/model/api.ts web/src/entities/chat/model/api.ts +5 −2 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 web/src/entities/chat/model/meta.ts +2 −2 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 web/src/features/chat/ui/chat-composer.tsx +1 −1 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 web/src/features/chat/ui/chat-polish.css +62 −3 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 web/src/features/chat/ui/chat-room.tsx +161 −17 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 web/src/features/chat/ui/emoji-picker.tsx +117 −87 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 web/src/features/chat/ui/side-panel.tsx +35 −13 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 web/src/features/chat/ui/transcript.tsx +3 −4 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 web/src/shared/i18n/messages/en/chat.ts +8 −2 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 web/src/shared/i18n/messages/zh-CN/chat.ts +8 −2 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 web/bun.lock +5 −0 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 web/package.json +1 −0 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 web/tsconfig.app.json +9 −0 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 },