@dxos/plugin-thread 0.6.12-staging.e11e696 → 0.6.12

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 (81) hide show
  1. package/dist/lib/browser/{ThreadArticle-4VU752F4.mjs → ThreadArticle-O64ZECMF.mjs} +3 -3
  2. package/dist/lib/browser/{ThreadMain-ML3P4XSD.mjs → ThreadMain-ZDCRC6RG.mjs} +3 -3
  3. package/dist/lib/browser/{chunk-7NUYW5IW.mjs → chunk-5IBJO53M.mjs} +5 -2
  4. package/dist/lib/browser/chunk-5IBJO53M.mjs.map +7 -0
  5. package/dist/lib/browser/{chunk-GJRBBPXI.mjs → chunk-GKPZHUT3.mjs} +2 -2
  6. package/dist/lib/browser/{chunk-GJRBBPXI.mjs.map → chunk-GKPZHUT3.mjs.map} +1 -1
  7. package/dist/lib/browser/{chunk-XF5QKI75.mjs → chunk-P7ZE5N5M.mjs} +11 -13
  8. package/dist/lib/browser/chunk-P7ZE5N5M.mjs.map +7 -0
  9. package/dist/lib/browser/index.mjs +234 -233
  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-ERUHPHVT.cjs → ThreadArticle-DO56JXBU.cjs} +7 -7
  15. package/dist/lib/node/{ThreadMain-6DN7FDNB.cjs → ThreadMain-XJX75GHG.cjs} +7 -7
  16. package/dist/lib/node/{chunk-PZVUW4W2.cjs → chunk-5PZNPPQF.cjs} +63 -65
  17. package/dist/lib/node/chunk-5PZNPPQF.cjs.map +7 -0
  18. package/dist/lib/node/{chunk-6EVXCSDS.cjs → chunk-BWBONZGU.cjs} +18 -5
  19. package/dist/lib/node/chunk-BWBONZGU.cjs.map +7 -0
  20. package/dist/lib/node/{chunk-FXD3DYMO.cjs → chunk-D5GOVXHS.cjs} +6 -6
  21. package/dist/lib/node/{chunk-FXD3DYMO.cjs.map → chunk-D5GOVXHS.cjs.map} +1 -1
  22. package/dist/lib/node/index.cjs +316 -313
  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/testing.d.ts +0 -2
  37. package/dist/types/src/components/testing.d.ts.map +1 -1
  38. package/dist/types/src/meta.d.ts +4 -1
  39. package/dist/types/src/meta.d.ts.map +1 -1
  40. package/dist/types/src/types.d.ts +1 -1
  41. package/dist/types/src/types.d.ts.map +1 -1
  42. package/dist/types/src/types.test.d.ts +2 -0
  43. package/dist/types/src/types.test.d.ts.map +1 -0
  44. package/package.json +37 -40
  45. package/src/ThreadPlugin.tsx +182 -70
  46. package/src/components/ChatContainer.tsx +8 -7
  47. package/src/components/CommentContainer.tsx +19 -18
  48. package/src/components/Comments.stories.tsx +2 -2
  49. package/src/components/CommentsContainer.tsx +31 -18
  50. package/src/components/MessageContainer.tsx +1 -2
  51. package/src/meta.tsx +5 -1
  52. package/src/types.test.ts +13 -0
  53. package/src/types.ts +1 -1
  54. package/dist/lib/browser/chunk-7NUYW5IW.mjs.map +0 -7
  55. package/dist/lib/browser/chunk-XF5QKI75.mjs.map +0 -7
  56. package/dist/lib/node/chunk-6EVXCSDS.cjs.map +0 -7
  57. package/dist/lib/node/chunk-PZVUW4W2.cjs.map +0 -7
  58. package/dist/lib/node-esm/ThreadArticle-NKI5F4W5.mjs +0 -19
  59. package/dist/lib/node-esm/ThreadArticle-NKI5F4W5.mjs.map +0 -7
  60. package/dist/lib/node-esm/ThreadMain-2A3VQLCW.mjs +0 -27
  61. package/dist/lib/node-esm/ThreadMain-2A3VQLCW.mjs.map +0 -7
  62. package/dist/lib/node-esm/chunk-A44C4MTQ.mjs +0 -413
  63. package/dist/lib/node-esm/chunk-A44C4MTQ.mjs.map +0 -7
  64. package/dist/lib/node-esm/chunk-Y475KZTX.mjs +0 -20
  65. package/dist/lib/node-esm/chunk-Y475KZTX.mjs.map +0 -7
  66. package/dist/lib/node-esm/chunk-YO46JGAE.mjs +0 -21
  67. package/dist/lib/node-esm/chunk-YO46JGAE.mjs.map +0 -7
  68. package/dist/lib/node-esm/index.mjs +0 -1102
  69. package/dist/lib/node-esm/index.mjs.map +0 -7
  70. package/dist/lib/node-esm/meta.json +0 -1
  71. package/dist/lib/node-esm/meta.mjs +0 -12
  72. package/dist/lib/node-esm/meta.mjs.map +0 -7
  73. package/dist/lib/node-esm/types.mjs +0 -9
  74. package/dist/lib/node-esm/types.mjs.map +0 -7
  75. package/dist/types/src/components/ThreadComplementary.d.ts +0 -10
  76. package/dist/types/src/components/ThreadComplementary.d.ts.map +0 -1
  77. package/src/components/ThreadComplementary.tsx +0 -113
  78. /package/dist/lib/browser/{ThreadArticle-4VU752F4.mjs.map → ThreadArticle-O64ZECMF.mjs.map} +0 -0
  79. /package/dist/lib/browser/{ThreadMain-ML3P4XSD.mjs.map → ThreadMain-ZDCRC6RG.mjs.map} +0 -0
  80. /package/dist/lib/node/{ThreadArticle-ERUHPHVT.cjs.map → ThreadArticle-DO56JXBU.cjs.map} +0 -0
  81. /package/dist/lib/node/{ThreadMain-6DN7FDNB.cjs.map → ThreadMain-XJX75GHG.cjs.map} +0 -0
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { Chat, type IconProps } from '@phosphor-icons/react';
5
6
  import { computed, effect } from '@preact/signals-core';
6
7
  import React from 'react';
7
8
 
@@ -15,40 +16,57 @@ import {
15
16
  parseIntentPlugin,
16
17
  parseNavigationPlugin,
17
18
  resolvePlugin,
19
+ parseGraphPlugin,
20
+ SLUG_PATH_SEPARATOR,
21
+ SLUG_COLLECTION_INDICATOR,
18
22
  isLayoutParts,
19
23
  parseMetadataResolverPlugin,
24
+ type IntentDispatcher,
25
+ useResolvePlugins,
20
26
  } from '@dxos/app-framework';
21
27
  import { type UnsubscribeCallback } from '@dxos/async';
22
28
  import { type EchoReactiveObject, getTypename } from '@dxos/echo-schema';
23
29
  import { create } from '@dxos/echo-schema';
24
30
  import { LocalStorageStore } from '@dxos/local-storage';
25
- import { log } from '@dxos/log';
31
+ import { type AttentionPluginProvides, parseAttentionPlugin } from '@dxos/plugin-attention';
26
32
  import { parseClientPlugin } from '@dxos/plugin-client';
27
- 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';
28
35
  import { ObservabilityAction } from '@dxos/plugin-observability/meta';
29
36
  import { SpaceAction } from '@dxos/plugin-space';
30
37
  import { ThreadType, MessageType, ChannelType } from '@dxos/plugin-space/types';
31
38
  import {
32
39
  getSpace,
33
40
  getTextInRange,
41
+ Filter,
34
42
  createDocAccessor,
35
43
  fullyQualifiedId,
36
44
  loadObjectReferences,
37
45
  } from '@dxos/react-client/echo';
46
+ import { ScrollArea } from '@dxos/react-ui';
47
+ import { createAttendableAttributes } from '@dxos/react-ui-attention';
38
48
  import { comments, createExternalCommentSync, listener } from '@dxos/react-ui-editor';
39
49
  import { translations as threadTranslations } from '@dxos/react-ui-thread';
40
50
  import { nonNullable } from '@dxos/util';
41
51
 
42
- import { ThreadMain, ThreadSettings, ChatContainer, ChatHeading, ThreadArticle } from './components';
43
- import { ThreadComplementary } from './components/ThreadComplementary';
52
+ import {
53
+ ThreadMain,
54
+ ThreadSettings,
55
+ CommentsContainer,
56
+ CommentsHeading,
57
+ ChatContainer,
58
+ ChatHeading,
59
+ ThreadArticle,
60
+ } from './components';
44
61
  import meta, { THREAD_ITEM, THREAD_PLUGIN } from './meta';
45
62
  import translations from './translations';
46
- import { ThreadAction, type ThreadPluginProvides, type ThreadSettingsProps } from './types';
63
+ import { ThreadAction, type ThreadProvides, type ThreadPluginProvides, type ThreadSettingsProps } from './types';
47
64
 
48
65
  type ThreadState = {
49
66
  /** An in-memory staging area for threads that are being drafted. */
50
67
  staging: Record<string, ThreadType[]>;
51
68
  current?: string | undefined;
69
+ focus?: boolean;
52
70
  };
53
71
 
54
72
  type SubjectId = string;
@@ -56,6 +74,8 @@ const initialViewState = { showResolvedThreads: false };
56
74
  type ViewStore = Record<SubjectId, typeof initialViewState>;
57
75
 
58
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;
59
79
 
60
80
  // TODO(Zan): Every instance of `cursor` should be replaced with `anchor`.
61
81
  export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
@@ -70,8 +90,10 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
70
90
  return viewStore[subjectId];
71
91
  };
72
92
 
93
+ let attentionPlugin: Plugin<AttentionPluginProvides> | undefined;
73
94
  let navigationPlugin: Plugin<LocationProvides> | undefined;
74
95
  let intentPlugin: Plugin<IntentPluginProvides> | undefined;
96
+ let dispatch: IntentDispatcher | undefined;
75
97
 
76
98
  const unsubscribeCallbacks = [] as UnsubscribeCallback[];
77
99
 
@@ -80,8 +102,45 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
80
102
  ready: async (plugins) => {
81
103
  settings.prop({ key: 'standalone', type: LocalStorageStore.bool({ allowUndefined: true }) });
82
104
 
105
+ attentionPlugin = resolvePlugin(plugins, parseAttentionPlugin);
83
106
  navigationPlugin = resolvePlugin(plugins, parseNavigationPlugin);
84
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);
85
144
  },
86
145
  unload: async () => {
87
146
  unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe());
@@ -92,7 +151,8 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
92
151
  records: {
93
152
  [ChannelType.typename]: {
94
153
  placeholder: ['channel title placeholder', { ns: THREAD_PLUGIN }],
95
- icon: 'ph--chat--regular',
154
+ icon: (props: IconProps) => <Chat {...props} />,
155
+ iconSymbol: 'ph--chat--regular',
96
156
  // TODO(wittjosiah): Move out of metadata.
97
157
  loadReferences: (channel: ChannelType) => loadObjectReferences(channel, (channel) => channel.threads),
98
158
  },
@@ -138,42 +198,31 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
138
198
  return;
139
199
  }
140
200
 
141
- // TODO(Zan): Find util (or make one).
142
- const subjectId = id.split('~').at(0);
143
- const [spaceId, objectId] = subjectId?.split(':') ?? [];
201
+ // TODO(Zan): Find util (or make one)
202
+ const docId = id.split('~').at(0);
203
+ const [spaceId, objectId] = docId?.split(':') ?? [];
144
204
  const space = client.spaces.get().find((space) => space.id === spaceId);
145
- const object = toSignal(
146
- (onChange) => {
147
- const timeout = setTimeout(async () => {
148
- await space?.db.loadObjectById(objectId);
149
- onChange();
150
- });
151
-
152
- return () => clearTimeout(timeout);
153
- },
154
- () => space?.db.getObjectById(objectId),
155
- subjectId,
156
- );
157
- if (!object || !subjectId) {
205
+ const doc = space?.db.getObjectById(objectId);
206
+ if (!doc || !docId) {
158
207
  return;
159
208
  }
160
209
 
161
- const meta = metadataResolver(getTypename(object) ?? '');
162
- const label = meta.label?.(object) ||
163
- object.name ||
164
- 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;
165
215
 
166
- const viewState = getViewState(subjectId);
216
+ const viewState = getViewState(docId);
167
217
 
168
218
  return {
169
219
  id,
170
220
  type: 'orphan-comments-for-subject',
171
- data: null,
221
+ data: doc,
172
222
  properties: {
173
- icon: 'ph--chat-text--regular',
223
+ icon: meta.iconComponent,
174
224
  label,
175
225
  showResolvedThreads: viewState.showResolvedThreads,
176
- object,
177
226
  },
178
227
  };
179
228
  },
@@ -207,7 +256,6 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
207
256
  menuItemType: 'toggle',
208
257
  isChecked: viewState.showResolvedThreads,
209
258
  testId: 'threadPlugin.toggleShowResolved',
210
- icon: viewState.showResolvedThreads ? 'ph--eye-slash--regular' : 'ph--eye--regular',
211
259
  },
212
260
  },
213
261
  ];
@@ -239,7 +287,8 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
239
287
  },
240
288
  properties: {
241
289
  label: ['create channel label', { ns: THREAD_PLUGIN }],
242
- icon: 'ph--chat--regular',
290
+ icon: (props: IconProps) => <Chat {...props} />,
291
+ iconSymbol: 'ph--chat--regular',
243
292
  testId: 'threadPlugin.createObject',
244
293
  },
245
294
  },
@@ -265,6 +314,8 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
265
314
  case 'article':
266
315
  case 'complementary': {
267
316
  const location = navigationPlugin?.provides.location;
317
+ const threadsIntegrators = useResolvePlugins(providesThreadsConfig);
318
+ const threadProvides = threadsIntegrators.map((p) => p.provides.thread);
268
319
 
269
320
  if (data.object instanceof ChannelType && data.object.threads[0]) {
270
321
  const channel = data.object;
@@ -297,16 +348,85 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
297
348
  'threads' in data.subject &&
298
349
  Array.isArray(data.subject.threads)
299
350
  ) {
300
- 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);
301
370
 
302
371
  return (
303
- <ThreadComplementary
304
- role={role}
305
- subject={data.subject}
306
- stagedThreads={state.staging[fullyQualifiedId(data.subject)]}
307
- current={state.current}
308
- showResolvedThreads={showResolvedThreads}
309
- />
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>
310
430
  );
311
431
  }
312
432
  }
@@ -322,17 +442,6 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
322
442
  if (intent.data && intent.data.cursor !== undefined) {
323
443
  const { cursor, name, subject } = intent.data;
324
444
 
325
- // Seed the threads array if it does not exist.
326
- if (subject?.threads === undefined) {
327
- try {
328
- // Static schema will throw an error if subject does not support threads array property.
329
- subject.threads = [];
330
- } catch (e) {
331
- log.error('Subject does not support threads array', subject?.typename);
332
- return;
333
- }
334
- }
335
-
336
445
  const subjectId = fullyQualifiedId(subject);
337
446
  const thread = create(ThreadType, { name, anchor: cursor, messages: [], status: 'staged' });
338
447
  const stagingArea = state.staging[subjectId];
@@ -346,9 +455,17 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
346
455
  data: thread,
347
456
  intents: [
348
457
  [
458
+ {
459
+ action: NavigationAction.OPEN,
460
+ data: {
461
+ activeParts: {
462
+ complementary: `${subjectId}${SLUG_PATH_SEPARATOR}comments${SLUG_COLLECTION_INDICATOR}`,
463
+ },
464
+ },
465
+ },
349
466
  {
350
467
  action: ThreadAction.SELECT,
351
- data: { current: fullyQualifiedId(thread) },
468
+ data: { current: thread.id, focus: true },
352
469
  },
353
470
  {
354
471
  action: LayoutAction.SET_LAYOUT,
@@ -368,6 +485,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
368
485
  }
369
486
 
370
487
  case ThreadAction.SELECT: {
488
+ state.focus = intent.data?.current === state.current ? state.focus : intent.data?.focus;
371
489
  state.current = intent.data?.current;
372
490
  return { data: true };
373
491
  }
@@ -401,10 +519,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
401
519
 
402
520
  case ThreadAction.DELETE: {
403
521
  const { subject, thread } = intent.data ?? {};
404
- if (
405
- !(thread instanceof ThreadType) ||
406
- !(subject && typeof subject === 'object' && 'threads' in subject)
407
- ) {
522
+ if (!(subject instanceof DocumentType) || !(thread instanceof ThreadType)) {
408
523
  return;
409
524
  }
410
525
 
@@ -416,7 +531,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
416
531
  const index = stagingArea.findIndex((t) => t.id === thread.id);
417
532
  if (index !== -1) {
418
533
  stagingArea.splice(index, 1);
419
- return { data: true };
534
+ return;
420
535
  }
421
536
  }
422
537
 
@@ -426,7 +541,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
426
541
  }
427
542
 
428
543
  if (!intent.undo) {
429
- const index = subject.threads.findIndex((t: any) => t?.id === thread.id);
544
+ const index = subject.threads.findIndex((t) => t?.id === thread.id);
430
545
  const cursor = subject.threads[index]?.anchor;
431
546
  if (index !== -1) {
432
547
  subject.threads?.splice(index, 1);
@@ -472,7 +587,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
472
587
  const { thread, subject } = intent.data ?? {};
473
588
  if (
474
589
  !(thread instanceof ThreadType) ||
475
- !(subject && typeof subject === 'object' && 'threads' in subject && Array.isArray(subject.threads))
590
+ !(subject && typeof subject === 'object' && 'threads' in subject)
476
591
  ) {
477
592
  return;
478
593
  }
@@ -512,10 +627,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
512
627
  const { subject, thread, messageId } = intent.data ?? {};
513
628
  const space = getSpace(subject);
514
629
 
515
- if (
516
- !(thread instanceof ThreadType) ||
517
- !(subject && typeof subject === 'object' && 'threads' in subject)
518
- ) {
630
+ if (!(subject instanceof DocumentType) || !(thread instanceof ThreadType) || !space) {
519
631
  return;
520
632
  }
521
633
 
@@ -546,7 +658,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
546
658
  action: ObservabilityAction.SEND_EVENT,
547
659
  data: {
548
660
  name: 'threads.message.delete',
549
- properties: { threadId: thread.id, spaceId: space?.id },
661
+ properties: { threadId: thread.id, spaceId: space.id },
550
662
  },
551
663
  },
552
664
  ],
@@ -570,7 +682,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
570
682
  action: ObservabilityAction.SEND_EVENT,
571
683
  data: {
572
684
  name: 'threads.message.undo-delete',
573
- properties: { threadId: thread.id, spaceId: space?.id },
685
+ properties: { threadId: thread.id, spaceId: space.id },
574
686
  },
575
687
  },
576
688
  ],
@@ -616,15 +728,15 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
616
728
  },
617
729
  }),
618
730
  createExternalCommentSync(
619
- fullyQualifiedId(doc),
731
+ doc.id,
620
732
  (sink) => effect(() => sink()),
621
733
  () =>
622
734
  threads.value
623
735
  .filter((thread) => thread?.anchor)
624
- .map((thread) => ({ id: fullyQualifiedId(thread), cursor: thread.anchor! })),
736
+ .map((thread) => ({ id: thread.id, cursor: thread.anchor! })),
625
737
  ),
626
738
  comments({
627
- id: fullyQualifiedId(doc),
739
+ id: doc.id,
628
740
  onCreate: ({ cursor }) => {
629
741
  const [start, end] = cursor.split(':');
630
742
  const name = doc.content && getTextInRange(createDocAccessor(doc.content, ['content']), start, end);
@@ -642,7 +754,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
642
754
  // If the thread is in the staging area, remove it.
643
755
  const stagingArea = state.staging[fullyQualifiedId(doc)];
644
756
  if (stagingArea) {
645
- const index = stagingArea.findIndex((thread) => fullyQualifiedId(thread) === id);
757
+ const index = stagingArea.findIndex((thread) => thread.id === id);
646
758
  if (index !== -1) {
647
759
  stagingArea.splice(index, 1);
648
760
  }
@@ -655,7 +767,7 @@ export const ThreadPlugin = (): PluginDefinition<ThreadPluginProvides> => {
655
767
  },
656
768
  onUpdate: ({ id, cursor }) => {
657
769
  const thread =
658
- state.staging[fullyQualifiedId(doc)]?.find((thread) => fullyQualifiedId(thread) === id) ??
770
+ state.staging[fullyQualifiedId(doc)]?.find((thread) => thread.id === id) ??
659
771
  doc.threads.find((thread) => thread?.id === id);
660
772
 
661
773
  if (thread instanceof ThreadType && thread.anchor) {
@@ -2,14 +2,15 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { Chat } from '@phosphor-icons/react';
5
6
  import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
6
7
 
7
8
  import { create } from '@dxos/echo-schema';
8
9
  import { MessageType } from '@dxos/plugin-space/types';
9
- import { fullyQualifiedId, getSpace, useMembers } from '@dxos/react-client/echo';
10
+ import { getSpace, useMembers } from '@dxos/react-client/echo';
10
11
  import { useIdentity } from '@dxos/react-client/halo';
11
- import { Icon, ScrollArea, useThemeContext, useTranslation } from '@dxos/react-ui';
12
- import { PlankHeading } from '@dxos/react-ui-deck';
12
+ import { ScrollArea, useThemeContext, useTranslation } from '@dxos/react-ui';
13
+ import { PlankHeading, plankHeadingIconProps } from '@dxos/react-ui-deck';
13
14
  import { createBasicExtensions, createThemeExtensions, listener } from '@dxos/react-ui-editor';
14
15
  import { mx } from '@dxos/react-ui-theme';
15
16
  import { MessageTextbox, type MessageTextboxProps, Thread, ThreadFooter, threadLayout } from '@dxos/react-ui-thread';
@@ -27,7 +28,7 @@ export const ChatHeading = ({ attendableId }: { attendableId?: string }) => {
27
28
  return (
28
29
  <div role='none' className='flex items-center'>
29
30
  <PlankHeading.Button attendableId={attendableId}>
30
- <Icon icon='ph--chat--regular' size={5} />
31
+ <Chat {...plankHeadingIconProps} />
31
32
  </PlankHeading.Button>
32
33
  <PlankHeading.Label attendableId={attendableId}>{t('chat heading')}</PlankHeading.Label>
33
34
  </div>
@@ -38,7 +39,7 @@ export const ChatContainer = ({ thread, context, current, autoFocusTextbox }: Th
38
39
  const identity = useIdentity()!;
39
40
  const space = getSpace(thread);
40
41
  const members = useMembers(space?.key);
41
- const activity = useStatus(space, fullyQualifiedId(thread));
42
+ const activity = useStatus(space, thread.id);
42
43
  const { t } = useTranslation(THREAD_PLUGIN);
43
44
  // TODO(wittjosiah): This is a hack to reset the editor after a message is sent.
44
45
  const [_count, _setCount] = useState(0);
@@ -47,7 +48,7 @@ export const ChatContainer = ({ thread, context, current, autoFocusTextbox }: Th
47
48
  const threadScrollRef = useRef<HTMLDivElement | null>(null);
48
49
  const { themeMode } = useThemeContext();
49
50
 
50
- const textboxMetadata = getMessageMetadata(fullyQualifiedId(thread), identity);
51
+ const textboxMetadata = getMessageMetadata(thread.id, identity);
51
52
  const messageRef = useRef('');
52
53
  const extensions = useMemo(
53
54
  () => [
@@ -102,7 +103,7 @@ export const ChatContainer = ({ thread, context, current, autoFocusTextbox }: Th
102
103
  return (
103
104
  <Thread
104
105
  current={current}
105
- id={fullyQualifiedId(thread)}
106
+ id={thread.id}
106
107
  classNames='bs-full grid-rows-[1fr_min-content_min-content] overflow-hidden transition-[padding-block-end] [[data-sidebar-inline-start-state=open]_&]:lg:pbe-0'
107
108
  >
108
109
  <ScrollArea.Root classNames='col-span-2'>
@@ -3,11 +3,11 @@
3
3
  //
4
4
 
5
5
  import { CheckCircle, X } from '@phosphor-icons/react';
6
- import React, { useCallback, useMemo, useRef, useState } from 'react';
6
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
7
 
8
8
  import { create } from '@dxos/echo-schema';
9
9
  import { MessageType } from '@dxos/plugin-space/types';
10
- import { fullyQualifiedId, getSpace, useMembers } from '@dxos/react-client/echo';
10
+ import { getSpace, useMembers } from '@dxos/react-client/echo';
11
11
  import { useIdentity } from '@dxos/react-client/halo';
12
12
  import { Button, Tag, Tooltip, useThemeContext, useTranslation } from '@dxos/react-ui';
13
13
  import { createBasicExtensions, createThemeExtensions, listener } from '@dxos/react-ui-editor';
@@ -93,6 +93,7 @@ export const CommentContainer = ({
93
93
  detached,
94
94
  context,
95
95
  current,
96
+ autoFocusTextbox,
96
97
  onAttend,
97
98
  onThreadDelete,
98
99
  onMessageDelete,
@@ -102,12 +103,13 @@ export const CommentContainer = ({
102
103
  const identity = useIdentity()!;
103
104
  const space = getSpace(thread);
104
105
  const members = useMembers(space?.key);
105
- const activity = useStatus(space, fullyQualifiedId(thread));
106
+ const activity = useStatus(space, thread.id);
106
107
  const { t } = useTranslation(THREAD_PLUGIN);
107
108
  const threadScrollRef = useRef<HTMLDivElement | null>(null);
109
+ const [autoFocus, setAutoFocus] = useState(!!autoFocusTextbox);
108
110
  const { themeMode } = useThemeContext();
109
111
 
110
- const textboxMetadata = getMessageMetadata(fullyQualifiedId(thread), identity);
112
+ const textboxMetadata = getMessageMetadata(thread.id, identity);
111
113
  // TODO(wittjosiah): This is a hack to reset the editor after a message is sent.
112
114
  const [_count, _setCount] = useState(0);
113
115
  const rerenderEditor = () => _setCount((count) => count + 1);
@@ -122,6 +124,13 @@ export const CommentContainer = ({
122
124
  [_count],
123
125
  );
124
126
 
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
+
125
134
  // TODO(thure): Factor out.
126
135
  const scrollToEnd = (behavior: ScrollBehavior) =>
127
136
  setTimeout(() => threadScrollRef.current?.scrollIntoView({ behavior, block: 'end' }), 10);
@@ -142,6 +151,7 @@ export const CommentContainer = ({
142
151
  onComment?.(thread);
143
152
 
144
153
  messageRef.current = '';
154
+ setAutoFocus(true);
145
155
  scrollToEnd('instant');
146
156
  rerenderEditor();
147
157
 
@@ -149,8 +159,10 @@ export const CommentContainer = ({
149
159
  return true;
150
160
  }, [thread, identity]);
151
161
 
162
+ const handleDeleteMessage = (id: string) => onMessageDelete?.(id);
163
+
152
164
  return (
153
- <Thread onClickCapture={onAttend} onFocusCapture={onAttend} current={current} id={fullyQualifiedId(thread)}>
165
+ <Thread onClickCapture={onAttend} onFocusCapture={onAttend} current={current} id={thread.id}>
154
166
  <div
155
167
  role='none'
156
168
  className={mx(
@@ -183,20 +195,9 @@ export const CommentContainer = ({
183
195
  </div>
184
196
  </div>
185
197
  {thread.messages.filter(nonNullable).map((message) => (
186
- <MessageContainer
187
- key={message.id}
188
- message={message}
189
- members={members}
190
- onDelete={(id: string) => onMessageDelete?.(id)}
191
- />
198
+ <MessageContainer key={message.id} message={message} members={members} onDelete={handleDeleteMessage} />
192
199
  ))}
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} />
200
+ <MessageTextbox extensions={extensions} autoFocus={autoFocus} onSend={handleCreate} {...textboxMetadata} />
200
201
  <ThreadFooter activity={activity}>{t('activity message')}</ThreadFooter>
201
202
  {/* NOTE(thure): This can’t also be the `overflow-anchor` because `ScrollArea` injects an interceding node that contains this necessary ref’d element. */}
202
203
  <div role='none' className='bs-px -mbs-px' ref={threadScrollRef} />
@@ -8,7 +8,7 @@ import React, { useEffect, useState } from 'react';
8
8
 
9
9
  import { MessageType, ThreadType } from '@dxos/plugin-space/types';
10
10
  import { faker } from '@dxos/random';
11
- import { Filter, fullyQualifiedId, useQuery, useSpace } from '@dxos/react-client/echo';
11
+ import { Filter, useQuery, useSpace } from '@dxos/react-client/echo';
12
12
  import { useIdentity } from '@dxos/react-client/halo';
13
13
  import { type ClientRepeatedComponentProps, ClientRepeater } from '@dxos/react-client/testing';
14
14
  import { withLayout, withTheme } from '@dxos/storybook-utils';
@@ -30,7 +30,7 @@ const Story = ({ spaceKey }: ClientRepeatedComponentProps) => {
30
30
  const t = setTimeout(async () => {
31
31
  space.db.add(createCommentThread(identity));
32
32
  const thread = space.db.add(createCommentThread(identity));
33
- setDetached([fullyQualifiedId(thread)]);
33
+ setDetached([thread.id]);
34
34
  });
35
35
 
36
36
  return () => clearTimeout(t);