@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
package/package.json CHANGED
@@ -1,56 +1,72 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-thread",
3
- "version": "0.8.4-main.fffef41",
3
+ "version": "0.8.4-staging.60fe92afc8",
4
4
  "description": "A set of components to use with threads, including comments and messages.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
- "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
8
12
  "author": "DXOS.org",
9
- "sideEffects": true,
13
+ "sideEffects": false,
10
14
  "type": "module",
15
+ "imports": {
16
+ "#translations": "./src/translations.ts"
17
+ },
11
18
  "exports": {
12
19
  ".": {
13
20
  "source": "./src/index.ts",
14
21
  "types": "./dist/types/src/index.d.ts",
15
22
  "browser": "./dist/lib/browser/index.mjs"
23
+ },
24
+ "./translations": {
25
+ "source": "./src/translations.ts",
26
+ "types": "./dist/types/src/translations.d.ts",
27
+ "browser": "./dist/lib/browser/translations.mjs",
28
+ "node": "./dist/lib/node-esm/translations.mjs"
16
29
  }
17
30
  },
18
31
  "types": "dist/types/src/index.d.ts",
19
- "typesVersions": {
20
- "*": {}
21
- },
22
32
  "files": [
23
33
  "dist",
24
34
  "src"
25
35
  ],
26
36
  "dependencies": {
27
- "@preact-signals/safe-react": "^0.9.0",
37
+ "@codemirror/language": "^6.12.3",
38
+ "@codemirror/state": "^6.6.0",
39
+ "@codemirror/view": "^6.43.0",
40
+ "@radix-ui/react-context": "1.1.1",
28
41
  "@radix-ui/react-primitive": "2.0.2",
29
- "date-fns": "^3.3.1",
30
- "@dxos/react-ui-editor": "0.8.4-main.fffef41",
31
- "@dxos/util": "0.8.4-main.fffef41"
42
+ "date-fns": "^3.6.0",
43
+ "@dxos/echo": "0.8.4-staging.60fe92afc8",
44
+ "@dxos/echo-react": "0.8.4-staging.60fe92afc8",
45
+ "@dxos/react-ui-mosaic": "0.8.4-staging.60fe92afc8",
46
+ "@dxos/types": "0.8.4-staging.60fe92afc8",
47
+ "@dxos/ui-editor": "0.8.4-staging.60fe92afc8",
48
+ "@dxos/util": "0.8.4-staging.60fe92afc8",
49
+ "@dxos/react-ui-editor": "0.8.4-staging.60fe92afc8"
32
50
  },
33
51
  "devDependencies": {
34
- "@types/react": "~19.2.2",
35
- "@types/react-dom": "~19.2.2",
36
- "react": "~19.2.0",
37
- "react-dom": "~19.2.0",
38
- "vite": "7.1.9",
39
- "@dxos/echo": "0.8.4-main.fffef41",
40
- "@dxos/echo-db": "0.8.4-main.fffef41",
41
- "@dxos/keys": "0.8.4-main.fffef41",
42
- "@dxos/live-object": "0.8.4-main.fffef41",
43
- "@dxos/log": "0.8.4-main.fffef41",
44
- "@dxos/random": "0.8.4-main.fffef41",
45
- "@dxos/react-ui": "0.8.4-main.fffef41",
46
- "@dxos/react-ui-theme": "0.8.4-main.fffef41",
47
- "@dxos/storybook-utils": "0.8.4-main.fffef41"
52
+ "@types/react": "~19.2.7",
53
+ "@types/react-dom": "~19.2.3",
54
+ "react": "~19.2.3",
55
+ "react-dom": "~19.2.3",
56
+ "vite": "^8.0.16",
57
+ "@dxos/keys": "0.8.4-staging.60fe92afc8",
58
+ "@dxos/log": "0.8.4-staging.60fe92afc8",
59
+ "@dxos/react-ui": "0.8.4-staging.60fe92afc8",
60
+ "@dxos/random": "0.8.4-staging.60fe92afc8",
61
+ "@dxos/schema": "0.8.4-staging.60fe92afc8",
62
+ "@dxos/storybook-utils": "0.8.4-staging.60fe92afc8",
63
+ "@dxos/ui-theme": "0.8.4-staging.60fe92afc8"
48
64
  },
49
65
  "peerDependencies": {
50
- "react": "^19.0.0",
51
- "react-dom": "^19.0.0",
52
- "@dxos/react-ui": "0.8.4-main.fffef41",
53
- "@dxos/react-ui-theme": "0.8.4-main.fffef41"
66
+ "react": "~19.2.3",
67
+ "react-dom": "~19.2.3",
68
+ "@dxos/react-ui": "0.8.4-staging.60fe92afc8",
69
+ "@dxos/ui-theme": "0.8.4-staging.60fe92afc8"
54
70
  },
55
71
  "publishConfig": {
56
72
  "access": "public"
@@ -3,43 +3,38 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React, { useState } from 'react';
6
+ import React, { useMemo } from 'react';
7
7
 
8
- import { PublicKey } from '@dxos/keys';
9
- import { withTheme } from '@dxos/react-ui/testing';
10
- import { hoverableControls, hoverableFocusedWithinControls } from '@dxos/react-ui-theme';
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
9
 
12
- import { type MessageEntity, MessageStoryText } from '../testing';
13
- import { Thread } from '../Thread';
14
- import { translations } from '../translations';
10
+ import { translations } from '#translations';
15
11
 
16
- import { MessageRoot } from './Message';
12
+ import { createMessages, getStoryMetadata } from '../testing';
13
+ import { Thread } from '../Thread';
14
+ import { Message } from './Message';
17
15
 
18
- const DefaultStory = () => {
19
- const [identityKey] = useState(PublicKey.random());
20
- const [message] = useState<MessageEntity>({
21
- id: 'm1',
22
- timestamp: new Date().toISOString(),
23
- authorId: identityKey.toHex(),
24
- text: 'hello',
25
- });
16
+ type StoryProps = { editable: boolean };
26
17
 
18
+ // Sample messages are authored by 'did:key:alice'; the local identity matches,
19
+ // so `editable` toggles whether the author's own message shows the edit affordance.
20
+ const DefaultStory = ({ editable }: StoryProps) => {
21
+ const [message] = useMemo(() => createMessages(1), []);
27
22
  return (
28
- <div className='mli-auto is-96 overflow-y-auto'>
29
- <Thread.Root id='t1'>
30
- <MessageRoot {...message} classNames={[hoverableControls, hoverableFocusedWithinControls]}>
31
- <MessageStoryText {...message} onDelete={() => console.log('delete')} />
32
- </MessageRoot>
33
- </Thread.Root>
34
- </div>
23
+ <Thread.Root
24
+ getMetadata={getStoryMetadata}
25
+ identityDid='did:key:alice'
26
+ editable={editable}
27
+ onMessageDelete={() => {}}
28
+ >
29
+ <Message.Tile message={message} />
30
+ </Thread.Root>
35
31
  );
36
32
  };
37
33
 
38
34
  const meta = {
39
35
  title: 'ui/react-ui-thread/Message',
40
- component: MessageRoot as any,
41
36
  render: DefaultStory,
42
- decorators: [withTheme],
37
+ decorators: [withTheme(), withLayout({ layout: 'centered', classNames: 'border w-card-min-width' })],
43
38
  parameters: {
44
39
  layout: 'fullscreen',
45
40
  translations,
@@ -50,4 +45,14 @@ export default meta;
50
45
 
51
46
  type Story = StoryObj<typeof meta>;
52
47
 
53
- export const Default: Story = {};
48
+ export const Editable: Story = {
49
+ args: {
50
+ editable: true,
51
+ },
52
+ };
53
+
54
+ export const NonEditable: Story = {
55
+ args: {
56
+ editable: false,
57
+ },
58
+ };
@@ -2,64 +2,109 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { EditorView } from '@codemirror/view';
5
6
  import { formatDistanceToNow } from 'date-fns/formatDistanceToNow';
6
- import React, { type ComponentPropsWithRef, type ComponentPropsWithoutRef, forwardRef } from 'react';
7
+ import React, {
8
+ type ComponentPropsWithRef,
9
+ type ComponentPropsWithoutRef,
10
+ type ReactNode,
11
+ forwardRef,
12
+ useCallback,
13
+ useEffect,
14
+ useImperativeHandle,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
7
18
 
8
- // TODO(burdon): Remove dep.
9
- import { Avatar, type ThemedClassName, useTranslation } from '@dxos/react-ui';
10
- import { type UseTextEditorProps, keymap, listener, useTextEditor } from '@dxos/react-ui-editor';
11
- import { focusRing, mx } from '@dxos/react-ui-theme';
19
+ import { Obj, Ref } from '@dxos/echo';
20
+ import {
21
+ Avatar,
22
+ IconButton,
23
+ type ThemedClassName,
24
+ useOnTransition,
25
+ useThemeContext,
26
+ useTranslation,
27
+ } from '@dxos/react-ui';
28
+ import { type UseTextEditorProps, useTextEditor } from '@dxos/react-ui-editor';
29
+ import { type ContentBlock, type Message as MessageType } from '@dxos/types';
30
+ import { createBasicExtensions, createThemeExtensions, keymap, listener } from '@dxos/ui-editor';
31
+ import { hoverableControlItem, hoverableControls, hoverableFocusedWithinControls, mx } from '@dxos/ui-theme';
12
32
  import { hexToEmoji, hexToHue, isTruthy } from '@dxos/util';
13
33
 
34
+ import { command } from '../command';
35
+ import { useThreadContext } from '../context';
14
36
  import { translationKey } from '../translations';
15
37
  import { type MessageMetadata } from '../types';
16
38
 
17
39
  const avatarSize = 7;
18
40
 
19
- export type MessageRootProps = ThemedClassName<ComponentPropsWithRef<'div'>> &
20
- MessageMetadata &
21
- Partial<{ continues: boolean }>;
41
+ const buttonGroupClassNames = 'flex flex-row items-center gap-0.5 pe-2';
42
+ const buttonClassNames = 'p-1! transition-opacity';
43
+
44
+ //
45
+ // Root
46
+ //
47
+
48
+ export type MessageRootProps = ThemedClassName<
49
+ ComponentPropsWithRef<'div'> & MessageMetadata & Partial<{ continues: boolean; controls: ReactNode }>
50
+ >;
22
51
 
23
52
  // TODO(burdon): Show authorName on tooltip.
24
- export const MessageRoot = forwardRef<HTMLDivElement, MessageRootProps>(
53
+ const MessageRoot = forwardRef<HTMLDivElement, MessageRootProps>(
25
54
  (
26
- { authorImgSrc, authorId, authorName, authorAvatarProps, continues = true, children, classNames, ...rootProps },
55
+ {
56
+ authorImgSrc,
57
+ authorId,
58
+ authorName,
59
+ authorAvatarProps,
60
+ continues = true,
61
+ controls,
62
+ children,
63
+ classNames,
64
+ ...rootProps
65
+ },
27
66
  forwardedRef,
28
67
  ) => {
68
+ // Must wrap the message since Avatar.Label may be used in the content.
69
+ // Columns mirror Thread.Header (avatar/rail · content · controls) so trailing
70
+ // controls align with the thread header's controls.
29
71
  return (
30
- // Must wrap the message since Avatar.Label may be used in the content.
31
72
  <Avatar.Root>
32
73
  <div
33
- role='none'
34
74
  data-testid='thread.message'
35
75
  {...rootProps}
36
- className={mx('grid grid-cols-subgrid col-span-2', classNames)}
76
+ className={mx('grid grid-cols-[var(--dx-rail-size)_1fr_min-content] w-full', classNames)}
37
77
  ref={forwardedRef}
38
78
  >
39
- <div role='none' className='flex flex-col items-center gap-2 pbs-1'>
79
+ <div className='flex flex-col items-center gap-2 pt-1'>
40
80
  <Avatar.Content
41
81
  size={avatarSize}
42
82
  hue={authorAvatarProps?.hue || hexToHue(authorId ?? '0')}
43
83
  fallback={authorAvatarProps?.emoji || hexToEmoji(authorId ?? '0')}
44
84
  {...(authorImgSrc && { imgSrc: authorImgSrc })}
45
85
  />
46
- {continues && <div role='none' className='is-px grow bg-separator' />}
47
- </div>
48
- <div role='none' className='plb-1 min-is-0'>
49
- {children}
86
+ {continues && <div className='w-px grow bg-separator' />}
50
87
  </div>
88
+ <div className='py-1 min-w-0'>{children}</div>
89
+ {controls && <div className='self-start'>{controls}</div>}
51
90
  </div>
52
91
  </Avatar.Root>
53
92
  );
54
93
  },
55
94
  );
56
95
 
96
+ MessageRoot.displayName = 'Message.Root';
97
+
98
+ //
99
+ // Heading
100
+ //
101
+
57
102
  export type MessageHeadingProps = ThemedClassName<ComponentPropsWithoutRef<'div'>> &
58
103
  Pick<MessageMetadata, 'authorName' | 'timestamp'>;
59
104
 
60
- export const MessageHeading = ({ children, classNames, timestamp, authorName, ...props }: MessageHeadingProps) => {
105
+ const MessageHeading = ({ children, classNames, timestamp, authorName, ...props }: MessageHeadingProps) => {
61
106
  return (
62
- <div role='none' {...props} className={mx('flex gap-2 items-start', classNames)}>
107
+ <div {...props} className={mx('flex gap-2 items-start', classNames)}>
63
108
  <p className='grow'>
64
109
  <MessageAuthorName authorName={authorName} />
65
110
  {timestamp && <MessageTime timestamp={timestamp} />}
@@ -69,33 +114,130 @@ export const MessageHeading = ({ children, classNames, timestamp, authorName, ..
69
114
  );
70
115
  };
71
116
 
117
+ MessageHeading.displayName = 'Message.Heading';
118
+
72
119
  export type MessageAuthorNameProps = Pick<MessageMetadata, 'authorName'>;
73
120
 
74
- export const MessageAuthorName = ({ authorName }: MessageAuthorNameProps) => {
121
+ const MessageAuthorName = ({ authorName }: MessageAuthorNameProps) => {
75
122
  const { t } = useTranslation(translationKey);
76
123
  return (
77
- <Avatar.Label classNames='block truncate text-sm text-subdued'>{authorName ?? t('anonymous label')}</Avatar.Label>
124
+ <Avatar.Label classNames='block truncate text-sm text-subdued'>{authorName ?? t('anonymous.label')}</Avatar.Label>
78
125
  );
79
126
  };
80
127
 
128
+ MessageAuthorName.displayName = 'Message.AuthorName';
129
+
81
130
  export type MessageTimeProps = Pick<MessageMetadata, 'timestamp'>;
82
131
 
83
- export const MessageTime = ({ timestamp }: MessageTimeProps) => {
132
+ const MessageTime = ({ timestamp }: MessageTimeProps) => {
84
133
  const { dtLocale } = useTranslation(translationKey);
85
134
  const dt = timestamp ? new Date(timestamp) : undefined;
86
135
  return (
87
- <time className='block text-subdued text-xs pbe-0.5' dateTime={dt?.toISOString()}>
136
+ <time className='block text-subdued text-xs pb-0.5' dateTime={dt?.toISOString()}>
88
137
  {dt ? formatDistanceToNow(dt, { locale: dtLocale, addSuffix: true }) : ''}
89
138
  </time>
90
139
  );
91
140
  };
92
141
 
93
- export type MessageBodyProps = ComponentPropsWithoutRef<'p'>;
142
+ MessageTime.displayName = 'Message.Time';
143
+
144
+ //
145
+ // Body
146
+ //
147
+
148
+ export type MessageBodyProps = {
149
+ /** The message whose content blocks are rendered. */
150
+ message: MessageType.Message;
151
+ /** When true, the local user authored this message. */
152
+ isAuthor?: boolean;
153
+ /** When true, the text block is editable. */
154
+ editing?: boolean;
155
+ /** Persist an edited text block. */
156
+ onSave?: (text: string) => void;
157
+ };
158
+
159
+ /**
160
+ * Renders a message's content blocks: text (via editor), proposal, and
161
+ * object/reference tiles (delegated to the injected `Object` component).
162
+ */
163
+ const MessageBody = ({ message, isAuthor, editing, onSave }: MessageBodyProps) => {
164
+ const { components } = useThreadContext('Message.Body');
165
+ const textBlockIndex = message.blocks.findIndex((block) => block._tag === 'text');
166
+ const textBlock = textBlockIndex !== -1 ? (message.blocks[textBlockIndex] as ContentBlock.Text) : undefined;
167
+ const proposalBlock = message.blocks.find((block) => block._tag === 'proposal') as ContentBlock.Proposal | undefined;
168
+ const references = message.blocks
169
+ .filter((block) => block._tag === 'reference')
170
+ .map((block) => (block as ContentBlock.Reference).reference);
171
+ const Object = components.Object;
172
+
173
+ return (
174
+ <>
175
+ {textBlock && <TextBlock block={textBlock} isAuthor={isAuthor} editing={editing} onSave={onSave} />}
176
+ {proposalBlock && <div className='me-4 italic'>{proposalBlock.text}</div>}
177
+ {Object &&
178
+ Ref.Array.targets(references).map((reference, index) => (
179
+ <Object key={index} subject={reference as Obj.Unknown} />
180
+ ))}
181
+ </>
182
+ );
183
+ };
184
+
185
+ MessageBody.displayName = 'Message.Body';
186
+
187
+ const TextBlock = ({
188
+ block,
189
+ isAuthor,
190
+ editing,
191
+ onSave,
192
+ }: {
193
+ block: ContentBlock.Text;
194
+ isAuthor?: boolean;
195
+ editing?: boolean;
196
+ onSave?: (text: string) => void;
197
+ }) => {
198
+ const { themeMode } = useThemeContext();
199
+ const inMemoryContentRef = useRef(block.text);
200
+
201
+ const handleDocumentChange = useCallback((next: string) => {
202
+ inMemoryContentRef.current = next;
203
+ }, []);
204
+
205
+ const saveDocumentChange = useCallback(() => {
206
+ onSave?.(inMemoryContentRef.current);
207
+ }, [onSave]);
208
+
209
+ useOnTransition(editing, true, false, saveDocumentChange);
94
210
 
95
- export const MessageBody = ({ children, ...props }: MessageBodyProps) => {
96
- return <p {...props}>{children}</p>;
211
+ const { parentRef, focusAttributes, view } = useTextEditor(
212
+ () => ({
213
+ initialValue: block.text,
214
+ extensions: [
215
+ createBasicExtensions({ readOnly: !isAuthor || !editing }),
216
+ createThemeExtensions({ themeMode }),
217
+ command,
218
+ EditorView.updateListener.of((update) => {
219
+ if (update.docChanged) {
220
+ handleDocumentChange(update.state.doc.toString());
221
+ }
222
+ }),
223
+ ],
224
+ }),
225
+ [block.text, editing, isAuthor, themeMode, handleDocumentChange],
226
+ );
227
+
228
+ useEffect(() => {
229
+ if (editing) {
230
+ view?.focus();
231
+ }
232
+ }, [editing, view]);
233
+
234
+ return <div ref={parentRef} className='me-4' {...focusAttributes} />;
97
235
  };
98
236
 
237
+ //
238
+ // Textbox
239
+ //
240
+
99
241
  export type MessageTextboxProps = {
100
242
  disabled?: boolean;
101
243
  onSend?: () => void;
@@ -111,65 +253,180 @@ const keyBindings = ({ onSend, onClear }: Pick<MessageTextboxProps, 'onSend' | '
111
253
  if (onSend) {
112
254
  onSend();
113
255
  return true;
114
- } else {
115
- return false;
116
256
  }
257
+ return false;
117
258
  },
118
259
  },
119
260
  {
120
- // TODO(burdon): Other key bindings.
121
261
  key: 'Meta+Backspace',
122
262
  run: () => {
123
263
  if (onClear) {
124
264
  onClear();
125
265
  return true;
126
- } else {
127
- return false;
128
266
  }
267
+ return false;
129
268
  },
130
269
  },
131
270
  ];
132
271
 
133
- export const MessageTextbox = ({
134
- id,
135
- authorId,
136
- authorName,
137
- authorImgSrc,
138
- authorAvatarProps,
139
- disabled,
140
- extensions,
141
- onSend,
142
- onClear,
143
- onEditorFocus,
144
- ...editorProps
145
- }: MessageTextboxProps) => {
146
- const { parentRef, focusAttributes } = useTextEditor(
147
- () => ({
272
+ export type MessageTextboxHandle = { focus: () => void };
273
+
274
+ const MessageTextbox = forwardRef<MessageTextboxHandle, MessageTextboxProps>(
275
+ (
276
+ {
148
277
  id,
149
- extensions: [
150
- keymap.of(keyBindings({ onSend, onClear })),
151
- listener({
152
- onFocus: ({ focusing }) => {
153
- if (focusing) {
154
- onEditorFocus?.();
155
- }
156
- },
157
- }),
158
- extensions,
159
- ].filter(isTruthy),
160
- ...editorProps,
161
- }),
162
- [id, extensions],
278
+ authorId,
279
+ authorName,
280
+ authorImgSrc,
281
+ authorAvatarProps,
282
+ disabled,
283
+ extensions,
284
+ onSend,
285
+ onClear,
286
+ onEditorFocus,
287
+ ...editorProps
288
+ },
289
+ forwardedRef,
290
+ ) => {
291
+ const { parentRef, focusAttributes, view } = useTextEditor(
292
+ () => ({
293
+ id,
294
+ extensions: [
295
+ keymap.of(keyBindings({ onSend, onClear })),
296
+ listener({
297
+ onFocus: ({ focusing }) => {
298
+ if (focusing) {
299
+ onEditorFocus?.();
300
+ }
301
+ },
302
+ }),
303
+ extensions,
304
+ ].filter(isTruthy),
305
+ ...editorProps,
306
+ }),
307
+ [id, extensions],
308
+ );
309
+
310
+ useImperativeHandle(forwardedRef, () => ({ focus: () => view?.focus() }), [view]);
311
+
312
+ return (
313
+ <MessageRoot {...{ id, authorId, authorName, authorImgSrc, authorAvatarProps }} continues={false}>
314
+ <div
315
+ ref={parentRef}
316
+ className={mx('py-0.5 me-1 rounded-xs dx-focus-ring', disabled && 'opacity-50')}
317
+ {...focusAttributes}
318
+ />
319
+ </MessageRoot>
320
+ );
321
+ },
322
+ );
323
+
324
+ MessageTextbox.displayName = 'Message.Textbox';
325
+
326
+ //
327
+ // Tile
328
+ //
329
+
330
+ export type MessageTileProps = {
331
+ message: MessageType.Message;
332
+ classNames?: MessageRootProps['classNames'];
333
+ };
334
+
335
+ /**
336
+ * Full message tile (frame + heading + controls + body). Reads `Thread.Root`
337
+ * context for metadata resolution, injected renderers, and callbacks. This is
338
+ * the unit rendered by `Thread.Messages`.
339
+ */
340
+ const MessageTile = ({ message, classNames }: MessageTileProps) => {
341
+ const { t } = useTranslation(translationKey);
342
+ const { getMetadata, identityDid, editable, onMessageDelete, onAcceptProposal } = useThreadContext('Message.Tile');
343
+ const [editing, setEditing] = useState(false);
344
+
345
+ const metadata = getMetadata(message);
346
+ const isAuthor = !!identityDid && identityDid === metadata.authorId;
347
+ const hasProposal = message.blocks.some((block) => block._tag === 'proposal');
348
+
349
+ const handleEdit = useCallback(() => setEditing((value) => !value), []);
350
+ const handleDelete = useCallback(() => onMessageDelete?.(message.id), [onMessageDelete, message.id]);
351
+ const handleAcceptProposal = useCallback(() => onAcceptProposal?.(message.id), [onAcceptProposal, message.id]);
352
+ const handleSave = useCallback(
353
+ (text: string) => {
354
+ Obj.update(message, (message) => {
355
+ const block = message.blocks.find((block) => block._tag === 'text');
356
+ if (block && block._tag === 'text') {
357
+ block.text = text;
358
+ }
359
+ });
360
+ },
361
+ [message],
163
362
  );
164
363
 
364
+ const showEdit = isAuthor && editable;
365
+ const showAccept = hasProposal && !!onAcceptProposal;
366
+ const showDelete = !!onMessageDelete;
367
+ const controls =
368
+ showEdit || showAccept || showDelete ? (
369
+ <div className={buttonGroupClassNames}>
370
+ {showEdit && (
371
+ <IconButton
372
+ data-testid={editing ? 'thread.message.save' : 'thread.message.edit'}
373
+ variant='ghost'
374
+ icon={editing ? 'ph--check--regular' : 'ph--pencil-simple--regular'}
375
+ iconOnly
376
+ label={t(editing ? 'save-message.label' : 'edit-message.label')}
377
+ classNames={[buttonClassNames, hoverableControlItem]}
378
+ onClick={handleEdit}
379
+ />
380
+ )}
381
+ {showAccept && (
382
+ <IconButton
383
+ data-testid='thread.message.accept'
384
+ variant='ghost'
385
+ icon='ph--check--regular'
386
+ iconOnly
387
+ label={t('accept-proposal.label')}
388
+ classNames={[buttonClassNames, hoverableControlItem]}
389
+ onClick={handleAcceptProposal}
390
+ />
391
+ )}
392
+ {showDelete && (
393
+ <IconButton
394
+ data-testid='thread.message.delete'
395
+ variant='ghost'
396
+ icon='ph--x--regular'
397
+ iconOnly
398
+ label={t('delete-message.label')}
399
+ classNames={[buttonClassNames, hoverableControlItem]}
400
+ onClick={handleDelete}
401
+ />
402
+ )}
403
+ </div>
404
+ ) : undefined;
405
+
165
406
  return (
166
- <MessageRoot {...{ id, authorId, authorName, authorImgSrc, authorAvatarProps }} continues={false}>
167
- <div
168
- role='none'
169
- ref={parentRef}
170
- className={mx('plb-0.5 mie-1 rounded-sm', focusRing, disabled && 'opacity-50')}
171
- {...focusAttributes}
172
- />
407
+ <MessageRoot
408
+ {...metadata}
409
+ controls={controls}
410
+ classNames={[hoverableControls, hoverableFocusedWithinControls, classNames]}
411
+ >
412
+ <MessageHeading authorName={metadata.authorName} timestamp={metadata.timestamp} />
413
+ <MessageBody message={message} isAuthor={isAuthor} editing={editing} onSave={handleSave} />
173
414
  </MessageRoot>
174
415
  );
175
416
  };
417
+
418
+ MessageTile.displayName = 'Message.Tile';
419
+
420
+ //
421
+ // Message
422
+ //
423
+
424
+ export const Message = {
425
+ Root: MessageRoot,
426
+ Heading: MessageHeading,
427
+ AuthorName: MessageAuthorName,
428
+ Time: MessageTime,
429
+ Body: MessageBody,
430
+ Textbox: MessageTextbox,
431
+ Tile: MessageTile,
432
+ };
@@ -2,4 +2,13 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './Message';
5
+ export { Message } from './Message';
6
+ export type {
7
+ MessageRootProps,
8
+ MessageHeadingProps,
9
+ MessageAuthorNameProps,
10
+ MessageTimeProps,
11
+ MessageBodyProps,
12
+ MessageTextboxProps,
13
+ MessageTileProps,
14
+ } from './Message';