@dxos/plugin-space 0.6.14-main.8b352a0 → 0.6.14-staging.54a8bab

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 (58) hide show
  1. package/dist/lib/browser/{chunk-FOI7DAUV.mjs → chunk-WZAM3FNP.mjs} +1 -1
  2. package/dist/lib/browser/{chunk-FOI7DAUV.mjs.map → chunk-WZAM3FNP.mjs.map} +2 -2
  3. package/dist/lib/browser/index.mjs +432 -387
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/types/index.mjs +1 -1
  7. package/dist/lib/node/{chunk-OTDRTHT4.cjs → chunk-HTAM5LQD.cjs} +4 -4
  8. package/dist/lib/node/{chunk-OTDRTHT4.cjs.map → chunk-HTAM5LQD.cjs.map} +2 -2
  9. package/dist/lib/node/index.cjs +474 -433
  10. package/dist/lib/node/index.cjs.map +4 -4
  11. package/dist/lib/node/meta.json +1 -1
  12. package/dist/lib/node/types/index.cjs +11 -11
  13. package/dist/lib/node/types/index.cjs.map +1 -1
  14. package/dist/lib/node-esm/{chunk-FYDGMPSC.mjs → chunk-TRJKV4PK.mjs} +1 -1
  15. package/dist/lib/node-esm/{chunk-FYDGMPSC.mjs.map → chunk-TRJKV4PK.mjs.map} +2 -2
  16. package/dist/lib/node-esm/index.mjs +432 -387
  17. package/dist/lib/node-esm/index.mjs.map +4 -4
  18. package/dist/lib/node-esm/meta.json +1 -1
  19. package/dist/lib/node-esm/types/index.mjs +1 -1
  20. package/dist/types/src/SpacePlugin.d.ts.map +1 -1
  21. package/dist/types/src/components/SpaceSettingsPanel.d.ts.map +1 -1
  22. package/dist/types/src/components/SyncStatus/Space.d.ts +8 -0
  23. package/dist/types/src/components/SyncStatus/Space.d.ts.map +1 -0
  24. package/dist/types/src/components/SyncStatus/SyncStatus.d.ts +3 -2
  25. package/dist/types/src/components/SyncStatus/SyncStatus.d.ts.map +1 -1
  26. package/dist/types/src/components/SyncStatus/SyncStatus.stories.d.ts +5 -20
  27. package/dist/types/src/components/SyncStatus/SyncStatus.stories.d.ts.map +1 -1
  28. package/dist/types/src/components/SyncStatus/save-tracker.d.ts +3 -0
  29. package/dist/types/src/components/SyncStatus/save-tracker.d.ts.map +1 -0
  30. package/dist/types/src/components/SyncStatus/status.d.ts +9 -0
  31. package/dist/types/src/components/SyncStatus/status.d.ts.map +1 -0
  32. package/dist/types/src/components/SyncStatus/{types.d.ts → sync-state.d.ts} +1 -1
  33. package/dist/types/src/components/SyncStatus/sync-state.d.ts.map +1 -0
  34. package/dist/types/src/components/index.d.ts +0 -2
  35. package/dist/types/src/components/index.d.ts.map +1 -1
  36. package/dist/types/src/translations.d.ts +6 -0
  37. package/dist/types/src/translations.d.ts.map +1 -1
  38. package/dist/types/src/types/types.d.ts +2 -1
  39. package/dist/types/src/types/types.d.ts.map +1 -1
  40. package/package.json +34 -33
  41. package/src/SpacePlugin.tsx +55 -30
  42. package/src/components/SpaceSettings.tsx +5 -5
  43. package/src/components/SpaceSettingsPanel.tsx +18 -8
  44. package/src/components/SyncStatus/Space.tsx +109 -0
  45. package/src/components/SyncStatus/SyncStatus.stories.tsx +13 -4
  46. package/src/components/SyncStatus/SyncStatus.tsx +43 -129
  47. package/src/components/{SaveStatus.tsx → SyncStatus/save-tracker.ts} +1 -25
  48. package/src/components/SyncStatus/status.ts +44 -0
  49. package/src/components/index.ts +0 -2
  50. package/src/translations.ts +6 -0
  51. package/src/types/types.ts +3 -1
  52. package/dist/types/src/components/MissingObject.d.ts +0 -5
  53. package/dist/types/src/components/MissingObject.d.ts.map +0 -1
  54. package/dist/types/src/components/SaveStatus.d.ts +0 -3
  55. package/dist/types/src/components/SaveStatus.d.ts.map +0 -1
  56. package/dist/types/src/components/SyncStatus/types.d.ts.map +0 -1
  57. package/src/components/MissingObject.tsx +0 -54
  58. /package/src/components/SyncStatus/{types.ts → sync-state.ts} +0 -0
@@ -6,9 +6,11 @@ import { signal } from '@preact/signals-core';
6
6
  import React from 'react';
7
7
 
8
8
  import {
9
+ type GraphProvides,
9
10
  type IntentDispatcher,
10
11
  type IntentPluginProvides,
11
12
  LayoutAction,
13
+ type LayoutProvides,
12
14
  type LocationProvides,
13
15
  NavigationAction,
14
16
  type Plugin,
@@ -18,6 +20,7 @@ import {
18
20
  openIds,
19
21
  parseGraphPlugin,
20
22
  parseIntentPlugin,
23
+ parseLayoutPlugin,
21
24
  parseMetadataResolverPlugin,
22
25
  parseNavigationPlugin,
23
26
  resolvePlugin,
@@ -48,6 +51,7 @@ import {
48
51
  isSpace,
49
52
  loadObjectReferences,
50
53
  parseId,
54
+ FQ_ID_LENGTH,
51
55
  } from '@dxos/react-client/echo';
52
56
  import { Dialog } from '@dxos/react-ui';
53
57
  import { ClipboardProvider, InvitationManager, type InvitationManagerProps, osTranslations } from '@dxos/shell/react';
@@ -59,10 +63,8 @@ import {
59
63
  CollectionSection,
60
64
  DefaultObjectSettings,
61
65
  MenuFooter,
62
- MissingObject,
63
66
  PopoverRenameObject,
64
67
  PopoverRenameSpace,
65
- SaveStatus,
66
68
  ShareSpaceButton,
67
69
  SmallPresence,
68
70
  SmallPresenceLive,
@@ -91,7 +93,6 @@ import {
91
93
  } from './util';
92
94
 
93
95
  const ACTIVE_NODE_BROADCAST_INTERVAL = 30_000;
94
- const OBJECT_ID_LENGTH = 60; // 33 (space id) + 26 (object id) + 1 (separator).
95
96
  const SPACE_MAX_OBJECTS = 500;
96
97
  // https://stackoverflow.com/a/19016910
97
98
  const DIRECTORY_TYPE = 'text/directory';
@@ -138,17 +139,22 @@ export const SpacePlugin = ({
138
139
  const graphSubscriptions = new Map<string, UnsubscribeCallback>();
139
140
 
140
141
  let clientPlugin: Plugin<ClientPluginProvides> | undefined;
142
+ let graphPlugin: Plugin<GraphProvides> | undefined;
141
143
  let intentPlugin: Plugin<IntentPluginProvides> | undefined;
144
+ let layoutPlugin: Plugin<LayoutProvides> | undefined;
142
145
  let navigationPlugin: Plugin<LocationProvides> | undefined;
143
146
  let attentionPlugin: Plugin<AttentionPluginProvides> | undefined;
144
147
 
145
148
  const onSpaceReady = async () => {
146
- if (!clientPlugin || !navigationPlugin || !attentionPlugin) {
149
+ if (!clientPlugin || !intentPlugin || !graphPlugin || !navigationPlugin || !layoutPlugin || !attentionPlugin) {
147
150
  return;
148
151
  }
149
152
 
150
153
  const client = clientPlugin.provides.client;
154
+ const dispatch = intentPlugin.provides.intent.dispatch;
155
+ const graph = graphPlugin.provides.graph;
151
156
  const location = navigationPlugin.provides.location;
157
+ const layout = layoutPlugin.provides.layout;
152
158
  const attention = attentionPlugin.provides.attention;
153
159
  const defaultSpace = client.spaces.default;
154
160
 
@@ -166,6 +172,26 @@ export const SpacePlugin = ({
166
172
  defaultSpace.db.add(create({ key: SHARED, order: [] }));
167
173
  }
168
174
 
175
+ // Await missing objects.
176
+ subscriptions.add(
177
+ scheduledEffect(
178
+ () => ({
179
+ layoutMode: layout.layoutMode,
180
+ soloPart: location.active.solo?.[0],
181
+ }),
182
+ ({ layoutMode, soloPart }) => {
183
+ if (layoutMode !== 'solo' || !soloPart) {
184
+ return;
185
+ }
186
+
187
+ const node = graph.findNode(soloPart.id);
188
+ if (!node && soloPart.id.length === FQ_ID_LENGTH) {
189
+ void dispatch({ plugin: SPACE_PLUGIN, action: SpaceAction.WAIT_FOR_OBJECT, data: { id: soloPart.id } });
190
+ }
191
+ },
192
+ ),
193
+ );
194
+
169
195
  // Cache space names.
170
196
  subscriptions.add(
171
197
  client.spaces.subscribe(async (spaces) => {
@@ -289,6 +315,8 @@ export const SpacePlugin = ({
289
315
  settings.prop({ key: 'showHidden', type: LocalStorageStore.bool({ allowUndefined: true }) });
290
316
  state.prop({ key: 'spaceNames', type: LocalStorageStore.json<Record<string, string>>() });
291
317
 
318
+ graphPlugin = resolvePlugin(plugins, parseGraphPlugin);
319
+ layoutPlugin = resolvePlugin(plugins, parseLayoutPlugin);
292
320
  navigationPlugin = resolvePlugin(plugins, parseNavigationPlugin);
293
321
  attentionPlugin = resolvePlugin(plugins, parseAttentionPlugin);
294
322
  clientPlugin = resolvePlugin(plugins, parseClientPlugin);
@@ -311,19 +339,20 @@ export const SpacePlugin = ({
311
339
  await onFirstRun?.({ client, dispatch });
312
340
  };
313
341
 
314
- // No need to unsubscribe because this observable completes when spaces are ready.
315
- client.spaces.isReady.subscribe(async (ready) => {
316
- if (ready) {
317
- await clientPlugin?.provides.client.spaces.default.waitUntilReady();
318
- if (firstRun) {
319
- void firstRun?.wait().then(handleFirstRun);
320
- } else {
321
- await handleFirstRun();
322
- }
342
+ subscriptions.add(
343
+ client.spaces.isReady.subscribe(async (ready) => {
344
+ if (ready) {
345
+ await clientPlugin?.provides.client.spaces.default.waitUntilReady();
346
+ if (firstRun) {
347
+ void firstRun?.wait().then(handleFirstRun);
348
+ } else {
349
+ await handleFirstRun();
350
+ }
323
351
 
324
- await onSpaceReady();
325
- }
326
- });
352
+ await onSpaceReady();
353
+ }
354
+ }).unsubscribe,
355
+ );
327
356
  },
328
357
  unload: async () => {
329
358
  settings.close();
@@ -336,6 +365,11 @@ export const SpacePlugin = ({
336
365
  space: state.values,
337
366
  settings: settings.values,
338
367
  translations: [...translations, osTranslations],
368
+ complementary: {
369
+ panels: [
370
+ { id: 'settings', label: ['open settings panel label', { ns: SPACE_PLUGIN }], icon: 'ph--gear--regular' },
371
+ ],
372
+ },
339
373
  root: () => (state.values.awaiting ? <AwaitingObject id={state.values.awaiting} /> : null),
340
374
  metadata: {
341
375
  records: {
@@ -356,24 +390,20 @@ export const SpacePlugin = ({
356
390
  },
357
391
  surface: {
358
392
  component: ({ data, role, ...rest }) => {
359
- const primary = data.active ?? data.object;
360
393
  switch (role) {
361
394
  case 'article':
362
- case 'main':
363
395
  // TODO(wittjosiah): Need to avoid shotgun parsing space state everywhere.
364
- return isSpace(primary) && primary.state.get() === SpaceState.SPACE_READY ? (
396
+ return isSpace(data.object) && data.object.state.get() === SpaceState.SPACE_READY ? (
365
397
  <Surface
366
- data={{ active: primary.properties[CollectionType.typename], id: primary.id }}
398
+ data={{ object: data.object.properties[CollectionType.typename], id: data.object.id }}
367
399
  role={role}
368
400
  {...rest}
369
401
  />
370
- ) : primary instanceof CollectionType ? (
402
+ ) : data.object instanceof CollectionType ? (
371
403
  {
372
- node: <CollectionMain collection={primary} />,
404
+ node: <CollectionMain collection={data.object} />,
373
405
  disposition: 'fallback',
374
406
  }
375
- ) : typeof primary === 'string' && primary.length === OBJECT_ID_LENGTH ? (
376
- <MissingObject id={primary} />
377
407
  ) : null;
378
408
  case 'complementary--settings':
379
409
  return isSpace(data.subject) ? (
@@ -450,12 +480,7 @@ export const SpacePlugin = ({
450
480
  return null;
451
481
  }
452
482
  case 'status': {
453
- return (
454
- <>
455
- <SyncStatus />
456
- <SaveStatus />
457
- </>
458
- );
483
+ return <SyncStatus />;
459
484
  }
460
485
  default:
461
486
  return null;
@@ -6,7 +6,7 @@ import React from 'react';
6
6
 
7
7
  import { useIntentDispatcher, useResolvePlugins } from '@dxos/app-framework';
8
8
  import { Input, Select, toLocalizedString, useTranslation } from '@dxos/react-ui';
9
- import { FormInput } from '@dxos/react-ui-data';
9
+ import { DeprecatedFormInput } from '@dxos/react-ui-data';
10
10
 
11
11
  import { SpaceAction, SPACE_PLUGIN } from '../meta';
12
12
  import { parseSpaceInitPlugin, type SpaceSettingsProps } from '../types';
@@ -18,7 +18,7 @@ export const SpaceSettings = ({ settings }: { settings: SpaceSettingsProps }) =>
18
18
 
19
19
  return (
20
20
  <>
21
- <FormInput label={t('show hidden spaces label')}>
21
+ <DeprecatedFormInput label={t('show hidden spaces label')}>
22
22
  <Input.Switch
23
23
  checked={settings.showHidden}
24
24
  onCheckedChange={(checked) =>
@@ -29,9 +29,9 @@ export const SpaceSettings = ({ settings }: { settings: SpaceSettingsProps }) =>
29
29
  })
30
30
  }
31
31
  />
32
- </FormInput>
32
+ </DeprecatedFormInput>
33
33
 
34
- <FormInput label={t('default on space create label')}>
34
+ <DeprecatedFormInput label={t('default on space create label')}>
35
35
  <Select.Root
36
36
  value={settings.onSpaceCreate}
37
37
  onValueChange={(value) => {
@@ -57,7 +57,7 @@ export const SpaceSettings = ({ settings }: { settings: SpaceSettingsProps }) =>
57
57
  </Select.Content>
58
58
  </Select.Portal>
59
59
  </Select.Root>
60
- </FormInput>
60
+ </DeprecatedFormInput>
61
61
  </>
62
62
  );
63
63
  };
@@ -6,7 +6,9 @@ import React, { useCallback, useState } from 'react';
6
6
 
7
7
  import { log } from '@dxos/log';
8
8
  import { EdgeReplicationSetting } from '@dxos/protocols/proto/dxos/echo/metadata';
9
+ import { useClient } from '@dxos/react-client';
9
10
  import { type Space } from '@dxos/react-client/echo';
11
+ import { DeviceType, useDevices } from '@dxos/react-client/halo';
10
12
  import { Input, useTranslation } from '@dxos/react-ui';
11
13
 
12
14
  import { SPACE_PLUGIN } from '../meta';
@@ -17,10 +19,16 @@ export type SpaceSettingsPanelProps = {
17
19
 
18
20
  export const SpaceSettingsPanel = ({ space }: SpaceSettingsPanelProps) => {
19
21
  const { t } = useTranslation(SPACE_PLUGIN);
22
+
23
+ const client = useClient();
24
+ const devices = useDevices();
25
+ const managedDeviceAvailable = devices.find((device) => device.profile?.type === DeviceType.AGENT_MANAGED);
26
+ const edgeAgents = Boolean(client.config.values.runtime?.client?.edgeFeatures?.agents);
27
+ const edgeReplicationAvailable = edgeAgents && managedDeviceAvailable;
28
+
20
29
  const [edgeReplication, setEdgeReplication] = useState(
21
30
  space.internal.data.edgeReplication === EdgeReplicationSetting.ENABLED,
22
31
  );
23
-
24
32
  const toggleEdgeReplication = useCallback(
25
33
  async (next: boolean) => {
26
34
  setEdgeReplication(next);
@@ -41,19 +49,21 @@ export const SpaceSettingsPanel = ({ space }: SpaceSettingsPanelProps) => {
41
49
  <Input.Label>{t('name label')}</Input.Label>
42
50
  <Input.TextInput
43
51
  placeholder={t('name placeholder')}
44
- value={space.properties.name}
52
+ value={space.properties.name ?? ''}
45
53
  onChange={(event) => {
46
54
  space.properties.name = event.target.value;
47
55
  }}
48
56
  />
49
57
  </div>
50
58
  </Input.Root>
51
- <Input.Root>
52
- <div role='none' className='flex justify-between'>
53
- <Input.Label>{t('edge replication label')}</Input.Label>
54
- <Input.Switch checked={edgeReplication} onCheckedChange={toggleEdgeReplication} />
55
- </div>
56
- </Input.Root>
59
+ {edgeReplicationAvailable && (
60
+ <Input.Root>
61
+ <div role='none' className='flex justify-between'>
62
+ <Input.Label>{t('edge replication label')}</Input.Label>
63
+ <Input.Switch checked={edgeReplication} onCheckedChange={toggleEdgeReplication} />
64
+ </div>
65
+ </Input.Root>
66
+ )}
57
67
  </div>
58
68
  );
59
69
  };
@@ -0,0 +1,109 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { type HTMLAttributes, useEffect, useState } from 'react';
6
+
7
+ import { Icon } from '@dxos/react-ui';
8
+ import { type ThemedClassName } from '@dxos/react-ui';
9
+ import { mx } from '@dxos/react-ui-theme';
10
+
11
+ import { type Progress, type PeerSyncState } from './sync-state';
12
+
13
+ export const SYNC_STALLED_TIMEOUT = 5_000;
14
+
15
+ // TODO(wittjosiah): Define sematic color tokens.
16
+ const styles = {
17
+ barBg: 'bg-neutral-50 dark:bg-green-900 text-black',
18
+ barFg: 'bg-neutral-100 bg-green-500',
19
+ barHover: 'dark:hover:bg-green-500',
20
+ };
21
+
22
+ const useActive = (count: number) => {
23
+ const [current, setCurrent] = useState(count);
24
+ const [active, setActive] = useState(false);
25
+ useEffect(() => {
26
+ let t: NodeJS.Timeout | undefined;
27
+ if (count !== current) {
28
+ setActive(true);
29
+ setCurrent(count);
30
+ t && clearTimeout(t);
31
+ t = setTimeout(() => {
32
+ setActive(false);
33
+ }, SYNC_STALLED_TIMEOUT);
34
+ }
35
+
36
+ return () => {
37
+ setActive(false);
38
+ clearTimeout(t);
39
+ };
40
+ }, [count, current]);
41
+ return active;
42
+ };
43
+
44
+ export const SpaceRow = ({
45
+ spaceId,
46
+ state: { localDocumentCount, remoteDocumentCount, missingOnLocal, missingOnRemote },
47
+ }: {
48
+ spaceId: string;
49
+ state: PeerSyncState;
50
+ }) => {
51
+ const downActive = useActive(localDocumentCount);
52
+ const upActive = useActive(remoteDocumentCount);
53
+
54
+ return (
55
+ <div
56
+ className={mx('flex items-center mx-[2px] gap-[2px] cursor-pointer', styles.barHover)}
57
+ title={spaceId}
58
+ onClick={() => {
59
+ void navigator.clipboard.writeText(spaceId);
60
+ }}
61
+ >
62
+ <Icon
63
+ icon='ph--arrow-fat-line-left--regular'
64
+ size={3}
65
+ classNames={mx(downActive && 'animate-[pulse_1s_infinite]')}
66
+ />
67
+ <Candle
68
+ up={{ count: remoteDocumentCount, total: remoteDocumentCount + missingOnRemote }}
69
+ down={{ count: localDocumentCount, total: localDocumentCount + missingOnLocal }}
70
+ title={spaceId}
71
+ />
72
+ <Icon
73
+ icon='ph--arrow-fat-line-right--regular'
74
+ size={3}
75
+ classNames={mx(upActive && 'animate-[pulse_1s_step-start_infinite]')}
76
+ />
77
+ </div>
78
+ );
79
+ };
80
+
81
+ type CandleProps = ThemedClassName<Pick<HTMLAttributes<HTMLDivElement>, 'title'>> & { up: Progress; down: Progress };
82
+
83
+ const Candle = ({ classNames, up, down }: CandleProps) => {
84
+ return (
85
+ <div className={mx('grid grid-cols-[1fr_2rem_1fr] w-full h-3', classNames)}>
86
+ <Bar classNames='justify-end' {...up} />
87
+ <div className='relative'>
88
+ <div className={mx('absolute inset-0 flex items-center justify-center text-xs', styles.barBg)}>{up.total}</div>
89
+ </div>
90
+ <Bar {...down} />
91
+ </div>
92
+ );
93
+ };
94
+
95
+ const Bar = ({ classNames, count, total }: ThemedClassName<Progress>) => {
96
+ let p = (count / total) * 100;
97
+ if (count < total) {
98
+ p = Math.min(p, 95);
99
+ }
100
+
101
+ return (
102
+ <div className={mx('relative flex w-full', styles.barBg, classNames)}>
103
+ <div className={mx('shrink-0', styles.barFg)} style={{ width: `${p}%` }}></div>
104
+ {count !== total && (
105
+ <div className='absolute top-0 bottom-0 flex items-center mx-0.5 text-black text-xs'>{count}</div>
106
+ )}
107
+ </div>
108
+ );
109
+ };
@@ -4,14 +4,14 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
- import { type Meta } from '@storybook/react';
7
+ import { type Meta, type StoryObj } from '@storybook/react';
8
8
  import React from 'react';
9
9
 
10
10
  import { SpaceId } from '@dxos/keys';
11
11
  import { withTheme, withLayout } from '@dxos/storybook-utils';
12
12
 
13
13
  import { SyncStatusDetail, SyncStatusIndicator } from './SyncStatus';
14
- import { getSyncSummary, type SpaceSyncStateMap } from './types';
14
+ import { getSyncSummary, type SpaceSyncStateMap } from './sync-state';
15
15
  import translations from '../../translations';
16
16
 
17
17
  const DefaultStory = (props: any) => {
@@ -39,13 +39,22 @@ const state: SpaceSyncStateMap = Array.from({ length: 5 }).reduce<SpaceSyncState
39
39
  return map;
40
40
  }, {});
41
41
 
42
- export const Default = {
42
+ export const Default: StoryObj<typeof SyncStatusIndicator> = {
43
43
  args: {
44
44
  state,
45
+ saved: true,
45
46
  },
46
47
  };
47
48
 
48
- export const Detail = {
49
+ export const Saving: StoryObj<typeof SyncStatusIndicator> = {
50
+ args: {
51
+ state,
52
+ saved: false,
53
+ },
54
+ };
55
+
56
+ // TODO(wittjosiah): Separate story path for separate component.
57
+ export const Detail: StoryObj<typeof SyncStatusDetail> = {
49
58
  render: SyncStatusDetail,
50
59
  args: {
51
60
  state,
@@ -2,73 +2,76 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { type HTMLAttributes, useEffect, useState } from 'react';
5
+ import React, { useEffect, useState } from 'react';
6
6
 
7
7
  import { StatusBar } from '@dxos/plugin-status-bar';
8
+ import { useClient } from '@dxos/react-client';
8
9
  import { Icon, Popover, useTranslation } from '@dxos/react-ui';
9
10
  import { type ThemedClassName } from '@dxos/react-ui';
10
11
  import { SyntaxHighlighter } from '@dxos/react-ui-syntax-highlighter';
11
12
  import { mx } from '@dxos/react-ui-theme';
12
13
 
13
- import { type Progress, type PeerSyncState, type SpaceSyncStateMap, getSyncSummary, useSyncState } from './types';
14
+ import { SpaceRow, SYNC_STALLED_TIMEOUT } from './Space';
15
+ import { createClientSaveTracker } from './save-tracker';
16
+ import { getIcon, getStatus } from './status';
17
+ import { type PeerSyncState, type SpaceSyncStateMap, getSyncSummary, useSyncState } from './sync-state';
14
18
  import { SPACE_PLUGIN } from '../../meta';
15
19
 
16
- const SYNC_STALLED_TIMEOUT = 5_000;
17
-
18
- const styles = {
19
- barBg: 'bg-neutral-50 dark:bg-green-900 text-black',
20
- barFg: 'bg-neutral-100 bg-green-500',
21
- barHover: 'dark:hover:bg-green-500',
22
- };
23
-
24
20
  export const SyncStatus = () => {
21
+ const client = useClient();
25
22
  const state = useSyncState();
26
- return <SyncStatusIndicator state={state} />;
23
+ const [saved, setSaved] = useState(true);
24
+
25
+ useEffect(() => {
26
+ return createClientSaveTracker(client, (state) => {
27
+ setSaved(state === 'saved');
28
+ });
29
+ }, []);
30
+
31
+ return <SyncStatusIndicator state={state} saved={saved} />;
27
32
  };
28
33
 
29
- export const SyncStatusIndicator = ({ state }: { state: SpaceSyncStateMap }) => {
34
+ export const SyncStatusIndicator = ({ state, saved }: { state: SpaceSyncStateMap; saved: Boolean }) => {
35
+ const { t } = useTranslation(SPACE_PLUGIN);
30
36
  const summary = getSyncSummary(state);
31
- const offline = false;
32
-
37
+ const offline = Object.values(state).length === 0;
33
38
  const needsToUpload = summary.differentDocuments > 0 || summary.missingOnRemote > 0;
34
39
  const needsToDownload = summary.differentDocuments > 0 || summary.missingOnLocal > 0;
40
+ const status = getStatus({ offline, saved, needsToUpload, needsToDownload });
41
+
35
42
  const [classNames, setClassNames] = useState<string>();
36
43
  useEffect(() => {
37
44
  setClassNames(undefined);
38
- if (!needsToUpload && !needsToDownload) {
45
+ if (offline || (!needsToUpload && !needsToDownload)) {
39
46
  return;
40
47
  }
41
48
 
42
49
  const t = setTimeout(() => {
50
+ // TODO(wittjosiah): Use semantic color tokens.
43
51
  setClassNames('text-orange-500');
44
52
  }, SYNC_STALLED_TIMEOUT);
45
53
  return () => clearTimeout(t);
46
- }, [needsToUpload, needsToDownload]);
54
+ }, [offline, needsToUpload, needsToDownload]);
47
55
 
48
- return (
49
- <StatusBar.Item>
56
+ const title = t(`${status} label`);
57
+ const icon = <Icon icon={getIcon(status)} size={4} classNames={classNames} />;
58
+
59
+ if (offline) {
60
+ return <StatusBar.Item title={title}>{icon}</StatusBar.Item>;
61
+ } else {
62
+ return (
50
63
  <Popover.Root>
51
- <Popover.Trigger>
52
- <Icon
53
- icon={
54
- offline
55
- ? 'ph--cloud-x--regular'
56
- : needsToUpload
57
- ? 'ph--cloud-arrow-up--regular'
58
- : needsToDownload
59
- ? 'ph--cloud-arrow-down--regular'
60
- : 'ph--cloud-check--regular'
61
- }
62
- size={4}
63
- classNames={classNames}
64
- />
64
+ <Popover.Trigger asChild>
65
+ <StatusBar.Button title={title}>{icon}</StatusBar.Button>
65
66
  </Popover.Trigger>
66
- <Popover.Content sideOffset={16}>
67
- <SyncStatusDetail state={state} summary={summary} debug={false} />
68
- </Popover.Content>
67
+ <Popover.Portal>
68
+ <Popover.Content sideOffset={16}>
69
+ <SyncStatusDetail state={state} summary={summary} debug={false} />
70
+ </Popover.Content>
71
+ </Popover.Portal>
69
72
  </Popover.Root>
70
- </StatusBar.Item>
71
- );
73
+ );
74
+ }
72
75
  };
73
76
 
74
77
  export const SyncStatusDetail = ({
@@ -86,9 +89,9 @@ export const SyncStatusDetail = ({
86
89
 
87
90
  // TODO(burdon): Normalize to max document count?
88
91
  return (
89
- <div className={mx('flex flex-col text-xs min-w-[16rem]', classNames)}>
90
- <h1 className='p-2'>{t('sync status title')}</h1>
91
- <div className='flex flex-col gap-[2px] my-[2px]'>
92
+ <div className={mx('flex flex-col gap-3 p-2 text-xs min-w-[16rem]', classNames)}>
93
+ <h1>{t('sync status title')}</h1>
94
+ <div className='flex flex-col gap-2'>
92
95
  {entries.map(([spaceId, state]) => (
93
96
  <SpaceRow key={spaceId} spaceId={spaceId} state={state} />
94
97
  ))}
@@ -97,92 +100,3 @@ export const SyncStatusDetail = ({
97
100
  </div>
98
101
  );
99
102
  };
100
-
101
- const useActive = (count: number) => {
102
- const [current, setCurrent] = useState(count);
103
- const [active, setActive] = useState(false);
104
- useEffect(() => {
105
- let t: NodeJS.Timeout | undefined;
106
- if (count !== current) {
107
- setActive(true);
108
- setCurrent(count);
109
- t && clearTimeout(t);
110
- t = setTimeout(() => {
111
- setActive(false);
112
- }, SYNC_STALLED_TIMEOUT);
113
- }
114
-
115
- return () => {
116
- setActive(false);
117
- clearTimeout(t);
118
- };
119
- }, [count, current]);
120
- return active;
121
- };
122
-
123
- const SpaceRow = ({
124
- spaceId,
125
- state: { localDocumentCount, remoteDocumentCount, missingOnLocal, missingOnRemote },
126
- }: {
127
- spaceId: string;
128
- state: PeerSyncState;
129
- }) => {
130
- const downActive = useActive(localDocumentCount);
131
- const upActive = useActive(remoteDocumentCount);
132
-
133
- return (
134
- <div
135
- className={mx('flex items-center mx-[2px] gap-[2px] cursor-pointer', styles.barHover)}
136
- title={spaceId}
137
- onClick={() => {
138
- void navigator.clipboard.writeText(spaceId);
139
- }}
140
- >
141
- <Icon
142
- icon='ph--arrow-fat-line-left--regular'
143
- size={3}
144
- classNames={mx(downActive && 'animate-[pulse_1s_infinite]')}
145
- />
146
- <Candle
147
- up={{ count: remoteDocumentCount, total: remoteDocumentCount + missingOnRemote }}
148
- down={{ count: localDocumentCount, total: localDocumentCount + missingOnLocal }}
149
- title={spaceId}
150
- />
151
- <Icon
152
- icon='ph--arrow-fat-line-right--regular'
153
- size={3}
154
- classNames={mx(upActive && 'animate-[pulse_1s_step-start_infinite]')}
155
- />
156
- </div>
157
- );
158
- };
159
-
160
- type CandleProps = ThemedClassName<Pick<HTMLAttributes<HTMLDivElement>, 'title'>> & { up: Progress; down: Progress };
161
-
162
- const Candle = ({ classNames, up, down }: CandleProps) => {
163
- return (
164
- <div className={mx('grid grid-cols-[1fr_2rem_1fr] w-full h-3', classNames)}>
165
- <Bar classNames='justify-end' {...up} />
166
- <div className='relative'>
167
- <div className={mx('absolute inset-0 flex items-center justify-center text-xs', styles.barBg)}>{up.total}</div>
168
- </div>
169
- <Bar {...down} />
170
- </div>
171
- );
172
- };
173
-
174
- const Bar = ({ classNames, count, total }: ThemedClassName<Progress>) => {
175
- let p = (count / total) * 100;
176
- if (count < total) {
177
- p = Math.min(p, 95);
178
- }
179
-
180
- return (
181
- <div className={mx('relative flex w-full', styles.barBg, classNames)}>
182
- <div className={mx('shrink-0', styles.barFg)} style={{ width: `${p}%` }}></div>
183
- {count !== total && (
184
- <div className='absolute top-0 bottom-0 flex items-center mx-0.5 text-black text-xs'>{count}</div>
185
- )}
186
- </div>
187
- );
188
- };