@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.
- package/LICENSE +102 -5
- package/dist/lib/browser/chunk-QFRRLZVL.mjs +31 -0
- package/dist/lib/browser/chunk-QFRRLZVL.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +507 -220
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +9 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/types/src/Message/Message.d.ts +81 -10
- package/dist/types/src/Message/Message.d.ts.map +1 -1
- package/dist/types/src/Message/Message.stories.d.ts +22 -14
- package/dist/types/src/Message/Message.stories.d.ts.map +1 -1
- package/dist/types/src/Message/index.d.ts +2 -1
- package/dist/types/src/Message/index.d.ts.map +1 -1
- package/dist/types/src/Thread/Thread.d.ts +76 -13
- package/dist/types/src/Thread/Thread.d.ts.map +1 -1
- package/dist/types/src/Thread/Thread.stories.d.ts +16 -12
- package/dist/types/src/Thread/Thread.stories.d.ts.map +1 -1
- package/dist/types/src/Thread/index.d.ts +2 -1
- package/dist/types/src/Thread/index.d.ts.map +1 -1
- package/dist/types/src/command.d.ts +3 -0
- package/dist/types/src/command.d.ts.map +1 -0
- package/dist/types/src/context.d.ts +227 -0
- package/dist/types/src/context.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +7 -5
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/testing.d.ts +9 -14
- package/dist/types/src/testing.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +17 -12
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +36 -3
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +44 -28
- package/src/Message/Message.stories.tsx +31 -26
- package/src/Message/Message.tsx +325 -68
- package/src/Message/index.ts +10 -1
- package/src/Thread/Thread.stories.tsx +19 -67
- package/src/Thread/Thread.tsx +296 -38
- package/src/Thread/index.ts +9 -1
- package/src/command.ts +58 -0
- package/src/context.ts +21 -0
- package/src/index.ts +28 -6
- package/src/testing.ts +48 -0
- package/src/translations.ts +16 -11
- package/src/types.ts +38 -5
- package/dist/types/src/Thread/Comments.stories.d.ts +0 -35
- package/dist/types/src/Thread/Comments.stories.d.ts.map +0 -1
- package/src/Thread/Comments.stories.tsx +0 -384
- 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, {
|
|
6
|
+
import React, { useState } from 'react';
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
16
|
+
const IDENTITY = { role: 'user' as const, identityDid: 'did:key:alice', name: 'Alice' };
|
|
21
17
|
|
|
22
18
|
const DefaultStory = () => {
|
|
23
|
-
const [
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
<
|
|
63
|
-
<Thread.
|
|
64
|
-
{messages
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
45
|
+
} satisfies Meta<typeof DefaultStory>;
|
|
94
46
|
|
|
95
47
|
export default meta;
|
|
96
48
|
|
package/src/Thread/Thread.tsx
CHANGED
|
@@ -2,37 +2,119 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import 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 {
|
|
8
|
-
import {
|
|
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
|
|
38
|
+
import { type MessageMetadata, type ThreadContextValue } from '../types';
|
|
12
39
|
|
|
13
|
-
|
|
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 =
|
|
46
|
+
export type ThreadRootProps = PropsWithChildren<
|
|
47
|
+
Omit<ThreadContextValue, 'components'> & Partial<Pick<ThreadContextValue, 'components'>>
|
|
48
|
+
>;
|
|
21
49
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
29
|
-
{
|
|
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
|
-
//
|
|
129
|
+
// Header
|
|
46
130
|
//
|
|
47
131
|
|
|
48
|
-
type ThreadHeaderProps =
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
-
{
|
|
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<
|
|
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
|
|
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='
|
|
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-
|
|
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
|
|
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 };
|
package/src/Thread/index.ts
CHANGED
|
@@ -2,4 +2,12 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
export
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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';
|