@dxos/plugin-thread 0.6.12-main.15a606f → 0.6.12-main.2d19bf1

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 (74) hide show
  1. package/dist/lib/browser/{ThreadArticle-4VU752F4.mjs → ThreadArticle-GBPIH47W.mjs} +3 -3
  2. package/dist/lib/browser/{ThreadMain-ML3P4XSD.mjs → ThreadMain-PGF7W5EV.mjs} +3 -3
  3. package/dist/lib/browser/{chunk-7NUYW5IW.mjs → chunk-5G3KL6TC.mjs} +4 -6
  4. package/dist/lib/browser/chunk-5G3KL6TC.mjs.map +7 -0
  5. package/dist/lib/browser/{chunk-GJRBBPXI.mjs → chunk-JFQZ5W2I.mjs} +2 -2
  6. package/dist/lib/browser/{chunk-GJRBBPXI.mjs.map → chunk-JFQZ5W2I.mjs.map} +1 -1
  7. package/dist/lib/browser/{chunk-XF5QKI75.mjs → chunk-O4YRHIZC.mjs} +2 -2
  8. package/dist/lib/browser/index.mjs +185 -196
  9. package/dist/lib/browser/index.mjs.map +4 -4
  10. package/dist/lib/browser/meta.json +1 -1
  11. package/dist/lib/browser/meta.mjs +1 -1
  12. package/dist/lib/browser/types.mjs +2 -2
  13. package/dist/lib/node/{ThreadArticle-ERUHPHVT.cjs → ThreadArticle-GH5UBWFU.cjs} +7 -7
  14. package/dist/lib/node/{ThreadMain-6DN7FDNB.cjs → ThreadMain-CO6I3DNX.cjs} +7 -7
  15. package/dist/lib/node/{chunk-PZVUW4W2.cjs → chunk-FDYRG7HE.cjs} +9 -9
  16. package/dist/lib/node/{chunk-FXD3DYMO.cjs → chunk-FOZUQZF4.cjs} +6 -6
  17. package/dist/lib/node/{chunk-FXD3DYMO.cjs.map → chunk-FOZUQZF4.cjs.map} +1 -1
  18. package/dist/lib/node/{chunk-6EVXCSDS.cjs → chunk-JIJ7Z6NO.cjs} +6 -8
  19. package/dist/lib/node/chunk-JIJ7Z6NO.cjs.map +7 -0
  20. package/dist/lib/node/index.cjs +207 -220
  21. package/dist/lib/node/index.cjs.map +4 -4
  22. package/dist/lib/node/meta.cjs +4 -4
  23. package/dist/lib/node/meta.cjs.map +1 -1
  24. package/dist/lib/node/meta.json +1 -1
  25. package/dist/lib/node/types.cjs +3 -3
  26. package/dist/lib/node/types.cjs.map +1 -1
  27. package/dist/lib/node-esm/{ThreadArticle-NKI5F4W5.mjs → ThreadArticle-ZTYD52TR.mjs} +3 -3
  28. package/dist/lib/node-esm/{ThreadMain-2A3VQLCW.mjs → ThreadMain-LEHGOF7R.mjs} +3 -3
  29. package/dist/lib/node-esm/{chunk-Y475KZTX.mjs → chunk-DC5ZA6H5.mjs} +4 -6
  30. package/dist/lib/node-esm/chunk-DC5ZA6H5.mjs.map +7 -0
  31. package/dist/lib/node-esm/{chunk-A44C4MTQ.mjs → chunk-XOVJ65DG.mjs} +2 -2
  32. package/dist/lib/node-esm/{chunk-YO46JGAE.mjs → chunk-ZXIPFXSA.mjs} +2 -2
  33. package/dist/lib/node-esm/{chunk-YO46JGAE.mjs.map → chunk-ZXIPFXSA.mjs.map} +1 -1
  34. package/dist/lib/node-esm/index.mjs +185 -196
  35. package/dist/lib/node-esm/index.mjs.map +4 -4
  36. package/dist/lib/node-esm/meta.json +1 -1
  37. package/dist/lib/node-esm/meta.mjs +1 -1
  38. package/dist/lib/node-esm/types.mjs +2 -2
  39. package/dist/types/src/ThreadPlugin.d.ts.map +1 -1
  40. package/dist/types/src/components/CommentContainer.d.ts +1 -1
  41. package/dist/types/src/components/CommentContainer.d.ts.map +1 -1
  42. package/dist/types/src/components/CommentsContainer.d.ts +1 -5
  43. package/dist/types/src/components/CommentsContainer.d.ts.map +1 -1
  44. package/dist/types/src/components/ThreadComplementary.d.ts +2 -3
  45. package/dist/types/src/components/ThreadComplementary.d.ts.map +1 -1
  46. package/dist/types/src/extensions/index.d.ts +2 -0
  47. package/dist/types/src/extensions/index.d.ts.map +1 -0
  48. package/dist/types/src/extensions/threads.d.ts +9 -0
  49. package/dist/types/src/extensions/threads.d.ts.map +1 -0
  50. package/dist/types/src/meta.d.ts +3 -6
  51. package/dist/types/src/meta.d.ts.map +1 -1
  52. package/dist/types/src/types.d.ts +5 -0
  53. package/dist/types/src/types.d.ts.map +1 -1
  54. package/package.json +31 -31
  55. package/src/ThreadPlugin.tsx +58 -142
  56. package/src/components/CommentContainer.tsx +8 -12
  57. package/src/components/CommentsContainer.tsx +1 -18
  58. package/src/components/ThreadComplementary.tsx +9 -16
  59. package/src/extensions/index.ts +5 -0
  60. package/src/extensions/threads.ts +117 -0
  61. package/src/{meta.tsx → meta.ts} +3 -4
  62. package/src/types.ts +7 -2
  63. package/dist/lib/browser/chunk-7NUYW5IW.mjs.map +0 -7
  64. package/dist/lib/node/chunk-6EVXCSDS.cjs.map +0 -7
  65. package/dist/lib/node-esm/chunk-Y475KZTX.mjs.map +0 -7
  66. /package/dist/lib/browser/{ThreadArticle-4VU752F4.mjs.map → ThreadArticle-GBPIH47W.mjs.map} +0 -0
  67. /package/dist/lib/browser/{ThreadMain-ML3P4XSD.mjs.map → ThreadMain-PGF7W5EV.mjs.map} +0 -0
  68. /package/dist/lib/browser/{chunk-XF5QKI75.mjs.map → chunk-O4YRHIZC.mjs.map} +0 -0
  69. /package/dist/lib/node/{ThreadArticle-ERUHPHVT.cjs.map → ThreadArticle-GH5UBWFU.cjs.map} +0 -0
  70. /package/dist/lib/node/{ThreadMain-6DN7FDNB.cjs.map → ThreadMain-CO6I3DNX.cjs.map} +0 -0
  71. /package/dist/lib/node/{chunk-PZVUW4W2.cjs.map → chunk-FDYRG7HE.cjs.map} +0 -0
  72. /package/dist/lib/node-esm/{ThreadArticle-NKI5F4W5.mjs.map → ThreadArticle-ZTYD52TR.mjs.map} +0 -0
  73. /package/dist/lib/node-esm/{ThreadMain-2A3VQLCW.mjs.map → ThreadMain-LEHGOF7R.mjs.map} +0 -0
  74. /package/dist/lib/node-esm/{chunk-A44C4MTQ.mjs.map → chunk-XOVJ65DG.mjs.map} +0 -0
@@ -2,12 +2,10 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { computed, effect } from '@preact/signals-core';
6
5
  import React from 'react';
7
6
 
8
7
  import {
9
8
  type IntentPluginProvides,
10
- LayoutAction,
11
9
  NavigationAction,
12
10
  type LocationProvides,
13
11
  type Plugin,
@@ -28,40 +26,27 @@ import { type ActionGroup, createExtension, isActionGroup, toSignal } from '@dxo
28
26
  import { ObservabilityAction } from '@dxos/plugin-observability/meta';
29
27
  import { SpaceAction } from '@dxos/plugin-space';
30
28
  import { ThreadType, MessageType, ChannelType } from '@dxos/plugin-space/types';
31
- import {
32
- getSpace,
33
- getTextInRange,
34
- createDocAccessor,
35
- fullyQualifiedId,
36
- loadObjectReferences,
37
- } from '@dxos/react-client/echo';
38
- import { comments, createExternalCommentSync, listener } from '@dxos/react-ui-editor';
29
+ import { getSpace, fullyQualifiedId, loadObjectReferences } from '@dxos/react-client/echo';
39
30
  import { translations as threadTranslations } from '@dxos/react-ui-thread';
40
- import { nonNullable } from '@dxos/util';
41
31
 
42
32
  import { ThreadMain, ThreadSettings, ChatContainer, ChatHeading, ThreadArticle } from './components';
43
33
  import { ThreadComplementary } from './components/ThreadComplementary';
34
+ import { threads } from './extensions';
44
35
  import meta, { THREAD_ITEM, THREAD_PLUGIN } from './meta';
45
36
  import translations from './translations';
46
- import { ThreadAction, type ThreadPluginProvides, type ThreadSettingsProps } from './types';
47
-
48
- type ThreadState = {
49
- /** An in-memory staging area for threads that are being drafted. */
50
- staging: Record<string, ThreadType[]>;
51
- current?: string | undefined;
52
- focus?: boolean;
53
- };
37
+ import { ThreadAction, type ThreadPluginProvides, type ThreadSettingsProps, type ThreadState } from './types';
54
38
 
55
39
  type SubjectId = string;
40
+
56
41
  const initialViewState = { showResolvedThreads: false };
57
- type ViewStore = Record<SubjectId, typeof initialViewState>;
58
42
 
59
- // TODO(Zan): More robust runtime check.
43
+ type ViewStore = Record<SubjectId, typeof initialViewState>;
60
44
 
61
45
  // TODO(Zan): Every instance of `cursor` should be replaced with `anchor`.
46
+ // NOTE(burdon): Review/discuss CursorConverter semantics.
62
47
  export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
63
48
  const settings = new LocalStorageStore<ThreadSettingsProps>(THREAD_PLUGIN);
64
- const state = create<ThreadState>({ staging: {} });
49
+ const state = create<ThreadState>({ drafts: {} });
65
50
 
66
51
  const viewStore = create<ViewStore>({});
67
52
  const getViewState = (subjectId: string) => {
@@ -153,6 +138,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
153
138
  return () => clearTimeout(timeout);
154
139
  },
155
140
  () => space?.db.getObjectById(objectId),
141
+ subjectId,
156
142
  );
157
143
  if (!object || !subjectId) {
158
144
  return;
@@ -168,11 +154,12 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
168
154
  return {
169
155
  id,
170
156
  type: 'orphan-comments-for-subject',
171
- data: object,
157
+ data: null,
172
158
  properties: {
173
- icon: meta.icon,
159
+ icon: 'ph--chat-text--regular',
174
160
  label,
175
161
  showResolvedThreads: viewState.showResolvedThreads,
162
+ object,
176
163
  },
177
164
  };
178
165
  },
@@ -261,8 +248,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
261
248
  return data.plugin === meta.id ? <ThreadSettings settings={settings.values} /> : null;
262
249
  }
263
250
 
264
- case 'article':
265
- case 'complementary': {
251
+ case 'article': {
266
252
  const location = navigationPlugin?.provides.location;
267
253
 
268
254
  if (data.object instanceof ChannelType && data.object.threads[0]) {
@@ -290,6 +276,10 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
290
276
  );
291
277
  }
292
278
 
279
+ break;
280
+ }
281
+
282
+ case 'complementary--comments': {
293
283
  if (
294
284
  data.subject &&
295
285
  typeof data.subject === 'object' &&
@@ -302,13 +292,14 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
302
292
  <ThreadComplementary
303
293
  role={role}
304
294
  subject={data.subject}
305
- stagedThreads={state.staging[fullyQualifiedId(data.subject)]}
295
+ drafts={state.drafts[fullyQualifiedId(data.subject)]}
306
296
  current={state.current}
307
- focus
308
297
  showResolvedThreads={showResolvedThreads}
309
298
  />
310
299
  );
311
300
  }
301
+
302
+ break;
312
303
  }
313
304
  }
314
305
 
@@ -327,7 +318,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
327
318
  try {
328
319
  // Static schema will throw an error if subject does not support threads array property.
329
320
  subject.threads = [];
330
- } catch (e) {
321
+ } catch (err) {
331
322
  log.error('Subject does not support threads array', subject?.typename);
332
323
  return;
333
324
  }
@@ -335,11 +326,11 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
335
326
 
336
327
  const subjectId = fullyQualifiedId(subject);
337
328
  const thread = create(ThreadType, { name, anchor: cursor, messages: [], status: 'staged' });
338
- const stagingArea = state.staging[subjectId];
339
- if (stagingArea) {
340
- stagingArea.push(thread);
329
+ const draft = state.drafts[subjectId];
330
+ if (draft) {
331
+ draft.push(thread);
341
332
  } else {
342
- state.staging[subjectId] = [thread];
333
+ state.drafts[subjectId] = [thread];
343
334
  }
344
335
 
345
336
  return {
@@ -348,14 +339,14 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
348
339
  [
349
340
  {
350
341
  action: ThreadAction.SELECT,
351
- data: { current: fullyQualifiedId(thread), focus: true },
342
+ data: { current: fullyQualifiedId(thread) },
352
343
  },
344
+ ],
345
+ [
353
346
  {
354
- action: LayoutAction.SET_LAYOUT,
347
+ action: NavigationAction.OPEN,
355
348
  data: {
356
- element: 'complementary',
357
- subject: subjectId,
358
- state: true,
349
+ activeParts: { complementary: 'comments' },
359
350
  },
360
351
  },
361
352
  ],
@@ -363,14 +354,30 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
363
354
  };
364
355
  } else {
365
356
  // NOTE: This is the standalone thread creation case.
366
- return { data: create(ChannelType, { threads: [create(ThreadType, { messages: [] })] }) };
357
+ return {
358
+ data: create(ChannelType, { threads: [create(ThreadType, { messages: [] })] }),
359
+ };
367
360
  }
368
361
  }
369
362
 
370
363
  case ThreadAction.SELECT: {
371
- state.focus = intent.data?.current === state.current ? state.focus : intent.data?.focus;
372
364
  state.current = intent.data?.current;
373
- return { data: true };
365
+
366
+ return {
367
+ data: true,
368
+ intents: !intent.data?.skipOpen
369
+ ? [
370
+ [
371
+ {
372
+ action: NavigationAction.OPEN,
373
+ data: {
374
+ activeParts: { complementary: 'comments' },
375
+ },
376
+ },
377
+ ],
378
+ ]
379
+ : [],
380
+ };
374
381
  }
375
382
 
376
383
  case ThreadAction.TOGGLE_RESOLVED: {
@@ -389,6 +396,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
389
396
  const spaceId = space?.id;
390
397
 
391
398
  return {
399
+ data: true,
392
400
  intents: [
393
401
  [
394
402
  {
@@ -410,13 +418,12 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
410
418
  }
411
419
 
412
420
  const subjectId = fullyQualifiedId(subject);
413
- const stagingArea = state.staging[subjectId];
414
- if (stagingArea) {
415
- // Check if we're deleting a thread that's in the staging area.
416
- // If so, remove it from the staging area without ceremony.
417
- const index = stagingArea.findIndex((t) => t.id === thread.id);
421
+ const draft = state.drafts[subjectId];
422
+ if (draft) {
423
+ // Check if we're deleting a draft; if so, remove it.
424
+ const index = draft.findIndex((t) => t.id === thread.id);
418
425
  if (index !== -1) {
419
- stagingArea.splice(index, 1);
426
+ draft.splice(index, 1);
420
427
  return { data: true };
421
428
  }
422
429
  }
@@ -483,12 +490,11 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
483
490
  const intents = [];
484
491
  const analyticsProperties = { threadId: thread.id, spaceId: space?.id };
485
492
 
486
- if (state.staging[subjectId]?.find((t) => t === thread)) {
487
- // Move thread from staging to document.
493
+ if (state.drafts[subjectId]?.find((t) => t === thread)) {
494
+ // Move draft to document.
488
495
  thread.status = 'active';
489
496
  subject.threads ? subject.threads.push(thread) : (subject.threads = [thread]);
490
- state.staging[subjectId] = state.staging[subjectId]?.filter((t) => t.id !== thread.id);
491
-
497
+ state.drafts[subjectId] = state.drafts[subjectId]?.filter(({ id }) => id !== thread.id);
492
498
  intents.push({
493
499
  action: ObservabilityAction.SEND_EVENT,
494
500
  data: { name: 'threads.thread-created', properties: analyticsProperties },
@@ -556,13 +562,11 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
556
562
  } else if (intent.undo) {
557
563
  const message = intent.data?.message;
558
564
  const messageIndex = intent.data?.messageIndex;
559
-
560
565
  if (!(message instanceof MessageType) || !(typeof messageIndex === 'number')) {
561
566
  return;
562
567
  }
563
568
 
564
569
  thread.messages.splice(messageIndex, 0, message);
565
-
566
570
  return {
567
571
  data: true,
568
572
  intents: [
@@ -583,96 +587,8 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
583
587
  },
584
588
  },
585
589
  markdown: {
586
- // TODO(burdon): Factor out extension factory into separate file (for simplicity).
587
590
  extensions: ({ document: doc }) => {
588
- const space = doc && getSpace(doc);
589
- if (!doc || !space) {
590
- // Include no-op comments extension here to ensure that the facets are always present when they are expected.
591
- // TODO(wittjosiah): The Editor should only look for these facets when comments are available.
592
- return [comments()];
593
- }
594
-
595
- // TODO(Zan): When we have the deepsignal specific equivalent of this we should use that instead.
596
- const threads = computed(() =>
597
- [...doc.threads.filter(nonNullable), ...(state.staging[fullyQualifiedId(doc)] ?? [])].filter(
598
- (thread) => !(thread?.status === 'resolved'),
599
- ),
600
- );
601
-
602
- return [
603
- listener({
604
- onChange: () => {
605
- doc.threads.forEach((thread) => {
606
- if (thread?.anchor) {
607
- const [start, end] = thread.anchor.split(':');
608
- const name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
609
- // TODO(burdon): This seems unsafe; review.
610
- // Only update if the name has changed, otherwise this will cause an infinite loop.
611
- // Skip if the name is empty - this means comment text was deleted, but thread name should remain.
612
- if (name && name !== thread.name) {
613
- thread.name = name;
614
- }
615
- }
616
- });
617
- },
618
- }),
619
- createExternalCommentSync(
620
- doc.id,
621
- (sink) => effect(() => sink()),
622
- () =>
623
- threads.value
624
- .filter((thread) => thread?.anchor)
625
- .map((thread) => ({ id: fullyQualifiedId(thread), cursor: thread.anchor! })),
626
- ),
627
- comments({
628
- id: doc.id,
629
- onCreate: ({ cursor }) => {
630
- const [start, end] = cursor.split(':');
631
- const name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
632
-
633
- void intentPlugin?.provides.intent.dispatch({
634
- action: ThreadAction.CREATE,
635
- data: {
636
- cursor,
637
- name,
638
- subject: doc,
639
- },
640
- });
641
- },
642
- onDelete: ({ id }) => {
643
- // If the thread is in the staging area, remove it.
644
- const stagingArea = state.staging[fullyQualifiedId(doc)];
645
- if (stagingArea) {
646
- const index = stagingArea.findIndex((thread) => fullyQualifiedId(thread) === id);
647
- if (index !== -1) {
648
- stagingArea.splice(index, 1);
649
- }
650
- }
651
-
652
- const thread = doc.threads.find((thread) => thread?.id === id);
653
- if (thread) {
654
- thread.anchor = undefined;
655
- }
656
- },
657
- onUpdate: ({ id, cursor }) => {
658
- const thread =
659
- state.staging[fullyQualifiedId(doc)]?.find((thread) => fullyQualifiedId(thread) === id) ??
660
- doc.threads.find((thread) => thread?.id === id);
661
-
662
- if (thread instanceof ThreadType && thread.anchor) {
663
- const [start, end] = thread.anchor.split(':');
664
- thread.name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
665
- thread.anchor = cursor;
666
- }
667
- },
668
- onSelect: ({ selection: { current, closest } }) => {
669
- const dispatch = intentPlugin?.provides.intent.dispatch;
670
- if (dispatch) {
671
- void dispatch([{ action: ThreadAction.SELECT, data: { current: current ?? closest } }]);
672
- }
673
- },
674
- }),
675
- ];
591
+ return threads(state, doc, intentPlugin?.provides.intent.dispatch);
676
592
  },
677
593
  },
678
594
  },
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import { CheckCircle, X } from '@phosphor-icons/react';
6
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
7
7
 
8
8
  import { create } from '@dxos/echo-schema';
9
9
  import { MessageType } from '@dxos/plugin-space/types';
@@ -93,7 +93,6 @@ export const CommentContainer = ({
93
93
  detached,
94
94
  context,
95
95
  current,
96
- autoFocusTextbox,
97
96
  onAttend,
98
97
  onThreadDelete,
99
98
  onMessageDelete,
@@ -106,7 +105,6 @@ export const CommentContainer = ({
106
105
  const activity = useStatus(space, fullyQualifiedId(thread));
107
106
  const { t } = useTranslation(THREAD_PLUGIN);
108
107
  const threadScrollRef = useRef<HTMLDivElement | null>(null);
109
- const [autoFocus, setAutoFocus] = useState(!!autoFocusTextbox);
110
108
  const { themeMode } = useThemeContext();
111
109
 
112
110
  const textboxMetadata = getMessageMetadata(fullyQualifiedId(thread), identity);
@@ -124,13 +122,6 @@ export const CommentContainer = ({
124
122
  [_count],
125
123
  );
126
124
 
127
- // TODO(thure): Because of the way the `autoFocus` property is handled by TextEditor,
128
- // this is the least-bad way of moving focus at the right time, though it is an anti-pattern.
129
- // Refactor to behave more like <input/>’s `autoFocus` or `autofocus` (yes, they’re different).
130
- useEffect(() => {
131
- setAutoFocus(!!autoFocusTextbox);
132
- }, [autoFocusTextbox]);
133
-
134
125
  // TODO(thure): Factor out.
135
126
  const scrollToEnd = (behavior: ScrollBehavior) =>
136
127
  setTimeout(() => threadScrollRef.current?.scrollIntoView({ behavior, block: 'end' }), 10);
@@ -151,7 +142,6 @@ export const CommentContainer = ({
151
142
  onComment?.(thread);
152
143
 
153
144
  messageRef.current = '';
154
- setAutoFocus(true);
155
145
  scrollToEnd('instant');
156
146
  rerenderEditor();
157
147
 
@@ -200,7 +190,13 @@ export const CommentContainer = ({
200
190
  onDelete={(id: string) => onMessageDelete?.(id)}
201
191
  />
202
192
  ))}
203
- <MessageTextbox extensions={extensions} autoFocus={autoFocus} onSend={handleCreate} {...textboxMetadata} />
193
+ {/*
194
+ TODO(wittjosiah): Can't autofocus this generally.
195
+ There can be multiple threads with inputs and they can't all be focused.
196
+ Also, it steals focus from documents when first rendered.
197
+ Need to find a way to autofocus in one scenario only: when a new thread is created.
198
+ */}
199
+ <MessageTextbox extensions={extensions} onSend={handleCreate} {...textboxMetadata} />
204
200
  <ThreadFooter activity={activity}>{t('activity message')}</ThreadFooter>
205
201
  {/* NOTE(thure): This can’t also be the `overflow-anchor` because `ScrollArea` injects an interceding node that contains this necessary ref’d element. */}
206
202
  <div role='none' className='bs-px -mbs-px' ref={threadScrollRef} />
@@ -7,8 +7,7 @@ import React, { useEffect } from 'react';
7
7
 
8
8
  import { type ThreadType } from '@dxos/plugin-space/types';
9
9
  import { fullyQualifiedId } from '@dxos/react-client/echo';
10
- import { useTranslation, Icon, Trans } from '@dxos/react-ui';
11
- import { PlankHeading } from '@dxos/react-ui-deck';
10
+ import { useTranslation, Trans } from '@dxos/react-ui';
12
11
  import { descriptionText, mx } from '@dxos/react-ui-theme';
13
12
 
14
13
  import { CommentContainer } from './CommentContainer';
@@ -25,7 +24,6 @@ export type ThreadsContainerProps = Omit<
25
24
  */
26
25
  detached?: string[];
27
26
  currentId?: string;
28
- autoFocusCurrentTextbox?: boolean;
29
27
  showResolvedThreads?: boolean;
30
28
  onThreadAttend?: (thread: ThreadType) => void;
31
29
  onThreadDelete?: (thread: ThreadType) => void;
@@ -34,19 +32,6 @@ export type ThreadsContainerProps = Omit<
34
32
  onComment?: (thread: ThreadType) => void;
35
33
  };
36
34
 
37
- export const CommentsHeading = ({ attendableId }: { attendableId?: string }) => {
38
- const { t } = useTranslation(THREAD_PLUGIN);
39
-
40
- return (
41
- <div role='none' className='flex items-center'>
42
- <PlankHeading.Button attendableId={attendableId}>
43
- <Icon icon='ph--quotes--regular' size={5} />
44
- </PlankHeading.Button>
45
- <PlankHeading.Label attendableId={attendableId}>{t('comments heading')}</PlankHeading.Label>
46
- </div>
47
- );
48
- };
49
-
50
35
  /**
51
36
  * Comment threads.
52
37
  */
@@ -54,7 +39,6 @@ export const CommentsContainer = ({
54
39
  threads,
55
40
  detached = [],
56
41
  currentId,
57
- autoFocusCurrentTextbox,
58
42
  showResolvedThreads,
59
43
  onThreadAttend,
60
44
  onThreadDelete,
@@ -82,7 +66,6 @@ export const CommentsContainer = ({
82
66
  thread={thread}
83
67
  current={currentId === threadId}
84
68
  detached={detached.includes(threadId)}
85
- autoFocusTextbox={autoFocusCurrentTextbox && currentId === threadId}
86
69
  {...(onThreadAttend && { onAttend: () => onThreadAttend(thread) })}
87
70
  {...(onThreadDelete && { onThreadDelete: () => onThreadDelete(thread) })}
88
71
  {...(onMessageDelete && { onMessageDelete: (messageId: string) => onMessageDelete(thread, messageId) })}
@@ -8,10 +8,10 @@ import { LayoutAction, type Plugin, useIntentDispatcher, useResolvePlugins } fro
8
8
  import { type ThreadType } from '@dxos/plugin-space';
9
9
  import { fullyQualifiedId } from '@dxos/react-client/echo';
10
10
  import { ScrollArea } from '@dxos/react-ui';
11
- import { createAttendableAttributes, useAttentionContext } from '@dxos/react-ui-attention';
11
+ import { useAttended } from '@dxos/react-ui-attention';
12
12
  import { nonNullable } from '@dxos/util';
13
13
 
14
- import { CommentsContainer, CommentsHeading } from '../components';
14
+ import { CommentsContainer } from '../components';
15
15
  import { THREAD_PLUGIN } from '../meta';
16
16
  import { ThreadAction, type ThreadProvides } from '../types';
17
17
 
@@ -21,16 +21,14 @@ const providesThreadsConfig = (plugin: any): Plugin<ThreadProvides<any>> | undef
21
21
  export const ThreadComplementary = ({
22
22
  role,
23
23
  subject,
24
- stagedThreads,
24
+ drafts,
25
25
  current,
26
- focus,
27
26
  showResolvedThreads,
28
27
  }: {
29
28
  role: string;
30
29
  subject: any;
31
- stagedThreads: ThreadType[] | undefined;
30
+ drafts: ThreadType[] | undefined;
32
31
  current?: string;
33
- focus?: boolean;
34
32
  showResolvedThreads?: boolean;
35
33
  }) => {
36
34
  const dispatch = useIntentDispatcher();
@@ -44,8 +42,8 @@ export const ThreadComplementary = ({
44
42
  const sort = useMemo(() => createSort?.(subject), [createSort, subject]);
45
43
 
46
44
  const threads = useMemo(() => {
47
- return subject.threads.concat(stagedThreads ?? []).filter(nonNullable) as ThreadType[];
48
- }, [JSON.stringify(subject.threads), JSON.stringify(stagedThreads)]);
45
+ return subject.threads.concat(drafts ?? []).filter(nonNullable) as ThreadType[];
46
+ }, [JSON.stringify(subject.threads), JSON.stringify(drafts)]);
49
47
 
50
48
  const detachedIds = useMemo(() => {
51
49
  return threads.filter(({ anchor }) => !anchor).map((thread) => fullyQualifiedId(thread));
@@ -58,22 +56,17 @@ export const ThreadComplementary = ({
58
56
  threads.sort((a, b) => sort(a?.anchor, b?.anchor));
59
57
  }, [sort, threads]);
60
58
 
61
- // TODO(Zan): Maybe we should have a hook for this?
62
- const { attended } = useAttentionContext('Thread Complementary');
59
+ const attended = useAttended();
63
60
  const qualifiedSubjectId = fullyQualifiedId(subject);
64
61
 
65
- const attendableAttrs = createAttendableAttributes(qualifiedSubjectId);
66
-
67
62
  return (
68
- <div role='none' className='contents group/attention' {...attendableAttrs}>
69
- {role === 'complementary' && <CommentsHeading attendableId={qualifiedSubjectId} />}
63
+ <div role='none' className='contents'>
70
64
  <ScrollArea.Root classNames='row-span-2'>
71
65
  <ScrollArea.Viewport>
72
66
  <CommentsContainer
73
67
  threads={threads}
74
68
  detached={detachedIds}
75
- currentId={attended.has(qualifiedSubjectId) ? current : undefined}
76
- autoFocusCurrentTextbox={focus}
69
+ currentId={attended.includes(qualifiedSubjectId) ? current : undefined}
77
70
  showResolvedThreads={showResolvedThreads}
78
71
  onThreadAttend={(thread) => {
79
72
  const threadId = fullyQualifiedId(thread);
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './threads';
@@ -0,0 +1,117 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { computed, effect } from '@preact/signals-core';
8
+
9
+ import { type IntentDispatcher } from '@dxos/app-framework';
10
+ import { type DocumentType } from '@dxos/plugin-markdown/types';
11
+ import { ThreadType } from '@dxos/plugin-space/types';
12
+ import { getSpace, getTextInRange, createDocAccessor, fullyQualifiedId } from '@dxos/react-client/echo';
13
+ import { comments, createExternalCommentSync } from '@dxos/react-ui-editor';
14
+ import { nonNullable } from '@dxos/util';
15
+
16
+ import { ThreadAction, type ThreadState } from '../types';
17
+
18
+ // TODO(burdon): Factor out.
19
+ const getName = (doc: DocumentType, anchor: string): string | undefined => {
20
+ if (doc.content) {
21
+ const [start, end] = anchor.split(':');
22
+ return getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Construct plugins.
28
+ */
29
+ export const threads = (state: ThreadState, doc?: DocumentType, dispatch?: IntentDispatcher): Extension => {
30
+ const space = doc && getSpace(doc);
31
+ if (!doc || !space || !dispatch) {
32
+ // Include no-op comments extension here to ensure that the facets are always present when they are expected.
33
+ // TODO(wittjosiah): The Editor should only look for these facets when comments are available.
34
+ return [comments()];
35
+ }
36
+
37
+ // TODO(Zan): When we have the deepsignal specific equivalent of this we should use that instead.
38
+ const threads = computed(() =>
39
+ [...doc.threads.filter(nonNullable), ...(state.drafts[fullyQualifiedId(doc)] ?? [])].filter(
40
+ (thread) => !(thread?.status === 'resolved'),
41
+ ),
42
+ );
43
+
44
+ return [
45
+ EditorView.updateListener.of((update) => {
46
+ if (update.docChanged) {
47
+ doc.threads.forEach((thread) => {
48
+ if (thread?.anchor) {
49
+ // Only update if the name has changed, otherwise this will cause an infinite loop.
50
+ // Skip if the name is empty; this means comment text was deleted, but thread name should remain.
51
+ const name = getName(doc, thread.anchor);
52
+ if (name && name !== thread.name) {
53
+ thread.name = name;
54
+ }
55
+ }
56
+ });
57
+ }
58
+ }),
59
+
60
+ createExternalCommentSync(
61
+ fullyQualifiedId(doc),
62
+ (sink) => effect(() => sink()),
63
+ () =>
64
+ threads.value
65
+ .filter((thread) => thread?.anchor)
66
+ .map((thread) => ({ id: fullyQualifiedId(thread), cursor: thread.anchor! })),
67
+ ),
68
+
69
+ comments({
70
+ id: fullyQualifiedId(doc),
71
+ onCreate: ({ cursor }) => {
72
+ const name = getName(doc, cursor);
73
+ void dispatch({
74
+ action: ThreadAction.CREATE,
75
+ data: {
76
+ cursor,
77
+ name,
78
+ subject: doc,
79
+ },
80
+ });
81
+ },
82
+ onDelete: ({ id }) => {
83
+ const draft = state.drafts[fullyQualifiedId(doc)];
84
+ if (draft) {
85
+ const index = draft.findIndex((thread) => fullyQualifiedId(thread) === id);
86
+ if (index !== -1) {
87
+ draft.splice(index, 1);
88
+ }
89
+ }
90
+
91
+ const thread = doc.threads.find((thread) => thread?.id === id);
92
+ if (thread) {
93
+ thread.anchor = undefined;
94
+ }
95
+ },
96
+ onUpdate: ({ id, cursor }) => {
97
+ const thread =
98
+ state.drafts[fullyQualifiedId(doc)]?.find((thread) => fullyQualifiedId(thread) === id) ??
99
+ doc.threads.find((thread) => thread?.id === id);
100
+
101
+ if (thread instanceof ThreadType && thread.anchor) {
102
+ thread.name = getName(doc, thread.anchor);
103
+ thread.anchor = cursor;
104
+ }
105
+ },
106
+ onSelect: ({ selection: { current, closest } }) => {
107
+ void dispatch({
108
+ action: ThreadAction.SELECT,
109
+ data: {
110
+ current: current ?? closest,
111
+ skipOpen: true,
112
+ },
113
+ });
114
+ },
115
+ }),
116
+ ];
117
+ };
@@ -2,15 +2,14 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { pluginMeta } from '@dxos/app-framework';
5
+ import { type PluginMeta } from '@dxos/app-framework';
6
6
 
7
7
  export const THREAD_PLUGIN = 'dxos.org/plugin/thread';
8
8
  export const THREAD_ITEM = `${THREAD_PLUGIN}/item`;
9
9
 
10
- export default pluginMeta({
10
+ export default {
11
11
  id: THREAD_PLUGIN,
12
12
  name: 'Threads',
13
- // TODO(wittjosiah): Update once scope of threads expands.
14
13
  description: 'Comment threads on documents.',
15
14
  icon: 'ph--chat--regular',
16
- });
15
+ } satisfies PluginMeta;