@dxos/plugin-thread 0.6.13-main.548ca8d → 0.6.13-staging.1e988a3

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 (92) hide show
  1. package/dist/lib/browser/{ThreadArticle-GBPIH47W.mjs → ThreadArticle-O64ZECMF.mjs} +3 -3
  2. package/dist/lib/browser/{ThreadMain-PGF7W5EV.mjs → ThreadMain-ZDCRC6RG.mjs} +3 -3
  3. package/dist/lib/browser/chunk-5IBJO53M.mjs +21 -0
  4. package/dist/lib/browser/chunk-5IBJO53M.mjs.map +7 -0
  5. package/dist/lib/browser/{chunk-JFQZ5W2I.mjs → chunk-GKPZHUT3.mjs} +2 -2
  6. package/dist/lib/browser/{chunk-JFQZ5W2I.mjs.map → chunk-GKPZHUT3.mjs.map} +1 -1
  7. package/dist/lib/browser/{chunk-O4YRHIZC.mjs → chunk-P7ZE5N5M.mjs} +11 -13
  8. package/dist/lib/browser/chunk-P7ZE5N5M.mjs.map +7 -0
  9. package/dist/lib/browser/index.mjs +393 -411
  10. package/dist/lib/browser/index.mjs.map +4 -4
  11. package/dist/lib/browser/meta.json +1 -1
  12. package/dist/lib/browser/meta.mjs +1 -1
  13. package/dist/lib/browser/types.mjs +2 -2
  14. package/dist/lib/node/{ThreadArticle-GH5UBWFU.cjs → ThreadArticle-DO56JXBU.cjs} +7 -7
  15. package/dist/lib/node/{ThreadMain-CO6I3DNX.cjs → ThreadMain-XJX75GHG.cjs} +7 -7
  16. package/dist/lib/node/{chunk-FDYRG7HE.cjs → chunk-5PZNPPQF.cjs} +63 -65
  17. package/dist/lib/node/chunk-5PZNPPQF.cjs.map +7 -0
  18. package/dist/lib/node/{chunk-JIJ7Z6NO.cjs → chunk-BWBONZGU.cjs} +22 -7
  19. package/dist/lib/node/chunk-BWBONZGU.cjs.map +7 -0
  20. package/dist/lib/node/{chunk-FOZUQZF4.cjs → chunk-D5GOVXHS.cjs} +6 -6
  21. package/dist/lib/node/{chunk-FOZUQZF4.cjs.map → chunk-D5GOVXHS.cjs.map} +1 -1
  22. package/dist/lib/node/index.cjs +428 -442
  23. package/dist/lib/node/index.cjs.map +4 -4
  24. package/dist/lib/node/meta.cjs +4 -4
  25. package/dist/lib/node/meta.cjs.map +1 -1
  26. package/dist/lib/node/meta.json +1 -1
  27. package/dist/lib/node/types.cjs +3 -3
  28. package/dist/lib/node/types.cjs.map +1 -1
  29. package/dist/types/src/ThreadPlugin.d.ts.map +1 -1
  30. package/dist/types/src/components/ChatContainer.d.ts.map +1 -1
  31. package/dist/types/src/components/CommentContainer.d.ts +1 -1
  32. package/dist/types/src/components/CommentContainer.d.ts.map +1 -1
  33. package/dist/types/src/components/CommentsContainer.d.ts +5 -1
  34. package/dist/types/src/components/CommentsContainer.d.ts.map +1 -1
  35. package/dist/types/src/components/MessageContainer.d.ts.map +1 -1
  36. package/dist/types/src/components/index.d.ts +2 -3
  37. package/dist/types/src/components/index.d.ts.map +1 -1
  38. package/dist/types/src/components/testing.d.ts +0 -2
  39. package/dist/types/src/components/testing.d.ts.map +1 -1
  40. package/dist/types/src/meta.d.ts +9 -3
  41. package/dist/types/src/meta.d.ts.map +1 -1
  42. package/dist/types/src/types.d.ts +1 -6
  43. package/dist/types/src/types.d.ts.map +1 -1
  44. package/dist/types/src/types.test.d.ts +2 -0
  45. package/dist/types/src/types.test.d.ts.map +1 -0
  46. package/package.json +37 -40
  47. package/src/ThreadPlugin.tsx +310 -120
  48. package/src/components/ChatContainer.tsx +8 -7
  49. package/src/components/CommentContainer.tsx +19 -18
  50. package/src/components/Comments.stories.tsx +2 -2
  51. package/src/components/CommentsContainer.tsx +31 -18
  52. package/src/components/MessageContainer.tsx +1 -2
  53. package/src/components/index.ts +2 -3
  54. package/src/meta.tsx +20 -0
  55. package/src/types.test.ts +13 -0
  56. package/src/types.ts +3 -8
  57. package/dist/lib/browser/chunk-5G3KL6TC.mjs +0 -16
  58. package/dist/lib/browser/chunk-5G3KL6TC.mjs.map +0 -7
  59. package/dist/lib/browser/chunk-O4YRHIZC.mjs.map +0 -7
  60. package/dist/lib/node/chunk-FDYRG7HE.cjs.map +0 -7
  61. package/dist/lib/node/chunk-JIJ7Z6NO.cjs.map +0 -7
  62. package/dist/lib/node-esm/ThreadArticle-ZTYD52TR.mjs +0 -19
  63. package/dist/lib/node-esm/ThreadArticle-ZTYD52TR.mjs.map +0 -7
  64. package/dist/lib/node-esm/ThreadMain-LEHGOF7R.mjs +0 -27
  65. package/dist/lib/node-esm/ThreadMain-LEHGOF7R.mjs.map +0 -7
  66. package/dist/lib/node-esm/chunk-DC5ZA6H5.mjs +0 -18
  67. package/dist/lib/node-esm/chunk-DC5ZA6H5.mjs.map +0 -7
  68. package/dist/lib/node-esm/chunk-XOVJ65DG.mjs +0 -413
  69. package/dist/lib/node-esm/chunk-XOVJ65DG.mjs.map +0 -7
  70. package/dist/lib/node-esm/chunk-ZXIPFXSA.mjs +0 -21
  71. package/dist/lib/node-esm/chunk-ZXIPFXSA.mjs.map +0 -7
  72. package/dist/lib/node-esm/index.mjs +0 -1121
  73. package/dist/lib/node-esm/index.mjs.map +0 -7
  74. package/dist/lib/node-esm/meta.json +0 -1
  75. package/dist/lib/node-esm/meta.mjs +0 -12
  76. package/dist/lib/node-esm/meta.mjs.map +0 -7
  77. package/dist/lib/node-esm/types.mjs +0 -9
  78. package/dist/lib/node-esm/types.mjs.map +0 -7
  79. package/dist/types/src/components/ThreadComplementary.d.ts +0 -10
  80. package/dist/types/src/components/ThreadComplementary.d.ts.map +0 -1
  81. package/dist/types/src/extensions/index.d.ts +0 -2
  82. package/dist/types/src/extensions/index.d.ts.map +0 -1
  83. package/dist/types/src/extensions/threads.d.ts +0 -9
  84. package/dist/types/src/extensions/threads.d.ts.map +0 -1
  85. package/src/components/ThreadComplementary.tsx +0 -113
  86. package/src/extensions/index.ts +0 -5
  87. package/src/extensions/threads.ts +0 -117
  88. package/src/meta.ts +0 -15
  89. /package/dist/lib/browser/{ThreadArticle-GBPIH47W.mjs.map → ThreadArticle-O64ZECMF.mjs.map} +0 -0
  90. /package/dist/lib/browser/{ThreadMain-PGF7W5EV.mjs.map → ThreadMain-ZDCRC6RG.mjs.map} +0 -0
  91. /package/dist/lib/node/{ThreadArticle-GH5UBWFU.cjs.map → ThreadArticle-DO56JXBU.cjs.map} +0 -0
  92. /package/dist/lib/node/{ThreadMain-CO6I3DNX.cjs.map → ThreadMain-XJX75GHG.cjs.map} +0 -0
@@ -2,56 +2,85 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { Chat, type IconProps } from '@phosphor-icons/react';
6
+ import { computed, effect } from '@preact/signals-core';
5
7
  import React from 'react';
6
8
 
7
9
  import {
8
10
  type IntentPluginProvides,
9
- type LocationProvides,
11
+ LayoutAction,
10
12
  NavigationAction,
13
+ type LocationProvides,
11
14
  type Plugin,
12
15
  type PluginDefinition,
13
- isLayoutParts,
14
16
  parseIntentPlugin,
15
17
  parseNavigationPlugin,
16
- parseMetadataResolverPlugin,
17
18
  resolvePlugin,
19
+ parseGraphPlugin,
20
+ SLUG_PATH_SEPARATOR,
21
+ SLUG_COLLECTION_INDICATOR,
22
+ isLayoutParts,
23
+ parseMetadataResolverPlugin,
24
+ type IntentDispatcher,
25
+ useResolvePlugins,
18
26
  } from '@dxos/app-framework';
19
27
  import { type UnsubscribeCallback } from '@dxos/async';
20
- import { create, type EchoReactiveObject, getTypename } from '@dxos/echo-schema';
28
+ import { type EchoReactiveObject, getTypename } from '@dxos/echo-schema';
29
+ import { create } from '@dxos/echo-schema';
21
30
  import { LocalStorageStore } from '@dxos/local-storage';
22
- import { log } from '@dxos/log';
31
+ import { type AttentionPluginProvides, parseAttentionPlugin } from '@dxos/plugin-attention';
23
32
  import { parseClientPlugin } from '@dxos/plugin-client';
24
- import { type ActionGroup, createExtension, isActionGroup, toSignal } from '@dxos/plugin-graph';
33
+ import { type ActionGroup, createExtension, isActionGroup } from '@dxos/plugin-graph';
34
+ import { DocumentType } from '@dxos/plugin-markdown/types';
25
35
  import { ObservabilityAction } from '@dxos/plugin-observability/meta';
26
36
  import { SpaceAction } from '@dxos/plugin-space';
27
37
  import { ThreadType, MessageType, ChannelType } from '@dxos/plugin-space/types';
28
- import { getSpace, fullyQualifiedId, loadObjectReferences, parseFullyQualifiedId } from '@dxos/react-client/echo';
38
+ import {
39
+ getSpace,
40
+ getTextInRange,
41
+ Filter,
42
+ createDocAccessor,
43
+ fullyQualifiedId,
44
+ loadObjectReferences,
45
+ } from '@dxos/react-client/echo';
46
+ import { ScrollArea } from '@dxos/react-ui';
47
+ import { createAttendableAttributes } from '@dxos/react-ui-attention';
48
+ import { comments, createExternalCommentSync, listener } from '@dxos/react-ui-editor';
29
49
  import { translations as threadTranslations } from '@dxos/react-ui-thread';
50
+ import { nonNullable } from '@dxos/util';
30
51
 
31
52
  import {
53
+ ThreadMain,
54
+ ThreadSettings,
55
+ CommentsContainer,
56
+ CommentsHeading,
32
57
  ChatContainer,
33
58
  ChatHeading,
34
59
  ThreadArticle,
35
- ThreadComplementary,
36
- ThreadMain,
37
- ThreadSettings,
38
60
  } from './components';
39
- import { threads } from './extensions';
40
61
  import meta, { THREAD_ITEM, THREAD_PLUGIN } from './meta';
41
62
  import translations from './translations';
42
- import { ThreadAction, type ThreadPluginProvides, type ThreadSettingsProps, type ThreadState } from './types';
63
+ import { ThreadAction, type ThreadProvides, type ThreadPluginProvides, type ThreadSettingsProps } from './types';
43
64
 
44
- type SubjectId = string;
65
+ type ThreadState = {
66
+ /** An in-memory staging area for threads that are being drafted. */
67
+ staging: Record<string, ThreadType[]>;
68
+ current?: string | undefined;
69
+ focus?: boolean;
70
+ };
45
71
 
72
+ type SubjectId = string;
46
73
  const initialViewState = { showResolvedThreads: false };
47
-
48
74
  type ViewStore = Record<SubjectId, typeof initialViewState>;
49
75
 
76
+ // TODO(Zan): More robust runtime check.
77
+ const providesThreadsConfig = (plugin: any): Plugin<ThreadProvides<any>> | undefined =>
78
+ 'thread' in plugin.provides ? (plugin as Plugin<ThreadProvides<any>>) : undefined;
79
+
50
80
  // TODO(Zan): Every instance of `cursor` should be replaced with `anchor`.
51
- // NOTE(burdon): Review/discuss CursorConverter semantics.
52
81
  export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
53
82
  const settings = new LocalStorageStore<ThreadSettingsProps>(THREAD_PLUGIN);
54
- const state = create<ThreadState>({ drafts: {} });
83
+ const state = create<ThreadState>({ staging: {} });
55
84
 
56
85
  const viewStore = create<ViewStore>({});
57
86
  const getViewState = (subjectId: string) => {
@@ -61,8 +90,10 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
61
90
  return viewStore[subjectId];
62
91
  };
63
92
 
93
+ let attentionPlugin: Plugin<AttentionPluginProvides> | undefined;
64
94
  let navigationPlugin: Plugin<LocationProvides> | undefined;
65
95
  let intentPlugin: Plugin<IntentPluginProvides> | undefined;
96
+ let dispatch: IntentDispatcher | undefined;
66
97
 
67
98
  const unsubscribeCallbacks = [] as UnsubscribeCallback[];
68
99
 
@@ -71,8 +102,45 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
71
102
  ready: async (plugins) => {
72
103
  settings.prop({ key: 'standalone', type: LocalStorageStore.bool({ allowUndefined: true }) });
73
104
 
105
+ attentionPlugin = resolvePlugin(plugins, parseAttentionPlugin);
74
106
  navigationPlugin = resolvePlugin(plugins, parseNavigationPlugin);
75
107
  intentPlugin = resolvePlugin(plugins, parseIntentPlugin)!;
108
+ dispatch = intentPlugin?.provides.intent.dispatch;
109
+
110
+ const graphPlugin = resolvePlugin(plugins, parseGraphPlugin);
111
+ const client = resolvePlugin(plugins, parseClientPlugin)?.provides.client;
112
+ if (!client) {
113
+ return;
114
+ }
115
+
116
+ // TODO(wittjosiah): This is a hack to make standalone threads work in the c11y sidebar.
117
+ // This should have a better solution when deck is introduced.
118
+ const channelsQuery = client.spaces.query(Filter.schema(ChannelType));
119
+ const queryUnsubscribe = channelsQuery.subscribe();
120
+ const unsubscribe = effect(() => {
121
+ const attention = attentionPlugin?.provides.attention;
122
+ if (!attention?.attended) {
123
+ return;
124
+ }
125
+
126
+ const firstAttendedNodeWithComments = Array.from(attention.attended)
127
+ .map((id) => graphPlugin?.provides.graph.findNode(id))
128
+ .find((node) => node?.data instanceof DocumentType && (node.data.threads?.length ?? 0) > 0);
129
+
130
+ if (firstAttendedNodeWithComments) {
131
+ void intentPlugin?.provides.intent.dispatch({
132
+ action: NavigationAction.OPEN,
133
+ data: {
134
+ activeParts: {
135
+ complementary: `${firstAttendedNodeWithComments.id}${SLUG_PATH_SEPARATOR}comments`,
136
+ },
137
+ },
138
+ });
139
+ }
140
+ });
141
+
142
+ unsubscribeCallbacks.push(queryUnsubscribe);
143
+ unsubscribeCallbacks.push(unsubscribe);
76
144
  },
77
145
  unload: async () => {
78
146
  unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe());
@@ -83,7 +151,8 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
83
151
  records: {
84
152
  [ChannelType.typename]: {
85
153
  placeholder: ['channel title placeholder', { ns: THREAD_PLUGIN }],
86
- icon: 'ph--chat--regular',
154
+ icon: (props: IconProps) => <Chat {...props} />,
155
+ iconSymbol: 'ph--chat--regular',
87
156
  // TODO(wittjosiah): Move out of metadata.
88
157
  loadReferences: (channel: ChannelType) => loadObjectReferences(channel, (channel) => channel.threads),
89
158
  },
@@ -125,46 +194,35 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
125
194
  createExtension({
126
195
  id: `${THREAD_PLUGIN}/comments-for-subject`,
127
196
  resolver: ({ id }) => {
128
- // TODO(Zan): Find util (or make one).
129
197
  if (!id.endsWith('~comments')) {
130
198
  return;
131
199
  }
132
200
 
133
- const [subjectId] = id.split('~');
134
- const [spaceId, objectId] = parseFullyQualifiedId(subjectId);
201
+ // TODO(Zan): Find util (or make one)
202
+ const docId = id.split('~').at(0);
203
+ const [spaceId, objectId] = docId?.split(':') ?? [];
135
204
  const space = client.spaces.get().find((space) => space.id === spaceId);
136
- const object = toSignal(
137
- (onChange) => {
138
- const timeout = setTimeout(async () => {
139
- await space?.db.loadObjectById(objectId);
140
- onChange();
141
- });
142
-
143
- return () => clearTimeout(timeout);
144
- },
145
- () => space?.db.getObjectById(objectId),
146
- subjectId,
147
- );
148
- if (!object || !subjectId) {
205
+ const doc = space?.db.getObjectById(objectId);
206
+ if (!doc || !docId) {
149
207
  return;
150
208
  }
151
209
 
152
- const meta = metadataResolver(getTypename(object) ?? '');
153
- const label = meta.label?.(object) ||
154
- object.name ||
155
- meta.placeholder || ['unnamed object threads label', { ns: THREAD_PLUGIN }];
210
+ const docMeta = metadataResolver(getTypename(doc) ?? '');
211
+ const label = docMeta.label?.(doc) ||
212
+ doc.name ||
213
+ docMeta.placeholder || ['unnamed object threads label', { ns: THREAD_PLUGIN }];
214
+ // const label = docMeta.label?.(doc) || doc.name || docMeta.placeholder;
156
215
 
157
- const viewState = getViewState(subjectId);
216
+ const viewState = getViewState(docId);
158
217
 
159
218
  return {
160
219
  id,
161
220
  type: 'orphan-comments-for-subject',
162
- data: null,
221
+ data: doc,
163
222
  properties: {
164
- icon: 'ph--chat-text--regular',
223
+ icon: meta.iconComponent,
165
224
  label,
166
225
  showResolvedThreads: viewState.showResolvedThreads,
167
- object,
168
226
  },
169
227
  };
170
228
  },
@@ -198,7 +256,6 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
198
256
  menuItemType: 'toggle',
199
257
  isChecked: viewState.showResolvedThreads,
200
258
  testId: 'threadPlugin.toggleShowResolved',
201
- icon: viewState.showResolvedThreads ? 'ph--eye-slash--regular' : 'ph--eye--regular',
202
259
  },
203
260
  },
204
261
  ];
@@ -230,7 +287,8 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
230
287
  },
231
288
  properties: {
232
289
  label: ['create channel label', { ns: THREAD_PLUGIN }],
233
- icon: 'ph--chat--regular',
290
+ icon: (props: IconProps) => <Chat {...props} />,
291
+ iconSymbol: 'ph--chat--regular',
234
292
  testId: 'threadPlugin.createObject',
235
293
  },
236
294
  },
@@ -253,8 +311,11 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
253
311
  return data.plugin === meta.id ? <ThreadSettings settings={settings.values} /> : null;
254
312
  }
255
313
 
256
- case 'article': {
314
+ case 'article':
315
+ case 'complementary': {
257
316
  const location = navigationPlugin?.provides.location;
317
+ const threadsIntegrators = useResolvePlugins(providesThreadsConfig);
318
+ const threadProvides = threadsIntegrators.map((p) => p.provides.thread);
258
319
 
259
320
  if (data.object instanceof ChannelType && data.object.threads[0]) {
260
321
  const channel = data.object;
@@ -281,30 +342,93 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
281
342
  );
282
343
  }
283
344
 
284
- break;
285
- }
286
-
287
- case 'complementary--comments': {
288
345
  if (
289
346
  data.subject &&
290
347
  typeof data.subject === 'object' &&
291
348
  'threads' in data.subject &&
292
349
  Array.isArray(data.subject.threads)
293
350
  ) {
294
- const { showResolvedThreads } = getViewState(fullyQualifiedId(data.subject));
351
+ const threads = data.subject.threads
352
+ .concat(state.staging[fullyQualifiedId(data.subject)])
353
+ .filter(nonNullable);
354
+
355
+ const createSort = threadProvides.find((p) => p.predicate(data.subject))?.createSort;
356
+ if (createSort) {
357
+ const sort = createSort(data.subject);
358
+ threads.sort((a, b) => sort(a.anchor, b.anchor));
359
+ }
360
+
361
+ const detached = data.subject.threads
362
+ .filter(nonNullable)
363
+ .filter(({ anchor }) => !anchor)
364
+ .map((thread) => thread.id);
365
+
366
+ const qualifiedSubjectId = fullyQualifiedId(data.subject);
367
+ const attention = attentionPlugin?.provides.attention?.attended ?? new Set([qualifiedSubjectId]);
368
+ const attendableAttrs = createAttendableAttributes(qualifiedSubjectId);
369
+ const { showResolvedThreads } = getViewState(qualifiedSubjectId);
295
370
 
296
371
  return (
297
- <ThreadComplementary
298
- role={role}
299
- subject={data.subject}
300
- drafts={state.drafts[fullyQualifiedId(data.subject)]}
301
- current={state.current}
302
- showResolvedThreads={showResolvedThreads}
303
- />
372
+ <div role='none' className='contents group/attention' {...attendableAttrs}>
373
+ {role === 'complementary' && <CommentsHeading attendableId={qualifiedSubjectId} />}
374
+ <ScrollArea.Root classNames='row-span-2'>
375
+ <ScrollArea.Viewport>
376
+ <CommentsContainer
377
+ threads={threads}
378
+ detached={detached}
379
+ currentId={attention.has(qualifiedSubjectId) ? state.current : undefined}
380
+ autoFocusCurrentTextbox={state.focus}
381
+ showResolvedThreads={showResolvedThreads}
382
+ onThreadAttend={(thread) => {
383
+ if (state.current !== thread.id) {
384
+ state.current = thread.id;
385
+ void dispatch?.({
386
+ action: LayoutAction.SCROLL_INTO_VIEW,
387
+ data: {
388
+ id: fullyQualifiedId(data.subject!),
389
+ thread: fullyQualifiedId(thread),
390
+ cursor: thread.anchor,
391
+ },
392
+ });
393
+ }
394
+ }}
395
+ onThreadDelete={(thread) =>
396
+ dispatch?.({
397
+ plugin: THREAD_PLUGIN,
398
+ action: ThreadAction.DELETE,
399
+ data: { subject: data.subject, thread },
400
+ })
401
+ }
402
+ onMessageDelete={(thread, messageId) =>
403
+ dispatch?.({
404
+ plugin: THREAD_PLUGIN,
405
+ action: ThreadAction.DELETE_MESSAGE,
406
+ data: { subject: data.subject, thread, messageId },
407
+ })
408
+ }
409
+ onThreadToggleResolved={(thread) =>
410
+ dispatch?.({
411
+ plugin: THREAD_PLUGIN,
412
+ action: ThreadAction.TOGGLE_RESOLVED,
413
+ data: { thread },
414
+ })
415
+ }
416
+ onComment={(thread) => {
417
+ void intentPlugin?.provides.intent.dispatch({
418
+ action: ThreadAction.ON_MESSAGE_ADD,
419
+ data: { thread, subject: data.subject },
420
+ });
421
+ }}
422
+ />
423
+ <div role='none' className='bs-10' />
424
+ <ScrollArea.Scrollbar>
425
+ <ScrollArea.Thumb />
426
+ </ScrollArea.Scrollbar>
427
+ </ScrollArea.Viewport>
428
+ </ScrollArea.Root>
429
+ </div>
304
430
  );
305
431
  }
306
-
307
- break;
308
432
  }
309
433
  }
310
434
 
@@ -318,40 +442,37 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
318
442
  if (intent.data && intent.data.cursor !== undefined) {
319
443
  const { cursor, name, subject } = intent.data;
320
444
 
321
- // Seed the threads array if it does not exist.
322
- if (subject?.threads === undefined) {
323
- try {
324
- // Static schema will throw an error if subject does not support threads array property.
325
- subject.threads = [];
326
- } catch (err) {
327
- log.error('Subject does not support threads array', subject?.typename);
328
- return;
329
- }
330
- }
331
-
332
445
  const subjectId = fullyQualifiedId(subject);
333
446
  const thread = create(ThreadType, { name, anchor: cursor, messages: [], status: 'staged' });
334
- const draft = state.drafts[subjectId];
335
- if (draft) {
336
- draft.push(thread);
447
+ const stagingArea = state.staging[subjectId];
448
+ if (stagingArea) {
449
+ stagingArea.push(thread);
337
450
  } else {
338
- state.drafts[subjectId] = [thread];
451
+ state.staging[subjectId] = [thread];
339
452
  }
340
453
 
341
454
  return {
342
455
  data: thread,
343
456
  intents: [
344
457
  [
458
+ {
459
+ action: NavigationAction.OPEN,
460
+ data: {
461
+ activeParts: {
462
+ complementary: `${subjectId}${SLUG_PATH_SEPARATOR}comments${SLUG_COLLECTION_INDICATOR}`,
463
+ },
464
+ },
465
+ },
345
466
  {
346
467
  action: ThreadAction.SELECT,
347
- data: { current: fullyQualifiedId(thread) },
468
+ data: { current: thread.id, focus: true },
348
469
  },
349
- ],
350
- [
351
470
  {
352
- action: NavigationAction.OPEN,
471
+ action: LayoutAction.SET_LAYOUT,
353
472
  data: {
354
- activeParts: { complementary: 'comments' },
473
+ element: 'complementary',
474
+ subject: subjectId,
475
+ state: true,
355
476
  },
356
477
  },
357
478
  ],
@@ -359,30 +480,14 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
359
480
  };
360
481
  } else {
361
482
  // NOTE: This is the standalone thread creation case.
362
- return {
363
- data: create(ChannelType, { threads: [create(ThreadType, { messages: [] })] }),
364
- };
483
+ return { data: create(ChannelType, { threads: [create(ThreadType, { messages: [] })] }) };
365
484
  }
366
485
  }
367
486
 
368
487
  case ThreadAction.SELECT: {
488
+ state.focus = intent.data?.current === state.current ? state.focus : intent.data?.focus;
369
489
  state.current = intent.data?.current;
370
-
371
- return {
372
- data: true,
373
- intents: !intent.data?.skipOpen
374
- ? [
375
- [
376
- {
377
- action: NavigationAction.OPEN,
378
- data: {
379
- activeParts: { complementary: 'comments' },
380
- },
381
- },
382
- ],
383
- ]
384
- : [],
385
- };
490
+ return { data: true };
386
491
  }
387
492
 
388
493
  case ThreadAction.TOGGLE_RESOLVED: {
@@ -401,7 +506,6 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
401
506
  const spaceId = space?.id;
402
507
 
403
508
  return {
404
- data: true,
405
509
  intents: [
406
510
  [
407
511
  {
@@ -415,21 +519,19 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
415
519
 
416
520
  case ThreadAction.DELETE: {
417
521
  const { subject, thread } = intent.data ?? {};
418
- if (
419
- !(thread instanceof ThreadType) ||
420
- !(subject && typeof subject === 'object' && 'threads' in subject)
421
- ) {
522
+ if (!(subject instanceof DocumentType) || !(thread instanceof ThreadType)) {
422
523
  return;
423
524
  }
424
525
 
425
526
  const subjectId = fullyQualifiedId(subject);
426
- const draft = state.drafts[subjectId];
427
- if (draft) {
428
- // Check if we're deleting a draft; if so, remove it.
429
- const index = draft.findIndex((t) => t.id === thread.id);
527
+ const stagingArea = state.staging[subjectId];
528
+ if (stagingArea) {
529
+ // Check if we're deleting a thread that's in the staging area.
530
+ // If so, remove it from the staging area without ceremony.
531
+ const index = stagingArea.findIndex((t) => t.id === thread.id);
430
532
  if (index !== -1) {
431
- draft.splice(index, 1);
432
- return { data: true };
533
+ stagingArea.splice(index, 1);
534
+ return;
433
535
  }
434
536
  }
435
537
 
@@ -439,7 +541,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
439
541
  }
440
542
 
441
543
  if (!intent.undo) {
442
- const index = subject.threads.findIndex((t: any) => t?.id === thread.id);
544
+ const index = subject.threads.findIndex((t) => t?.id === thread.id);
443
545
  const cursor = subject.threads[index]?.anchor;
444
546
  if (index !== -1) {
445
547
  subject.threads?.splice(index, 1);
@@ -485,7 +587,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
485
587
  const { thread, subject } = intent.data ?? {};
486
588
  if (
487
589
  !(thread instanceof ThreadType) ||
488
- !(subject && typeof subject === 'object' && 'threads' in subject && Array.isArray(subject.threads))
590
+ !(subject && typeof subject === 'object' && 'threads' in subject)
489
591
  ) {
490
592
  return;
491
593
  }
@@ -495,11 +597,12 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
495
597
  const intents = [];
496
598
  const analyticsProperties = { threadId: thread.id, spaceId: space?.id };
497
599
 
498
- if (state.drafts[subjectId]?.find((t) => t === thread)) {
499
- // Move draft to document.
600
+ if (state.staging[subjectId]?.find((t) => t === thread)) {
601
+ // Move thread from staging to document.
500
602
  thread.status = 'active';
501
603
  subject.threads ? subject.threads.push(thread) : (subject.threads = [thread]);
502
- state.drafts[subjectId] = state.drafts[subjectId]?.filter(({ id }) => id !== thread.id);
604
+ state.staging[subjectId] = state.staging[subjectId]?.filter((t) => t.id !== thread.id);
605
+
503
606
  intents.push({
504
607
  action: ObservabilityAction.SEND_EVENT,
505
608
  data: { name: 'threads.thread-created', properties: analyticsProperties },
@@ -524,10 +627,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
524
627
  const { subject, thread, messageId } = intent.data ?? {};
525
628
  const space = getSpace(subject);
526
629
 
527
- if (
528
- !(thread instanceof ThreadType) ||
529
- !(subject && typeof subject === 'object' && 'threads' in subject)
530
- ) {
630
+ if (!(subject instanceof DocumentType) || !(thread instanceof ThreadType) || !space) {
531
631
  return;
532
632
  }
533
633
 
@@ -558,7 +658,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
558
658
  action: ObservabilityAction.SEND_EVENT,
559
659
  data: {
560
660
  name: 'threads.message.delete',
561
- properties: { threadId: thread.id, spaceId: space?.id },
661
+ properties: { threadId: thread.id, spaceId: space.id },
562
662
  },
563
663
  },
564
664
  ],
@@ -567,11 +667,13 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
567
667
  } else if (intent.undo) {
568
668
  const message = intent.data?.message;
569
669
  const messageIndex = intent.data?.messageIndex;
670
+
570
671
  if (!(message instanceof MessageType) || !(typeof messageIndex === 'number')) {
571
672
  return;
572
673
  }
573
674
 
574
675
  thread.messages.splice(messageIndex, 0, message);
676
+
575
677
  return {
576
678
  data: true,
577
679
  intents: [
@@ -580,7 +682,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
580
682
  action: ObservabilityAction.SEND_EVENT,
581
683
  data: {
582
684
  name: 'threads.message.undo-delete',
583
- properties: { threadId: thread.id, spaceId: space?.id },
685
+ properties: { threadId: thread.id, spaceId: space.id },
584
686
  },
585
687
  },
586
688
  ],
@@ -592,8 +694,96 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
592
694
  },
593
695
  },
594
696
  markdown: {
697
+ // TODO(burdon): Factor out extension factory into separate file (for simplicity).
595
698
  extensions: ({ document: doc }) => {
596
- return threads(state, doc, intentPlugin?.provides.intent.dispatch);
699
+ const space = doc && getSpace(doc);
700
+ if (!doc || !space) {
701
+ // Include no-op comments extension here to ensure that the facets are always present when they are expected.
702
+ // TODO(wittjosiah): The Editor should only look for these facets when comments are available.
703
+ return [comments()];
704
+ }
705
+
706
+ // TODO(Zan): When we have the deepsignal specific equivalent of this we should use that instead.
707
+ const threads = computed(() =>
708
+ [...doc.threads.filter(nonNullable), ...(state.staging[fullyQualifiedId(doc)] ?? [])].filter(
709
+ (thread) => !(thread?.status === 'resolved'),
710
+ ),
711
+ );
712
+
713
+ return [
714
+ listener({
715
+ onChange: () => {
716
+ doc.threads.forEach((thread) => {
717
+ if (thread?.anchor) {
718
+ const [start, end] = thread.anchor.split(':');
719
+ const name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
720
+ // TODO(burdon): This seems unsafe; review.
721
+ // Only update if the name has changed, otherwise this will cause an infinite loop.
722
+ // Skip if the name is empty - this means comment text was deleted, but thread name should remain.
723
+ if (name && name !== thread.name) {
724
+ thread.name = name;
725
+ }
726
+ }
727
+ });
728
+ },
729
+ }),
730
+ createExternalCommentSync(
731
+ doc.id,
732
+ (sink) => effect(() => sink()),
733
+ () =>
734
+ threads.value
735
+ .filter((thread) => thread?.anchor)
736
+ .map((thread) => ({ id: thread.id, cursor: thread.anchor! })),
737
+ ),
738
+ comments({
739
+ id: doc.id,
740
+ onCreate: ({ cursor }) => {
741
+ const [start, end] = cursor.split(':');
742
+ const name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
743
+
744
+ void intentPlugin?.provides.intent.dispatch({
745
+ action: ThreadAction.CREATE,
746
+ data: {
747
+ cursor,
748
+ name,
749
+ subject: doc,
750
+ },
751
+ });
752
+ },
753
+ onDelete: ({ id }) => {
754
+ // If the thread is in the staging area, remove it.
755
+ const stagingArea = state.staging[fullyQualifiedId(doc)];
756
+ if (stagingArea) {
757
+ const index = stagingArea.findIndex((thread) => thread.id === id);
758
+ if (index !== -1) {
759
+ stagingArea.splice(index, 1);
760
+ }
761
+ }
762
+
763
+ const thread = doc.threads.find((thread) => thread?.id === id);
764
+ if (thread) {
765
+ thread.anchor = undefined;
766
+ }
767
+ },
768
+ onUpdate: ({ id, cursor }) => {
769
+ const thread =
770
+ state.staging[fullyQualifiedId(doc)]?.find((thread) => thread.id === id) ??
771
+ doc.threads.find((thread) => thread?.id === id);
772
+
773
+ if (thread instanceof ThreadType && thread.anchor) {
774
+ const [start, end] = thread.anchor.split(':');
775
+ thread.name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
776
+ thread.anchor = cursor;
777
+ }
778
+ },
779
+ onSelect: ({ selection: { current, closest } }) => {
780
+ const dispatch = intentPlugin?.provides.intent.dispatch;
781
+ if (dispatch) {
782
+ void dispatch([{ action: ThreadAction.SELECT, data: { current: current ?? closest } }]);
783
+ }
784
+ },
785
+ }),
786
+ ];
597
787
  },
598
788
  },
599
789
  },