@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.
Files changed (39) hide show
  1. package/dist/lib/index.mjs +738 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +25 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/types/src/Message/Message.d.ts +19 -2
  6. package/dist/types/src/Message/Message.d.ts.map +1 -1
  7. package/dist/types/src/Message/Message.stories.d.ts +3 -0
  8. package/dist/types/src/Message/Message.stories.d.ts.map +1 -1
  9. package/dist/types/src/Message/index.d.ts +1 -1
  10. package/dist/types/src/Message/index.d.ts.map +1 -1
  11. package/dist/types/src/Thread/Thread.d.ts +20 -6
  12. package/dist/types/src/Thread/Thread.d.ts.map +1 -1
  13. package/dist/types/src/Thread/Thread.stories.d.ts +4 -0
  14. package/dist/types/src/Thread/Thread.stories.d.ts.map +1 -1
  15. package/dist/types/src/context.d.ts +1 -0
  16. package/dist/types/src/context.d.ts.map +1 -1
  17. package/dist/types/src/index.d.ts +1 -1
  18. package/dist/types/src/index.d.ts.map +1 -1
  19. package/dist/types/src/translations.d.ts +2 -0
  20. package/dist/types/src/translations.d.ts.map +1 -1
  21. package/dist/types/src/types.d.ts +9 -1
  22. package/dist/types/src/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +24 -24
  25. package/src/Message/Message.stories.tsx +52 -0
  26. package/src/Message/Message.tsx +181 -9
  27. package/src/Message/index.ts +1 -0
  28. package/src/Thread/Thread.stories.tsx +78 -1
  29. package/src/Thread/Thread.tsx +173 -19
  30. package/src/index.ts +1 -0
  31. package/src/translations.ts +2 -0
  32. package/src/types.ts +9 -1
  33. package/dist/lib/browser/chunk-QFRRLZVL.mjs +0 -31
  34. package/dist/lib/browser/chunk-QFRRLZVL.mjs.map +0 -7
  35. package/dist/lib/browser/index.mjs +0 -554
  36. package/dist/lib/browser/index.mjs.map +0 -7
  37. package/dist/lib/browser/meta.json +0 -1
  38. package/dist/lib/browser/translations.mjs +0 -9
  39. 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.10.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
- "browser": "./dist/lib/browser/index.mjs"
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
- "browser": "./dist/lib/browser/translations.mjs",
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.3",
38
- "@codemirror/state": "^6.6.0",
39
- "@codemirror/view": "^6.43.0",
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-ui-editor": "0.10.0",
44
- "@dxos/echo-react": "0.10.0",
45
- "@dxos/echo": "0.10.0",
46
- "@dxos/react-ui-mosaic": "0.10.0",
47
- "@dxos/types": "0.10.0",
48
- "@dxos/ui-editor": "0.10.0",
49
- "@dxos/util": "0.10.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.0.16",
57
- "@dxos/keys": "0.10.0",
58
- "@dxos/log": "0.10.0",
59
- "@dxos/react-ui": "0.10.0",
60
- "@dxos/schema": "0.10.0",
61
- "@dxos/ui-theme": "0.10.0",
62
- "@dxos/storybook-utils": "0.10.0",
63
- "@dxos/random": "0.10.0"
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.10.0",
69
- "@dxos/ui-theme": "0.10.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
+ };
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import { EditorView } from '@codemirror/view';
6
- import { formatDistanceToNow } from 'date-fns/formatDistanceToNow';
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'>{authorName ?? t('anonymous.label')}</Avatar.Label>
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='block text-subdued text-xs pb-0.5' dateTime={dt?.toISOString()}>
137
- {dt ? formatDistanceToNow(dt, { locale: dtLocale, addSuffix: true }) : ''}
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 { getMetadata, identityDid, editable, onMessageDelete, onAcceptProposal } = useThreadContext('Message.Tile');
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
- classNames={[hoverableControls, hoverableFocusedWithinControls, classNames]}
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
  };
@@ -6,6 +6,7 @@ export { Message } from './Message';
6
6
  export type {
7
7
  MessageAuthorNameProps,
8
8
  MessageBodyProps,
9
+ MessageGroupProps,
9
10
  MessageHeadingProps,
10
11
  MessageRootProps,
11
12
  MessageTextboxProps,
@@ -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
+ };