@dxos/plugin-space 0.6.8-main.046e6cf

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 (109) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +15 -0
  3. package/dist/lib/browser/chunk-DTVUOG2C.mjs +95 -0
  4. package/dist/lib/browser/chunk-DTVUOG2C.mjs.map +7 -0
  5. package/dist/lib/browser/chunk-LZEGRS7H.mjs +35 -0
  6. package/dist/lib/browser/chunk-LZEGRS7H.mjs.map +7 -0
  7. package/dist/lib/browser/index.mjs +2660 -0
  8. package/dist/lib/browser/index.mjs.map +7 -0
  9. package/dist/lib/browser/meta.json +1 -0
  10. package/dist/lib/browser/meta.mjs +13 -0
  11. package/dist/lib/browser/meta.mjs.map +7 -0
  12. package/dist/lib/browser/types/index.mjs +21 -0
  13. package/dist/lib/browser/types/index.mjs.map +7 -0
  14. package/dist/lib/node/chunk-6CNYF6YU.cjs +60 -0
  15. package/dist/lib/node/chunk-6CNYF6YU.cjs.map +7 -0
  16. package/dist/lib/node/chunk-CVZPI2P3.cjs +120 -0
  17. package/dist/lib/node/chunk-CVZPI2P3.cjs.map +7 -0
  18. package/dist/lib/node/index.cjs +2682 -0
  19. package/dist/lib/node/index.cjs.map +7 -0
  20. package/dist/lib/node/meta.cjs +34 -0
  21. package/dist/lib/node/meta.cjs.map +7 -0
  22. package/dist/lib/node/meta.json +1 -0
  23. package/dist/lib/node/types/index.cjs +43 -0
  24. package/dist/lib/node/types/index.cjs.map +7 -0
  25. package/dist/types/src/SpacePlugin.d.ts +27 -0
  26. package/dist/types/src/SpacePlugin.d.ts.map +1 -0
  27. package/dist/types/src/components/AwaitingObject.d.ts +5 -0
  28. package/dist/types/src/components/AwaitingObject.d.ts.map +1 -0
  29. package/dist/types/src/components/CollectionMain.d.ts +6 -0
  30. package/dist/types/src/components/CollectionMain.d.ts.map +1 -0
  31. package/dist/types/src/components/CollectionSection.d.ts +6 -0
  32. package/dist/types/src/components/CollectionSection.d.ts.map +1 -0
  33. package/dist/types/src/components/EmptySpace.d.ts +3 -0
  34. package/dist/types/src/components/EmptySpace.d.ts.map +1 -0
  35. package/dist/types/src/components/EmptyTree.d.ts +3 -0
  36. package/dist/types/src/components/EmptyTree.d.ts.map +1 -0
  37. package/dist/types/src/components/MenuFooter.d.ts +6 -0
  38. package/dist/types/src/components/MenuFooter.d.ts.map +1 -0
  39. package/dist/types/src/components/MissingObject.d.ts +5 -0
  40. package/dist/types/src/components/MissingObject.d.ts.map +1 -0
  41. package/dist/types/src/components/PersistenceStatus.d.ts +6 -0
  42. package/dist/types/src/components/PersistenceStatus.d.ts.map +1 -0
  43. package/dist/types/src/components/PopoverRenameObject.d.ts +6 -0
  44. package/dist/types/src/components/PopoverRenameObject.d.ts.map +1 -0
  45. package/dist/types/src/components/PopoverRenameSpace.d.ts +6 -0
  46. package/dist/types/src/components/PopoverRenameSpace.d.ts.map +1 -0
  47. package/dist/types/src/components/ShareSpaceButton.d.ts +8 -0
  48. package/dist/types/src/components/ShareSpaceButton.d.ts.map +1 -0
  49. package/dist/types/src/components/ShareSpaceButton.stories.d.ts +98 -0
  50. package/dist/types/src/components/ShareSpaceButton.stories.d.ts.map +1 -0
  51. package/dist/types/src/components/SpaceMain/SpaceMain.d.ts +10 -0
  52. package/dist/types/src/components/SpaceMain/SpaceMain.d.ts.map +1 -0
  53. package/dist/types/src/components/SpaceMain/SpaceMembersSection.d.ts +6 -0
  54. package/dist/types/src/components/SpaceMain/SpaceMembersSection.d.ts.map +1 -0
  55. package/dist/types/src/components/SpaceMain/index.d.ts +2 -0
  56. package/dist/types/src/components/SpaceMain/index.d.ts.map +1 -0
  57. package/dist/types/src/components/SpacePresence.d.ts +34 -0
  58. package/dist/types/src/components/SpacePresence.d.ts.map +1 -0
  59. package/dist/types/src/components/SpacePresence.stories.d.ts +97 -0
  60. package/dist/types/src/components/SpacePresence.stories.d.ts.map +1 -0
  61. package/dist/types/src/components/SpaceSettings.d.ts +6 -0
  62. package/dist/types/src/components/SpaceSettings.d.ts.map +1 -0
  63. package/dist/types/src/components/index.d.ts +15 -0
  64. package/dist/types/src/components/index.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +9 -0
  66. package/dist/types/src/index.d.ts.map +1 -0
  67. package/dist/types/src/meta.d.ts +26 -0
  68. package/dist/types/src/meta.d.ts.map +1 -0
  69. package/dist/types/src/translations.d.ts +83 -0
  70. package/dist/types/src/translations.d.ts.map +1 -0
  71. package/dist/types/src/types/collection.d.ts +18 -0
  72. package/dist/types/src/types/collection.d.ts.map +1 -0
  73. package/dist/types/src/types/index.d.ts +4 -0
  74. package/dist/types/src/types/index.d.ts.map +1 -0
  75. package/dist/types/src/types/thread.d.ts +225 -0
  76. package/dist/types/src/types/thread.d.ts.map +1 -0
  77. package/dist/types/src/types/types.d.ts +54 -0
  78. package/dist/types/src/types/types.d.ts.map +1 -0
  79. package/dist/types/src/util.d.ts +85 -0
  80. package/dist/types/src/util.d.ts.map +1 -0
  81. package/package.json +101 -0
  82. package/src/SpacePlugin.tsx +1234 -0
  83. package/src/components/AwaitingObject.tsx +118 -0
  84. package/src/components/CollectionMain.tsx +33 -0
  85. package/src/components/CollectionSection.tsx +20 -0
  86. package/src/components/EmptySpace.tsx +25 -0
  87. package/src/components/EmptyTree.tsx +25 -0
  88. package/src/components/MenuFooter.tsx +33 -0
  89. package/src/components/MissingObject.tsx +54 -0
  90. package/src/components/PersistenceStatus.tsx +87 -0
  91. package/src/components/PopoverRenameObject.tsx +54 -0
  92. package/src/components/PopoverRenameSpace.tsx +44 -0
  93. package/src/components/ShareSpaceButton.stories.tsx +23 -0
  94. package/src/components/ShareSpaceButton.tsx +27 -0
  95. package/src/components/SpaceMain/SpaceMain.tsx +81 -0
  96. package/src/components/SpaceMain/SpaceMembersSection.tsx +205 -0
  97. package/src/components/SpaceMain/index.ts +5 -0
  98. package/src/components/SpacePresence.stories.tsx +102 -0
  99. package/src/components/SpacePresence.tsx +244 -0
  100. package/src/components/SpaceSettings.tsx +34 -0
  101. package/src/components/index.ts +18 -0
  102. package/src/index.ts +15 -0
  103. package/src/meta.ts +31 -0
  104. package/src/translations.ts +92 -0
  105. package/src/types/collection.ts +16 -0
  106. package/src/types/index.ts +7 -0
  107. package/src/types/thread.ts +68 -0
  108. package/src/types/types.ts +81 -0
  109. package/src/util.tsx +642 -0
@@ -0,0 +1,1234 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type IconProps, Plus, SignIn, CardsThree, Warning } from '@phosphor-icons/react';
6
+ import { effect, signal } from '@preact/signals-core';
7
+ import React from 'react';
8
+
9
+ import {
10
+ type IntentDispatcher,
11
+ type IntentPluginProvides,
12
+ LayoutAction,
13
+ Surface,
14
+ type LocationProvides,
15
+ NavigationAction,
16
+ type Plugin,
17
+ type PluginDefinition,
18
+ openIds,
19
+ firstIdInPart,
20
+ parseIntentPlugin,
21
+ parseNavigationPlugin,
22
+ parseMetadataResolverPlugin,
23
+ resolvePlugin,
24
+ parseGraphPlugin,
25
+ } from '@dxos/app-framework';
26
+ import { EventSubscriptions, type Trigger, type UnsubscribeCallback } from '@dxos/async';
27
+ import { type Identifiable, isReactiveObject, type EchoReactiveObject } from '@dxos/echo-schema';
28
+ import { LocalStorageStore } from '@dxos/local-storage';
29
+ import { log } from '@dxos/log';
30
+ import { Migrations } from '@dxos/migrations';
31
+ import { type AttentionPluginProvides, parseAttentionPlugin } from '@dxos/plugin-attention';
32
+ import { type ClientPluginProvides, parseClientPlugin } from '@dxos/plugin-client';
33
+ import { createExtension, isGraphNode, memoize, type Node, toSignal } from '@dxos/plugin-graph';
34
+ import { ObservabilityAction } from '@dxos/plugin-observability/meta';
35
+ import { type Client, PublicKey } from '@dxos/react-client';
36
+ import {
37
+ type PropertiesTypeProps,
38
+ type ReactiveObject,
39
+ type Space,
40
+ create,
41
+ Expando,
42
+ Filter,
43
+ fullyQualifiedId,
44
+ getSpace,
45
+ getTypename,
46
+ isEchoObject,
47
+ isSpace,
48
+ loadObjectReferences,
49
+ SpaceState,
50
+ } from '@dxos/react-client/echo';
51
+ import { Dialog } from '@dxos/react-ui';
52
+ import { InvitationManager, type InvitationManagerProps, osTranslations, ClipboardProvider } from '@dxos/shell/react';
53
+ import { ComplexMap, nonNullable, reduceGroupBy } from '@dxos/util';
54
+
55
+ import {
56
+ AwaitingObject,
57
+ CollectionMain,
58
+ CollectionSection,
59
+ EmptySpace,
60
+ EmptyTree,
61
+ MenuFooter,
62
+ MissingObject,
63
+ PopoverRenameObject,
64
+ PopoverRenameSpace,
65
+ ShareSpaceButton,
66
+ SmallPresence,
67
+ SmallPresenceLive,
68
+ SpacePresence,
69
+ SpaceSettings,
70
+ } from './components';
71
+ import meta, { SPACE_PLUGIN, SpaceAction } from './meta';
72
+ import translations from './translations';
73
+ import { CollectionType, type SpacePluginProvides, type SpaceSettingsProps, type PluginState } from './types';
74
+ import {
75
+ COMPOSER_SPACE_LOCK,
76
+ SHARED,
77
+ SPACES,
78
+ SPACE_TYPE,
79
+ cloneObject,
80
+ constructObjectActionGroups,
81
+ constructObjectActions,
82
+ constructSpaceActionGroups,
83
+ constructSpaceActions,
84
+ constructSpaceNode,
85
+ createObjectNode,
86
+ getNestedObjects,
87
+ memoizeQuery,
88
+ } from './util';
89
+
90
+ const ACTIVE_NODE_BROADCAST_INTERVAL = 30_000;
91
+ const OBJECT_ID_LENGTH = 60; // 33 (space id) + 26 (object id) + 1 (separator).
92
+ const SPACE_MAX_OBJECTS = 500;
93
+ // https://stackoverflow.com/a/19016910
94
+ const DIRECTORY_TYPE = 'text/directory';
95
+
96
+ export const parseSpacePlugin = (plugin?: Plugin) =>
97
+ Array.isArray((plugin?.provides as any).space?.enabled) ? (plugin as Plugin<SpacePluginProvides>) : undefined;
98
+
99
+ export type SpacePluginOptions = {
100
+ /**
101
+ * Fired when first run logic should be executed.
102
+ *
103
+ * This trigger is invoked once the HALO identity is created but must only be run in one instance of the application.
104
+ * As such it cannot depend directly on the HALO identity event.
105
+ */
106
+ firstRun?: Trigger<void>;
107
+
108
+ /**
109
+ * Root collection structure is created on application first run if it does not yet exist.
110
+ * This callback is invoked immediately following the creation of the root collection structure.
111
+ *
112
+ * @param params.client DXOS Client
113
+ * @param params.dispatch Function to dispatch intents
114
+ */
115
+ onFirstRun?: (params: { client: Client; dispatch: IntentDispatcher }) => Promise<void>;
116
+ };
117
+
118
+ export const SpacePlugin = ({
119
+ firstRun,
120
+ onFirstRun,
121
+ }: SpacePluginOptions = {}): PluginDefinition<SpacePluginProvides> => {
122
+ const settings = new LocalStorageStore<SpaceSettingsProps>(SPACE_PLUGIN);
123
+ const state = new LocalStorageStore<PluginState>(SPACE_PLUGIN, {
124
+ awaiting: undefined,
125
+ spaceNames: {},
126
+ viewersByObject: {},
127
+ viewersByIdentity: new ComplexMap(PublicKey.hash),
128
+ sdkMigrationRunning: {},
129
+ });
130
+ const subscriptions = new EventSubscriptions();
131
+ const spaceSubscriptions = new EventSubscriptions();
132
+ const graphSubscriptions = new Map<string, UnsubscribeCallback>();
133
+
134
+ let clientPlugin: Plugin<ClientPluginProvides> | undefined;
135
+ let intentPlugin: Plugin<IntentPluginProvides> | undefined;
136
+ let navigationPlugin: Plugin<LocationProvides> | undefined;
137
+ let attentionPlugin: Plugin<AttentionPluginProvides> | undefined;
138
+
139
+ const onSpaceReady = async () => {
140
+ if (!clientPlugin || !navigationPlugin || !attentionPlugin) {
141
+ return;
142
+ }
143
+
144
+ const client = clientPlugin.provides.client;
145
+ const location = navigationPlugin.provides.location;
146
+ const attention = attentionPlugin.provides.attention;
147
+ const defaultSpace = client.spaces.default;
148
+
149
+ // Initialize space sharing lock in default space.
150
+ if (typeof defaultSpace.properties[COMPOSER_SPACE_LOCK] !== 'boolean') {
151
+ defaultSpace.properties[COMPOSER_SPACE_LOCK] = true;
152
+ }
153
+
154
+ const {
155
+ objects: [spacesOrder],
156
+ } = await defaultSpace.db.query(Filter.schema(Expando, { key: SHARED })).run();
157
+ if (!spacesOrder) {
158
+ // TODO(wittjosiah): Cannot be a Folder because Spaces are not TypedObjects so can't be saved in the database.
159
+ // Instead, we store order as an array of space ids.
160
+ defaultSpace.db.add(create({ key: SHARED, order: [] }));
161
+ }
162
+
163
+ // Cache space names.
164
+ subscriptions.add(
165
+ client.spaces.subscribe(async (spaces) => {
166
+ // TODO(wittjosiah): Remove. This is a hack to be able to migrate the default space properties.
167
+ if (defaultSpace.state.get() === SpaceState.SPACE_REQUIRES_MIGRATION) {
168
+ await defaultSpace.internal.migrate();
169
+ }
170
+
171
+ spaces
172
+ .filter((space) => space.state.get() === SpaceState.SPACE_READY)
173
+ .forEach((space) => {
174
+ subscriptions.add(
175
+ effect(() => {
176
+ state.values.spaceNames[space.id] = space.properties.name;
177
+ }),
178
+ );
179
+ });
180
+ }).unsubscribe,
181
+ );
182
+
183
+ // Broadcast active node to other peers in the space.
184
+ subscriptions.add(
185
+ effect(() => {
186
+ const send = () => {
187
+ const spaces = client.spaces.get();
188
+ const identity = client.halo.identity.get();
189
+ if (identity && location.active) {
190
+ const ids = openIds(location.active);
191
+
192
+ // Group parts by space for efficient messaging.
193
+ const idsBySpace = reduceGroupBy(ids, (id) => {
194
+ const [spaceId] = id.split(':');
195
+ return spaceId;
196
+ });
197
+
198
+ // NOTE: Ensure all spaces are included so that we send the correct `removed` object arrays.
199
+ for (const space of spaces) {
200
+ if (!idsBySpace.has(space.id)) {
201
+ idsBySpace.set(space.id, []);
202
+ }
203
+ }
204
+
205
+ for (const [spaceId, ids] of idsBySpace) {
206
+ const space = spaces.find((space) => space.id === spaceId);
207
+ if (!space) {
208
+ continue;
209
+ }
210
+
211
+ const removed = location.closed ? [location.closed].flat() : [];
212
+
213
+ void space
214
+ .postMessage('viewing', {
215
+ identityKey: identity.identityKey.toHex(),
216
+ attended: attention.attended ? [...attention.attended] : [],
217
+ added: ids,
218
+ // TODO(Zan): When we re-open a part, we should remove it from the removed list in the navigation plugin.
219
+ removed: removed.filter((id) => !ids.includes(id)),
220
+ })
221
+ // TODO(burdon): This seems defensive; why would this fail? Backoff interval.
222
+ .catch((err) => {
223
+ log.warn('Failed to broadcast active node for presence.', { err: err.message });
224
+ });
225
+ }
226
+ }
227
+ };
228
+
229
+ send();
230
+ const interval = setInterval(() => send(), ACTIVE_NODE_BROADCAST_INTERVAL);
231
+ return () => clearInterval(interval);
232
+ }),
233
+ );
234
+
235
+ // Listen for active nodes from other peers in the space.
236
+ subscriptions.add(
237
+ client.spaces.subscribe((spaces) => {
238
+ spaceSubscriptions.clear();
239
+ spaces.forEach((space) => {
240
+ spaceSubscriptions.add(
241
+ space.listen('viewing', (message) => {
242
+ const { added, removed, attended } = message.payload;
243
+
244
+ const identityKey = PublicKey.safeFrom(message.payload.identityKey);
245
+ if (identityKey && Array.isArray(added) && Array.isArray(removed)) {
246
+ added.forEach((id) => {
247
+ if (typeof id === 'string') {
248
+ if (!(id in state.values.viewersByObject)) {
249
+ state.values.viewersByObject[id] = new ComplexMap(PublicKey.hash);
250
+ }
251
+ state.values.viewersByObject[id]!.set(identityKey, {
252
+ lastSeen: Date.now(),
253
+ currentlyAttended: new Set(attended).has(id),
254
+ });
255
+ if (!state.values.viewersByIdentity.has(identityKey)) {
256
+ state.values.viewersByIdentity.set(identityKey, new Set());
257
+ }
258
+ state.values.viewersByIdentity.get(identityKey)!.add(id);
259
+ }
260
+ });
261
+
262
+ removed.forEach((id) => {
263
+ if (typeof id === 'string') {
264
+ state.values.viewersByObject[id]?.delete(identityKey);
265
+ state.values.viewersByIdentity.get(identityKey)?.delete(id);
266
+ // It’s okay for these to be empty sets/maps, reduces churn.
267
+ }
268
+ });
269
+ }
270
+ }),
271
+ );
272
+ });
273
+ }).unsubscribe,
274
+ );
275
+ };
276
+
277
+ return {
278
+ meta,
279
+ ready: async (plugins) => {
280
+ settings.prop({
281
+ key: 'showHidden',
282
+ storageKey: 'show-hidden',
283
+ type: LocalStorageStore.bool({ allowUndefined: true }),
284
+ });
285
+
286
+ state.prop({
287
+ key: 'spaceNames',
288
+ storageKey: 'space-names',
289
+ type: LocalStorageStore.json<Record<string, string>>(),
290
+ });
291
+
292
+ navigationPlugin = resolvePlugin(plugins, parseNavigationPlugin);
293
+ attentionPlugin = resolvePlugin(plugins, parseAttentionPlugin);
294
+ clientPlugin = resolvePlugin(plugins, parseClientPlugin);
295
+ intentPlugin = resolvePlugin(plugins, parseIntentPlugin);
296
+ if (!clientPlugin || !intentPlugin) {
297
+ return;
298
+ }
299
+
300
+ const client = clientPlugin.provides.client;
301
+ const dispatch = intentPlugin.provides.intent.dispatch;
302
+
303
+ const handleFirstRun = async () => {
304
+ const defaultSpace = client.spaces.default;
305
+
306
+ // Create root collection structure.
307
+ const personalSpaceCollection = create(CollectionType, { objects: [], views: {} });
308
+ defaultSpace.properties[CollectionType.typename] = personalSpaceCollection;
309
+ if (Migrations.versionProperty) {
310
+ defaultSpace.properties[Migrations.versionProperty] = Migrations.targetVersion;
311
+ }
312
+ await onFirstRun?.({ client, dispatch });
313
+ };
314
+
315
+ // No need to unsubscribe because this observable completes when spaces are ready.
316
+ client.spaces.isReady.subscribe(async (ready) => {
317
+ if (ready) {
318
+ await clientPlugin?.provides.client.spaces.default.waitUntilReady();
319
+
320
+ if (firstRun) {
321
+ void firstRun?.wait().then(handleFirstRun);
322
+ } else {
323
+ await handleFirstRun();
324
+ }
325
+
326
+ await onSpaceReady();
327
+ }
328
+ });
329
+ },
330
+ unload: async () => {
331
+ settings.close();
332
+ spaceSubscriptions.clear();
333
+ subscriptions.clear();
334
+ graphSubscriptions.forEach((cb) => cb());
335
+ graphSubscriptions.clear();
336
+ },
337
+ provides: {
338
+ space: state.values,
339
+ settings: settings.values,
340
+ translations: [...translations, osTranslations],
341
+ root: () => (state.values.awaiting ? <AwaitingObject id={state.values.awaiting} /> : null),
342
+ metadata: {
343
+ records: {
344
+ [CollectionType.typename]: {
345
+ placeholder: ['unnamed collection label', { ns: SPACE_PLUGIN }],
346
+ icon: (props: IconProps) => <CardsThree {...props} />,
347
+ iconSymbol: 'ph--cards-three--regular',
348
+ // TODO(wittjosiah): Move out of metadata.
349
+ loadReferences: (collection: CollectionType) =>
350
+ loadObjectReferences(collection, (collection) => [
351
+ ...collection.objects,
352
+ ...Object.values(collection.views),
353
+ ]),
354
+ },
355
+ },
356
+ },
357
+ echo: {
358
+ schema: [CollectionType],
359
+ },
360
+ surface: {
361
+ component: ({ data, role, ...rest }) => {
362
+ const primary = data.active ?? data.object;
363
+ switch (role) {
364
+ case 'article':
365
+ case 'main':
366
+ // TODO(wittjosiah): Need to avoid shotgun parsing space state everywhere.
367
+ return isSpace(primary) && primary.state.get() === SpaceState.SPACE_READY ? (
368
+ <Surface data={{ active: primary.properties[CollectionType.typename] }} role={role} {...rest} />
369
+ ) : primary instanceof CollectionType ? (
370
+ { node: <CollectionMain collection={primary} />, disposition: 'fallback' }
371
+ ) : typeof primary === 'string' && primary.length === OBJECT_ID_LENGTH ? (
372
+ <MissingObject id={primary} />
373
+ ) : null;
374
+ // TODO(burdon): Add role name syntax to minimal plugin docs.
375
+ case 'tree--empty':
376
+ switch (true) {
377
+ case data.plugin === SPACE_PLUGIN:
378
+ return <EmptyTree />;
379
+ case isGraphNode(data.activeNode) && isSpace(data.activeNode.data):
380
+ return <EmptySpace />;
381
+ default:
382
+ return null;
383
+ }
384
+ case 'dialog':
385
+ if (data.component === 'dxos.org/plugin/space/InvitationManagerDialog') {
386
+ return (
387
+ <Dialog.Content>
388
+ <ClipboardProvider>
389
+ <InvitationManager active {...(data.subject as InvitationManagerProps)} />
390
+ </ClipboardProvider>
391
+ </Dialog.Content>
392
+ );
393
+ } else {
394
+ return null;
395
+ }
396
+ case 'popover':
397
+ if (data.component === 'dxos.org/plugin/space/RenameSpacePopover' && isSpace(data.subject)) {
398
+ return <PopoverRenameSpace space={data.subject} />;
399
+ }
400
+ if (data.component === 'dxos.org/plugin/space/RenameObjectPopover' && isReactiveObject(data.subject)) {
401
+ return <PopoverRenameObject object={data.subject} />;
402
+ }
403
+ return null;
404
+ case 'presence--glyph': {
405
+ return isReactiveObject(data.object) ? (
406
+ <SmallPresenceLive
407
+ viewers={state.values.viewersByObject[fullyQualifiedId(data.object)]}
408
+ onCloseClick={() => {
409
+ const objectId = fullyQualifiedId(data.object as ReactiveObject<any>);
410
+ return intentPlugin?.provides.intent.dispatch({
411
+ action: NavigationAction.CLOSE,
412
+ data: { activeParts: { main: [objectId], sidebar: [objectId], complementary: [objectId] } },
413
+ });
414
+ }}
415
+ />
416
+ ) : (
417
+ <SmallPresence count={0} />
418
+ );
419
+ }
420
+ case 'navbar-start': {
421
+ return null;
422
+ }
423
+ case 'navbar-end': {
424
+ if (!isEchoObject(data.object) && !isSpace(data.object)) {
425
+ return null;
426
+ }
427
+
428
+ const space = isSpace(data.object) ? data.object : getSpace(data.object);
429
+ const object = isSpace(data.object)
430
+ ? data.object.state.get() === SpaceState.SPACE_READY
431
+ ? (space?.properties[CollectionType.typename] as CollectionType)
432
+ : undefined
433
+ : data.object;
434
+ return space && object
435
+ ? {
436
+ node: (
437
+ <>
438
+ <SpacePresence object={object} />
439
+ {space.properties[COMPOSER_SPACE_LOCK] ? null : <ShareSpaceButton spaceId={space.id} />}
440
+ </>
441
+ ),
442
+ disposition: 'hoist',
443
+ }
444
+ : null;
445
+ }
446
+ case 'section':
447
+ return data.object instanceof CollectionType ? <CollectionSection collection={data.object} /> : null;
448
+ case 'settings':
449
+ return data.plugin === meta.id ? <SpaceSettings settings={settings.values} /> : null;
450
+ case 'menu-footer':
451
+ if (!isEchoObject(data.object)) {
452
+ return null;
453
+ } else {
454
+ return <MenuFooter object={data.object} />;
455
+ }
456
+ default:
457
+ return null;
458
+ }
459
+ },
460
+ },
461
+ graph: {
462
+ builder: (plugins) => {
463
+ const clientPlugin = resolvePlugin(plugins, parseClientPlugin);
464
+ const metadataPlugin = resolvePlugin(plugins, parseMetadataResolverPlugin);
465
+ const graphPlugin = resolvePlugin(plugins, parseGraphPlugin);
466
+
467
+ const client = clientPlugin?.provides.client;
468
+ const dispatch = intentPlugin?.provides.intent.dispatch;
469
+ const resolve = metadataPlugin?.provides.metadata.resolver;
470
+ const graph = graphPlugin?.provides.graph;
471
+
472
+ if (!graph || !dispatch || !resolve || !client) {
473
+ return [];
474
+ }
475
+
476
+ return [
477
+ // Create spaces group node.
478
+ createExtension({
479
+ id: `${SPACE_PLUGIN}/root`,
480
+ filter: (node): node is Node<null> => node.id === 'root',
481
+ connector: () => {
482
+ const isReady = toSignal(
483
+ (onChange) => {
484
+ let defaultSpaceUnsubscribe: UnsubscribeCallback | undefined;
485
+ // No need to unsubscribe because this observable completes when spaces are ready.
486
+ client.spaces.isReady.subscribe((ready) => {
487
+ if (ready) {
488
+ defaultSpaceUnsubscribe = client.spaces.default.state.subscribe(() => onChange()).unsubscribe;
489
+ }
490
+ });
491
+
492
+ return () => defaultSpaceUnsubscribe?.();
493
+ },
494
+ () => client.spaces.isReady.get() && client.spaces.default.state.get() === SpaceState.SPACE_READY,
495
+ );
496
+ if (!isReady) {
497
+ return [];
498
+ }
499
+
500
+ return [
501
+ {
502
+ id: SPACES,
503
+ type: SPACES,
504
+ properties: {
505
+ label: ['spaces label', { ns: SPACE_PLUGIN }],
506
+ palette: 'teal',
507
+ testId: 'spacePlugin.spaces',
508
+ role: 'branch',
509
+ childrenPersistenceClass: 'echo',
510
+ onRearrangeChildren: async (nextOrder: Space[]) => {
511
+ // NOTE: This is needed to ensure order is updated by next animation frame.
512
+ // TODO(wittjosiah): Is there a better way to do this?
513
+ // If not, graph should be passed as an argument to the extension.
514
+ graph._sortEdges(
515
+ SPACES,
516
+ 'outbound',
517
+ nextOrder.map(({ id }) => id),
518
+ );
519
+
520
+ const {
521
+ objects: [spacesOrder],
522
+ } = await client.spaces.default.db.query(Filter.schema(Expando, { key: SHARED })).run();
523
+ if (spacesOrder) {
524
+ spacesOrder.order = nextOrder.map(({ id }) => id);
525
+ } else {
526
+ log.warn('spaces order object not found');
527
+ }
528
+ },
529
+ },
530
+ },
531
+ ];
532
+ },
533
+ }),
534
+
535
+ // Create space nodes.
536
+ createExtension({
537
+ id: SPACES,
538
+ filter: (node): node is Node<null> => node.id === SPACES,
539
+ actions: () => [
540
+ {
541
+ id: SpaceAction.CREATE,
542
+ data: async () => {
543
+ await dispatch([
544
+ {
545
+ plugin: SPACE_PLUGIN,
546
+ action: SpaceAction.CREATE,
547
+ },
548
+ {
549
+ action: NavigationAction.OPEN,
550
+ },
551
+ ]);
552
+ },
553
+ properties: {
554
+ label: ['create space label', { ns: SPACE_PLUGIN }],
555
+ icon: (props: IconProps) => <Plus {...props} />,
556
+ iconSymbol: 'ph--plus--regular',
557
+ disposition: 'toolbar',
558
+ testId: 'spacePlugin.createSpace',
559
+ },
560
+ },
561
+ {
562
+ id: SpaceAction.JOIN,
563
+ data: async () => {
564
+ await dispatch([
565
+ {
566
+ plugin: SPACE_PLUGIN,
567
+ action: SpaceAction.JOIN,
568
+ },
569
+ {
570
+ action: NavigationAction.OPEN,
571
+ },
572
+ ]);
573
+ },
574
+ properties: {
575
+ label: ['join space label', { ns: SPACE_PLUGIN }],
576
+ icon: (props: IconProps) => <SignIn {...props} />,
577
+ iconSymbol: 'ph--sign-in--regular',
578
+ testId: 'spacePlugin.joinSpace',
579
+ },
580
+ },
581
+ ],
582
+ connector: () => {
583
+ const spaces = toSignal(
584
+ (onChange) => client.spaces.subscribe(() => onChange()).unsubscribe,
585
+ () => client.spaces.get(),
586
+ );
587
+
588
+ if (!spaces) {
589
+ return;
590
+ }
591
+
592
+ const [spacesOrder] = memoizeQuery(client.spaces.default, Filter.schema(Expando, { key: SHARED }));
593
+ const order: string[] = spacesOrder?.order ?? [];
594
+ const orderMap = new Map(order.map((id, index) => [id, index]));
595
+ return [
596
+ ...spaces
597
+ .filter((space) => orderMap.has(space.id))
598
+ .sort((a, b) => orderMap.get(a.id)! - orderMap.get(b.id)!),
599
+ ...spaces.filter((space) => !orderMap.has(space.id)),
600
+ ]
601
+ .filter((space) =>
602
+ settings.values.showHidden ? true : space.state.get() !== SpaceState.SPACE_INACTIVE,
603
+ )
604
+ .map((space) =>
605
+ constructSpaceNode({
606
+ space,
607
+ personal: space === client.spaces.default,
608
+ namesCache: state.values.spaceNames,
609
+ resolve,
610
+ }),
611
+ );
612
+ },
613
+ }),
614
+
615
+ // Find an object by its fully qualified id.
616
+ createExtension({
617
+ id: `${SPACE_PLUGIN}/objects`,
618
+ resolver: ({ id }) => {
619
+ const [spaceId, objectId] = id.split(':');
620
+ const space = client.spaces.get().find((space) => space.id === spaceId);
621
+ if (!space) {
622
+ return;
623
+ }
624
+
625
+ const state = toSignal(
626
+ (onChange) => space.state.subscribe(() => onChange()).unsubscribe,
627
+ () => space.state.get(),
628
+ space.id,
629
+ );
630
+ if (state !== SpaceState.SPACE_READY) {
631
+ return;
632
+ }
633
+
634
+ const store = memoize(() => signal(space.db.getObjectById(objectId)), id);
635
+ memoize(() => {
636
+ if (!store.value) {
637
+ void space.db.loadObjectById(objectId).then((o) => (store.value = o));
638
+ }
639
+ }, id);
640
+ const object = store.value;
641
+ if (!object) {
642
+ return;
643
+ }
644
+
645
+ return createObjectNode({ object, space, resolve });
646
+ },
647
+ }),
648
+
649
+ // Create space actions and action groups.
650
+ createExtension({
651
+ id: `${SPACE_PLUGIN}/actions`,
652
+ filter: (node): node is Node<Space> => isSpace(node.data),
653
+ actionGroups: ({ node }) => constructSpaceActionGroups({ space: node.data, dispatch }),
654
+ actions: ({ node }) => {
655
+ const space = node.data;
656
+ return constructSpaceActions({
657
+ space,
658
+ dispatch,
659
+ personal: space === client.spaces.default,
660
+ migrating: state.values.sdkMigrationRunning[space.id],
661
+ });
662
+ },
663
+ }),
664
+
665
+ // Create nodes for objects in the root collection of a space.
666
+ createExtension({
667
+ id: `${SPACE_PLUGIN}/root-collection`,
668
+ filter: (node): node is Node<Space> => isSpace(node.data),
669
+ connector: ({ node }) => {
670
+ const space = node.data;
671
+ const state = toSignal(
672
+ (onChange) => space.state.subscribe(() => onChange()).unsubscribe,
673
+ () => space.state.get(),
674
+ space.id,
675
+ );
676
+ if (state !== SpaceState.SPACE_READY) {
677
+ return;
678
+ }
679
+
680
+ const collection = space.properties[CollectionType.typename] as CollectionType | undefined;
681
+ if (!collection) {
682
+ return;
683
+ }
684
+
685
+ return collection.objects
686
+ .filter(nonNullable)
687
+ .map((object) => createObjectNode({ object, space, resolve }))
688
+ .filter(nonNullable);
689
+ },
690
+ }),
691
+
692
+ // Create collection actions and action groups.
693
+ createExtension({
694
+ id: `${SPACE_PLUGIN}/object-actions`,
695
+ filter: (node): node is Node<EchoReactiveObject<any>> => isEchoObject(node.data),
696
+ actionGroups: ({ node }) => constructObjectActionGroups({ object: node.data, dispatch }),
697
+ actions: ({ node }) => constructObjectActions({ node, dispatch }),
698
+ }),
699
+
700
+ // Create nodes for objects in collections.
701
+ createExtension({
702
+ id: `${SPACE_PLUGIN}/collection-objects`,
703
+ filter: (node): node is Node<CollectionType> => node.data instanceof CollectionType,
704
+ connector: ({ node }) => {
705
+ const collection = node.data;
706
+ const space = getSpace(collection);
707
+ if (!space) {
708
+ return;
709
+ }
710
+
711
+ return collection.objects
712
+ .filter(nonNullable)
713
+ .map((object) => createObjectNode({ object, space, resolve }))
714
+ .filter(nonNullable);
715
+ },
716
+ }),
717
+ ];
718
+ },
719
+ serializer: (plugins) => {
720
+ const dispatch = resolvePlugin(plugins, parseIntentPlugin)?.provides.intent.dispatch;
721
+ if (!dispatch) {
722
+ return [];
723
+ }
724
+
725
+ return [
726
+ {
727
+ inputType: SPACES,
728
+ outputType: DIRECTORY_TYPE,
729
+ serialize: (node) => ({
730
+ name: translations[0]['en-US'][SPACE_PLUGIN]['spaces label'],
731
+ data: translations[0]['en-US'][SPACE_PLUGIN]['spaces label'],
732
+ type: DIRECTORY_TYPE,
733
+ }),
734
+ deserialize: () => {
735
+ // No-op.
736
+ },
737
+ },
738
+ {
739
+ inputType: SPACE_TYPE,
740
+ outputType: DIRECTORY_TYPE,
741
+ serialize: (node) => ({
742
+ name: node.properties.name ?? translations[0]['en-US'][SPACE_PLUGIN]['unnamed space label'],
743
+ data: node.properties.name ?? translations[0]['en-US'][SPACE_PLUGIN]['unnamed space label'],
744
+ type: DIRECTORY_TYPE,
745
+ }),
746
+ deserialize: async (data) => {
747
+ const result = await dispatch({
748
+ plugin: SPACE_PLUGIN,
749
+ action: SpaceAction.CREATE,
750
+ data: { name: data.name },
751
+ });
752
+ return result?.data.space;
753
+ },
754
+ },
755
+ {
756
+ inputType: CollectionType.typename,
757
+ outputType: DIRECTORY_TYPE,
758
+ serialize: (node) => ({
759
+ name: node.properties.name ?? translations[0]['en-US'][SPACE_PLUGIN]['unnamed collection label'],
760
+ data: node.properties.name ?? translations[0]['en-US'][SPACE_PLUGIN]['unnamed collection label'],
761
+ type: DIRECTORY_TYPE,
762
+ }),
763
+ deserialize: async (data, ancestors) => {
764
+ const space = ancestors.find(isSpace);
765
+ const collection =
766
+ ancestors.findLast((ancestor) => ancestor instanceof CollectionType) ??
767
+ space?.properties[CollectionType.typename];
768
+ if (!space || !collection) {
769
+ return;
770
+ }
771
+
772
+ const result = await dispatch({
773
+ plugin: SPACE_PLUGIN,
774
+ action: SpaceAction.ADD_OBJECT,
775
+ data: {
776
+ target: collection,
777
+ object: create(CollectionType, { name: data.name, objects: [], views: {} }),
778
+ },
779
+ });
780
+
781
+ return result?.data.object;
782
+ },
783
+ },
784
+ ];
785
+ },
786
+ },
787
+ intent: {
788
+ resolver: async (intent, plugins) => {
789
+ const clientPlugin = resolvePlugin(plugins, parseClientPlugin);
790
+ const client = clientPlugin?.provides.client;
791
+ switch (intent.action) {
792
+ case SpaceAction.WAIT_FOR_OBJECT: {
793
+ state.values.awaiting = intent.data?.id;
794
+ return { data: true };
795
+ }
796
+
797
+ case SpaceAction.CREATE: {
798
+ if (!client) {
799
+ return;
800
+ }
801
+
802
+ const space = await client.spaces.create(intent.data as PropertiesTypeProps);
803
+ await space.waitUntilReady();
804
+ const collection = create(CollectionType, { objects: [], views: {} });
805
+ space.properties[CollectionType.typename] = collection;
806
+
807
+ if (Migrations.versionProperty) {
808
+ space.properties[Migrations.versionProperty] = Migrations.targetVersion;
809
+ }
810
+
811
+ return {
812
+ data: { space, id: space.id, activeParts: { main: [space.id] } },
813
+
814
+ intents: [
815
+ [
816
+ {
817
+ action: ObservabilityAction.SEND_EVENT,
818
+ data: {
819
+ name: 'space.create',
820
+ properties: {
821
+ spaceId: space.id,
822
+ },
823
+ },
824
+ },
825
+ ],
826
+ ],
827
+ };
828
+ }
829
+
830
+ case SpaceAction.JOIN: {
831
+ if (client) {
832
+ const { space } = await client.shell.joinSpace({ invitationCode: intent.data?.invitationCode });
833
+ if (space) {
834
+ return {
835
+ data: { space, id: space.id, activeParts: { main: [space.id] } },
836
+
837
+ intents: [
838
+ [
839
+ {
840
+ action: ObservabilityAction.SEND_EVENT,
841
+ data: {
842
+ name: 'space.join',
843
+ properties: {
844
+ spaceId: space.id,
845
+ },
846
+ },
847
+ },
848
+ ],
849
+ ],
850
+ };
851
+ }
852
+ }
853
+ break;
854
+ }
855
+
856
+ case SpaceAction.SHARE: {
857
+ const spaceId = intent.data?.spaceId;
858
+ if (clientPlugin && typeof spaceId === 'string') {
859
+ if (!navigationPlugin?.provides.location.active) {
860
+ return;
861
+ }
862
+ const target = firstIdInPart(navigationPlugin?.provides.location.active, 'main');
863
+ const result = await clientPlugin.provides.client.shell.shareSpace({ spaceId, target });
864
+ return {
865
+ data: result,
866
+ intents: [
867
+ [
868
+ {
869
+ action: ObservabilityAction.SEND_EVENT,
870
+ data: {
871
+ name: 'space.share',
872
+ properties: {
873
+ spaceId,
874
+ members: result.members?.length,
875
+ error: result.error?.message,
876
+ cancelled: result.cancelled,
877
+ },
878
+ },
879
+ },
880
+ ],
881
+ ],
882
+ };
883
+ }
884
+ break;
885
+ }
886
+
887
+ case SpaceAction.LOCK: {
888
+ const space = intent.data?.space;
889
+ if (isSpace(space)) {
890
+ space.properties[COMPOSER_SPACE_LOCK] = true;
891
+ return {
892
+ data: true,
893
+ intents: [
894
+ [
895
+ {
896
+ action: ObservabilityAction.SEND_EVENT,
897
+ data: {
898
+ name: 'space.lock',
899
+ properties: {
900
+ spaceId: space.id,
901
+ },
902
+ },
903
+ },
904
+ ],
905
+ ],
906
+ };
907
+ }
908
+ break;
909
+ }
910
+
911
+ case SpaceAction.UNLOCK: {
912
+ const space = intent.data?.space;
913
+ if (isSpace(space)) {
914
+ space.properties[COMPOSER_SPACE_LOCK] = false;
915
+ return {
916
+ data: true,
917
+ intents: [
918
+ [
919
+ {
920
+ action: ObservabilityAction.SEND_EVENT,
921
+ data: {
922
+ name: 'space.unlock',
923
+ properties: {
924
+ spaceId: space.id,
925
+ },
926
+ },
927
+ },
928
+ ],
929
+ ],
930
+ };
931
+ }
932
+ break;
933
+ }
934
+
935
+ case SpaceAction.RENAME: {
936
+ const { caller, space } = intent.data ?? {};
937
+ if (typeof caller === 'string' && isSpace(space)) {
938
+ return {
939
+ intents: [
940
+ [
941
+ {
942
+ action: LayoutAction.SET_LAYOUT,
943
+ data: {
944
+ element: 'popover',
945
+ anchorId: `dxos.org/ui/${caller}/${space.id}`,
946
+ component: 'dxos.org/plugin/space/RenameSpacePopover',
947
+ subject: space,
948
+ },
949
+ },
950
+ ],
951
+ ],
952
+ };
953
+ }
954
+ break;
955
+ }
956
+
957
+ case SpaceAction.OPEN: {
958
+ const space = intent.data?.space;
959
+ if (isSpace(space)) {
960
+ await space.open();
961
+ return { data: true };
962
+ }
963
+ break;
964
+ }
965
+
966
+ case SpaceAction.CLOSE: {
967
+ const space = intent.data?.space;
968
+ if (isSpace(space)) {
969
+ await space.close();
970
+ return { data: true };
971
+ }
972
+ break;
973
+ }
974
+
975
+ case SpaceAction.MIGRATE: {
976
+ const space = intent.data?.space;
977
+ if (isSpace(space)) {
978
+ if (space.state.get() === SpaceState.SPACE_REQUIRES_MIGRATION) {
979
+ state.values.sdkMigrationRunning[space.id] = true;
980
+ await space.internal.migrate();
981
+ state.values.sdkMigrationRunning[space.id] = false;
982
+ }
983
+ const result = await Migrations.migrate(space, intent.data?.version);
984
+ return {
985
+ data: result,
986
+ intents: [
987
+ [
988
+ {
989
+ action: ObservabilityAction.SEND_EVENT,
990
+ data: {
991
+ name: 'space.migrate',
992
+ properties: {
993
+ spaceId: space.id,
994
+ version: intent.data?.version,
995
+ },
996
+ },
997
+ },
998
+ ],
999
+ ],
1000
+ };
1001
+ }
1002
+ break;
1003
+ }
1004
+
1005
+ case SpaceAction.ADD_OBJECT: {
1006
+ const object = intent.data?.object ?? intent.data?.result;
1007
+ if (!isReactiveObject(object)) {
1008
+ return;
1009
+ }
1010
+
1011
+ const space = isSpace(intent.data?.target) ? intent.data?.target : getSpace(intent.data?.target);
1012
+ if (!space) {
1013
+ return;
1014
+ }
1015
+
1016
+ if (space.db.coreDatabase.getAllObjectIds().length >= SPACE_MAX_OBJECTS) {
1017
+ return {
1018
+ data: false,
1019
+ intents: [
1020
+ [
1021
+ {
1022
+ action: LayoutAction.SET_LAYOUT,
1023
+ data: {
1024
+ element: 'toast',
1025
+ subject: {
1026
+ id: `${SPACE_PLUGIN}/space-limit`,
1027
+ title: translations[0]['en-US'][SPACE_PLUGIN]['space limit label'],
1028
+ description: translations[0]['en-US'][SPACE_PLUGIN]['space limit description'],
1029
+ duration: 5_000,
1030
+ icon: (props: IconProps) => <Warning {...props} />,
1031
+ iconSymbol: 'ph--warning--regular',
1032
+ actionLabel: translations[0]['en-US'][SPACE_PLUGIN]['remove deleted objects label'],
1033
+ actionAlt: translations[0]['en-US'][SPACE_PLUGIN]['remove deleted objects alt'],
1034
+ // TODO(wittjosiah): Use OS namespace.
1035
+ closeLabel: translations[0]['en-US'][SPACE_PLUGIN]['space limit close label'],
1036
+ onAction: () => space.db.coreDatabase.unlinkDeletedObjects(),
1037
+ },
1038
+ },
1039
+ },
1040
+ ],
1041
+ [
1042
+ {
1043
+ action: ObservabilityAction.SEND_EVENT,
1044
+ data: {
1045
+ name: 'space.limit',
1046
+ properties: {
1047
+ spaceId: space.id,
1048
+ },
1049
+ },
1050
+ },
1051
+ ],
1052
+ ],
1053
+ };
1054
+ }
1055
+
1056
+ if (intent.data?.target instanceof CollectionType) {
1057
+ intent.data?.target.objects.push(object as Identifiable);
1058
+ } else if (isSpace(intent.data?.target)) {
1059
+ const collection = space.properties[CollectionType.typename];
1060
+ if (collection instanceof CollectionType) {
1061
+ collection.objects.push(object as Identifiable);
1062
+ } else {
1063
+ // TODO(wittjosiah): Can't add non-echo objects by including in a collection because of types.
1064
+ const collection = create(CollectionType, { objects: [object as Identifiable], views: {} });
1065
+ space.properties[CollectionType.typename] = collection;
1066
+ }
1067
+ }
1068
+
1069
+ return {
1070
+ data: { id: object.id, object, activeParts: { main: [fullyQualifiedId(object)] } },
1071
+ intents: [
1072
+ [
1073
+ {
1074
+ action: ObservabilityAction.SEND_EVENT,
1075
+ data: {
1076
+ name: 'space.object.add',
1077
+ properties: {
1078
+ spaceId: space.id,
1079
+ objectId: object.id,
1080
+ typename: getTypename(object),
1081
+ },
1082
+ },
1083
+ },
1084
+ ],
1085
+ ],
1086
+ };
1087
+ }
1088
+
1089
+ case SpaceAction.REMOVE_OBJECT: {
1090
+ const object = intent.data?.object ?? intent.data?.result;
1091
+ const space = getSpace(object);
1092
+ if (!(isEchoObject(object) && space)) {
1093
+ return;
1094
+ }
1095
+
1096
+ const resolve = resolvePlugin(plugins, parseMetadataResolverPlugin)?.provides.metadata.resolver;
1097
+ const activeParts = navigationPlugin?.provides.location.active;
1098
+ const openObjectIds = new Set<string>(openIds(activeParts ?? {}));
1099
+
1100
+ if (!intent.undo && resolve) {
1101
+ // Capture the current state for undo.
1102
+ const parentCollection = intent.data?.collection ?? space.properties[CollectionType.typename];
1103
+ const nestedObjects = await getNestedObjects(object, resolve);
1104
+ const deletionData = {
1105
+ object,
1106
+ parentCollection,
1107
+ index:
1108
+ parentCollection instanceof CollectionType
1109
+ ? parentCollection.objects.indexOf(object as Expando)
1110
+ : -1,
1111
+ nestedObjects,
1112
+ wasActive: [object, ...nestedObjects]
1113
+ .map((obj) => fullyQualifiedId(obj))
1114
+ .filter((id) => openObjectIds.has(id)),
1115
+ };
1116
+
1117
+ // If the item is active, navigate to "nowhere" to avoid navigating to a removed item.
1118
+ if (deletionData.wasActive.length > 0) {
1119
+ await intentPlugin?.provides.intent.dispatch({
1120
+ action: NavigationAction.CLOSE,
1121
+ data: {
1122
+ activeParts: {
1123
+ main: deletionData.wasActive,
1124
+ sidebar: deletionData.wasActive,
1125
+ complementary: deletionData.wasActive,
1126
+ },
1127
+ },
1128
+ });
1129
+ }
1130
+
1131
+ if (parentCollection instanceof CollectionType) {
1132
+ // TODO(Zan): Is there a nicer way to do this without casting to Expando?
1133
+ const index = parentCollection.objects.indexOf(object as Expando);
1134
+ if (index !== -1) {
1135
+ parentCollection.objects.splice(index, 1);
1136
+ }
1137
+ }
1138
+
1139
+ // If the object is a collection, also delete its nested objects.
1140
+ deletionData.nestedObjects.forEach((obj) => {
1141
+ space.db.remove(obj);
1142
+ });
1143
+ space.db.remove(object);
1144
+
1145
+ const undoMessageKey =
1146
+ object instanceof CollectionType ? 'collection deleted label' : 'object deleted label';
1147
+
1148
+ return {
1149
+ data: true,
1150
+ undoable: {
1151
+ // Consider using a translation key here.
1152
+ message: translations[0]['en-US'][SPACE_PLUGIN][undoMessageKey],
1153
+ data: deletionData,
1154
+ },
1155
+ };
1156
+ } else {
1157
+ const undoData = intent.data;
1158
+ if (undoData && isEchoObject(undoData.object) && undoData.parentCollection instanceof CollectionType) {
1159
+ // Restore the object to the space.
1160
+ const restoredObject = space.db.add(undoData.object);
1161
+
1162
+ // Restore nested objects if the object was a collection.
1163
+ undoData.nestedObjects.forEach((obj: Expando) => {
1164
+ space.db.add(obj);
1165
+ });
1166
+
1167
+ // Restore the object to its original position in the collection.
1168
+ if (undoData.index !== -1) {
1169
+ undoData.parentCollection.objects.splice(undoData.index, 0, restoredObject as Expando);
1170
+ }
1171
+
1172
+ // Restore active state if it was active before removal.
1173
+ if (undoData.wasActive.length > 0) {
1174
+ await intentPlugin?.provides.intent.dispatch({
1175
+ action: NavigationAction.OPEN,
1176
+ data: { activeParts: { main: undoData.wasActive } },
1177
+ });
1178
+ }
1179
+
1180
+ return { data: true };
1181
+ }
1182
+
1183
+ return { data: false };
1184
+ }
1185
+ }
1186
+
1187
+ case SpaceAction.RENAME_OBJECT: {
1188
+ const object = intent.data?.object ?? intent.data?.result;
1189
+ const caller = intent.data?.caller;
1190
+ if (isReactiveObject(object) && caller) {
1191
+ return {
1192
+ intents: [
1193
+ [
1194
+ {
1195
+ action: LayoutAction.SET_LAYOUT,
1196
+ data: {
1197
+ element: 'popover',
1198
+ anchorId: `dxos.org/ui/${caller}/${fullyQualifiedId(object)}`,
1199
+ component: 'dxos.org/plugin/space/RenameObjectPopover',
1200
+ subject: object,
1201
+ },
1202
+ },
1203
+ ],
1204
+ ],
1205
+ };
1206
+ }
1207
+ break;
1208
+ }
1209
+
1210
+ case SpaceAction.DUPLICATE_OBJECT: {
1211
+ const originalObject = intent.data?.object ?? intent.data?.result;
1212
+ const resolve = resolvePlugin(plugins, parseMetadataResolverPlugin)?.provides.metadata.resolver;
1213
+ if (!isEchoObject(originalObject) || !resolve) {
1214
+ return;
1215
+ }
1216
+
1217
+ const newObject = await cloneObject(originalObject, resolve);
1218
+ return {
1219
+ intents: [
1220
+ [{ action: SpaceAction.ADD_OBJECT, data: { object: newObject, target: intent.data?.target } }],
1221
+ ],
1222
+ };
1223
+ }
1224
+
1225
+ case SpaceAction.TOGGLE_HIDDEN: {
1226
+ settings.values.showHidden = intent.data?.state ?? !settings.values.showHidden;
1227
+ return { data: true };
1228
+ }
1229
+ }
1230
+ },
1231
+ },
1232
+ },
1233
+ };
1234
+ };