@dxos/react-ui-thread 0.3.11-main.0e02eb0

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/LICENSE +8 -0
  2. package/README.md +3 -0
  3. package/dist/lib/browser/index.mjs +246 -0
  4. package/dist/lib/browser/index.mjs.map +7 -0
  5. package/dist/lib/browser/meta.json +1 -0
  6. package/dist/types/src/Message/Message.d.ts +30 -0
  7. package/dist/types/src/Message/Message.d.ts.map +1 -0
  8. package/dist/types/src/Message/Message.stories.d.ts +28 -0
  9. package/dist/types/src/Message/Message.stories.d.ts.map +1 -0
  10. package/dist/types/src/Message/command-extension.d.ts +3 -0
  11. package/dist/types/src/Message/command-extension.d.ts.map +1 -0
  12. package/dist/types/src/Message/index.d.ts +2 -0
  13. package/dist/types/src/Message/index.d.ts.map +1 -0
  14. package/dist/types/src/Thread/Comments.stories.d.ts +61 -0
  15. package/dist/types/src/Thread/Comments.stories.d.ts.map +1 -0
  16. package/dist/types/src/Thread/Thread.d.ts +14 -0
  17. package/dist/types/src/Thread/Thread.d.ts.map +1 -0
  18. package/dist/types/src/Thread/Thread.stories.d.ts +29 -0
  19. package/dist/types/src/Thread/Thread.stories.d.ts.map +1 -0
  20. package/dist/types/src/Thread/index.d.ts +2 -0
  21. package/dist/types/src/Thread/index.d.ts.map +1 -0
  22. package/dist/types/src/index.d.ts +6 -0
  23. package/dist/types/src/index.d.ts.map +1 -0
  24. package/dist/types/src/translations.d.ts +19 -0
  25. package/dist/types/src/translations.d.ts.map +1 -0
  26. package/dist/types/src/types.d.ts +19 -0
  27. package/dist/types/src/types.d.ts.map +1 -0
  28. package/package.json +51 -0
  29. package/src/Message/Message.stories.tsx +45 -0
  30. package/src/Message/Message.tsx +186 -0
  31. package/src/Message/command-extension.ts +40 -0
  32. package/src/Message/index.ts +5 -0
  33. package/src/Thread/Comments.stories.tsx +368 -0
  34. package/src/Thread/Thread.stories.tsx +77 -0
  35. package/src/Thread/Thread.tsx +82 -0
  36. package/src/Thread/index.ts +5 -0
  37. package/src/index.ts +10 -0
  38. package/src/translations.ts +24 -0
  39. package/src/types.ts +26 -0
@@ -0,0 +1,368 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import '@dxosTheme';
6
+
7
+ import { faker } from '@faker-js/faker';
8
+ import { Check, Trash } from '@phosphor-icons/react';
9
+ import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
10
+
11
+ import { TextObject } from '@dxos/echo-schema';
12
+ import { PublicKey } from '@dxos/keys';
13
+ import { log } from '@dxos/log';
14
+ import { Button } from '@dxos/react-ui';
15
+ import {
16
+ MarkdownEditor,
17
+ comments,
18
+ type CommentsOptions,
19
+ focusComment,
20
+ useComments,
21
+ type Comment,
22
+ type Range,
23
+ useTextModel,
24
+ type EditorView,
25
+ TextEditor,
26
+ } from '@dxos/react-ui-editor';
27
+ import { withTheme } from '@dxos/storybook-utils';
28
+
29
+ import { Thread, ThreadFooter, ThreadHeading } from './Thread';
30
+ import { Message, type MessageBlockProps, MessageTextbox } from '../Message';
31
+ import translations from '../translations';
32
+ import { type MessageEntity } from '../types';
33
+
34
+ faker.seed(101);
35
+
36
+ const authorId = PublicKey.random().toHex();
37
+
38
+ //
39
+ // Editor
40
+ //
41
+
42
+ const Editor: FC<{
43
+ item: { text: TextObject };
44
+ comments: Comment[];
45
+ selected?: string;
46
+ onCreateComment: CommentsOptions['onCreate'];
47
+ onDeleteComment: CommentsOptions['onDelete'];
48
+ onUpdateComment: CommentsOptions['onUpdate'];
49
+ onSelectComment: CommentsOptions['onSelect'];
50
+ }> = ({
51
+ item,
52
+ selected: selectedValue,
53
+ comments: commentRanges,
54
+ onCreateComment,
55
+ onDeleteComment,
56
+ onUpdateComment,
57
+ onSelectComment,
58
+ }) => {
59
+ const model = useTextModel({ text: item.text });
60
+ const view = useRef<EditorView>(null);
61
+ const [selected, setSelected] = useState<string>();
62
+ useEffect(() => {
63
+ if (!view.current?.hasFocus && selectedValue !== selected) {
64
+ setSelected(selectedValue);
65
+ if (selectedValue) {
66
+ focusComment(view.current!, selectedValue);
67
+ }
68
+ }
69
+ }, [selected, commentRanges, selectedValue]);
70
+
71
+ useComments(view.current, commentRanges);
72
+
73
+ const extensions = useMemo(() => {
74
+ return [
75
+ comments({
76
+ onCreate: onCreateComment,
77
+ onDelete: onDeleteComment,
78
+ onUpdate: onUpdateComment,
79
+ onSelect: onSelectComment,
80
+ }),
81
+ ];
82
+ }, []);
83
+
84
+ if (!model) {
85
+ return null;
86
+ }
87
+
88
+ // TODO(burdon): Highlight currently selected comment.
89
+ return <MarkdownEditor ref={view} model={model} extensions={extensions} />;
90
+ };
91
+
92
+ //
93
+ // Comments thread
94
+ //
95
+
96
+ type StoryCommentThread = {
97
+ id: string;
98
+ cursor?: string;
99
+ range?: Range;
100
+ yPos?: number;
101
+ messages: MessageEntity<{ text: TextObject }>[];
102
+ };
103
+
104
+ const StoryMessageBlock = (props: MessageBlockProps<{ text: TextObject }>) => {
105
+ const model = useTextModel({ text: props.block.text });
106
+ return model ? <TextEditor model={model} slots={{ root: { className: 'col-span-3' } }} /> : null;
107
+ };
108
+
109
+ const StoryThread: FC<{
110
+ thread: StoryCommentThread;
111
+ selected: boolean;
112
+ onSelect: () => void;
113
+ onResolve: () => void;
114
+ }> = ({ thread, selected, onSelect, onResolve }) => {
115
+ const [autoFocus, setAutoFocus] = useState(false);
116
+ const [item, setItem] = useState({ text: new TextObject() });
117
+ const model = useTextModel({ text: item.text });
118
+ const editorRef = useRef<EditorView>(null);
119
+
120
+ const containerRef = useRef<HTMLDivElement>(null);
121
+
122
+ useEffect(() => {
123
+ if (selected) {
124
+ containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
125
+ if (thread.messages.length === 0) {
126
+ setAutoFocus(true);
127
+ }
128
+ }
129
+ }, [selected]);
130
+
131
+ const handleCreateMessage = () => {
132
+ const text = model?.text().trim();
133
+ if (text?.length) {
134
+ thread.messages.push({
135
+ id: item.text.id,
136
+ authorId,
137
+ blocks: [{ text: item.text, timestamp: new Date().toISOString() }],
138
+ });
139
+ setItem({ text: new TextObject() });
140
+ setAutoFocus(true);
141
+ }
142
+ };
143
+
144
+ if (!model) {
145
+ return null;
146
+ }
147
+
148
+ return (
149
+ <Thread current={selected} onClickCapture={onSelect}>
150
+ <div className='col-start-2 flex gap-1 text-xs fg-description'>
151
+ <span>id:{thread.id.slice(0, 4)}</span>
152
+ <span>y:{thread.yPos}</span>
153
+ <span className='grow' />
154
+ {!thread.cursor && <Trash />}
155
+ </div>
156
+ <ThreadHeading>
157
+ {thread.range?.from} &ndash; {thread.range?.to}
158
+ </ThreadHeading>
159
+
160
+ {thread.messages.map((message) => (
161
+ <Message<{ text: TextObject }> key={message.id} {...message} MessageBlockComponent={StoryMessageBlock} />
162
+ ))}
163
+
164
+ <div ref={containerRef} className='contents'>
165
+ <MessageTextbox
166
+ authorId={authorId}
167
+ ref={editorRef}
168
+ autoFocus={autoFocus}
169
+ onEditorFocus={onSelect}
170
+ model={model}
171
+ onSend={handleCreateMessage}
172
+ />
173
+ <ThreadFooter />
174
+ <Button variant='ghost' classNames='px-1' title='Resolve' onClick={onResolve}>
175
+ <Check />
176
+ </Button>
177
+ </div>
178
+ </Thread>
179
+ );
180
+ };
181
+
182
+ const Sidebar: FC<{
183
+ threads: StoryCommentThread[];
184
+ selected?: string;
185
+ onSelect: (thread: string) => void;
186
+ onResolve: (thread: string) => void;
187
+ }> = ({ threads, selected, onSelect, onResolve }) => {
188
+ // Sort by y-position.
189
+ const sortedThreads = useMemo(() => {
190
+ const sorted = [...threads];
191
+ return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
192
+ return a < b ? -1 : a > b ? 1 : 0;
193
+ });
194
+ }, [threads]);
195
+
196
+ return (
197
+ <>
198
+ {sortedThreads.map((thread) => (
199
+ <StoryThread
200
+ key={thread.id}
201
+ thread={thread}
202
+ selected={thread.id === selected}
203
+ onSelect={() => onSelect(thread.id)}
204
+ onResolve={() => onResolve(thread.id)}
205
+ />
206
+ ))}
207
+ </>
208
+ );
209
+ };
210
+
211
+ //
212
+ // Story container.
213
+ //
214
+
215
+ type StoryProps = {
216
+ text?: string;
217
+ autoCreate?: boolean;
218
+ };
219
+
220
+ const Story = ({ text, autoCreate }: StoryProps) => {
221
+ const [item] = useState({ text: new TextObject(text) });
222
+ const [threads, setThreads] = useState<StoryCommentThread[]>([]);
223
+ const [selected, setSelected] = useState<string>();
224
+
225
+ const comments = useMemo<Comment[]>(() => threads.map(({ id, cursor }) => ({ id, cursor })), [threads]);
226
+
227
+ // Filter by visibility.
228
+ const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
229
+
230
+ const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
231
+ const id = PublicKey.random().toHex();
232
+ log.info('create', { id: id.slice(0, 4), cursor });
233
+ setThreads((threads) => [
234
+ ...threads,
235
+ {
236
+ id,
237
+ cursor,
238
+ yPos: location ? Math.floor(location.top) : undefined,
239
+ messages: autoCreate
240
+ ? faker.helpers.multiple(
241
+ () => ({
242
+ id: PublicKey.random().toHex(),
243
+ authorId: PublicKey.random().toHex(),
244
+ blocks: [
245
+ {
246
+ timestamp: new Date().toISOString(),
247
+ text: new TextObject(faker.lorem.sentence()),
248
+ },
249
+ ],
250
+ }),
251
+ { count: { min: 1, max: 3 } },
252
+ )
253
+ : [],
254
+ },
255
+ ]);
256
+ setSelected(id); // TODO(burdon): Not required.
257
+ return id;
258
+ };
259
+
260
+ const handleDeleteComment: CommentsOptions['onDelete'] = (id) => {
261
+ log.info('delete', { id: id.slice(0, 4) });
262
+ setThreads((threads) =>
263
+ threads.map((thread) => {
264
+ if (thread.id === id) {
265
+ thread.cursor = undefined;
266
+ }
267
+
268
+ return thread;
269
+ }),
270
+ );
271
+
272
+ setSelected((selected) => (selected === id ? undefined : selected));
273
+ };
274
+
275
+ const handleUpdateComment: CommentsOptions['onUpdate'] = (id, cursor) => {
276
+ log.info('update', { id: id.slice(0, 4), cursor });
277
+ setThreads((threads) =>
278
+ threads.map((thread) => {
279
+ if (thread.id === id) {
280
+ thread.cursor = cursor;
281
+ }
282
+
283
+ return thread;
284
+ }),
285
+ );
286
+ };
287
+
288
+ const handleSelectComment: CommentsOptions['onSelect'] = ({ comments, selection: { current, closest } }) => {
289
+ log.info('select', { active: current?.slice(0, 4), closest: closest?.slice(0, 4) });
290
+ setThreads((threads) =>
291
+ threads.map((thread) => {
292
+ const comment = comments.find(({ comment }) => comment.id === thread.id);
293
+ if (comment) {
294
+ thread.yPos = comment.location ? Math.floor(comment.location.top) : undefined;
295
+ thread.range = { from: comment.range.from, to: comment.range.to };
296
+ }
297
+
298
+ return thread;
299
+ }),
300
+ );
301
+
302
+ setSelected(current ?? closest);
303
+ };
304
+
305
+ const handleSelectThread = (id: string) => {
306
+ setSelected(id);
307
+ };
308
+
309
+ const handleResolveThread = (id: string) => {
310
+ setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
311
+ setSelected(undefined);
312
+ };
313
+
314
+ return (
315
+ <main className='fixed inset-0 grid grid-cols-[1fr_24rem]'>
316
+ <div role='none' className='max-bs-full overflow-y-auto p-4'>
317
+ <Editor
318
+ item={item}
319
+ selected={selected}
320
+ comments={comments}
321
+ onCreateComment={handleCreateComment}
322
+ onDeleteComment={handleDeleteComment}
323
+ onUpdateComment={handleUpdateComment}
324
+ onSelectComment={handleSelectComment}
325
+ />
326
+ </div>
327
+
328
+ <div role='none' className='max-bs-full overflow-y-auto p-4'>
329
+ <Sidebar
330
+ threads={visibleThreads}
331
+ selected={selected}
332
+ onSelect={handleSelectThread}
333
+ onResolve={handleResolveThread}
334
+ />
335
+ </div>
336
+ </main>
337
+ );
338
+ };
339
+
340
+ export default {
341
+ title: 'react-ui-thread/Comments',
342
+ component: StoryThread,
343
+ decorators: [withTheme],
344
+ render: (args: StoryProps) => <Story {...args} />,
345
+ parameters: { translations, layout: 'fullscreen' },
346
+ };
347
+
348
+ const str = (...lines: string[]) => lines.join('\n');
349
+
350
+ const document = str(
351
+ '# Comments',
352
+ '',
353
+ str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
354
+ '',
355
+ );
356
+
357
+ export const Default = {
358
+ args: {
359
+ text: document,
360
+ },
361
+ };
362
+
363
+ export const Testing = {
364
+ args: {
365
+ text: document,
366
+ autoCreate: true,
367
+ },
368
+ };
@@ -0,0 +1,77 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import '@dxosTheme';
6
+
7
+ import React, { useState } from 'react';
8
+
9
+ import { PublicKey } from '@dxos/keys';
10
+ import { useInMemoryTextModel } from '@dxos/react-ui-editor';
11
+ import { withTheme } from '@dxos/storybook-utils';
12
+
13
+ import { Thread, ThreadFooter } from './Thread';
14
+ import { Message, MessageTextbox } from '../Message';
15
+ import translations from '../translations';
16
+ import { type MessageEntity } from '../types';
17
+
18
+ const Story = () => {
19
+ const [pending, setPending] = useState(false);
20
+ const [identityKey1] = useState(PublicKey.random());
21
+ const [identityKey2] = useState(PublicKey.random());
22
+ const [messages] = useState<MessageEntity<{ id: string; text: string }>[]>([
23
+ {
24
+ id: 'm1',
25
+ authorId: identityKey1.toHex(),
26
+ blocks: [
27
+ {
28
+ id: 'b1',
29
+ timestamp: new Date().toISOString(),
30
+ text: 'hello',
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ id: 'm2',
36
+ authorId: identityKey2.toHex(),
37
+ blocks: [
38
+ {
39
+ id: 'b2',
40
+ timestamp: new Date().toISOString(),
41
+ text: 'hi there',
42
+ },
43
+ ],
44
+ },
45
+ ]);
46
+ const nextMessageModel = useInMemoryTextModel({ id: 't1__next' });
47
+
48
+ // TODO(thure): Why does pressing Enter clear the text content?
49
+ // Something to do with the in-memory text model perhaps?
50
+ const handleSend = () => {
51
+ setPending(true);
52
+ setTimeout(() => {
53
+ nextMessageModel.setContent('');
54
+ setPending(false);
55
+ }, 2_000);
56
+ };
57
+
58
+ return (
59
+ <Thread id='t1'>
60
+ {messages.map((message) => (
61
+ <Message key={message.id} {...message} />
62
+ ))}
63
+ <MessageTextbox disabled={pending} authorId={identityKey1.toHex()} onSend={handleSend} model={nextMessageModel} />
64
+ <ThreadFooter activity>Someone is typing</ThreadFooter>
65
+ </Thread>
66
+ );
67
+ };
68
+
69
+ export default {
70
+ title: 'react-ui-thread/Thread',
71
+ component: Thread,
72
+ render: Story,
73
+ decorators: [withTheme],
74
+ parameters: { translations },
75
+ };
76
+
77
+ export const Default = {};
@@ -0,0 +1,82 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { ArrowBendLeftDown, DotsThreeOutline } from '@phosphor-icons/react';
6
+ import React, { type ComponentProps, type ComponentPropsWithRef, forwardRef } from 'react';
7
+
8
+ import { type ThemedClassName, useTranslation } from '@dxos/react-ui';
9
+ import { hoverableControlItem, hoverableFocusedWithinControls, mx } from '@dxos/react-ui-theme';
10
+
11
+ import { translationKey } from '../translations';
12
+ import type { ThreadEntity } from '../types';
13
+
14
+ export type ThreadProps = ThemedClassName<ComponentPropsWithRef<'div'>> &
15
+ ThreadEntity & { current?: boolean | ComponentProps<'div'>['aria-current'] };
16
+
17
+ export const Thread = forwardRef<HTMLDivElement, ThreadProps>(
18
+ ({ current, children, classNames, ...props }, forwardedRef) => {
19
+ return (
20
+ <div
21
+ role='group'
22
+ {...(current && { 'aria-current': typeof current === 'string' ? current : 'location' })}
23
+ {...props}
24
+ className={mx(
25
+ 'is-full place-self-start grid grid-cols-[3rem_1fr] bg-[var(--surface-bg)] border-[color:var(--surface-separator)] border-bs border-be plb-1.5 attention attention-within attention-current [--controls-opacity:0]',
26
+ hoverableFocusedWithinControls,
27
+ classNames,
28
+ )}
29
+ ref={forwardedRef}
30
+ >
31
+ {children}
32
+ </div>
33
+ );
34
+ },
35
+ );
36
+
37
+ export type ThreadHeadingProps = ThemedClassName<ComponentPropsWithRef<'div'>>;
38
+
39
+ export const ThreadHeading = forwardRef<HTMLDivElement, ThreadHeadingProps>(
40
+ ({ classNames, children, ...props }, forwardedRef) => {
41
+ return (
42
+ <div
43
+ role='heading'
44
+ {...props}
45
+ className={mx('col-span-2 grid grid-cols-subgrid fg-description font-medium', classNames)}
46
+ ref={forwardedRef}
47
+ >
48
+ <div role='none' className='grid place-items-end pie-3.5 fg-separator'>
49
+ <ArrowBendLeftDown />
50
+ </div>
51
+ <p>{children}</p>
52
+ </div>
53
+ );
54
+ },
55
+ );
56
+
57
+ export type ThreadFooterProps = ThemedClassName<ComponentPropsWithRef<'div'>> & {
58
+ activity?: boolean;
59
+ };
60
+
61
+ export const ThreadFooter = forwardRef<HTMLDivElement, ThreadFooterProps>(
62
+ ({ activity, classNames, children, ...props }, forwardedRef) => {
63
+ const { t } = useTranslation(translationKey);
64
+ return (
65
+ <div
66
+ {...props}
67
+ className={mx('col-start-2 grid grid-cols-[min-content_1fr_max-content] text-xs fg-description', classNames)}
68
+ ref={forwardedRef}
69
+ >
70
+ <DotsThreeOutline
71
+ weight='fill'
72
+ data-visible={activity ? 'show' : 'hide'}
73
+ className='is-6 bs-4 invisible data-[visible=show]:visible'
74
+ />
75
+ <span className='truncate min-is-0' aria-live='polite'>
76
+ {activity ? children : null}
77
+ </span>
78
+ <span className={mx('text-end pie-1', hoverableControlItem)}>{t('enter to send message')}</span>
79
+ </div>
80
+ );
81
+ },
82
+ );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './Thread';
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import translations from './translations';
6
+
7
+ export * from './Thread';
8
+ export * from './Message';
9
+ export * from './types';
10
+ export { translations };
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export const translationKey = 'thread';
6
+
7
+ export default [
8
+ {
9
+ 'en-US': {
10
+ [translationKey]: {
11
+ 'plugin name': 'Threads',
12
+ 'thread title placeholder': 'New thread',
13
+ 'thread title label': 'Title',
14
+ 'delete thread label': 'Delete',
15
+ 'create thread label': 'Create thread',
16
+ 'message placeholder': 'Enter message...',
17
+ 'comment placeholder': 'Enter comment...',
18
+ 'settings standalone label': 'Enable standalone thread creation',
19
+ 'anonymous label': 'Anonymous',
20
+ 'enter to send message': 'Enter to add ⏎',
21
+ },
22
+ },
23
+ },
24
+ ];
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import type { AvatarStatus } from '@dxos/react-ui';
6
+
7
+ export type MessageMetadata = {
8
+ id: string;
9
+ authorId: string;
10
+ authorName?: string;
11
+ authorImgSrc?: string;
12
+ authorStatus?: AvatarStatus;
13
+ };
14
+
15
+ export type MessageEntityBlock<BlockValue> = BlockValue & {
16
+ timestamp?: string;
17
+ };
18
+
19
+ export type MessageEntity<BlockValue> = MessageMetadata & {
20
+ blocks: MessageEntityBlock<BlockValue>[];
21
+ };
22
+
23
+ export type ThreadEntity = {
24
+ topicId?: string;
25
+ topicTitle?: string;
26
+ };