@dxos/react-ui-thread 0.10.0 → 0.11.1
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/dist/lib/index.mjs +738 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/translations.mjs +25 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/src/Message/Message.d.ts +19 -2
- package/dist/types/src/Message/Message.d.ts.map +1 -1
- package/dist/types/src/Message/Message.stories.d.ts +3 -0
- package/dist/types/src/Message/Message.stories.d.ts.map +1 -1
- package/dist/types/src/Message/index.d.ts +1 -1
- package/dist/types/src/Message/index.d.ts.map +1 -1
- package/dist/types/src/Thread/Thread.d.ts +20 -6
- package/dist/types/src/Thread/Thread.d.ts.map +1 -1
- package/dist/types/src/Thread/Thread.stories.d.ts +4 -0
- package/dist/types/src/Thread/Thread.stories.d.ts.map +1 -1
- package/dist/types/src/context.d.ts +1 -0
- package/dist/types/src/context.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +2 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +9 -1
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +24 -24
- package/src/Message/Message.stories.tsx +52 -0
- package/src/Message/Message.tsx +181 -9
- package/src/Message/index.ts +1 -0
- package/src/Thread/Thread.stories.tsx +78 -1
- package/src/Thread/Thread.tsx +173 -19
- package/src/index.ts +1 -0
- package/src/translations.ts +2 -0
- package/src/types.ts +9 -1
- package/dist/lib/browser/chunk-QFRRLZVL.mjs +0 -31
- package/dist/lib/browser/chunk-QFRRLZVL.mjs.map +0 -7
- package/dist/lib/browser/index.mjs +0 -554
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/translations.mjs +0 -9
- package/dist/lib/browser/translations.mjs.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-ui-thread",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
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",
|
|
@@ -19,13 +19,12 @@
|
|
|
19
19
|
".": {
|
|
20
20
|
"source": "./src/index.ts",
|
|
21
21
|
"types": "./dist/types/src/index.d.ts",
|
|
22
|
-
"
|
|
22
|
+
"import": "./dist/lib/index.mjs"
|
|
23
23
|
},
|
|
24
24
|
"./translations": {
|
|
25
25
|
"source": "./src/translations.ts",
|
|
26
26
|
"types": "./dist/types/src/translations.d.ts",
|
|
27
|
-
"
|
|
28
|
-
"node": "./dist/lib/node-esm/translations.mjs"
|
|
27
|
+
"import": "./dist/lib/translations.mjs"
|
|
29
28
|
}
|
|
30
29
|
},
|
|
31
30
|
"types": "dist/types/src/index.d.ts",
|
|
@@ -34,39 +33,40 @@
|
|
|
34
33
|
"src"
|
|
35
34
|
],
|
|
36
35
|
"dependencies": {
|
|
37
|
-
"@codemirror/language": "^6.12.
|
|
38
|
-
"@codemirror/state": "^6.
|
|
39
|
-
"@codemirror/view": "^6.43.
|
|
36
|
+
"@codemirror/language": "^6.12.4",
|
|
37
|
+
"@codemirror/state": "^6.7.1",
|
|
38
|
+
"@codemirror/view": "^6.43.6",
|
|
40
39
|
"@radix-ui/react-context": "1.1.1",
|
|
41
40
|
"@radix-ui/react-primitive": "2.0.2",
|
|
42
41
|
"date-fns": "^3.6.0",
|
|
43
|
-
"@dxos/react
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/react-ui-mosaic": "0.
|
|
47
|
-
"@dxos/types": "0.
|
|
48
|
-
"@dxos/ui-editor": "0.
|
|
49
|
-
"@dxos/util": "0.
|
|
42
|
+
"@dxos/echo-react": "0.11.1",
|
|
43
|
+
"@dxos/react-ui-dnd": "0.11.1",
|
|
44
|
+
"@dxos/react-ui-editor": "0.11.1",
|
|
45
|
+
"@dxos/react-ui-mosaic": "0.11.1",
|
|
46
|
+
"@dxos/types": "0.11.1",
|
|
47
|
+
"@dxos/ui-editor": "0.11.1",
|
|
48
|
+
"@dxos/util": "0.11.1",
|
|
49
|
+
"@dxos/echo": "0.11.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/react": "~19.2.17",
|
|
53
53
|
"@types/react-dom": "~19.2.3",
|
|
54
54
|
"react": "~19.2.7",
|
|
55
55
|
"react-dom": "~19.2.7",
|
|
56
|
-
"vite": "^8.
|
|
57
|
-
"@dxos/keys": "0.
|
|
58
|
-
"@dxos/log": "0.
|
|
59
|
-
"@dxos/
|
|
60
|
-
"@dxos/
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/storybook-utils": "0.
|
|
63
|
-
"@dxos/
|
|
56
|
+
"vite": "^8.1.4",
|
|
57
|
+
"@dxos/keys": "0.11.1",
|
|
58
|
+
"@dxos/log": "0.11.1",
|
|
59
|
+
"@dxos/random": "0.11.1",
|
|
60
|
+
"@dxos/react-ui": "0.11.1",
|
|
61
|
+
"@dxos/schema": "0.11.1",
|
|
62
|
+
"@dxos/storybook-utils": "0.11.1",
|
|
63
|
+
"@dxos/ui-theme": "0.11.1"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"react": "~19.2.7",
|
|
67
67
|
"react-dom": "~19.2.7",
|
|
68
|
-
"@dxos/react-ui": "0.
|
|
69
|
-
"@dxos/ui-theme": "0.
|
|
68
|
+
"@dxos/react-ui": "^0.11.1",
|
|
69
|
+
"@dxos/ui-theme": "^0.11.1"
|
|
70
70
|
},
|
|
71
71
|
"publishConfig": {
|
|
72
72
|
"access": "public"
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
6
|
import React, { useMemo } from 'react';
|
|
7
|
+
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
7
8
|
|
|
9
|
+
import { Obj } from '@dxos/echo';
|
|
8
10
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
|
+
import { Message as MessageType } from '@dxos/types';
|
|
9
12
|
|
|
10
13
|
import { translations } from '#translations';
|
|
11
14
|
|
|
@@ -56,3 +59,52 @@ export const NonEditable: Story = {
|
|
|
56
59
|
editable: false,
|
|
57
60
|
},
|
|
58
61
|
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A message carrying a `change` content-block — the reusable tile for a document suggestion (struck
|
|
65
|
+
* original → proposed text) with Accept/Reject controls, driven by `onAcceptChange`/`onRejectChange`.
|
|
66
|
+
*/
|
|
67
|
+
const onAcceptChange = fn();
|
|
68
|
+
const onRejectChange = fn();
|
|
69
|
+
|
|
70
|
+
const ChangeStory = (_args: StoryArgs) => {
|
|
71
|
+
const message = useMemo(
|
|
72
|
+
() =>
|
|
73
|
+
Obj.make(MessageType.Message, {
|
|
74
|
+
created: new Date().toISOString(),
|
|
75
|
+
sender: { role: 'user', identityDid: 'did:key:alice', name: 'Alice' },
|
|
76
|
+
blocks: [{ _tag: 'change', before: 'quick brown fox', after: 'nimble auburn fox' }],
|
|
77
|
+
}),
|
|
78
|
+
[],
|
|
79
|
+
);
|
|
80
|
+
return (
|
|
81
|
+
<Thread.Root
|
|
82
|
+
getMetadata={getStoryMetadata}
|
|
83
|
+
identityDid='did:key:alice'
|
|
84
|
+
onAcceptChange={onAcceptChange}
|
|
85
|
+
onRejectChange={onRejectChange}
|
|
86
|
+
>
|
|
87
|
+
<Message.Tile message={message} />
|
|
88
|
+
</Thread.Root>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const WithChange: Story = {
|
|
93
|
+
args: { editable: false },
|
|
94
|
+
render: ChangeStory,
|
|
95
|
+
play: async ({ canvasElement }) => {
|
|
96
|
+
const canvas = within(canvasElement);
|
|
97
|
+
onAcceptChange.mockClear();
|
|
98
|
+
onRejectChange.mockClear();
|
|
99
|
+
|
|
100
|
+
// The change block shows both the struck original and the proposed replacement.
|
|
101
|
+
await canvas.findByText('quick brown fox');
|
|
102
|
+
await canvas.findByText('nimble auburn fox');
|
|
103
|
+
|
|
104
|
+
// Accept then reject fire the respective callbacks.
|
|
105
|
+
await userEvent.click(await canvas.findByTestId('thread.message.accept-change'));
|
|
106
|
+
await waitFor(() => expect(onAcceptChange).toHaveBeenCalledTimes(1));
|
|
107
|
+
await userEvent.click(await canvas.findByTestId('thread.message.reject-change'));
|
|
108
|
+
await waitFor(() => expect(onRejectChange).toHaveBeenCalledTimes(1));
|
|
109
|
+
},
|
|
110
|
+
};
|
package/src/Message/Message.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { EditorView } from '@codemirror/view';
|
|
6
|
-
import {
|
|
6
|
+
import { format } from 'date-fns/format';
|
|
7
7
|
import React, {
|
|
8
8
|
type ComponentPropsWithoutRef,
|
|
9
9
|
type ComponentPropsWithRef,
|
|
@@ -105,7 +105,7 @@ export type MessageHeadingProps = ThemedClassName<ComponentPropsWithoutRef<'div'
|
|
|
105
105
|
const MessageHeading = ({ children, classNames, timestamp, authorName, ...props }: MessageHeadingProps) => {
|
|
106
106
|
return (
|
|
107
107
|
<div {...props} className={mx('flex gap-2 items-start', classNames)}>
|
|
108
|
-
<p className='grow'>
|
|
108
|
+
<p className='grow flex items-baseline gap-2 min-w-0'>
|
|
109
109
|
<MessageAuthorName authorName={authorName} />
|
|
110
110
|
{timestamp && <MessageTime timestamp={timestamp} />}
|
|
111
111
|
</p>
|
|
@@ -121,7 +121,9 @@ export type MessageAuthorNameProps = Pick<MessageMetadata, 'authorName'>;
|
|
|
121
121
|
const MessageAuthorName = ({ authorName }: MessageAuthorNameProps) => {
|
|
122
122
|
const { t } = useTranslation(translationKey);
|
|
123
123
|
return (
|
|
124
|
-
<Avatar.Label classNames='block truncate text-sm text-subdued'>
|
|
124
|
+
<Avatar.Label classNames='block truncate min-w-0 shrink text-sm text-subdued'>
|
|
125
|
+
{authorName ?? t('anonymous.label')}
|
|
126
|
+
</Avatar.Label>
|
|
125
127
|
);
|
|
126
128
|
};
|
|
127
129
|
|
|
@@ -133,8 +135,8 @@ const MessageTime = ({ timestamp }: MessageTimeProps) => {
|
|
|
133
135
|
const { dtLocale } = useTranslation(translationKey);
|
|
134
136
|
const dt = timestamp ? new Date(timestamp) : undefined;
|
|
135
137
|
return (
|
|
136
|
-
<time className='
|
|
137
|
-
{dt ?
|
|
138
|
+
<time className='shrink-0 text-subdued text-xs' dateTime={dt?.toISOString()}>
|
|
139
|
+
{dt ? format(dt, 'p', { locale: dtLocale }) : ''}
|
|
138
140
|
</time>
|
|
139
141
|
);
|
|
140
142
|
};
|
|
@@ -165,6 +167,7 @@ const MessageBody = ({ message, isAuthor, editing, onSave }: MessageBodyProps) =
|
|
|
165
167
|
const textBlockIndex = message.blocks.findIndex((block) => block._tag === 'text');
|
|
166
168
|
const textBlock = textBlockIndex !== -1 ? (message.blocks[textBlockIndex] as ContentBlock.Text) : undefined;
|
|
167
169
|
const proposalBlock = message.blocks.find((block) => block._tag === 'proposal') as ContentBlock.Proposal | undefined;
|
|
170
|
+
const changeBlock = message.blocks.find((block) => block._tag === 'change') as ContentBlock.Change | undefined;
|
|
168
171
|
const references = message.blocks
|
|
169
172
|
.filter((block) => block._tag === 'reference')
|
|
170
173
|
.map((block) => (block as ContentBlock.Reference).reference);
|
|
@@ -174,6 +177,13 @@ const MessageBody = ({ message, isAuthor, editing, onSave }: MessageBodyProps) =
|
|
|
174
177
|
<>
|
|
175
178
|
{textBlock && <TextBlock block={textBlock} isAuthor={isAuthor} editing={editing} onSave={onSave} />}
|
|
176
179
|
{proposalBlock && <div className='me-4 italic'>{proposalBlock.text}</div>}
|
|
180
|
+
{changeBlock && (
|
|
181
|
+
<p className='me-4 text-sm break-words'>
|
|
182
|
+
{changeBlock.before && <span className='line-through opacity-60'>{changeBlock.before}</span>}
|
|
183
|
+
{changeBlock.before && changeBlock.after && ' '}
|
|
184
|
+
{changeBlock.after && <span>{changeBlock.after}</span>}
|
|
185
|
+
</p>
|
|
186
|
+
)}
|
|
177
187
|
{Object &&
|
|
178
188
|
Ref.Array.targets(references).map((reference, index) => (
|
|
179
189
|
<Object key={index} subject={reference as Obj.Unknown} />
|
|
@@ -330,6 +340,8 @@ MessageTextbox.displayName = 'Message.Textbox';
|
|
|
330
340
|
export type MessageTileProps = {
|
|
331
341
|
message: MessageType.Message;
|
|
332
342
|
classNames?: MessageRootProps['classNames'];
|
|
343
|
+
/** Whether the avatar-rail continuation line is drawn below the tile; false for the last tile. */
|
|
344
|
+
continues?: boolean;
|
|
333
345
|
};
|
|
334
346
|
|
|
335
347
|
/**
|
|
@@ -337,18 +349,32 @@ export type MessageTileProps = {
|
|
|
337
349
|
* context for metadata resolution, injected renderers, and callbacks. This is
|
|
338
350
|
* the unit rendered by `Thread.Messages`.
|
|
339
351
|
*/
|
|
340
|
-
const MessageTile = ({ message, classNames }: MessageTileProps) => {
|
|
352
|
+
const MessageTile = ({ message, classNames, continues = true }: MessageTileProps) => {
|
|
341
353
|
const { t } = useTranslation(translationKey);
|
|
342
|
-
const {
|
|
354
|
+
const {
|
|
355
|
+
getMetadata,
|
|
356
|
+
identityDid,
|
|
357
|
+
editable,
|
|
358
|
+
onMessageDelete,
|
|
359
|
+
onAcceptProposal,
|
|
360
|
+
onAcceptChange,
|
|
361
|
+
onRejectChange,
|
|
362
|
+
onMessageSelect,
|
|
363
|
+
currentMessageId,
|
|
364
|
+
} = useThreadContext('Message.Tile');
|
|
343
365
|
const [editing, setEditing] = useState(false);
|
|
344
366
|
|
|
345
367
|
const metadata = getMetadata(message);
|
|
346
368
|
const isAuthor = !!identityDid && identityDid === metadata.authorId;
|
|
347
369
|
const hasProposal = message.blocks.some((block) => block._tag === 'proposal');
|
|
370
|
+
const hasChange = message.blocks.some((block) => block._tag === 'change');
|
|
348
371
|
|
|
349
372
|
const handleEdit = useCallback(() => setEditing((value) => !value), []);
|
|
350
373
|
const handleDelete = useCallback(() => onMessageDelete?.(message.id), [onMessageDelete, message.id]);
|
|
351
374
|
const handleAcceptProposal = useCallback(() => onAcceptProposal?.(message.id), [onAcceptProposal, message.id]);
|
|
375
|
+
const handleAcceptChange = useCallback(() => onAcceptChange?.(message.id), [onAcceptChange, message.id]);
|
|
376
|
+
const handleRejectChange = useCallback(() => onRejectChange?.(message.id), [onRejectChange, message.id]);
|
|
377
|
+
const handleSelect = useCallback(() => onMessageSelect?.(message.id), [onMessageSelect, message.id]);
|
|
352
378
|
const handleSave = useCallback(
|
|
353
379
|
(text: string) => {
|
|
354
380
|
Obj.update(message, (message) => {
|
|
@@ -363,9 +389,11 @@ const MessageTile = ({ message, classNames }: MessageTileProps) => {
|
|
|
363
389
|
|
|
364
390
|
const showEdit = isAuthor && editable;
|
|
365
391
|
const showAccept = hasProposal && !!onAcceptProposal;
|
|
392
|
+
const showAcceptChange = hasChange && !!onAcceptChange;
|
|
393
|
+
const showRejectChange = hasChange && !!onRejectChange;
|
|
366
394
|
const showDelete = !!onMessageDelete;
|
|
367
395
|
const controls =
|
|
368
|
-
showEdit || showAccept || showDelete ? (
|
|
396
|
+
showEdit || showAccept || showAcceptChange || showRejectChange || showDelete ? (
|
|
369
397
|
<div className={buttonGroupClassNames}>
|
|
370
398
|
{showEdit && (
|
|
371
399
|
<IconButton
|
|
@@ -389,6 +417,28 @@ const MessageTile = ({ message, classNames }: MessageTileProps) => {
|
|
|
389
417
|
onClick={handleAcceptProposal}
|
|
390
418
|
/>
|
|
391
419
|
)}
|
|
420
|
+
{showAcceptChange && (
|
|
421
|
+
<IconButton
|
|
422
|
+
data-testid='thread.message.accept-change'
|
|
423
|
+
variant='ghost'
|
|
424
|
+
icon='ph--check--regular'
|
|
425
|
+
iconOnly
|
|
426
|
+
label={t('accept-change.label')}
|
|
427
|
+
classNames={[buttonClassNames, hoverableControlItem]}
|
|
428
|
+
onClick={handleAcceptChange}
|
|
429
|
+
/>
|
|
430
|
+
)}
|
|
431
|
+
{showRejectChange && (
|
|
432
|
+
<IconButton
|
|
433
|
+
data-testid='thread.message.reject-change'
|
|
434
|
+
variant='ghost'
|
|
435
|
+
icon='ph--x--regular'
|
|
436
|
+
iconOnly
|
|
437
|
+
label={t('reject-change.label')}
|
|
438
|
+
classNames={[buttonClassNames, hoverableControlItem]}
|
|
439
|
+
onClick={handleRejectChange}
|
|
440
|
+
/>
|
|
441
|
+
)}
|
|
392
442
|
{showDelete && (
|
|
393
443
|
<IconButton
|
|
394
444
|
data-testid='thread.message.delete'
|
|
@@ -406,8 +456,19 @@ const MessageTile = ({ message, classNames }: MessageTileProps) => {
|
|
|
406
456
|
return (
|
|
407
457
|
<MessageRoot
|
|
408
458
|
{...metadata}
|
|
459
|
+
continues={continues}
|
|
409
460
|
controls={controls}
|
|
410
|
-
|
|
461
|
+
// Selecting a tile is how the host reveals what it refers to (a suggestion's range in the
|
|
462
|
+
// document), so the whole tile is the target — the accent marks which one is showing.
|
|
463
|
+
onClick={onMessageSelect ? handleSelect : undefined}
|
|
464
|
+
aria-current={currentMessageId === message.id ? 'location' : undefined}
|
|
465
|
+
classNames={[
|
|
466
|
+
hoverableControls,
|
|
467
|
+
hoverableFocusedWithinControls,
|
|
468
|
+
onMessageSelect && 'cursor-pointer',
|
|
469
|
+
currentMessageId === message.id && 'bg-current-surface',
|
|
470
|
+
classNames,
|
|
471
|
+
]}
|
|
411
472
|
>
|
|
412
473
|
<MessageHeading authorName={metadata.authorName} timestamp={metadata.timestamp} />
|
|
413
474
|
<MessageBody message={message} isAuthor={isAuthor} editing={editing} onSave={handleSave} />
|
|
@@ -417,6 +478,116 @@ const MessageTile = ({ message, classNames }: MessageTileProps) => {
|
|
|
417
478
|
|
|
418
479
|
MessageTile.displayName = 'Message.Tile';
|
|
419
480
|
|
|
481
|
+
//
|
|
482
|
+
// Group
|
|
483
|
+
//
|
|
484
|
+
|
|
485
|
+
export type MessageGroupProps = {
|
|
486
|
+
/** Consecutive same-sender messages, in ascending time order, rendered as one tile. */
|
|
487
|
+
messages: readonly MessageType.Message[];
|
|
488
|
+
/** Whether the avatar-rail continuation line is drawn below the tile; false for the last tile. */
|
|
489
|
+
continues?: boolean;
|
|
490
|
+
classNames?: MessageRootProps['classNames'];
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Groups consecutive same-sender messages (see `Thread.Messages`'s grouping
|
|
495
|
+
* window) into a single tile: one heading (author + first message's
|
|
496
|
+
* timestamp) followed by one body per message, stacked in order. Per-message
|
|
497
|
+
* edit/delete controls are not shown per-row in a group — v1 shows them for
|
|
498
|
+
* the group's first message only, since the heading/controls layout is keyed
|
|
499
|
+
* to a single message.
|
|
500
|
+
*/
|
|
501
|
+
const MessageGroup = ({ messages, continues = true, classNames }: MessageGroupProps) => {
|
|
502
|
+
const { t } = useTranslation(translationKey);
|
|
503
|
+
const { getMetadata, identityDid, editable, onMessageDelete, onAcceptProposal } = useThreadContext('Message.Group');
|
|
504
|
+
const [editing, setEditing] = useState(false);
|
|
505
|
+
|
|
506
|
+
const first = messages[0];
|
|
507
|
+
const metadata = getMetadata(first);
|
|
508
|
+
const isAuthor = !!identityDid && identityDid === metadata.authorId;
|
|
509
|
+
const hasProposal = first.blocks.some((block) => block._tag === 'proposal');
|
|
510
|
+
|
|
511
|
+
const handleEdit = useCallback(() => setEditing((value) => !value), []);
|
|
512
|
+
const handleDelete = useCallback(() => onMessageDelete?.(first.id), [onMessageDelete, first.id]);
|
|
513
|
+
const handleAcceptProposal = useCallback(() => onAcceptProposal?.(first.id), [onAcceptProposal, first.id]);
|
|
514
|
+
const handleSave = useCallback(
|
|
515
|
+
(text: string) => {
|
|
516
|
+
Obj.update(first, (first) => {
|
|
517
|
+
const block = first.blocks.find((block) => block._tag === 'text');
|
|
518
|
+
if (block && block._tag === 'text') {
|
|
519
|
+
block.text = text;
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
},
|
|
523
|
+
[first],
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const showEdit = isAuthor && editable;
|
|
527
|
+
const showAccept = hasProposal && !!onAcceptProposal;
|
|
528
|
+
const showDelete = !!onMessageDelete;
|
|
529
|
+
const controls =
|
|
530
|
+
showEdit || showAccept || showDelete ? (
|
|
531
|
+
<div className={buttonGroupClassNames}>
|
|
532
|
+
{showEdit && (
|
|
533
|
+
<IconButton
|
|
534
|
+
data-testid={editing ? 'thread.message.save' : 'thread.message.edit'}
|
|
535
|
+
variant='ghost'
|
|
536
|
+
icon={editing ? 'ph--check--regular' : 'ph--pencil-simple--regular'}
|
|
537
|
+
iconOnly
|
|
538
|
+
label={t(editing ? 'save-message.label' : 'edit-message.label')}
|
|
539
|
+
classNames={[buttonClassNames, hoverableControlItem]}
|
|
540
|
+
onClick={handleEdit}
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
543
|
+
{showAccept && (
|
|
544
|
+
<IconButton
|
|
545
|
+
data-testid='thread.message.accept'
|
|
546
|
+
variant='ghost'
|
|
547
|
+
icon='ph--check--regular'
|
|
548
|
+
iconOnly
|
|
549
|
+
label={t('accept-proposal.label')}
|
|
550
|
+
classNames={[buttonClassNames, hoverableControlItem]}
|
|
551
|
+
onClick={handleAcceptProposal}
|
|
552
|
+
/>
|
|
553
|
+
)}
|
|
554
|
+
{showDelete && (
|
|
555
|
+
<IconButton
|
|
556
|
+
data-testid='thread.message.delete'
|
|
557
|
+
variant='ghost'
|
|
558
|
+
icon='ph--x--regular'
|
|
559
|
+
iconOnly
|
|
560
|
+
label={t('delete-message.label')}
|
|
561
|
+
classNames={[buttonClassNames, hoverableControlItem]}
|
|
562
|
+
onClick={handleDelete}
|
|
563
|
+
/>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
) : undefined;
|
|
567
|
+
|
|
568
|
+
return (
|
|
569
|
+
<MessageRoot
|
|
570
|
+
{...metadata}
|
|
571
|
+
continues={continues}
|
|
572
|
+
controls={controls}
|
|
573
|
+
classNames={[hoverableControls, hoverableFocusedWithinControls, classNames]}
|
|
574
|
+
>
|
|
575
|
+
<MessageHeading authorName={metadata.authorName} timestamp={metadata.timestamp} />
|
|
576
|
+
{messages.map((message) => (
|
|
577
|
+
<MessageBody
|
|
578
|
+
key={message.id}
|
|
579
|
+
message={message}
|
|
580
|
+
isAuthor={isAuthor}
|
|
581
|
+
editing={editing && message === first}
|
|
582
|
+
onSave={handleSave}
|
|
583
|
+
/>
|
|
584
|
+
))}
|
|
585
|
+
</MessageRoot>
|
|
586
|
+
);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
MessageGroup.displayName = 'Message.Group';
|
|
590
|
+
|
|
420
591
|
//
|
|
421
592
|
// Message
|
|
422
593
|
//
|
|
@@ -429,4 +600,5 @@ export const Message = {
|
|
|
429
600
|
Body: MessageBody,
|
|
430
601
|
Textbox: MessageTextbox,
|
|
431
602
|
Tile: MessageTile,
|
|
603
|
+
Group: MessageGroup,
|
|
432
604
|
};
|
package/src/Message/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
6
|
import React, { useState } from 'react';
|
|
7
7
|
|
|
8
|
+
import { withMosaic } from '@dxos/react-ui-mosaic/testing';
|
|
8
9
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
9
10
|
import { Message as MessageType } from '@dxos/types';
|
|
10
11
|
|
|
@@ -34,10 +35,78 @@ const DefaultStory = () => {
|
|
|
34
35
|
);
|
|
35
36
|
};
|
|
36
37
|
|
|
38
|
+
// Reproduces a report that the local identity's own sent messages don't
|
|
39
|
+
// appear: renders one message from each of three sender shapes — the local
|
|
40
|
+
// identity (matches `Thread.Root`'s `identityDid`), a name-only sender with
|
|
41
|
+
// no `identityDid` (e.g. an externally-synced guest, like freeq before
|
|
42
|
+
// `echo-message`), and a different identity — to check whether the render
|
|
43
|
+
// path itself drops any of them.
|
|
44
|
+
const MixedSendersStory = () => {
|
|
45
|
+
const messages = [
|
|
46
|
+
MessageType.make({
|
|
47
|
+
sender: { role: 'user', identityDid: 'did:key:alice', name: 'Alice' },
|
|
48
|
+
blocks: [{ _tag: 'text', text: 'Message from the local identity (Alice).' }],
|
|
49
|
+
}),
|
|
50
|
+
MessageType.make({
|
|
51
|
+
sender: { name: 'guest' },
|
|
52
|
+
blocks: [{ _tag: 'text', text: 'Message from a name-only sender (guest), no identityDid.' }],
|
|
53
|
+
}),
|
|
54
|
+
MessageType.make({
|
|
55
|
+
sender: { role: 'user', identityDid: 'did:key:bob', name: 'Bob' },
|
|
56
|
+
blocks: [{ _tag: 'text', text: 'Message from a different identity (Bob).' }],
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Thread.Root getMetadata={getStoryMetadata} identityDid={IDENTITY.identityDid} editable={false}>
|
|
62
|
+
<Thread.Content classNames='grow min-h-0'>
|
|
63
|
+
<Thread.Messages messages={messages} />
|
|
64
|
+
</Thread.Content>
|
|
65
|
+
</Thread.Root>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Exercises message grouping (consecutive same-sender messages within the
|
|
70
|
+
// default 60s window merge into one tile) and both divider kinds: a labeled
|
|
71
|
+
// day divider before each calendar day's first message, and a plain (gap)
|
|
72
|
+
// divider for a same-day silence over the default 3h threshold.
|
|
73
|
+
const GroupedStory = () => {
|
|
74
|
+
const day1 = new Date('2026-07-01T09:00:00.000Z').getTime();
|
|
75
|
+
const day2 = new Date('2026-07-02T09:00:00.000Z').getTime();
|
|
76
|
+
const alice = { role: 'user' as const, identityDid: 'did:key:alice', name: 'Alice' };
|
|
77
|
+
const bob = { role: 'user' as const, identityDid: 'did:key:bob', name: 'Bob' };
|
|
78
|
+
|
|
79
|
+
const at = (time: number, sender: typeof alice | typeof bob, text: string) =>
|
|
80
|
+
MessageType.make({ created: new Date(time).toISOString(), sender, blocks: [{ _tag: 'text', text }] });
|
|
81
|
+
|
|
82
|
+
const messages = [
|
|
83
|
+
// Same-sender burst within the 60s grouping window: one group, three bodies.
|
|
84
|
+
at(day1, alice, 'First message in a burst.'),
|
|
85
|
+
at(day1 + 10_000, alice, 'Second message, 10s later — same group.'),
|
|
86
|
+
at(day1 + 40_000, alice, 'Third message, 40s after the first — still same group.'),
|
|
87
|
+
// >60s gap, same sender: starts a new group (no divider — gap is well under 3h).
|
|
88
|
+
at(day1 + 120_000, alice, 'New group: 2 minutes after the burst.'),
|
|
89
|
+
// >3h gap, same day: plain (unlabeled) divider, new group.
|
|
90
|
+
at(day1 + 120_000 + 4 * 60 * 60 * 1000, alice, 'After a 4-hour silence — gap divider above.'),
|
|
91
|
+
// Different sender: always starts a new group even within the window.
|
|
92
|
+
at(day1 + 120_000 + 4 * 60 * 60 * 1000 + 5_000, bob, 'Bob replies 5s later — different sender, new group.'),
|
|
93
|
+
// Next calendar day: labeled day divider, new group (day boundary wins over gap boundary).
|
|
94
|
+
at(day2, bob, 'A new day.'),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Thread.Root getMetadata={getStoryMetadata} identityDid={IDENTITY.identityDid} editable={false}>
|
|
99
|
+
<Thread.Content classNames='grow min-h-0'>
|
|
100
|
+
<Thread.Messages messages={messages} />
|
|
101
|
+
</Thread.Content>
|
|
102
|
+
</Thread.Root>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
37
106
|
const meta = {
|
|
38
107
|
title: 'ui/react-ui-thread/Thread',
|
|
39
108
|
render: DefaultStory,
|
|
40
|
-
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
109
|
+
decorators: [withTheme(), withLayout({ layout: 'column' }), withMosaic()],
|
|
41
110
|
parameters: {
|
|
42
111
|
layout: 'fullscreen',
|
|
43
112
|
translations,
|
|
@@ -49,3 +118,11 @@ export default meta;
|
|
|
49
118
|
type Story = StoryObj<typeof meta>;
|
|
50
119
|
|
|
51
120
|
export const Default: Story = {};
|
|
121
|
+
|
|
122
|
+
export const MixedSenders: Story = {
|
|
123
|
+
render: MixedSendersStory,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const Grouped: Story = {
|
|
127
|
+
render: GroupedStory,
|
|
128
|
+
};
|