@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
@@ -2,6 +2,9 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { format } from 'date-fns/format';
6
+ import { type Locale } from 'date-fns/locale';
7
+ import { startOfDay } from 'date-fns/startOfDay';
5
8
  import React, {
6
9
  type ComponentPropsWithRef,
7
10
  type PropsWithChildren,
@@ -9,6 +12,7 @@ import React, {
9
12
  forwardRef,
10
13
  useCallback,
11
14
  useEffect,
15
+ useId,
12
16
  useMemo,
13
17
  useRef,
14
18
  useState,
@@ -26,6 +30,7 @@ import {
26
30
  useThemeContext,
27
31
  useTranslation,
28
32
  } from '@dxos/react-ui';
33
+ import { type DndContainerHandler } from '@dxos/react-ui-dnd';
29
34
  import { Mosaic, type MosaicTileProps } from '@dxos/react-ui-mosaic';
30
35
  import { type Message as MessageType } from '@dxos/types';
31
36
  import { type Extension, createBasicExtensions, createThemeExtensions, listener } from '@dxos/ui-editor';
@@ -49,9 +54,9 @@ export type ThreadRootProps = PropsWithChildren<
49
54
 
50
55
  /**
51
56
  * Headless root of a thread. Provides message-tile context (metadata resolver,
52
- * injected renderers, callbacks) and the Mosaic root that `Thread.Messages`
53
- * renders its virtual stack within. Renders no DOM of its own — wrap the visible
54
- * thread chrome in `Thread.Content`.
57
+ * injected renderers, callbacks). Requires an ambient `Dnd.Root` ancestor,
58
+ * within which `Thread.Messages` renders its virtual stack. Renders no DOM of
59
+ * its own — wrap the visible thread chrome in `Thread.Content`.
55
60
  */
56
61
  const ThreadRoot = ({
57
62
  children,
@@ -61,6 +66,10 @@ const ThreadRoot = ({
61
66
  editable,
62
67
  onMessageDelete,
63
68
  onAcceptProposal,
69
+ onAcceptChange,
70
+ onRejectChange,
71
+ onMessageSelect,
72
+ currentMessageId,
64
73
  }: ThreadRootProps) => {
65
74
  // Composer focus handler registered by Thread.Textbox; invoked by Thread.Header's caret.
66
75
  const composerFocus = useRef<(() => void) | undefined>(undefined);
@@ -81,8 +90,12 @@ const ThreadRoot = ({
81
90
  focusComposer={focusComposer}
82
91
  onMessageDelete={onMessageDelete}
83
92
  onAcceptProposal={onAcceptProposal}
93
+ onAcceptChange={onAcceptChange}
94
+ onRejectChange={onRejectChange}
95
+ onMessageSelect={onMessageSelect}
96
+ currentMessageId={currentMessageId}
84
97
  >
85
- <Mosaic.Root>{children}</Mosaic.Root>
98
+ {children}
86
99
  </ThreadContextProvider>
87
100
  );
88
101
  };
@@ -205,30 +218,171 @@ ThreadHeader.displayName = 'Thread.Header';
205
218
  // Messages
206
219
  //
207
220
 
208
- const MessageTileAdapter = ({
209
- id,
210
- data,
211
- location,
212
- draggable,
213
- current,
214
- selected,
215
- }: MosaicTileProps<MessageType.Message>) => (
221
+ /** A run of consecutive same-sender messages (within the grouping window) rendered as one tile. */
222
+ type MessageGroupItem = { kind: 'group'; id: string; messages: MessageType.Message[]; last?: boolean };
223
+
224
+ /**
225
+ * A divider row between groups: day-labeled (calendar-day boundary) or plain
226
+ * (a same-day gap exceeding `gapDividerMs`). Day boundaries take priority
227
+ * when both coincide on the same message — see `groupMessages`.
228
+ */
229
+ type DividerItem = { kind: 'divider'; id: string; label?: string };
230
+
231
+ type ThreadItem = MessageGroupItem | DividerItem;
232
+
233
+ const getThreadItemId = (item: ThreadItem) => item.id;
234
+
235
+ /** Stable per-sender key: prefers the DID, then the (deprecated) identity key, then name/email, else 'anon'. */
236
+ const senderKey = (message: MessageType.Message): string =>
237
+ message.sender.identityDid ?? message.sender.identityKey ?? message.sender.name ?? message.sender.email ?? 'anon';
238
+
239
+ /** Message send time in epoch ms; malformed/missing `created` sorts as if sent at the epoch. */
240
+ const messageTime = (message: MessageType.Message): number => {
241
+ const time = Date.parse(message.created);
242
+ return Number.isFinite(time) ? time : 0;
243
+ };
244
+
245
+ /**
246
+ * Preprocesses ascending, time-ordered `messages` into a flat list of divider
247
+ * and group items for `Thread.Messages`'s virtual stack:
248
+ * - A day divider (labeled with the calendar date) precedes the first message
249
+ * of each calendar day.
250
+ * - A plain divider (no label) marks a same-day gap exceeding `gapDividerMs`.
251
+ * - When both would fall on the same message, only the day divider is emitted.
252
+ * - Consecutive messages from the same sender within `groupWindowMs` of the
253
+ * previous message in the group are merged into one `MessageGroupItem`; a
254
+ * divider always starts a new group.
255
+ */
256
+ const groupMessages = (
257
+ messages: readonly MessageType.Message[],
258
+ {
259
+ groupWindowMs,
260
+ dayDivider,
261
+ gapDividerMs,
262
+ dtLocale,
263
+ }: { groupWindowMs: number; dayDivider: boolean; gapDividerMs: number; dtLocale?: Locale },
264
+ ): ThreadItem[] => {
265
+ const items: ThreadItem[] = [];
266
+ let currentGroup: MessageGroupItem | undefined;
267
+ // Tracks the last message appended to `currentGroup`, since `Array.prototype.at(-1)`
268
+ // on `currentGroup.messages` would need a non-null assertion despite `currentGroup`
269
+ // always being non-empty by construction.
270
+ let lastGroupMessage: MessageType.Message | undefined;
271
+ let prevTime: number | undefined;
272
+ let prevDay: number | undefined;
273
+
274
+ for (const message of messages) {
275
+ const time = messageTime(message);
276
+ const day = startOfDay(time).getTime();
277
+
278
+ const dayBoundary = dayDivider && (prevDay === undefined || day !== prevDay);
279
+ const gapBoundary = !dayBoundary && prevTime !== undefined && time - prevTime > gapDividerMs;
280
+
281
+ if (dayBoundary) {
282
+ items.push({
283
+ kind: 'divider',
284
+ id: `divider:day:${day}`,
285
+ label: format(day, 'EEEE, MMMM d', { locale: dtLocale }),
286
+ });
287
+ currentGroup = undefined;
288
+ } else if (gapBoundary) {
289
+ items.push({ kind: 'divider', id: `divider:gap:${time}` });
290
+ currentGroup = undefined;
291
+ }
292
+
293
+ const sameSenderAsGroup = currentGroup && lastGroupMessage && senderKey(lastGroupMessage) === senderKey(message);
294
+ const withinGroupWindow = currentGroup && lastGroupMessage && time - messageTime(lastGroupMessage) <= groupWindowMs;
295
+
296
+ if (currentGroup && sameSenderAsGroup && withinGroupWindow) {
297
+ currentGroup.messages.push(message);
298
+ } else {
299
+ currentGroup = { kind: 'group', id: getMessageId(message), messages: [message] };
300
+ items.push(currentGroup);
301
+ }
302
+ lastGroupMessage = message;
303
+
304
+ prevTime = time;
305
+ prevDay = day;
306
+ }
307
+
308
+ // The trailing group is the last message tile; suppress its avatar-rail continuation line.
309
+ if (currentGroup) {
310
+ currentGroup.last = true;
311
+ }
312
+
313
+ return items;
314
+ };
315
+
316
+ //
317
+ // Divider
318
+ //
319
+
320
+ const ThreadDivider = ({ label }: { label?: string }) =>
321
+ label ? (
322
+ <div className='flex items-center gap-2 px-2 py-2 text-xs text-description'>
323
+ <div className='h-px grow bg-separator' />
324
+ <span className='shrink-0'>{label}</span>
325
+ <div className='h-px grow bg-separator' />
326
+ </div>
327
+ ) : (
328
+ <div className='px-2 py-2'>
329
+ <div className='h-px bg-separator' />
330
+ </div>
331
+ );
332
+
333
+ ThreadDivider.displayName = 'Thread.Divider';
334
+
335
+ const ThreadItemAdapter = ({ id, data, location, draggable, current, selected }: MosaicTileProps<ThreadItem>) => (
216
336
  <Mosaic.Tile id={id} data={data} location={location} draggable={draggable} current={current} selected={selected}>
217
- <Message.Tile message={data} />
337
+ {data.kind === 'divider' ? (
338
+ <ThreadDivider label={data.label} />
339
+ ) : (
340
+ <Message.Group messages={data.messages} continues={!data.last} />
341
+ )}
218
342
  </Mosaic.Tile>
219
343
  );
220
344
 
221
345
  export type ThreadMessagesProps = ThemedClassName<{
222
346
  messages: readonly MessageType.Message[];
347
+ /** Stable id of the owning thread; scopes the Mosaic container so multiple threads don't collide. */
348
+ id?: string;
223
349
  /** Estimated tile height for the virtualizer. */
224
350
  estimateSize?: number;
225
351
  currentId?: string;
352
+ /** Consecutive same-sender messages within this window (ms) are merged into one tile. */
353
+ groupWindowMs?: number;
354
+ /** Whether to insert a labeled divider before the first message of each calendar day. */
355
+ dayDivider?: boolean;
356
+ /** A same-day gap exceeding this (ms) inserts an unlabeled divider between groups. */
357
+ gapDividerMs?: number;
226
358
  }>;
227
359
 
360
+ const DEFAULT_GROUP_WINDOW_MS = 60_000;
361
+ const DEFAULT_GAP_DIVIDER_MS = 3 * 60 * 60 * 1000;
362
+
228
363
  /** Virtualized stack of message tiles (via Mosaic), within an internal scroll area. */
229
- const ThreadMessages = ({ messages, estimateSize = 80, currentId, classNames }: ThreadMessagesProps) => {
364
+ const ThreadMessages = ({
365
+ messages,
366
+ id,
367
+ estimateSize = 80,
368
+ currentId,
369
+ groupWindowMs = DEFAULT_GROUP_WINDOW_MS,
370
+ dayDivider = true,
371
+ gapDividerMs = DEFAULT_GAP_DIVIDER_MS,
372
+ classNames,
373
+ }: ThreadMessagesProps) => {
374
+ const { dtLocale } = useTranslation(translationKey);
230
375
  const [viewport, setViewport] = useState<HTMLElement | null>(null);
231
- const items = useMemo(() => messages.filter(Boolean), [messages]);
376
+ const items = useMemo(
377
+ () => groupMessages(messages.filter(Boolean), { groupWindowMs, dayDivider, gapDividerMs, dtLocale }),
378
+ [messages, groupWindowMs, dayDivider, gapDividerMs, dtLocale],
379
+ );
380
+ // Per-instance id keeps concurrent threads (incl. the same thread mounted twice) distinct in the DnD registry.
381
+ const instanceId = useId();
382
+ const eventHandler = useMemo<DndContainerHandler>(
383
+ () => ({ id: `thread:${id ?? 'anon'}:${instanceId}`, canDrop: () => false }),
384
+ [id, instanceId],
385
+ );
232
386
 
233
387
  return (
234
388
  <Mosaic.Container
@@ -236,14 +390,14 @@ const ThreadMessages = ({ messages, estimateSize = 80, currentId, classNames }:
236
390
  orientation='vertical'
237
391
  autoScroll={viewport}
238
392
  currentId={currentId}
239
- eventHandler={{ id: 'thread', canDrop: () => false }}
393
+ eventHandler={eventHandler}
240
394
  >
241
- <ScrollArea.Root classNames={mx('col-span-2', classNames)} orientation='vertical'>
395
+ <ScrollArea.Root classNames={mx('col-span-2 flex-1 min-h-0', classNames)} orientation='vertical'>
242
396
  <ScrollArea.Viewport ref={setViewport}>
243
397
  <Mosaic.VirtualStack
398
+ Tile={ThreadItemAdapter}
244
399
  items={items}
245
- getId={getMessageId}
246
- Tile={MessageTileAdapter}
400
+ getId={getThreadItemId}
247
401
  draggable={false}
248
402
  getScrollElement={() => viewport}
249
403
  estimateSize={() => estimateSize}
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export { Message } from './Message';
15
15
  export type {
16
16
  MessageAuthorNameProps,
17
17
  MessageBodyProps,
18
+ MessageGroupProps,
18
19
  MessageHeadingProps,
19
20
  MessageRootProps,
20
21
  MessageTextboxProps,
@@ -25,6 +25,8 @@ export const translations = [
25
25
  'save-message.label': 'Save',
26
26
  'delete-message.label': 'Delete',
27
27
  'accept-proposal.label': 'Accept proposal',
28
+ 'accept-change.label': 'Accept change',
29
+ 'reject-change.label': 'Reject change',
28
30
  },
29
31
  },
30
32
  },
package/src/types.ts CHANGED
@@ -14,7 +14,7 @@ import { type FallbackValue } from '@dxos/util';
14
14
  * free of `@dxos/react-client`.
15
15
  */
16
16
  export type MessageMetadata = {
17
- id: string;
17
+ id?: string;
18
18
  timestamp?: string;
19
19
  authorId?: string;
20
20
  authorName?: string;
@@ -39,10 +39,18 @@ export type MessageCallbacks = {
39
39
  onMessageDelete?: (messageId: string) => void;
40
40
  /** Accept an assistant proposal block on a message (omit to hide the affordance). */
41
41
  onAcceptProposal?: (messageId: string) => void;
42
+ /** Accept a suggested-change block on a message (omit to hide the affordance). */
43
+ onAcceptChange?: (messageId: string) => void;
44
+ /** Reject a suggested-change block on a message (omit to hide the affordance). */
45
+ onRejectChange?: (messageId: string) => void;
46
+ /** Select a message — the host reveals what it refers to (omit to leave tiles inert). */
47
+ onMessageSelect?: (messageId: string) => void;
42
48
  };
43
49
 
44
50
  /** Shared context provided by `Thread.Root` to its message tiles. */
45
51
  export type ThreadContextValue = {
52
+ /** The selected message, accented in the list. */
53
+ currentMessageId?: string;
46
54
  /** Resolve presentational metadata for a message. */
47
55
  getMetadata: (message: Message.Message) => MessageMetadata;
48
56
  /** Injected renderers (e.g. object/reference tiles). */
@@ -1,31 +0,0 @@
1
- // src/translations.ts
2
- var translationKey = "@dxos/react-ui-thread";
3
- var translations = [
4
- {
5
- "en-US": {
6
- [translationKey]: {
7
- "plugin.name": "Threads",
8
- "thread-title.placeholder": "New thread",
9
- "thread-title.label": "Title",
10
- "delete-thread.label": "Delete",
11
- "select-thread.label": "Select comment",
12
- "create-thread.label": "Create thread",
13
- "message.placeholder": "Enter message...",
14
- "comment.placeholder": "Enter comment...",
15
- "settings-standalone.label": "Enable standalone thread creation",
16
- "anonymous.label": "Anonymous",
17
- "enter-to-send.message": "Enter to add \u23CE",
18
- "edit-message.label": "Edit",
19
- "save-message.label": "Save",
20
- "delete-message.label": "Delete",
21
- "accept-proposal.label": "Accept proposal"
22
- }
23
- }
24
- }
25
- ];
26
-
27
- export {
28
- translationKey,
29
- translations
30
- };
31
- //# sourceMappingURL=chunk-QFRRLZVL.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../src/translations.ts"],
4
- "sourcesContent": ["//\n// Copyright 2023 DXOS.org\n//\n\nimport { type Resource } from '@dxos/react-ui';\n\nexport const translationKey = '@dxos/react-ui-thread';\n\nexport const translations = [\n {\n 'en-US': {\n [translationKey]: {\n 'plugin.name': 'Threads',\n 'thread-title.placeholder': 'New thread',\n 'thread-title.label': 'Title',\n 'delete-thread.label': 'Delete',\n 'select-thread.label': 'Select comment',\n 'create-thread.label': 'Create thread',\n 'message.placeholder': 'Enter message...',\n 'comment.placeholder': 'Enter comment...',\n 'settings-standalone.label': 'Enable standalone thread creation',\n 'anonymous.label': 'Anonymous',\n 'enter-to-send.message': 'Enter to add ⏎',\n 'edit-message.label': 'Edit',\n 'save-message.label': 'Save',\n 'delete-message.label': 'Delete',\n 'accept-proposal.label': 'Accept proposal',\n },\n },\n },\n] as const satisfies Resource[];\n"],
5
- "mappings": ";AAMO,IAAMA,iBAAiB;AAEvB,IAAMC,eAAe;EAC1B;IACE,SAAS;MACP,CAACD,cAAAA,GAAiB;QAChB,eAAe;QACf,4BAA4B;QAC5B,sBAAsB;QACtB,uBAAuB;QACvB,uBAAuB;QACvB,uBAAuB;QACvB,uBAAuB;QACvB,uBAAuB;QACvB,6BAA6B;QAC7B,mBAAmB;QACnB,yBAAyB;QACzB,sBAAsB;QACtB,sBAAsB;QACtB,wBAAwB;QACxB,yBAAyB;MAC3B;IACF;EACF;;",
6
- "names": ["translationKey", "translations"]
7
- }