@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/src/Thread/Thread.tsx
CHANGED
|
@@ -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)
|
|
53
|
-
* renders its virtual stack
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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 = ({
|
|
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(
|
|
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={
|
|
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={
|
|
246
|
-
Tile={MessageTileAdapter}
|
|
400
|
+
getId={getThreadItemId}
|
|
247
401
|
draggable={false}
|
|
248
402
|
getScrollElement={() => viewport}
|
|
249
403
|
estimateSize={() => estimateSize}
|
package/src/index.ts
CHANGED
package/src/translations.ts
CHANGED
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
|
|
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
|
-
}
|