@dxos/react-ui-thread 0.8.4-main.fd6878d → 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 +508 -221
  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 +27 -17
  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 +33 -5
  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 +40 -29
  36. package/src/Message/Message.tsx +327 -69
  37. package/src/Message/index.ts +10 -1
  38. package/src/Thread/Thread.stories.tsx +28 -72
  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 -52
  48. package/dist/types/src/Thread/Comments.stories.d.ts.map +0 -1
  49. package/src/Thread/Comments.stories.tsx +0 -371
  50. package/src/testing.tsx +0 -57
package/package.json CHANGED
@@ -1,56 +1,72 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-thread",
3
- "version": "0.8.4-main.fd6878d",
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.fd6878d",
31
- "@dxos/util": "0.8.4-main.fd6878d"
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": "~18.2.0",
35
- "@types/react-dom": "~18.2.0",
36
- "react": "~18.2.0",
37
- "react-dom": "~18.2.0",
38
- "vite": "5.4.7",
39
- "@dxos/echo-db": "0.8.4-main.fd6878d",
40
- "@dxos/echo-schema": "0.8.4-main.fd6878d",
41
- "@dxos/live-object": "0.8.4-main.fd6878d",
42
- "@dxos/keys": "0.8.4-main.fd6878d",
43
- "@dxos/random": "0.8.4-main.fd6878d",
44
- "@dxos/log": "0.8.4-main.fd6878d",
45
- "@dxos/react-ui-theme": "0.8.4-main.fd6878d",
46
- "@dxos/react-ui": "0.8.4-main.fd6878d",
47
- "@dxos/storybook-utils": "0.8.4-main.fd6878d"
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": "~18.2.0",
51
- "react-dom": "~18.2.0",
52
- "@dxos/react-ui": "0.8.4-main.fd6878d",
53
- "@dxos/react-ui-theme": "0.8.4-main.fd6878d"
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"
@@ -2,46 +2,57 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useMemo } from 'react';
6
7
 
7
- import React, { useState } from 'react';
8
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
8
9
 
9
- import { PublicKey } from '@dxos/keys';
10
- import { hoverableControls, hoverableFocusedWithinControls } from '@dxos/react-ui-theme';
11
- import { withLayout, withTheme } from '@dxos/storybook-utils';
10
+ import { translations } from '#translations';
12
11
 
13
- import { type MessageEntity, MessageStoryText } from '../testing';
12
+ import { createMessages, getStoryMetadata } from '../testing';
14
13
  import { Thread } from '../Thread';
15
- import { translations } from '../translations';
14
+ import { Message } from './Message';
16
15
 
17
- import { MessageRoot } from './Message';
18
-
19
- const DefaultStory = () => {
20
- const [identityKey] = useState(PublicKey.random());
21
- const [message] = useState<MessageEntity>({
22
- id: 'm1',
23
- timestamp: new Date().toISOString(),
24
- authorId: identityKey.toHex(),
25
- text: 'hello',
26
- });
16
+ type StoryProps = { editable: boolean };
27
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), []);
28
22
  return (
29
- <div className='mli-auto is-96 overflow-y-auto'>
30
- <Thread.Root id='t1'>
31
- <MessageRoot {...message} classNames={[hoverableControls, hoverableFocusedWithinControls]}>
32
- <MessageStoryText {...message} onDelete={() => console.log('delete')} />
33
- </MessageRoot>
34
- </Thread.Root>
35
- </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>
36
31
  );
37
32
  };
38
33
 
39
- export default {
34
+ const meta = {
40
35
  title: 'ui/react-ui-thread/Message',
41
- component: MessageRoot,
42
36
  render: DefaultStory,
43
- decorators: [withTheme, withLayout({ fullscreen: true })],
44
- parameters: { translations },
37
+ decorators: [withTheme(), withLayout({ layout: 'centered', classNames: 'border w-card-min-width' })],
38
+ parameters: {
39
+ layout: 'fullscreen',
40
+ translations,
41
+ },
42
+ } satisfies Meta<typeof DefaultStory>;
43
+
44
+ export default meta;
45
+
46
+ type Story = StoryObj<typeof meta>;
47
+
48
+ export const Editable: Story = {
49
+ args: {
50
+ editable: true,
51
+ },
45
52
  };
46
53
 
47
- export const Default = {};
54
+ export const NonEditable: Story = {
55
+ args: {
56
+ editable: false,
57
+ },
58
+ };
@@ -2,63 +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';
12
- import { hexToEmoji, hexToHue, isNotFalsy } from '@dxos/util';
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';
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';
22
43
 
23
- export const MessageRoot = forwardRef<HTMLDivElement, MessageRootProps>(
44
+ //
45
+ // Root
46
+ //
47
+
48
+ export type MessageRootProps = ThemedClassName<
49
+ ComponentPropsWithRef<'div'> & MessageMetadata & Partial<{ continues: boolean; controls: ReactNode }>
50
+ >;
51
+
52
+ // TODO(burdon): Show authorName on tooltip.
53
+ const MessageRoot = forwardRef<HTMLDivElement, MessageRootProps>(
24
54
  (
25
- { 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
+ },
26
66
  forwardedRef,
27
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.
28
71
  return (
29
- // Must wrap the message since Avatar.Label may be used in the content.
30
72
  <Avatar.Root>
31
73
  <div
32
- role='none'
33
74
  data-testid='thread.message'
34
75
  {...rootProps}
35
- 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)}
36
77
  ref={forwardedRef}
37
78
  >
38
- <div role='none' className='flex flex-col items-center gap-2 pbs-2'>
79
+ <div className='flex flex-col items-center gap-2 pt-1'>
39
80
  <Avatar.Content
40
81
  size={avatarSize}
41
82
  hue={authorAvatarProps?.hue || hexToHue(authorId ?? '0')}
42
83
  fallback={authorAvatarProps?.emoji || hexToEmoji(authorId ?? '0')}
43
84
  {...(authorImgSrc && { imgSrc: authorImgSrc })}
44
85
  />
45
- {continues && <div role='none' className='is-px grow bg-separator' />}
46
- </div>
47
- <div role='none' className='plb-1 min-is-0'>
48
- {children}
86
+ {continues && <div className='w-px grow bg-separator' />}
49
87
  </div>
88
+ <div className='py-1 min-w-0'>{children}</div>
89
+ {controls && <div className='self-start'>{controls}</div>}
50
90
  </div>
51
91
  </Avatar.Root>
52
92
  );
53
93
  },
54
94
  );
55
95
 
96
+ MessageRoot.displayName = 'Message.Root';
97
+
98
+ //
99
+ // Heading
100
+ //
101
+
56
102
  export type MessageHeadingProps = ThemedClassName<ComponentPropsWithoutRef<'div'>> &
57
103
  Pick<MessageMetadata, 'authorName' | 'timestamp'>;
58
104
 
59
- export const MessageHeading = ({ children, classNames, timestamp, authorName, ...props }: MessageHeadingProps) => {
105
+ const MessageHeading = ({ children, classNames, timestamp, authorName, ...props }: MessageHeadingProps) => {
60
106
  return (
61
- <div role='none' {...props} className={mx('flex gap-2 items-start', classNames)}>
107
+ <div {...props} className={mx('flex gap-2 items-start', classNames)}>
62
108
  <p className='grow'>
63
109
  <MessageAuthorName authorName={authorName} />
64
110
  {timestamp && <MessageTime timestamp={timestamp} />}
@@ -68,33 +114,130 @@ export const MessageHeading = ({ children, classNames, timestamp, authorName, ..
68
114
  );
69
115
  };
70
116
 
117
+ MessageHeading.displayName = 'Message.Heading';
118
+
71
119
  export type MessageAuthorNameProps = Pick<MessageMetadata, 'authorName'>;
72
120
 
73
- export const MessageAuthorName = ({ authorName }: MessageAuthorNameProps) => {
121
+ const MessageAuthorName = ({ authorName }: MessageAuthorNameProps) => {
74
122
  const { t } = useTranslation(translationKey);
75
123
  return (
76
- <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>
77
125
  );
78
126
  };
79
127
 
128
+ MessageAuthorName.displayName = 'Message.AuthorName';
129
+
80
130
  export type MessageTimeProps = Pick<MessageMetadata, 'timestamp'>;
81
131
 
82
- export const MessageTime = ({ timestamp }: MessageTimeProps) => {
132
+ const MessageTime = ({ timestamp }: MessageTimeProps) => {
83
133
  const { dtLocale } = useTranslation(translationKey);
84
134
  const dt = timestamp ? new Date(timestamp) : undefined;
85
135
  return (
86
- <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()}>
87
137
  {dt ? formatDistanceToNow(dt, { locale: dtLocale, addSuffix: true }) : ''}
88
138
  </time>
89
139
  );
90
140
  };
91
141
 
92
- 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);
93
210
 
94
- export const MessageBody = ({ children, ...props }: MessageBodyProps) => {
95
- 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} />;
96
235
  };
97
236
 
237
+ //
238
+ // Textbox
239
+ //
240
+
98
241
  export type MessageTextboxProps = {
99
242
  disabled?: boolean;
100
243
  onSend?: () => void;
@@ -110,65 +253,180 @@ const keyBindings = ({ onSend, onClear }: Pick<MessageTextboxProps, 'onSend' | '
110
253
  if (onSend) {
111
254
  onSend();
112
255
  return true;
113
- } else {
114
- return false;
115
256
  }
257
+ return false;
116
258
  },
117
259
  },
118
260
  {
119
- // TODO(burdon): Other key bindings.
120
261
  key: 'Meta+Backspace',
121
262
  run: () => {
122
263
  if (onClear) {
123
264
  onClear();
124
265
  return true;
125
- } else {
126
- return false;
127
266
  }
267
+ return false;
128
268
  },
129
269
  },
130
270
  ];
131
271
 
132
- export const MessageTextbox = ({
133
- id,
134
- authorId,
135
- authorName,
136
- authorImgSrc,
137
- authorAvatarProps,
138
- disabled,
139
- extensions,
140
- onSend,
141
- onClear,
142
- onEditorFocus,
143
- ...editorProps
144
- }: MessageTextboxProps) => {
145
- const { parentRef, focusAttributes } = useTextEditor(
146
- () => ({
272
+ export type MessageTextboxHandle = { focus: () => void };
273
+
274
+ const MessageTextbox = forwardRef<MessageTextboxHandle, MessageTextboxProps>(
275
+ (
276
+ {
147
277
  id,
148
- extensions: [
149
- keymap.of(keyBindings({ onSend, onClear })),
150
- listener({
151
- onFocus: (focusing) => {
152
- if (focusing) {
153
- onEditorFocus?.();
154
- }
155
- },
156
- }),
157
- extensions,
158
- ].filter(isNotFalsy),
159
- ...editorProps,
160
- }),
161
- [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],
162
362
  );
163
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
+
164
406
  return (
165
- <MessageRoot {...{ id, authorId, authorName, authorImgSrc, authorAvatarProps }} continues={false}>
166
- <div
167
- role='none'
168
- ref={parentRef}
169
- className={mx('plb-0.5 mie-1 rounded-sm', focusRing, disabled && 'opacity-50')}
170
- {...focusAttributes}
171
- />
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} />
172
414
  </MessageRoot>
173
415
  );
174
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
+ };