@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
|
@@ -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
|