@dxos/react-ui-thread 0.10.0 → 0.11.0

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
@@ -0,0 +1,738 @@
1
+ import { translationKey } from "./translations.mjs";
2
+ import { format } from "date-fns/format";
3
+ import { startOfDay } from "date-fns/startOfDay";
4
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from "react";
5
+ import { Obj, Ref } from "@dxos/echo";
6
+ import { Avatar, Icon, IconButton, ScrollArea, composable, composableProps, useOnTransition, useThemeContext, useTranslation } from "@dxos/react-ui";
7
+ import { Mosaic } from "@dxos/react-ui-mosaic";
8
+ import { createBasicExtensions, createThemeExtensions, keymap, listener, tags } from "@dxos/ui-editor";
9
+ import { hoverableControlItem, hoverableControls, hoverableFocusedWithinControls, mx } from "@dxos/ui-theme";
10
+ import { HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
11
+ import { EditorView } from "@codemirror/view";
12
+ import { createContext } from "@radix-ui/react-context";
13
+ import { useTextEditor } from "@dxos/react-ui-editor";
14
+ import { hexToEmoji, hexToHue, isTruthy } from "@dxos/util";
15
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
16
+ //#region src/command.ts
17
+ /**
18
+ * Highlights leading slash-commands (e.g. `/resolve`) and `@mention` tags
19
+ * (e.g. `@Kai`, anywhere) in the message composer.
20
+ * https://github.com/codemirror/stream-parser/blob/main/test/test-stream-parser.ts
21
+ */
22
+ var parser = StreamLanguage.define({
23
+ startState: () => ({ count: 0 }),
24
+ token: (stream, state) => {
25
+ state.count++;
26
+ if (stream.eatSpace()) return null;
27
+ if (state.count === 1 && stream.match(/^\/\w+/)) return "tagName";
28
+ if (stream.match(/^@\w+/)) return "labelName";
29
+ stream.next();
30
+ return null;
31
+ }
32
+ });
33
+ var styles = HighlightStyle.define([{
34
+ tag: tags.tagName,
35
+ class: mx("dx-tag dx-tag--indigo")
36
+ }, {
37
+ tag: tags.labelName,
38
+ class: mx("dx-tag dx-tag--blue")
39
+ }]);
40
+ var theme = EditorView.theme({ ".dx-tag": { verticalAlign: "bottom" } });
41
+ var command = [
42
+ parser,
43
+ syntaxHighlighting(styles),
44
+ theme
45
+ ];
46
+ //#endregion
47
+ //#region src/context.ts
48
+ var [ThreadContextProvider, useThreadContext] = createContext("Thread");
49
+ //#endregion
50
+ //#region src/Message/Message.tsx
51
+ var avatarSize = 7;
52
+ var buttonGroupClassNames = "flex flex-row items-center gap-0.5 pe-2";
53
+ var buttonClassNames = "p-1! transition-opacity";
54
+ var MessageRoot = forwardRef(({ authorImgSrc, authorId, authorName, authorAvatarProps, continues = true, controls, children, classNames, ...rootProps }, forwardedRef) => {
55
+ return /* @__PURE__ */ jsx(Avatar.Root, { children: /* @__PURE__ */ jsxs("div", {
56
+ "data-testid": "thread.message",
57
+ ...rootProps,
58
+ className: mx("grid grid-cols-[var(--dx-rail-size)_1fr_min-content] w-full", classNames),
59
+ ref: forwardedRef,
60
+ children: [
61
+ /* @__PURE__ */ jsxs("div", {
62
+ className: "flex flex-col items-center gap-2 pt-1",
63
+ children: [/* @__PURE__ */ jsx(Avatar.Content, {
64
+ size: avatarSize,
65
+ hue: authorAvatarProps?.hue || hexToHue(authorId ?? "0"),
66
+ fallback: authorAvatarProps?.emoji || hexToEmoji(authorId ?? "0"),
67
+ ...authorImgSrc && { imgSrc: authorImgSrc }
68
+ }), continues && /* @__PURE__ */ jsx("div", { className: "w-px grow bg-separator" })]
69
+ }),
70
+ /* @__PURE__ */ jsx("div", {
71
+ className: "py-1 min-w-0",
72
+ children
73
+ }),
74
+ controls && /* @__PURE__ */ jsx("div", {
75
+ className: "self-start",
76
+ children: controls
77
+ })
78
+ ]
79
+ }) });
80
+ });
81
+ MessageRoot.displayName = "Message.Root";
82
+ var MessageHeading = ({ children, classNames, timestamp, authorName, ...props }) => {
83
+ return /* @__PURE__ */ jsxs("div", {
84
+ ...props,
85
+ className: mx("flex gap-2 items-start", classNames),
86
+ children: [/* @__PURE__ */ jsxs("p", {
87
+ className: "grow flex items-baseline gap-2 min-w-0",
88
+ children: [/* @__PURE__ */ jsx(MessageAuthorName, { authorName }), timestamp && /* @__PURE__ */ jsx(MessageTime, { timestamp })]
89
+ }), children]
90
+ });
91
+ };
92
+ MessageHeading.displayName = "Message.Heading";
93
+ var MessageAuthorName = ({ authorName }) => {
94
+ const { t } = useTranslation(translationKey);
95
+ return /* @__PURE__ */ jsx(Avatar.Label, {
96
+ classNames: "block truncate min-w-0 shrink text-sm text-subdued",
97
+ children: authorName ?? t("anonymous.label")
98
+ });
99
+ };
100
+ MessageAuthorName.displayName = "Message.AuthorName";
101
+ var MessageTime = ({ timestamp }) => {
102
+ const { dtLocale } = useTranslation(translationKey);
103
+ const dt = timestamp ? new Date(timestamp) : void 0;
104
+ return /* @__PURE__ */ jsx("time", {
105
+ className: "shrink-0 text-subdued text-xs",
106
+ dateTime: dt?.toISOString(),
107
+ children: dt ? format(dt, "p", { locale: dtLocale }) : ""
108
+ });
109
+ };
110
+ MessageTime.displayName = "Message.Time";
111
+ /**
112
+ * Renders a message's content blocks: text (via editor), proposal, and
113
+ * object/reference tiles (delegated to the injected `Object` component).
114
+ */
115
+ var MessageBody = ({ message, isAuthor, editing, onSave }) => {
116
+ const { components } = useThreadContext("Message.Body");
117
+ const textBlockIndex = message.blocks.findIndex((block) => block._tag === "text");
118
+ const textBlock = textBlockIndex !== -1 ? message.blocks[textBlockIndex] : void 0;
119
+ const proposalBlock = message.blocks.find((block) => block._tag === "proposal");
120
+ const changeBlock = message.blocks.find((block) => block._tag === "change");
121
+ const references = message.blocks.filter((block) => block._tag === "reference").map((block) => block.reference);
122
+ const Object = components.Object;
123
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
124
+ textBlock && /* @__PURE__ */ jsx(TextBlock, {
125
+ block: textBlock,
126
+ isAuthor,
127
+ editing,
128
+ onSave
129
+ }),
130
+ proposalBlock && /* @__PURE__ */ jsx("div", {
131
+ className: "me-4 italic",
132
+ children: proposalBlock.text
133
+ }),
134
+ changeBlock && /* @__PURE__ */ jsxs("p", {
135
+ className: "me-4 text-sm break-words",
136
+ children: [
137
+ changeBlock.before && /* @__PURE__ */ jsx("span", {
138
+ className: "line-through opacity-60",
139
+ children: changeBlock.before
140
+ }),
141
+ changeBlock.before && changeBlock.after && " ",
142
+ changeBlock.after && /* @__PURE__ */ jsx("span", { children: changeBlock.after })
143
+ ]
144
+ }),
145
+ Object && Ref.Array.targets(references).map((reference, index) => /* @__PURE__ */ jsx(Object, { subject: reference }, index))
146
+ ] });
147
+ };
148
+ MessageBody.displayName = "Message.Body";
149
+ var TextBlock = ({ block, isAuthor, editing, onSave }) => {
150
+ const { themeMode } = useThemeContext();
151
+ const inMemoryContentRef = useRef(block.text);
152
+ const handleDocumentChange = useCallback((next) => {
153
+ inMemoryContentRef.current = next;
154
+ }, []);
155
+ useOnTransition(editing, true, false, useCallback(() => {
156
+ onSave?.(inMemoryContentRef.current);
157
+ }, [onSave]));
158
+ const { parentRef, focusAttributes, view } = useTextEditor(() => ({
159
+ initialValue: block.text,
160
+ extensions: [
161
+ createBasicExtensions({ readOnly: !isAuthor || !editing }),
162
+ createThemeExtensions({ themeMode }),
163
+ command,
164
+ EditorView.updateListener.of((update) => {
165
+ if (update.docChanged) handleDocumentChange(update.state.doc.toString());
166
+ })
167
+ ]
168
+ }), [
169
+ block.text,
170
+ editing,
171
+ isAuthor,
172
+ themeMode,
173
+ handleDocumentChange
174
+ ]);
175
+ useEffect(() => {
176
+ if (editing) view?.focus();
177
+ }, [editing, view]);
178
+ return /* @__PURE__ */ jsx("div", {
179
+ ref: parentRef,
180
+ className: "me-4",
181
+ ...focusAttributes
182
+ });
183
+ };
184
+ var keyBindings = ({ onSend, onClear }) => [{
185
+ key: "Enter",
186
+ run: () => {
187
+ if (onSend) {
188
+ onSend();
189
+ return true;
190
+ }
191
+ return false;
192
+ }
193
+ }, {
194
+ key: "Meta+Backspace",
195
+ run: () => {
196
+ if (onClear) {
197
+ onClear();
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+ }];
203
+ var MessageTextbox = forwardRef(({ id, authorId, authorName, authorImgSrc, authorAvatarProps, disabled, extensions, onSend, onClear, onEditorFocus, ...editorProps }, forwardedRef) => {
204
+ const { parentRef, focusAttributes, view } = useTextEditor(() => ({
205
+ id,
206
+ extensions: [
207
+ keymap.of(keyBindings({
208
+ onSend,
209
+ onClear
210
+ })),
211
+ listener({ onFocus: ({ focusing }) => {
212
+ if (focusing) onEditorFocus?.();
213
+ } }),
214
+ extensions
215
+ ].filter(isTruthy),
216
+ ...editorProps
217
+ }), [id, extensions]);
218
+ useImperativeHandle(forwardedRef, () => ({ focus: () => view?.focus() }), [view]);
219
+ return /* @__PURE__ */ jsx(MessageRoot, {
220
+ id,
221
+ authorId,
222
+ authorName,
223
+ authorImgSrc,
224
+ authorAvatarProps,
225
+ continues: false,
226
+ children: /* @__PURE__ */ jsx("div", {
227
+ ref: parentRef,
228
+ className: mx("py-0.5 me-1 rounded-xs dx-focus-ring", disabled && "opacity-50"),
229
+ ...focusAttributes
230
+ })
231
+ });
232
+ });
233
+ MessageTextbox.displayName = "Message.Textbox";
234
+ /**
235
+ * Full message tile (frame + heading + controls + body). Reads `Thread.Root`
236
+ * context for metadata resolution, injected renderers, and callbacks. This is
237
+ * the unit rendered by `Thread.Messages`.
238
+ */
239
+ var MessageTile = ({ message, classNames, continues = true }) => {
240
+ const { t } = useTranslation(translationKey);
241
+ const { getMetadata, identityDid, editable, onMessageDelete, onAcceptProposal, onAcceptChange, onRejectChange, onMessageSelect, currentMessageId } = useThreadContext("Message.Tile");
242
+ const [editing, setEditing] = useState(false);
243
+ const metadata = getMetadata(message);
244
+ const isAuthor = !!identityDid && identityDid === metadata.authorId;
245
+ const hasProposal = message.blocks.some((block) => block._tag === "proposal");
246
+ const hasChange = message.blocks.some((block) => block._tag === "change");
247
+ const handleEdit = useCallback(() => setEditing((value) => !value), []);
248
+ const handleDelete = useCallback(() => onMessageDelete?.(message.id), [onMessageDelete, message.id]);
249
+ const handleAcceptProposal = useCallback(() => onAcceptProposal?.(message.id), [onAcceptProposal, message.id]);
250
+ const handleAcceptChange = useCallback(() => onAcceptChange?.(message.id), [onAcceptChange, message.id]);
251
+ const handleRejectChange = useCallback(() => onRejectChange?.(message.id), [onRejectChange, message.id]);
252
+ const handleSelect = useCallback(() => onMessageSelect?.(message.id), [onMessageSelect, message.id]);
253
+ const handleSave = useCallback((text) => {
254
+ Obj.update(message, (message) => {
255
+ const block = message.blocks.find((block) => block._tag === "text");
256
+ if (block && block._tag === "text") block.text = text;
257
+ });
258
+ }, [message]);
259
+ const showEdit = isAuthor && editable;
260
+ const showAccept = hasProposal && !!onAcceptProposal;
261
+ const showAcceptChange = hasChange && !!onAcceptChange;
262
+ const showRejectChange = hasChange && !!onRejectChange;
263
+ const showDelete = !!onMessageDelete;
264
+ const controls = showEdit || showAccept || showAcceptChange || showRejectChange || showDelete ? /* @__PURE__ */ jsxs("div", {
265
+ className: buttonGroupClassNames,
266
+ children: [
267
+ showEdit && /* @__PURE__ */ jsx(IconButton, {
268
+ "data-testid": editing ? "thread.message.save" : "thread.message.edit",
269
+ variant: "ghost",
270
+ icon: editing ? "ph--check--regular" : "ph--pencil-simple--regular",
271
+ iconOnly: true,
272
+ label: t(editing ? "save-message.label" : "edit-message.label"),
273
+ classNames: [buttonClassNames, hoverableControlItem],
274
+ onClick: handleEdit
275
+ }),
276
+ showAccept && /* @__PURE__ */ jsx(IconButton, {
277
+ "data-testid": "thread.message.accept",
278
+ variant: "ghost",
279
+ icon: "ph--check--regular",
280
+ iconOnly: true,
281
+ label: t("accept-proposal.label"),
282
+ classNames: [buttonClassNames, hoverableControlItem],
283
+ onClick: handleAcceptProposal
284
+ }),
285
+ showAcceptChange && /* @__PURE__ */ jsx(IconButton, {
286
+ "data-testid": "thread.message.accept-change",
287
+ variant: "ghost",
288
+ icon: "ph--check--regular",
289
+ iconOnly: true,
290
+ label: t("accept-change.label"),
291
+ classNames: [buttonClassNames, hoverableControlItem],
292
+ onClick: handleAcceptChange
293
+ }),
294
+ showRejectChange && /* @__PURE__ */ jsx(IconButton, {
295
+ "data-testid": "thread.message.reject-change",
296
+ variant: "ghost",
297
+ icon: "ph--x--regular",
298
+ iconOnly: true,
299
+ label: t("reject-change.label"),
300
+ classNames: [buttonClassNames, hoverableControlItem],
301
+ onClick: handleRejectChange
302
+ }),
303
+ showDelete && /* @__PURE__ */ jsx(IconButton, {
304
+ "data-testid": "thread.message.delete",
305
+ variant: "ghost",
306
+ icon: "ph--x--regular",
307
+ iconOnly: true,
308
+ label: t("delete-message.label"),
309
+ classNames: [buttonClassNames, hoverableControlItem],
310
+ onClick: handleDelete
311
+ })
312
+ ]
313
+ }) : void 0;
314
+ return /* @__PURE__ */ jsxs(MessageRoot, {
315
+ ...metadata,
316
+ continues,
317
+ controls,
318
+ onClick: onMessageSelect ? handleSelect : void 0,
319
+ "aria-current": currentMessageId === message.id ? "location" : void 0,
320
+ classNames: [
321
+ hoverableControls,
322
+ hoverableFocusedWithinControls,
323
+ onMessageSelect && "cursor-pointer",
324
+ currentMessageId === message.id && "bg-current-surface",
325
+ classNames
326
+ ],
327
+ children: [/* @__PURE__ */ jsx(MessageHeading, {
328
+ authorName: metadata.authorName,
329
+ timestamp: metadata.timestamp
330
+ }), /* @__PURE__ */ jsx(MessageBody, {
331
+ message,
332
+ isAuthor,
333
+ editing,
334
+ onSave: handleSave
335
+ })]
336
+ });
337
+ };
338
+ MessageTile.displayName = "Message.Tile";
339
+ /**
340
+ * Groups consecutive same-sender messages (see `Thread.Messages`'s grouping
341
+ * window) into a single tile: one heading (author + first message's
342
+ * timestamp) followed by one body per message, stacked in order. Per-message
343
+ * edit/delete controls are not shown per-row in a group — v1 shows them for
344
+ * the group's first message only, since the heading/controls layout is keyed
345
+ * to a single message.
346
+ */
347
+ var MessageGroup = ({ messages, continues = true, classNames }) => {
348
+ const { t } = useTranslation(translationKey);
349
+ const { getMetadata, identityDid, editable, onMessageDelete, onAcceptProposal } = useThreadContext("Message.Group");
350
+ const [editing, setEditing] = useState(false);
351
+ const first = messages[0];
352
+ const metadata = getMetadata(first);
353
+ const isAuthor = !!identityDid && identityDid === metadata.authorId;
354
+ const hasProposal = first.blocks.some((block) => block._tag === "proposal");
355
+ const handleEdit = useCallback(() => setEditing((value) => !value), []);
356
+ const handleDelete = useCallback(() => onMessageDelete?.(first.id), [onMessageDelete, first.id]);
357
+ const handleAcceptProposal = useCallback(() => onAcceptProposal?.(first.id), [onAcceptProposal, first.id]);
358
+ const handleSave = useCallback((text) => {
359
+ Obj.update(first, (first) => {
360
+ const block = first.blocks.find((block) => block._tag === "text");
361
+ if (block && block._tag === "text") block.text = text;
362
+ });
363
+ }, [first]);
364
+ const showEdit = isAuthor && editable;
365
+ const showAccept = hasProposal && !!onAcceptProposal;
366
+ const showDelete = !!onMessageDelete;
367
+ const controls = showEdit || showAccept || showDelete ? /* @__PURE__ */ jsxs("div", {
368
+ className: buttonGroupClassNames,
369
+ children: [
370
+ showEdit && /* @__PURE__ */ jsx(IconButton, {
371
+ "data-testid": editing ? "thread.message.save" : "thread.message.edit",
372
+ variant: "ghost",
373
+ icon: editing ? "ph--check--regular" : "ph--pencil-simple--regular",
374
+ iconOnly: true,
375
+ label: t(editing ? "save-message.label" : "edit-message.label"),
376
+ classNames: [buttonClassNames, hoverableControlItem],
377
+ onClick: handleEdit
378
+ }),
379
+ showAccept && /* @__PURE__ */ jsx(IconButton, {
380
+ "data-testid": "thread.message.accept",
381
+ variant: "ghost",
382
+ icon: "ph--check--regular",
383
+ iconOnly: true,
384
+ label: t("accept-proposal.label"),
385
+ classNames: [buttonClassNames, hoverableControlItem],
386
+ onClick: handleAcceptProposal
387
+ }),
388
+ showDelete && /* @__PURE__ */ jsx(IconButton, {
389
+ "data-testid": "thread.message.delete",
390
+ variant: "ghost",
391
+ icon: "ph--x--regular",
392
+ iconOnly: true,
393
+ label: t("delete-message.label"),
394
+ classNames: [buttonClassNames, hoverableControlItem],
395
+ onClick: handleDelete
396
+ })
397
+ ]
398
+ }) : void 0;
399
+ return /* @__PURE__ */ jsxs(MessageRoot, {
400
+ ...metadata,
401
+ continues,
402
+ controls,
403
+ classNames: [
404
+ hoverableControls,
405
+ hoverableFocusedWithinControls,
406
+ classNames
407
+ ],
408
+ children: [/* @__PURE__ */ jsx(MessageHeading, {
409
+ authorName: metadata.authorName,
410
+ timestamp: metadata.timestamp
411
+ }), messages.map((message) => /* @__PURE__ */ jsx(MessageBody, {
412
+ message,
413
+ isAuthor,
414
+ editing: editing && message === first,
415
+ onSave: handleSave
416
+ }, message.id))]
417
+ });
418
+ };
419
+ MessageGroup.displayName = "Message.Group";
420
+ var Message = {
421
+ Root: MessageRoot,
422
+ Heading: MessageHeading,
423
+ AuthorName: MessageAuthorName,
424
+ Time: MessageTime,
425
+ Body: MessageBody,
426
+ Textbox: MessageTextbox,
427
+ Tile: MessageTile,
428
+ Group: MessageGroup
429
+ };
430
+ //#endregion
431
+ //#region src/Thread/Thread.tsx
432
+ var getMessageId = (message) => Obj.getURI(message);
433
+ /**
434
+ * Headless root of a thread. Provides message-tile context (metadata resolver,
435
+ * injected renderers, callbacks). Requires an ambient `Dnd.Root` ancestor,
436
+ * within which `Thread.Messages` renders its virtual stack. Renders no DOM of
437
+ * its own — wrap the visible thread chrome in `Thread.Content`.
438
+ */
439
+ var ThreadRoot = ({ children, getMetadata, components, identityDid, editable, onMessageDelete, onAcceptProposal, onAcceptChange, onRejectChange, onMessageSelect, currentMessageId }) => {
440
+ const composerFocus = useRef(void 0);
441
+ const registerComposerFocus = useCallback((focus) => {
442
+ composerFocus.current = focus;
443
+ }, []);
444
+ const focusComposer = useCallback(() => composerFocus.current?.(), []);
445
+ return /* @__PURE__ */ jsx(ThreadContextProvider, {
446
+ getMetadata,
447
+ components: components ?? {},
448
+ identityDid,
449
+ editable,
450
+ registerComposerFocus,
451
+ focusComposer,
452
+ onMessageDelete,
453
+ onAcceptProposal,
454
+ onAcceptChange,
455
+ onRejectChange,
456
+ onMessageSelect,
457
+ currentMessageId,
458
+ children
459
+ });
460
+ };
461
+ ThreadRoot.displayName = "Thread.Root";
462
+ /** Visible thread container (the attention surface that hosts header / messages / composer). */
463
+ var ThreadContent = composable(({ children, current, id, ...props }, forwardedRef) => {
464
+ return /* @__PURE__ */ jsx("div", {
465
+ ...composableProps(props, {
466
+ role: "group",
467
+ classNames: ["flex flex-col bg-[var(--surface-bg)] current-related dx-attention-surface [--controls-opacity:0]", hoverableFocusedWithinControls]
468
+ }),
469
+ "data-testid": "thread",
470
+ id,
471
+ "aria-current": current ? "location" : void 0,
472
+ ref: forwardedRef,
473
+ children
474
+ });
475
+ });
476
+ ThreadContent.displayName = "Thread.Content";
477
+ /**
478
+ * Thread header row: caret (rail) · snippet (content) · controls. Owns its own
479
+ * `[rail · 1fr · controls]` grid so the trailing controls align with message-tile
480
+ * controls (which use the same template) — no grid is leaked in from the caller.
481
+ */
482
+ var ThreadHeader = composable(({ title, detached, current, controls, onSelect, ...props }, forwardedRef) => {
483
+ const { t } = useTranslation(translationKey);
484
+ const { focusComposer } = useThreadContext("Thread.Header");
485
+ const handleSelect = useCallback(() => {
486
+ onSelect?.();
487
+ focusComposer();
488
+ }, [onSelect, focusComposer]);
489
+ return /* @__PURE__ */ jsxs("div", {
490
+ ...composableProps(props, { classNames: [
491
+ "grid grid-cols-[var(--dx-rail-size)_1fr_min-content] items-center",
492
+ hoverableControls,
493
+ hoverableFocusedWithinControls
494
+ ] }),
495
+ ref: forwardedRef,
496
+ children: [
497
+ /* @__PURE__ */ jsx("div", {
498
+ className: "flex items-center justify-center",
499
+ children: /* @__PURE__ */ jsx(IconButton, {
500
+ iconOnly: true,
501
+ variant: "ghost",
502
+ density: "sm",
503
+ icon: "ph--caret-double-right--regular",
504
+ label: t("select-thread.label"),
505
+ classNames: "text-description",
506
+ onClick: handleSelect
507
+ })
508
+ }),
509
+ /* @__PURE__ */ jsx("div", {
510
+ className: "flex items-center overflow-hidden",
511
+ children: /* @__PURE__ */ jsx("p", {
512
+ role: "heading",
513
+ "data-testid": "thread.heading",
514
+ className: mx("me-2 font-medium truncate italic", current ? "text-accent-text" : "text-description", detached && "line-through decoration-1"),
515
+ children: title
516
+ })
517
+ }),
518
+ controls
519
+ ]
520
+ });
521
+ });
522
+ ThreadHeader.displayName = "Thread.Header";
523
+ var getThreadItemId = (item) => item.id;
524
+ /** Stable per-sender key: prefers the DID, then the (deprecated) identity key, then name/email, else 'anon'. */
525
+ var senderKey = (message) => message.sender.identityDid ?? message.sender.identityKey ?? message.sender.name ?? message.sender.email ?? "anon";
526
+ /** Message send time in epoch ms; malformed/missing `created` sorts as if sent at the epoch. */
527
+ var messageTime = (message) => {
528
+ const time = Date.parse(message.created);
529
+ return Number.isFinite(time) ? time : 0;
530
+ };
531
+ /**
532
+ * Preprocesses ascending, time-ordered `messages` into a flat list of divider
533
+ * and group items for `Thread.Messages`'s virtual stack:
534
+ * - A day divider (labeled with the calendar date) precedes the first message
535
+ * of each calendar day.
536
+ * - A plain divider (no label) marks a same-day gap exceeding `gapDividerMs`.
537
+ * - When both would fall on the same message, only the day divider is emitted.
538
+ * - Consecutive messages from the same sender within `groupWindowMs` of the
539
+ * previous message in the group are merged into one `MessageGroupItem`; a
540
+ * divider always starts a new group.
541
+ */
542
+ var groupMessages = (messages, { groupWindowMs, dayDivider, gapDividerMs, dtLocale }) => {
543
+ const items = [];
544
+ let currentGroup;
545
+ let lastGroupMessage;
546
+ let prevTime;
547
+ let prevDay;
548
+ for (const message of messages) {
549
+ const time = messageTime(message);
550
+ const day = startOfDay(time).getTime();
551
+ const dayBoundary = dayDivider && (prevDay === void 0 || day !== prevDay);
552
+ const gapBoundary = !dayBoundary && prevTime !== void 0 && time - prevTime > gapDividerMs;
553
+ if (dayBoundary) {
554
+ items.push({
555
+ kind: "divider",
556
+ id: `divider:day:${day}`,
557
+ label: format(day, "EEEE, MMMM d", { locale: dtLocale })
558
+ });
559
+ currentGroup = void 0;
560
+ } else if (gapBoundary) {
561
+ items.push({
562
+ kind: "divider",
563
+ id: `divider:gap:${time}`
564
+ });
565
+ currentGroup = void 0;
566
+ }
567
+ const sameSenderAsGroup = currentGroup && lastGroupMessage && senderKey(lastGroupMessage) === senderKey(message);
568
+ const withinGroupWindow = currentGroup && lastGroupMessage && time - messageTime(lastGroupMessage) <= groupWindowMs;
569
+ if (currentGroup && sameSenderAsGroup && withinGroupWindow) currentGroup.messages.push(message);
570
+ else {
571
+ currentGroup = {
572
+ kind: "group",
573
+ id: getMessageId(message),
574
+ messages: [message]
575
+ };
576
+ items.push(currentGroup);
577
+ }
578
+ lastGroupMessage = message;
579
+ prevTime = time;
580
+ prevDay = day;
581
+ }
582
+ if (currentGroup) currentGroup.last = true;
583
+ return items;
584
+ };
585
+ var ThreadDivider = ({ label }) => label ? /* @__PURE__ */ jsxs("div", {
586
+ className: "flex items-center gap-2 px-2 py-2 text-xs text-description",
587
+ children: [
588
+ /* @__PURE__ */ jsx("div", { className: "h-px grow bg-separator" }),
589
+ /* @__PURE__ */ jsx("span", {
590
+ className: "shrink-0",
591
+ children: label
592
+ }),
593
+ /* @__PURE__ */ jsx("div", { className: "h-px grow bg-separator" })
594
+ ]
595
+ }) : /* @__PURE__ */ jsx("div", {
596
+ className: "px-2 py-2",
597
+ children: /* @__PURE__ */ jsx("div", { className: "h-px bg-separator" })
598
+ });
599
+ ThreadDivider.displayName = "Thread.Divider";
600
+ var ThreadItemAdapter = ({ id, data, location, draggable, current, selected }) => /* @__PURE__ */ jsx(Mosaic.Tile, {
601
+ id,
602
+ data,
603
+ location,
604
+ draggable,
605
+ current,
606
+ selected,
607
+ children: data.kind === "divider" ? /* @__PURE__ */ jsx(ThreadDivider, { label: data.label }) : /* @__PURE__ */ jsx(Message.Group, {
608
+ messages: data.messages,
609
+ continues: !data.last
610
+ })
611
+ });
612
+ var DEFAULT_GROUP_WINDOW_MS = 6e4;
613
+ var DEFAULT_GAP_DIVIDER_MS = 10800 * 1e3;
614
+ /** Virtualized stack of message tiles (via Mosaic), within an internal scroll area. */
615
+ var ThreadMessages = ({ messages, id, estimateSize = 80, currentId, groupWindowMs = DEFAULT_GROUP_WINDOW_MS, dayDivider = true, gapDividerMs = DEFAULT_GAP_DIVIDER_MS, classNames }) => {
616
+ const { dtLocale } = useTranslation(translationKey);
617
+ const [viewport, setViewport] = useState(null);
618
+ const items = useMemo(() => groupMessages(messages.filter(Boolean), {
619
+ groupWindowMs,
620
+ dayDivider,
621
+ gapDividerMs,
622
+ dtLocale
623
+ }), [
624
+ messages,
625
+ groupWindowMs,
626
+ dayDivider,
627
+ gapDividerMs,
628
+ dtLocale
629
+ ]);
630
+ const instanceId = useId();
631
+ const eventHandler = useMemo(() => ({
632
+ id: `thread:${id ?? "anon"}:${instanceId}`,
633
+ canDrop: () => false
634
+ }), [id, instanceId]);
635
+ return /* @__PURE__ */ jsx(Mosaic.Container, {
636
+ asChild: true,
637
+ orientation: "vertical",
638
+ autoScroll: viewport,
639
+ currentId,
640
+ eventHandler,
641
+ children: /* @__PURE__ */ jsx(ScrollArea.Root, {
642
+ classNames: mx("col-span-2 flex-1 min-h-0", classNames),
643
+ orientation: "vertical",
644
+ children: /* @__PURE__ */ jsx(ScrollArea.Viewport, {
645
+ ref: setViewport,
646
+ children: /* @__PURE__ */ jsx(Mosaic.VirtualStack, {
647
+ Tile: ThreadItemAdapter,
648
+ items,
649
+ getId: getThreadItemId,
650
+ draggable: false,
651
+ getScrollElement: () => viewport,
652
+ estimateSize: () => estimateSize
653
+ })
654
+ })
655
+ })
656
+ });
657
+ };
658
+ ThreadMessages.displayName = "Thread.Messages";
659
+ /** Message composer pinned at the foot of a thread. */
660
+ var ThreadTextbox = ({ placeholder, autoFocus, disabled, extensions, onSend, ...metadata }) => {
661
+ const { t } = useTranslation(translationKey);
662
+ const { themeMode } = useThemeContext();
663
+ const { registerComposerFocus } = useThreadContext("Thread.Textbox");
664
+ const composerRef = useRef(null);
665
+ const messageRef = useRef("");
666
+ const [count, setCount] = useState(0);
667
+ useEffect(() => {
668
+ registerComposerFocus(() => composerRef.current?.focus());
669
+ return () => registerComposerFocus(void 0);
670
+ }, [registerComposerFocus]);
671
+ const editorExtensions = useMemo(() => [
672
+ createBasicExtensions({ placeholder: placeholder ?? t("message.placeholder") }),
673
+ createThemeExtensions({ themeMode }),
674
+ listener({ onChange: ({ text }) => messageRef.current = text }),
675
+ command,
676
+ extensions
677
+ ].filter(Boolean), [
678
+ themeMode,
679
+ placeholder,
680
+ t,
681
+ extensions,
682
+ count
683
+ ]);
684
+ const handleSend = useCallback(() => {
685
+ const text = messageRef.current;
686
+ if (!text?.length || !onSend) return;
687
+ if (onSend(text)) {
688
+ messageRef.current = "";
689
+ setCount((value) => value + 1);
690
+ }
691
+ }, []);
692
+ return /* @__PURE__ */ jsx(Message.Textbox, {
693
+ ref: composerRef,
694
+ ...metadata,
695
+ autoFocus,
696
+ disabled,
697
+ extensions: editorExtensions,
698
+ onSend: handleSend
699
+ });
700
+ };
701
+ ThreadTextbox.displayName = "Thread.Textbox";
702
+ var ThreadStatus = forwardRef(({ activity, classNames, children, ...props }, forwardedRef) => {
703
+ const { t } = useTranslation(translationKey);
704
+ return /* @__PURE__ */ jsxs("div", {
705
+ ...props,
706
+ className: mx("col-start-2 grid grid-cols-[min-content_1fr_max-content] pb-2 pe-2 text-xs text-description", classNames),
707
+ ref: forwardedRef,
708
+ children: [
709
+ /* @__PURE__ */ jsx(Icon, {
710
+ icon: "ph--spinner--bold",
711
+ classNames: "w-6 h-4 invisible data-[visible=show]:visible animate-spin-slow",
712
+ "data-visible": activity ? "show" : "hide"
713
+ }),
714
+ /* @__PURE__ */ jsx("span", {
715
+ className: "truncate min-w-0",
716
+ "aria-live": "polite",
717
+ children: activity ? children : null
718
+ }),
719
+ /* @__PURE__ */ jsx("span", {
720
+ className: mx("text-end", hoverableControlItem),
721
+ children: t("enter-to-send.message")
722
+ })
723
+ ]
724
+ });
725
+ });
726
+ ThreadStatus.displayName = "Thread.Status";
727
+ var Thread = {
728
+ Root: ThreadRoot,
729
+ Content: ThreadContent,
730
+ Header: ThreadHeader,
731
+ Messages: ThreadMessages,
732
+ Textbox: ThreadTextbox,
733
+ Status: ThreadStatus
734
+ };
735
+ //#endregion
736
+ export { Message, Thread, command, useThreadContext };
737
+
738
+ //# sourceMappingURL=index.mjs.map