@dxos/react-ui-thread 0.8.4-main.fffef41 → 0.8.4-staging.60fe92afc8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/chunk-QFRRLZVL.mjs +31 -0
  3. package/dist/lib/browser/chunk-QFRRLZVL.mjs.map +7 -0
  4. package/dist/lib/browser/index.mjs +507 -220
  5. package/dist/lib/browser/index.mjs.map +4 -4
  6. package/dist/lib/browser/meta.json +1 -1
  7. package/dist/lib/browser/translations.mjs +9 -0
  8. package/dist/lib/browser/translations.mjs.map +7 -0
  9. package/dist/types/src/Message/Message.d.ts +81 -10
  10. package/dist/types/src/Message/Message.d.ts.map +1 -1
  11. package/dist/types/src/Message/Message.stories.d.ts +22 -14
  12. package/dist/types/src/Message/Message.stories.d.ts.map +1 -1
  13. package/dist/types/src/Message/index.d.ts +2 -1
  14. package/dist/types/src/Message/index.d.ts.map +1 -1
  15. package/dist/types/src/Thread/Thread.d.ts +76 -13
  16. package/dist/types/src/Thread/Thread.d.ts.map +1 -1
  17. package/dist/types/src/Thread/Thread.stories.d.ts +16 -12
  18. package/dist/types/src/Thread/Thread.stories.d.ts.map +1 -1
  19. package/dist/types/src/Thread/index.d.ts +2 -1
  20. package/dist/types/src/Thread/index.d.ts.map +1 -1
  21. package/dist/types/src/command.d.ts +3 -0
  22. package/dist/types/src/command.d.ts.map +1 -0
  23. package/dist/types/src/context.d.ts +227 -0
  24. package/dist/types/src/context.d.ts.map +1 -0
  25. package/dist/types/src/index.d.ts +7 -5
  26. package/dist/types/src/index.d.ts.map +1 -1
  27. package/dist/types/src/testing.d.ts +9 -14
  28. package/dist/types/src/testing.d.ts.map +1 -1
  29. package/dist/types/src/translations.d.ts +17 -12
  30. package/dist/types/src/translations.d.ts.map +1 -1
  31. package/dist/types/src/types.d.ts +36 -3
  32. package/dist/types/src/types.d.ts.map +1 -1
  33. package/dist/types/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +44 -28
  35. package/src/Message/Message.stories.tsx +31 -26
  36. package/src/Message/Message.tsx +325 -68
  37. package/src/Message/index.ts +10 -1
  38. package/src/Thread/Thread.stories.tsx +19 -67
  39. package/src/Thread/Thread.tsx +296 -38
  40. package/src/Thread/index.ts +9 -1
  41. package/src/command.ts +58 -0
  42. package/src/context.ts +21 -0
  43. package/src/index.ts +28 -6
  44. package/src/testing.ts +48 -0
  45. package/src/translations.ts +16 -11
  46. package/src/types.ts +38 -5
  47. package/dist/types/src/Thread/Comments.stories.d.ts +0 -35
  48. package/dist/types/src/Thread/Comments.stories.d.ts.map +0 -1
  49. package/src/Thread/Comments.stories.tsx +0 -384
  50. package/src/testing.tsx +0 -58
@@ -3,94 +3,46 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React, { useMemo, useRef, useState } from 'react';
6
+ import React, { useState } from 'react';
7
7
 
8
- import { PublicKey } from '@dxos/keys';
9
- import { faker } from '@dxos/random';
10
- import { withTheme } from '@dxos/react-ui/testing';
11
- import { createBasicExtensions, createThemeExtensions } from '@dxos/react-ui-editor';
12
- import { hoverableControls, hoverableFocusedWithinControls } from '@dxos/react-ui-theme';
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
9
+ import { Message as MessageType } from '@dxos/types';
13
10
 
14
- import { MessageRoot, MessageTextbox } from '../Message';
15
- import { type MessageEntity, MessageStoryText } from '../testing';
16
- import { translations } from '../translations';
11
+ import { translations } from '#translations';
17
12
 
13
+ import { createMessages, getStoryMetadata } from '../testing';
18
14
  import { Thread } from './Thread';
19
15
 
20
- faker.seed(1);
16
+ const IDENTITY = { role: 'user' as const, identityDid: 'did:key:alice', name: 'Alice' };
21
17
 
22
18
  const DefaultStory = () => {
23
- const [pending, setPending] = useState(false);
24
- const [identityKey1] = useState(PublicKey.random());
25
- const [identityKey2] = useState(PublicKey.random());
26
- const [messages, setMessages] = useState<MessageEntity<{ id: string; text: string }>[]>(
27
- Array.from({ length: 8 }, (_, i) => ({
28
- id: `m${i + 1}`,
29
- timestamp: new Date().toISOString(),
30
- authorId: [identityKey1.toHex(), identityKey2.toHex()][i % 2],
31
- text: faker.lorem.paragraph(),
32
- })),
33
- );
34
-
35
- // TODO(wittjosiah): This is a hack to reset the editor after a message is sent.
36
- const [_count, _setCount] = useState(3);
37
- const rerenderEditor = () => _setCount((count) => count + 1);
38
- const messageRef = useRef('');
39
- const extensions = useMemo(() => [createBasicExtensions(), createThemeExtensions()], []);
19
+ const [messages, setMessages] = useState(() => createMessages(12));
40
20
 
41
- // TODO(thure): Why does pressing Enter clear the text content?
42
- // Something to do with the in-memory text model perhaps?
43
- const handleSend = () => {
44
- setPending(true);
45
- setTimeout(() => {
46
- setMessages((messages) => [
47
- ...messages,
48
- {
49
- id: `m${_count}`,
50
- timestamp: new Date().toISOString(),
51
- authorId: identityKey1.toHex(),
52
- text: messageRef.current,
53
- },
54
- ]);
55
- messageRef.current = '';
56
- setPending(false);
57
- rerenderEditor();
58
- }, 2_000);
21
+ const handleSend = (text: string) => {
22
+ setMessages((prev) => [...prev, MessageType.make({ sender: IDENTITY, blocks: [{ _tag: 'text', text }] })]);
23
+ return true;
59
24
  };
60
25
 
61
26
  return (
62
- <div className='mli-auto is-96 overflow-y-auto'>
63
- <Thread.Root id='t1'>
64
- {messages.map((message) => (
65
- <MessageRoot key={message.id} classNames={[hoverableControls, hoverableFocusedWithinControls]} {...message}>
66
- <MessageStoryText {...message} text={message.text} onDelete={() => console.log('delete')} />
67
- </MessageRoot>
68
- ))}
69
-
70
- <MessageTextbox
71
- id={String(_count)}
72
- authorId={identityKey1.toHex()}
73
- disabled={pending}
74
- extensions={extensions}
75
- onSend={handleSend}
76
- />
77
-
78
- <Thread.Status activity>Processing...</Thread.Status>
79
- </Thread.Root>
80
- </div>
27
+ <Thread.Root getMetadata={getStoryMetadata} identityDid={IDENTITY.identityDid} editable onMessageDelete={() => {}}>
28
+ <Thread.Content classNames='grow min-h-0'>
29
+ <Thread.Messages messages={messages} />
30
+ <Thread.Textbox id='composer' authorId={IDENTITY.identityDid} authorName={IDENTITY.name} onSend={handleSend} />
31
+ <Thread.Status />
32
+ </Thread.Content>
33
+ </Thread.Root>
81
34
  );
82
35
  };
83
36
 
84
37
  const meta = {
85
38
  title: 'ui/react-ui-thread/Thread',
86
- component: Thread.Root,
87
39
  render: DefaultStory,
88
- decorators: [withTheme],
40
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
89
41
  parameters: {
90
42
  layout: 'fullscreen',
91
43
  translations,
92
44
  },
93
- } satisfies Meta<typeof Thread.Root>;
45
+ } satisfies Meta<typeof DefaultStory>;
94
46
 
95
47
  export default meta;
96
48
 
@@ -2,37 +2,119 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import React, { type ComponentPropsWithRef, forwardRef } from 'react';
5
+ import React, {
6
+ type ComponentPropsWithRef,
7
+ type ReactNode,
8
+ forwardRef,
9
+ type PropsWithChildren,
10
+ useCallback,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ } from 'react';
6
16
 
7
- import { Icon, type ThemedClassName, useTranslation } from '@dxos/react-ui';
8
- import { hoverableControlItem, hoverableFocusedWithinControls, mx } from '@dxos/react-ui-theme';
17
+ import { Obj } from '@dxos/echo';
18
+ import {
19
+ type ComposableProps,
20
+ Icon,
21
+ IconButton,
22
+ ScrollArea,
23
+ type ThemedClassName,
24
+ composable,
25
+ composableProps,
26
+ useThemeContext,
27
+ useTranslation,
28
+ } from '@dxos/react-ui';
29
+ import { Mosaic, type MosaicTileProps } from '@dxos/react-ui-mosaic';
30
+ import { type Message as MessageType } from '@dxos/types';
31
+ import { type Extension, createBasicExtensions, createThemeExtensions, listener } from '@dxos/ui-editor';
32
+ import { hoverableControlItem, hoverableControls, hoverableFocusedWithinControls, mx } from '@dxos/ui-theme';
9
33
 
34
+ import { command } from '../command';
35
+ import { ThreadContextProvider, useThreadContext } from '../context';
36
+ import { Message } from '../Message';
10
37
  import { translationKey } from '../translations';
11
- import type { ThreadEntity } from '../types';
38
+ import { type MessageMetadata, type ThreadContextValue } from '../types';
12
39
 
13
- // TODO(burdon): Why is this exported?
14
- export const threadLayout = 'is-full place-self-start grid grid-cols-[var(--rail-size)_1fr]';
40
+ const getMessageId = (message: MessageType.Message) => Obj.getURI(message);
15
41
 
16
42
  //
17
43
  // Root
18
44
  //
19
45
 
20
- type ThreadRootProps = ThemedClassName<ComponentPropsWithRef<'div'>> & ThreadEntity & { current?: boolean };
46
+ export type ThreadRootProps = PropsWithChildren<
47
+ Omit<ThreadContextValue, 'components'> & Partial<Pick<ThreadContextValue, 'components'>>
48
+ >;
21
49
 
22
- const ThreadRoot = forwardRef<HTMLDivElement, ThreadRootProps>(
23
- ({ current, children, classNames, ...props }, forwardedRef) => {
50
+ /**
51
+ * Headless root of a thread. Provides message-tile context (metadata resolver,
52
+ * injected renderers, callbacks) and the Mosaic root that `Thread.Messages`
53
+ * renders its virtual stack within. Renders no DOM of its own — wrap the visible
54
+ * thread chrome in `Thread.Content`.
55
+ */
56
+ const ThreadRoot = ({
57
+ children,
58
+ getMetadata,
59
+ components,
60
+ identityDid,
61
+ editable,
62
+ onMessageDelete,
63
+ onAcceptProposal,
64
+ }: ThreadRootProps) => {
65
+ // Composer focus handler registered by Thread.Textbox; invoked by Thread.Header's caret.
66
+ const composerFocus = useRef<(() => void) | undefined>(undefined);
67
+
68
+ const registerComposerFocus = useCallback((focus: (() => void) | undefined) => {
69
+ composerFocus.current = focus;
70
+ }, []);
71
+
72
+ const focusComposer = useCallback(() => composerFocus.current?.(), []);
73
+
74
+ return (
75
+ <ThreadContextProvider
76
+ getMetadata={getMetadata}
77
+ components={components ?? {}}
78
+ identityDid={identityDid}
79
+ editable={editable}
80
+ registerComposerFocus={registerComposerFocus}
81
+ focusComposer={focusComposer}
82
+ onMessageDelete={onMessageDelete}
83
+ onAcceptProposal={onAcceptProposal}
84
+ >
85
+ <Mosaic.Root>{children}</Mosaic.Root>
86
+ </ThreadContextProvider>
87
+ );
88
+ };
89
+
90
+ ThreadRoot.displayName = 'Thread.Root';
91
+
92
+ //
93
+ // Content
94
+ //
95
+
96
+ type ThreadContentExtra = {
97
+ id?: string;
98
+ current?: boolean | string;
99
+ } & Pick<ComponentPropsWithRef<'div'>, 'onClickCapture' | 'onFocusCapture'>;
100
+
101
+ export type ThreadContentProps = ComposableProps<ThreadContentExtra>;
102
+
103
+ /** Visible thread container (the attention surface that hosts header / messages / composer). */
104
+ const ThreadContent = composable<HTMLDivElement, ThreadContentExtra>(
105
+ ({ children, current, id, ...props }, forwardedRef) => {
24
106
  return (
25
107
  <div
26
- role='group'
108
+ {...composableProps(props, {
109
+ role: 'group',
110
+ classNames: [
111
+ 'flex flex-col bg-[var(--surface-bg)] current-related dx-attention-surface [--controls-opacity:0]',
112
+ hoverableFocusedWithinControls,
113
+ ],
114
+ })}
27
115
  data-testid='thread'
28
- {...(current && { 'aria-current': typeof current === 'string' ? current : 'location' })}
29
- {...props}
30
- className={mx(
31
- threadLayout,
32
- hoverableFocusedWithinControls,
33
- 'bg-[var(--surface-bg)] current-related attention-surface [--controls-opacity:0]',
34
- classNames,
35
- )}
116
+ id={id}
117
+ aria-current={current ? 'location' : undefined}
36
118
  ref={forwardedRef}
37
119
  >
38
120
  {children}
@@ -41,43 +123,212 @@ const ThreadRoot = forwardRef<HTMLDivElement, ThreadRootProps>(
41
123
  },
42
124
  );
43
125
 
126
+ ThreadContent.displayName = 'Thread.Content';
127
+
44
128
  //
45
- // Heading
129
+ // Header
46
130
  //
47
131
 
48
- type ThreadHeaderProps = ComponentPropsWithRef<'div'> & { detached?: boolean };
132
+ export type ThreadHeaderProps = Omit<
133
+ ComposableProps<{
134
+ /** Snippet text rendered inside a `<p>` (a string, not a DOM element). */
135
+ title?: string;
136
+ detached?: boolean;
137
+ /** Whether this is the current/selected thread; accents the title. */
138
+ current?: boolean;
139
+ /** Trailing controls rendered in the right column (aligns with message-tile controls). */
140
+ controls?: ReactNode;
141
+ /** Invoked when the caret affordance is activated to select/focus this thread. */
142
+ onSelect?: () => void;
143
+ }>,
144
+ 'children'
145
+ >;
146
+
147
+ /**
148
+ * Thread header row: caret (rail) · snippet (content) · controls. Owns its own
149
+ * `[rail · 1fr · controls]` grid so the trailing controls align with message-tile
150
+ * controls (which use the same template) — no grid is leaked in from the caller.
151
+ */
152
+ const ThreadHeader = composable<HTMLDivElement, ThreadHeaderProps>(
153
+ ({ title, detached, current, controls, onSelect, ...props }, forwardedRef) => {
154
+ const { t } = useTranslation(translationKey);
155
+ const { focusComposer } = useThreadContext('Thread.Header');
156
+ const handleSelect = useCallback(() => {
157
+ onSelect?.();
158
+ focusComposer();
159
+ }, [onSelect, focusComposer]);
49
160
 
50
- const ThreadHeader = forwardRef<HTMLParagraphElement, ThreadHeaderProps>(
51
- ({ children, detached, ...props }, forwardedRef) => {
52
161
  return (
53
- <>
54
- <div role='none' className='flex items-center justify-center text-description'>
55
- <Icon icon='ph--caret-double-right--regular' />
162
+ <div
163
+ {...composableProps(props, {
164
+ classNames: [
165
+ 'grid grid-cols-[var(--dx-rail-size)_1fr_min-content] items-center',
166
+ hoverableControls,
167
+ hoverableFocusedWithinControls,
168
+ ],
169
+ })}
170
+ ref={forwardedRef}
171
+ >
172
+ <div className='flex items-center justify-center'>
173
+ <IconButton
174
+ iconOnly
175
+ variant='ghost'
176
+ density='sm'
177
+ icon='ph--caret-double-right--regular'
178
+ label={t('select-thread.label')}
179
+ classNames='text-description'
180
+ onClick={handleSelect}
181
+ />
56
182
  </div>
57
- <div role='none' className='flex items-center overflow-hidden'>
183
+ <div className='flex items-center overflow-hidden'>
58
184
  <p
59
185
  role='heading'
60
186
  data-testid='thread.heading'
61
- {...props}
62
187
  className={mx(
63
- 'mie-2 text-description font-medium truncate italic',
188
+ 'me-2 font-medium truncate italic',
189
+ current ? 'text-accent-text' : 'text-description',
64
190
  detached && 'line-through decoration-1',
65
191
  )}
66
- ref={forwardedRef}
67
192
  >
68
- {children}
193
+ {title}
69
194
  </p>
70
195
  </div>
71
- </>
196
+ {controls}
197
+ </div>
72
198
  );
73
199
  },
74
200
  );
75
201
 
202
+ ThreadHeader.displayName = 'Thread.Header';
203
+
204
+ //
205
+ // Messages
206
+ //
207
+
208
+ const MessageTileAdapter = ({
209
+ id,
210
+ data,
211
+ location,
212
+ draggable,
213
+ current,
214
+ selected,
215
+ }: MosaicTileProps<MessageType.Message>) => (
216
+ <Mosaic.Tile id={id} data={data} location={location} draggable={draggable} current={current} selected={selected}>
217
+ <Message.Tile message={data} />
218
+ </Mosaic.Tile>
219
+ );
220
+
221
+ export type ThreadMessagesProps = ThemedClassName<{
222
+ messages: readonly MessageType.Message[];
223
+ /** Estimated tile height for the virtualizer. */
224
+ estimateSize?: number;
225
+ currentId?: string;
226
+ }>;
227
+
228
+ /** Virtualized stack of message tiles (via Mosaic), within an internal scroll area. */
229
+ const ThreadMessages = ({ messages, estimateSize = 80, currentId, classNames }: ThreadMessagesProps) => {
230
+ const [viewport, setViewport] = useState<HTMLElement | null>(null);
231
+ const items = useMemo(() => messages.filter(Boolean), [messages]);
232
+
233
+ return (
234
+ <Mosaic.Container
235
+ asChild
236
+ orientation='vertical'
237
+ autoScroll={viewport}
238
+ currentId={currentId}
239
+ eventHandler={{ id: 'thread', canDrop: () => false }}
240
+ >
241
+ <ScrollArea.Root classNames={mx('col-span-2', classNames)} orientation='vertical'>
242
+ <ScrollArea.Viewport ref={setViewport}>
243
+ <Mosaic.VirtualStack
244
+ items={items}
245
+ getId={getMessageId}
246
+ Tile={MessageTileAdapter}
247
+ draggable={false}
248
+ getScrollElement={() => viewport}
249
+ estimateSize={() => estimateSize}
250
+ />
251
+ </ScrollArea.Viewport>
252
+ </ScrollArea.Root>
253
+ </Mosaic.Container>
254
+ );
255
+ };
256
+
257
+ ThreadMessages.displayName = 'Thread.Messages';
258
+
259
+ //
260
+ // Textbox
261
+ //
262
+
263
+ export type ThreadTextboxProps = MessageMetadata & {
264
+ placeholder?: string;
265
+ autoFocus?: boolean;
266
+ disabled?: boolean;
267
+ extensions?: Extension;
268
+ /**
269
+ * Called with the composer content on send. Return `true` to accept (the
270
+ * editor is cleared and remounted), `false` to reject.
271
+ */
272
+ onSend?: (text: string) => boolean;
273
+ };
274
+
275
+ /** Message composer pinned at the foot of a thread. */
276
+ const ThreadTextbox = ({ placeholder, autoFocus, disabled, extensions, onSend, ...metadata }: ThreadTextboxProps) => {
277
+ const { t } = useTranslation(translationKey);
278
+ const { themeMode } = useThemeContext();
279
+ const { registerComposerFocus } = useThreadContext('Thread.Textbox');
280
+ const composerRef = useRef<{ focus: () => void } | null>(null);
281
+ const messageRef = useRef('');
282
+ const [count, setCount] = useState(0);
283
+
284
+ // Expose the composer's focus to Thread.Root so the header caret can focus it.
285
+ useEffect(() => {
286
+ registerComposerFocus(() => composerRef.current?.focus());
287
+ return () => registerComposerFocus(undefined);
288
+ }, [registerComposerFocus]);
289
+
290
+ const editorExtensions = useMemo(
291
+ () =>
292
+ [
293
+ createBasicExtensions({ placeholder: placeholder ?? t('message.placeholder') }),
294
+ createThemeExtensions({ themeMode }),
295
+ listener({ onChange: ({ text }) => (messageRef.current = text) }),
296
+ command,
297
+ extensions,
298
+ ].filter(Boolean) as Extension[],
299
+ [themeMode, placeholder, t, extensions, count],
300
+ );
301
+
302
+ const handleSend = useCallback(() => {
303
+ const text = messageRef.current;
304
+ if (!text?.length || !onSend) {
305
+ return;
306
+ }
307
+ if (onSend(text)) {
308
+ messageRef.current = '';
309
+ setCount((value) => value + 1);
310
+ }
311
+ }, []);
312
+
313
+ return (
314
+ <Message.Textbox
315
+ ref={composerRef}
316
+ {...metadata}
317
+ autoFocus={autoFocus}
318
+ disabled={disabled}
319
+ extensions={editorExtensions}
320
+ onSend={handleSend}
321
+ />
322
+ );
323
+ };
324
+
325
+ ThreadTextbox.displayName = 'Thread.Textbox';
326
+
76
327
  //
77
328
  // Status
78
329
  //
79
330
 
80
- type ThreadStatusProps = ThemedClassName<ComponentPropsWithRef<'div'>> & {
331
+ export type ThreadStatusProps = ThemedClassName<PropsWithChildren> & {
81
332
  activity?: boolean;
82
333
  };
83
334
 
@@ -88,29 +339,36 @@ const ThreadStatus = forwardRef<HTMLDivElement, ThreadStatusProps>(
88
339
  <div
89
340
  {...props}
90
341
  className={mx(
91
- 'col-start-2 grid grid-cols-[min-content_1fr_max-content] pb-2 pie-2 text-xs text-description',
342
+ 'col-start-2 grid grid-cols-[min-content_1fr_max-content] pb-2 pe-2 text-xs text-description',
92
343
  classNames,
93
344
  )}
94
345
  ref={forwardedRef}
95
346
  >
96
347
  <Icon
97
348
  icon='ph--spinner--bold'
98
- classNames='is-6 bs-4 invisible data-[visible=show]:visible animate-spin-slow'
349
+ classNames='w-6 h-4 invisible data-[visible=show]:visible animate-spin-slow'
99
350
  data-visible={activity ? 'show' : 'hide'}
100
351
  />
101
- <span className='truncate min-is-0' aria-live='polite'>
352
+ <span className='truncate min-w-0' aria-live='polite'>
102
353
  {activity ? children : null}
103
354
  </span>
104
- <span className={mx('text-end', hoverableControlItem)}>{t('enter to send message')}</span>
355
+ <span className={mx('text-end', hoverableControlItem)}>{t('enter-to-send.message')}</span>
105
356
  </div>
106
357
  );
107
358
  },
108
359
  );
109
360
 
361
+ ThreadStatus.displayName = 'Thread.Status';
362
+
363
+ //
364
+ // Thread
365
+ //
366
+
110
367
  export const Thread = {
111
368
  Root: ThreadRoot,
369
+ Content: ThreadContent,
112
370
  Header: ThreadHeader,
371
+ Messages: ThreadMessages,
372
+ Textbox: ThreadTextbox,
113
373
  Status: ThreadStatus,
114
374
  };
115
-
116
- export type { ThreadRootProps, ThreadHeaderProps as ThreadHeadingProps, ThreadStatusProps };
@@ -2,4 +2,12 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export * from './Thread';
5
+ export { Thread } from './Thread';
6
+ export type {
7
+ ThreadRootProps,
8
+ ThreadContentProps,
9
+ ThreadHeaderProps,
10
+ ThreadMessagesProps,
11
+ ThreadTextboxProps,
12
+ ThreadStatusProps,
13
+ } from './Thread';
package/src/command.ts ADDED
@@ -0,0 +1,58 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { HighlightStyle, StreamLanguage, syntaxHighlighting } from '@codemirror/language';
6
+ import { type Extension } from '@codemirror/state';
7
+ import { EditorView } from '@codemirror/view';
8
+
9
+ import { tags } from '@dxos/ui-editor';
10
+ import { mx } from '@dxos/ui-theme';
11
+
12
+ /**
13
+ * Highlights leading slash-commands (e.g. `/resolve`) and `@mention` tags
14
+ * (e.g. `@Kai`, anywhere) in the message composer.
15
+ * https://github.com/codemirror/stream-parser/blob/main/test/test-stream-parser.ts
16
+ */
17
+ const parser = StreamLanguage.define<{ count: number }>({
18
+ startState: () => ({ count: 0 }),
19
+ token: (stream, state) => {
20
+ state.count++;
21
+ if (stream.eatSpace()) {
22
+ return null;
23
+ }
24
+ // Leading slash-command (first token only).
25
+ if (state.count === 1 && stream.match(/^\/\w+/)) {
26
+ return 'tagName';
27
+ }
28
+ // @mention (anywhere).
29
+ if (stream.match(/^@\w+/)) {
30
+ return 'labelName';
31
+ }
32
+ stream.next();
33
+ return null;
34
+ },
35
+ });
36
+
37
+ const styles = HighlightStyle.define([
38
+ {
39
+ tag: tags.tagName,
40
+ class: mx('dx-tag dx-tag--indigo'),
41
+ },
42
+ {
43
+ tag: tags.labelName,
44
+ class: mx('dx-tag dx-tag--blue'),
45
+ },
46
+ ]);
47
+
48
+ // Center the inline `.dx-tag` pills on the text line. `.dx-tag` is `inline-block`
49
+ // with vertical padding, so on a CodeMirror line it baseline-aligns and rides
50
+ // high; overriding `vertical-align` recentres it (cf. the `cm-reference-pill`
51
+ // precedent in `@dxos/react-ui-chat`).
52
+ const theme = EditorView.theme({
53
+ '.dx-tag': {
54
+ verticalAlign: 'bottom',
55
+ },
56
+ });
57
+
58
+ export const command: Extension = [parser, syntaxHighlighting(styles), theme];
package/src/context.ts ADDED
@@ -0,0 +1,21 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+
7
+ import { type ThreadContextValue } from './types';
8
+
9
+ /**
10
+ * Context provided by `Thread.Root` and consumed by its parts. Extends the
11
+ * consumer-facing {@link ThreadContextValue} with internal coordination (e.g.
12
+ * focusing the composer from the header's select affordance).
13
+ */
14
+ export type ThreadContext = ThreadContextValue & {
15
+ /** Register the composer's focus handler (set by `Thread.Textbox`). */
16
+ registerComposerFocus: (focus: (() => void) | undefined) => void;
17
+ /** Focus the thread composer (used by `Thread.Header`'s select affordance). */
18
+ focusComposer: () => void;
19
+ };
20
+
21
+ export const [ThreadContextProvider, useThreadContext] = createContext<ThreadContext>('Thread');
package/src/index.ts CHANGED
@@ -2,9 +2,31 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { translations } from './translations';
6
-
7
- export * from './Thread';
8
- export * from './Message';
9
- export type * from './types';
10
- export { translations };
5
+ export { Thread } from './Thread';
6
+ export type {
7
+ ThreadRootProps,
8
+ ThreadContentProps,
9
+ ThreadHeaderProps,
10
+ ThreadMessagesProps,
11
+ ThreadTextboxProps,
12
+ ThreadStatusProps,
13
+ } from './Thread';
14
+ export { Message } from './Message';
15
+ export type {
16
+ MessageRootProps,
17
+ MessageHeadingProps,
18
+ MessageAuthorNameProps,
19
+ MessageTimeProps,
20
+ MessageBodyProps,
21
+ MessageTextboxProps,
22
+ MessageTileProps,
23
+ } from './Message';
24
+ export { command } from './command';
25
+ export { useThreadContext } from './context';
26
+ export type {
27
+ MessageMetadata,
28
+ ObjectTileComponent,
29
+ ThreadComponents,
30
+ MessageCallbacks,
31
+ ThreadContextValue,
32
+ } from './types';