@dxos/plugin-space 0.6.13 → 0.6.14-main.69511f5

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 (99) hide show
  1. package/dist/lib/browser/{chunk-LZEGRS7H.mjs → chunk-AVLRQF6L.mjs} +1 -1
  2. package/dist/lib/browser/chunk-AVLRQF6L.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-DTVUOG2C.mjs → chunk-FOI7DAUV.mjs} +24 -5
  4. package/dist/lib/browser/chunk-FOI7DAUV.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +757 -298
  6. package/dist/lib/browser/index.mjs.map +4 -4
  7. package/dist/lib/browser/meta.json +1 -1
  8. package/dist/lib/browser/meta.mjs +1 -1
  9. package/dist/lib/browser/types/index.mjs +7 -3
  10. package/dist/lib/node/{chunk-CVZPI2P3.cjs → chunk-OTDRTHT4.cjs} +30 -9
  11. package/dist/lib/node/chunk-OTDRTHT4.cjs.map +7 -0
  12. package/dist/lib/node/{chunk-6CNYF6YU.cjs → chunk-P4XUXM7Y.cjs} +4 -4
  13. package/dist/lib/node/chunk-P4XUXM7Y.cjs.map +7 -0
  14. package/dist/lib/node/index.cjs +953 -498
  15. package/dist/lib/node/index.cjs.map +4 -4
  16. package/dist/lib/node/meta.cjs +5 -5
  17. package/dist/lib/node/meta.cjs.map +1 -1
  18. package/dist/lib/node/meta.json +1 -1
  19. package/dist/lib/node/types/index.cjs +14 -10
  20. package/dist/lib/node/types/index.cjs.map +2 -2
  21. package/dist/lib/node-esm/chunk-FYDGMPSC.mjs +116 -0
  22. package/dist/lib/node-esm/chunk-FYDGMPSC.mjs.map +7 -0
  23. package/dist/lib/node-esm/chunk-YPQGKWHJ.mjs +37 -0
  24. package/dist/lib/node-esm/chunk-YPQGKWHJ.mjs.map +7 -0
  25. package/dist/lib/node-esm/index.mjs +3100 -0
  26. package/dist/lib/node-esm/index.mjs.map +7 -0
  27. package/dist/lib/node-esm/meta.json +1 -0
  28. package/dist/lib/node-esm/meta.mjs +14 -0
  29. package/dist/lib/node-esm/meta.mjs.map +7 -0
  30. package/dist/lib/node-esm/types/index.mjs +26 -0
  31. package/dist/lib/node-esm/types/index.mjs.map +7 -0
  32. package/dist/types/src/SpacePlugin.d.ts.map +1 -1
  33. package/dist/types/src/components/DefaultObjectSettings.d.ts +7 -0
  34. package/dist/types/src/components/DefaultObjectSettings.d.ts.map +1 -0
  35. package/dist/types/src/components/MenuFooter.d.ts +1 -1
  36. package/dist/types/src/components/MenuFooter.d.ts.map +1 -1
  37. package/dist/types/src/components/SaveStatus.d.ts +3 -0
  38. package/dist/types/src/components/SaveStatus.d.ts.map +1 -0
  39. package/dist/types/src/components/ShareSpaceButton.stories.d.ts +3 -91
  40. package/dist/types/src/components/ShareSpaceButton.stories.d.ts.map +1 -1
  41. package/dist/types/src/components/SpaceMain/SpaceMain.d.ts.map +1 -1
  42. package/dist/types/src/components/SpacePresence.d.ts +4 -2
  43. package/dist/types/src/components/SpacePresence.d.ts.map +1 -1
  44. package/dist/types/src/components/SpacePresence.stories.d.ts +4 -92
  45. package/dist/types/src/components/SpacePresence.stories.d.ts.map +1 -1
  46. package/dist/types/src/components/SpaceSettings.d.ts.map +1 -1
  47. package/dist/types/src/components/SpaceSettingsPanel.d.ts +7 -0
  48. package/dist/types/src/components/SpaceSettingsPanel.d.ts.map +1 -0
  49. package/dist/types/src/components/SyncStatus/SyncStatus.d.ts +13 -0
  50. package/dist/types/src/components/SyncStatus/SyncStatus.d.ts.map +1 -0
  51. package/dist/types/src/components/SyncStatus/SyncStatus.stories.d.ts +24 -0
  52. package/dist/types/src/components/SyncStatus/SyncStatus.stories.d.ts.map +1 -0
  53. package/dist/types/src/components/SyncStatus/index.d.ts +2 -0
  54. package/dist/types/src/components/SyncStatus/index.d.ts.map +1 -0
  55. package/dist/types/src/components/SyncStatus/types.d.ts +14 -0
  56. package/dist/types/src/components/SyncStatus/types.d.ts.map +1 -0
  57. package/dist/types/src/components/index.d.ts +4 -2
  58. package/dist/types/src/components/index.d.ts.map +1 -1
  59. package/dist/types/src/meta.d.ts.map +1 -1
  60. package/dist/types/src/translations.d.ts +8 -0
  61. package/dist/types/src/translations.d.ts.map +1 -1
  62. package/dist/types/src/types/thread.d.ts +15 -1
  63. package/dist/types/src/types/thread.d.ts.map +1 -1
  64. package/dist/types/src/types/types.d.ts +21 -1
  65. package/dist/types/src/types/types.d.ts.map +1 -1
  66. package/dist/types/src/util.d.ts +4 -7
  67. package/dist/types/src/util.d.ts.map +1 -1
  68. package/package.json +45 -45
  69. package/src/SpacePlugin.tsx +229 -116
  70. package/src/components/DefaultObjectSettings.tsx +33 -0
  71. package/src/components/MenuFooter.tsx +2 -2
  72. package/src/components/SaveStatus.tsx +95 -0
  73. package/src/components/ShareSpaceButton.stories.tsx +11 -7
  74. package/src/components/SpaceMain/SpaceMain.tsx +1 -22
  75. package/src/components/SpacePresence.stories.tsx +11 -9
  76. package/src/components/SpacePresence.tsx +34 -23
  77. package/src/components/SpaceSettings.tsx +35 -6
  78. package/src/components/SpaceSettingsPanel.tsx +59 -0
  79. package/src/components/SyncStatus/SyncStatus.stories.tsx +65 -0
  80. package/src/components/SyncStatus/SyncStatus.tsx +188 -0
  81. package/src/components/SyncStatus/index.ts +5 -0
  82. package/src/components/SyncStatus/types.ts +77 -0
  83. package/src/components/index.ts +4 -2
  84. package/src/meta.ts +3 -1
  85. package/src/translations.ts +10 -2
  86. package/src/types/collection.ts +1 -1
  87. package/src/types/thread.ts +12 -2
  88. package/src/types/types.ts +28 -2
  89. package/src/util.tsx +23 -58
  90. package/dist/lib/browser/chunk-DTVUOG2C.mjs.map +0 -7
  91. package/dist/lib/browser/chunk-LZEGRS7H.mjs.map +0 -7
  92. package/dist/lib/node/chunk-6CNYF6YU.cjs.map +0 -7
  93. package/dist/lib/node/chunk-CVZPI2P3.cjs.map +0 -7
  94. package/dist/types/src/components/EmptySpace.d.ts +0 -3
  95. package/dist/types/src/components/EmptySpace.d.ts.map +0 -1
  96. package/dist/types/src/components/EmptyTree.d.ts +0 -3
  97. package/dist/types/src/components/EmptyTree.d.ts.map +0 -1
  98. package/src/components/EmptySpace.tsx +0 -25
  99. package/src/components/EmptyTree.tsx +0 -25
@@ -0,0 +1,95 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useState } from 'react';
6
+
7
+ import { type UnsubscribeCallback } from '@dxos/async';
8
+ import { type Client } from '@dxos/client';
9
+ import { type Space, type SpaceId } from '@dxos/client/echo';
10
+ import { Context } from '@dxos/context';
11
+ import { StatusBar } from '@dxos/plugin-status-bar';
12
+ import { useClient } from '@dxos/react-client';
13
+ import { Icon, useTranslation } from '@dxos/react-ui';
14
+
15
+ import { SPACE_PLUGIN } from '../meta';
16
+
17
+ export const SaveStatus = () => {
18
+ const { t } = useTranslation(SPACE_PLUGIN);
19
+ const client = useClient();
20
+ const [state, setState] = useState<'saved' | 'saving'>('saved');
21
+ useEffect(() => {
22
+ return createClientSaveTracker(client, (state) => {
23
+ setState(state);
24
+ });
25
+ }, []);
26
+
27
+ return (
28
+ <StatusBar.Item title={state === 'saving' ? t('saving label') : t('saved label')}>
29
+ <Icon icon={state === 'saving' ? 'ph--arrows-clockwise--regular' : 'ph--check-circle--regular'} size={4} />
30
+ </StatusBar.Item>
31
+ );
32
+ };
33
+
34
+ const createClientSaveTracker = (client: Client, cb: (state: 'saved' | 'saving') => void) => {
35
+ const unsubscribeCallbacks: Record<SpaceId, UnsubscribeCallback> = {};
36
+ const state: Record<SpaceId, 'saved' | 'saving'> = {};
37
+
38
+ const install = (spaces: Space[]) => {
39
+ for (const space of spaces) {
40
+ if (state[space.id]) {
41
+ continue;
42
+ }
43
+
44
+ state[space.id] = 'saved';
45
+ unsubscribeCallbacks[space.id] = createSpaceSaveTracker(space, (s) => {
46
+ state[space.id] = s;
47
+ cb(Object.values(state).some((s) => s === 'saving') ? 'saving' : 'saved');
48
+ });
49
+ }
50
+ };
51
+ client.spaces.subscribe((spaces) => {
52
+ install(spaces);
53
+ });
54
+ install(client.spaces.get());
55
+
56
+ return () => {
57
+ for (const unsubscribe of Object.values(unsubscribeCallbacks)) {
58
+ unsubscribe();
59
+ }
60
+ };
61
+ };
62
+
63
+ const createSpaceSaveTracker = (space: Space, cb: (state: 'saved' | 'saving') => void): UnsubscribeCallback => {
64
+ const ctx = new Context();
65
+
66
+ void space.waitUntilReady().then(() => {
67
+ if (ctx.disposed) {
68
+ return;
69
+ }
70
+
71
+ let hasUnsavedChanges = false;
72
+ let lastFlushPromise: Promise<void> | undefined;
73
+ space.crud.saveStateChanged.on(ctx, ({ unsavedDocuments }) => {
74
+ hasUnsavedChanges = unsavedDocuments.length > 0;
75
+ });
76
+ space.crud.saveStateChanged.debounce(500).on(ctx, () => {
77
+ if (hasUnsavedChanges) {
78
+ lastFlushPromise = undefined;
79
+ cb('saving');
80
+ } else {
81
+ const flushPromise = space.crud.flush();
82
+ lastFlushPromise = flushPromise;
83
+ void flushPromise.then(() => {
84
+ if (lastFlushPromise === flushPromise) {
85
+ cb('saved');
86
+ }
87
+ });
88
+ }
89
+ });
90
+ });
91
+
92
+ return () => {
93
+ void ctx.dispose();
94
+ };
95
+ };
@@ -4,20 +4,24 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
+ import { type Meta } from '@storybook/react';
8
+
7
9
  import { withTheme } from '@dxos/storybook-utils';
8
10
 
9
11
  import { ShareSpaceButtonImpl } from './ShareSpaceButton';
10
12
  import translations from '../translations';
11
13
 
12
- export default {
13
- title: 'plugin-space/ShareSpaceButton',
14
- component: ShareSpaceButtonImpl,
15
- decorators: [withTheme],
16
- parameters: { translations },
17
- };
18
-
19
14
  export const Default = {
20
15
  args: {
21
16
  onClick: () => console.log('clicked'),
22
17
  },
23
18
  };
19
+
20
+ const meta: Meta = {
21
+ title: 'plugins/plugin-space/ShareSpaceButton',
22
+ component: ShareSpaceButtonImpl,
23
+ decorators: [withTheme],
24
+ parameters: { translations },
25
+ };
26
+
27
+ export default meta;
@@ -6,35 +6,14 @@ import { Command } from '@phosphor-icons/react';
6
6
  import React from 'react';
7
7
 
8
8
  import { Surface } from '@dxos/app-framework';
9
- import { type Action } from '@dxos/plugin-graph';
10
9
  import { SpaceState, type Space } from '@dxos/react-client/echo';
11
- import { Button, Main, useTranslation, toLocalizedString } from '@dxos/react-ui';
10
+ import { Main, useTranslation } from '@dxos/react-ui';
12
11
  import { getSize, mx, topbarBlockPaddingStart } from '@dxos/react-ui-theme';
13
12
  import { ClipboardProvider } from '@dxos/shell/react';
14
13
 
15
14
  import { SpaceMembersSection } from './SpaceMembersSection';
16
15
  import { SPACE_PLUGIN } from '../../meta';
17
16
 
18
- const _InFlowSpaceActions = ({ actionsMap }: { actionsMap: Record<string, Action> }) => {
19
- const { t } = useTranslation(SPACE_PLUGIN);
20
- return (
21
- <section className='mbe-4 col-start-2 col-end-4 md:col-end-7 grid gap-2 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8rem,1fr))]'>
22
- {Object.entries(actionsMap)
23
- .filter(([_, { properties }]) => properties?.mainAreaDisposition === 'in-flow')
24
- .map(([actionId, { data: invoke, properties }]) => {
25
- const Icon = properties?.icon;
26
- const label = properties?.label;
27
- return (
28
- <Button key={actionId} classNames='block text-center plb-2 font-normal'>
29
- {Icon && <Icon size={5} className='mli-auto' />}
30
- <p>{toLocalizedString(label, t)}</p>
31
- </Button>
32
- );
33
- })}
34
- </section>
35
- );
36
- };
37
-
38
17
  const KeyShortcuts = () => {
39
18
  const { t } = useTranslation(SPACE_PLUGIN);
40
19
  return (
@@ -2,10 +2,11 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import React from 'react';
6
-
7
5
  import '@dxos-theme';
8
6
 
7
+ import { type Meta } from '@storybook/react';
8
+ import React from 'react';
9
+
9
10
  import { PublicKey } from '@dxos/keys';
10
11
  import { HaloSpaceMember, SpaceMember } from '@dxos/react-client/echo';
11
12
  import { withLayout, withTheme } from '@dxos/storybook-utils';
@@ -13,13 +14,6 @@ import { withLayout, withTheme } from '@dxos/storybook-utils';
13
14
  import { FullPresence, type MemberPresenceProps, SmallPresence, type Member } from './SpacePresence';
14
15
  import translations from '../translations';
15
16
 
16
- export default {
17
- title: 'plugin-space/SpacePresence',
18
- decorators: [withTheme, withLayout({ tooltips: true })],
19
- parameters: { translations },
20
- actions: { argTypesRegex: '^on.*' },
21
- };
22
-
23
17
  const nViewers = (n: number, currentlyAttended = true): Member[] =>
24
18
  Array.from({ length: n }, () => ({
25
19
  role: HaloSpaceMember.Role.ADMIN,
@@ -95,3 +89,11 @@ export const Small = (props: MemberPresenceProps) => {
95
89
  </div>
96
90
  );
97
91
  };
92
+
93
+ const meta: Meta = {
94
+ title: 'plugins/plugin-space/SpacePresence',
95
+ decorators: [withTheme, withLayout({ tooltips: true })],
96
+ parameters: { translations },
97
+ };
98
+
99
+ export default meta;
@@ -17,13 +17,12 @@ import {
17
17
  type Size,
18
18
  type ThemedClassName,
19
19
  Tooltip,
20
- useDensityContext,
21
20
  useTranslation,
22
21
  List,
23
22
  ListItem,
24
23
  useDefaultValue,
25
24
  } from '@dxos/react-ui';
26
- import { AttentionGlyph } from '@dxos/react-ui-attention';
25
+ import { AttentionGlyph, useAttention } from '@dxos/react-ui-attention';
27
26
  import { ComplexMap, keyToFallback } from '@dxos/util';
28
27
 
29
28
  import { SPACE_PLUGIN } from '../meta';
@@ -40,7 +39,6 @@ const noViewers = new ComplexMap<PublicKey, ObjectViewerProps>(PublicKey.hash);
40
39
  const getName = (identity: Identity) => identity.profile?.displayName ?? generateName(identity.identityKey.toHex());
41
40
 
42
41
  export const SpacePresence = ({ object, spaceKey }: { object: Expando; spaceKey?: PublicKey }) => {
43
- const density = useDensityContext();
44
42
  const spacePlugin = usePlugin<SpacePluginProvides>(SPACE_PLUGIN);
45
43
  const client = useClient();
46
44
  const identity = useIdentity();
@@ -86,11 +84,7 @@ export const SpacePresence = ({ object, spaceKey }: { object: Expando; spaceKey?
86
84
  })
87
85
  .toSorted((a, b) => a.lastSeen - b.lastSeen);
88
86
 
89
- return density === 'fine' ? (
90
- <SmallPresence count={membersForObject.length} />
91
- ) : (
92
- <FullPresence members={membersForObject} />
93
- );
87
+ return <FullPresence members={membersForObject} />;
94
88
  };
95
89
 
96
90
  export type Member = SpaceMember & {
@@ -110,7 +104,7 @@ export type MemberPresenceProps = ThemedClassName<{
110
104
 
111
105
  export const FullPresence = (props: MemberPresenceProps) => {
112
106
  const { size = 9, onMemberClick } = props;
113
- const members = useDefaultValue(props.members, []);
107
+ const members = useDefaultValue(props.members, () => []);
114
108
 
115
109
  if (members.length === 0) {
116
110
  return null;
@@ -200,28 +194,45 @@ const PrensenceAvatar = ({ identity, showName, match, group, index, onClick }: P
200
194
  );
201
195
  };
202
196
 
203
- export const SmallPresenceLive = ({ viewers }: { viewers?: ComplexMap<PublicKey, ObjectViewerProps> }) => {
204
- const [moment, setMoment] = useState(Date.now());
197
+ export const SmallPresenceLive = ({
198
+ id,
199
+ viewers,
200
+ }: {
201
+ id?: string;
202
+ viewers?: ComplexMap<PublicKey, ObjectViewerProps>;
203
+ }) => {
204
+ const getActiveViewers = (viewers: ComplexMap<PublicKey, ObjectViewerProps>): ObjectViewerProps[] => {
205
+ const moment = Date.now();
206
+ return Array.from(viewers.values()).filter(({ lastSeen }) => moment - lastSeen < ACTIVITY_DURATION);
207
+ };
205
208
 
206
- // NOTE(thure): This is necessary so Presence updates without any underlying data updating.
207
- useEffect(() => {
208
- const interval = setInterval(() => setMoment(Date.now()), REFRESH_INTERVAL);
209
- return () => clearInterval(interval);
210
- }, []);
211
-
212
- const activeViewers = viewers
213
- ? Array.from(viewers.values()).filter(({ lastSeen }) => moment - lastSeen < ACTIVITY_DURATION)
214
- : [];
209
+ const [activeViewers, setActiveViewers] = useState(viewers ? getActiveViewers(viewers) : []);
215
210
 
216
- return <SmallPresence count={activeViewers.length} />;
211
+ useEffect(() => {
212
+ if (viewers) {
213
+ setActiveViewers(getActiveViewers(viewers));
214
+ const interval = setInterval(() => {
215
+ setActiveViewers(getActiveViewers(viewers));
216
+ }, REFRESH_INTERVAL);
217
+ return () => clearInterval(interval);
218
+ }
219
+ }, [viewers]);
220
+
221
+ return <SmallPresence id={id} count={activeViewers.length} />;
217
222
  };
218
223
 
219
- export const SmallPresence = ({ count }: { count: number }) => {
224
+ export const SmallPresence = ({ id, count }: { id?: string; count: number }) => {
220
225
  const { t } = useTranslation(SPACE_PLUGIN);
226
+ const { hasAttention, isAncestor, isRelated } = useAttention(id);
227
+ const attention = hasAttention || isAncestor || isRelated;
228
+
221
229
  return (
222
230
  <Tooltip.Root>
223
231
  <Tooltip.Trigger asChild>
224
- <AttentionGlyph presence={count > 1 ? 'many' : count === 1 ? 'one' : 'none'} classNames='self-center mie-1' />
232
+ {/* TODO(wittjosiah): Don't depend on data attribute just pass prop to AttentionGlyph. */}
233
+ <div role='none' className='flex' data-attention={attention}>
234
+ <AttentionGlyph presence={count > 1 ? 'many' : count === 1 ? 'one' : 'none'} classNames='self-center mie-1' />
235
+ </div>
225
236
  </Tooltip.Trigger>
226
237
  <Tooltip.Portal>
227
238
  <Tooltip.Content side='bottom' classNames='z-[70]'>
@@ -4,20 +4,21 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import { useIntentDispatcher } from '@dxos/app-framework';
8
- import { SettingsValue } from '@dxos/plugin-settings';
9
- import { Input, useTranslation } from '@dxos/react-ui';
7
+ import { useIntentDispatcher, useResolvePlugins } from '@dxos/app-framework';
8
+ import { Input, Select, toLocalizedString, useTranslation } from '@dxos/react-ui';
9
+ import { FormInput } from '@dxos/react-ui-data';
10
10
 
11
11
  import { SpaceAction, SPACE_PLUGIN } from '../meta';
12
- import { type SpaceSettingsProps } from '../types';
12
+ import { parseSpaceInitPlugin, type SpaceSettingsProps } from '../types';
13
13
 
14
14
  export const SpaceSettings = ({ settings }: { settings: SpaceSettingsProps }) => {
15
15
  const { t } = useTranslation(SPACE_PLUGIN);
16
16
  const dispatch = useIntentDispatcher();
17
+ const plugins = useResolvePlugins(parseSpaceInitPlugin);
17
18
 
18
19
  return (
19
20
  <>
20
- <SettingsValue label={t('show hidden spaces label')}>
21
+ <FormInput label={t('show hidden spaces label')}>
21
22
  <Input.Switch
22
23
  checked={settings.showHidden}
23
24
  onCheckedChange={(checked) =>
@@ -28,7 +29,35 @@ export const SpaceSettings = ({ settings }: { settings: SpaceSettingsProps }) =>
28
29
  })
29
30
  }
30
31
  />
31
- </SettingsValue>
32
+ </FormInput>
33
+
34
+ <FormInput label={t('default on space create label')}>
35
+ <Select.Root
36
+ value={settings.onSpaceCreate}
37
+ onValueChange={(value) => {
38
+ settings.onSpaceCreate = value;
39
+ }}
40
+ >
41
+ <Select.TriggerButton />
42
+ <Select.Portal>
43
+ <Select.Content>
44
+ <Select.Viewport>
45
+ {plugins.map(
46
+ ({
47
+ provides: {
48
+ space: { onSpaceCreate },
49
+ },
50
+ }) => (
51
+ <Select.Option key={onSpaceCreate.action} value={onSpaceCreate.action}>
52
+ {toLocalizedString(onSpaceCreate.label, t)}
53
+ </Select.Option>
54
+ ),
55
+ )}
56
+ </Select.Viewport>
57
+ </Select.Content>
58
+ </Select.Portal>
59
+ </Select.Root>
60
+ </FormInput>
32
61
  </>
33
62
  );
34
63
  };
@@ -0,0 +1,59 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useState } from 'react';
6
+
7
+ import { log } from '@dxos/log';
8
+ import { EdgeReplicationSetting } from '@dxos/protocols/proto/dxos/echo/metadata';
9
+ import { type Space } from '@dxos/react-client/echo';
10
+ import { Input, useTranslation } from '@dxos/react-ui';
11
+
12
+ import { SPACE_PLUGIN } from '../meta';
13
+
14
+ export type SpaceSettingsPanelProps = {
15
+ space: Space;
16
+ };
17
+
18
+ export const SpaceSettingsPanel = ({ space }: SpaceSettingsPanelProps) => {
19
+ const { t } = useTranslation(SPACE_PLUGIN);
20
+ const [edgeReplication, setEdgeReplication] = useState(
21
+ space.internal.data.edgeReplication === EdgeReplicationSetting.ENABLED,
22
+ );
23
+
24
+ const toggleEdgeReplication = useCallback(
25
+ async (next: boolean) => {
26
+ setEdgeReplication(next);
27
+ await space?.internal
28
+ .setEdgeReplicationPreference(next ? EdgeReplicationSetting.ENABLED : EdgeReplicationSetting.DISABLED)
29
+ .catch((err) => {
30
+ log.catch(err);
31
+ setEdgeReplication(!next);
32
+ });
33
+ },
34
+ [space],
35
+ );
36
+
37
+ return (
38
+ <div role='form' className='flex flex-col w-full p-2 gap-4'>
39
+ <Input.Root>
40
+ <div role='none' className='flex flex-col gap-1'>
41
+ <Input.Label>{t('name label')}</Input.Label>
42
+ <Input.TextInput
43
+ placeholder={t('name placeholder')}
44
+ value={space.properties.name}
45
+ onChange={(event) => {
46
+ space.properties.name = event.target.value;
47
+ }}
48
+ />
49
+ </div>
50
+ </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>
57
+ </div>
58
+ );
59
+ };
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import '@dxos-theme';
6
+
7
+ import { type Meta } from '@storybook/react';
8
+ import React from 'react';
9
+
10
+ import { SpaceId } from '@dxos/keys';
11
+ import { withTheme, withLayout } from '@dxos/storybook-utils';
12
+
13
+ import { SyncStatusDetail, SyncStatusIndicator } from './SyncStatus';
14
+ import { getSyncSummary, type SpaceSyncStateMap } from './types';
15
+ import translations from '../../translations';
16
+
17
+ const DefaultStory = (props: any) => {
18
+ return (
19
+ <div className='flex flex-col-reverse p-4 '>
20
+ <SyncStatusIndicator {...props} />
21
+ </div>
22
+ );
23
+ };
24
+
25
+ const random = ({ min, max }: { min: number; max: number }) => min + Math.floor(Math.random() * (max - min));
26
+
27
+ const state: SpaceSyncStateMap = Array.from({ length: 5 }).reduce<SpaceSyncStateMap>((map) => {
28
+ const total = random({ min: 10, max: 500 });
29
+ const haveLocal = random({ min: 0, max: total });
30
+ const haveRemote = random({ min: 0, max: total });
31
+ map[SpaceId.random()] = {
32
+ localDocumentCount: haveLocal,
33
+ remoteDocumentCount: haveRemote,
34
+ missingOnLocal: total - haveLocal,
35
+ missingOnRemote: total - haveRemote,
36
+ differentDocuments: 0,
37
+ };
38
+
39
+ return map;
40
+ }, {});
41
+
42
+ export const Default = {
43
+ args: {
44
+ state,
45
+ },
46
+ };
47
+
48
+ export const Detail = {
49
+ render: SyncStatusDetail,
50
+ args: {
51
+ state,
52
+ summary: getSyncSummary(state),
53
+ classNames: 'm-2 w-[200px] border border-separator rounded-md',
54
+ },
55
+ };
56
+
57
+ const meta: Meta = {
58
+ title: 'plugins/plugin-space/SyncStatusIndicator',
59
+ component: SyncStatusIndicator,
60
+ render: DefaultStory,
61
+ decorators: [withTheme, withLayout({ fullscreen: true })],
62
+ parameters: { translations },
63
+ };
64
+
65
+ export default meta;
@@ -0,0 +1,188 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { type HTMLAttributes, useEffect, useState } from 'react';
6
+
7
+ import { StatusBar } from '@dxos/plugin-status-bar';
8
+ import { Icon, Popover, useTranslation } from '@dxos/react-ui';
9
+ import { type ThemedClassName } from '@dxos/react-ui';
10
+ import { SyntaxHighlighter } from '@dxos/react-ui-syntax-highlighter';
11
+ import { mx } from '@dxos/react-ui-theme';
12
+
13
+ import { type Progress, type PeerSyncState, type SpaceSyncStateMap, getSyncSummary, useSyncState } from './types';
14
+ import { SPACE_PLUGIN } from '../../meta';
15
+
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
+ export const SyncStatus = () => {
25
+ const state = useSyncState();
26
+ return <SyncStatusIndicator state={state} />;
27
+ };
28
+
29
+ export const SyncStatusIndicator = ({ state }: { state: SpaceSyncStateMap }) => {
30
+ const summary = getSyncSummary(state);
31
+ const offline = false;
32
+
33
+ const needsToUpload = summary.differentDocuments > 0 || summary.missingOnRemote > 0;
34
+ const needsToDownload = summary.differentDocuments > 0 || summary.missingOnLocal > 0;
35
+ const [classNames, setClassNames] = useState<string>();
36
+ useEffect(() => {
37
+ setClassNames(undefined);
38
+ if (!needsToUpload && !needsToDownload) {
39
+ return;
40
+ }
41
+
42
+ const t = setTimeout(() => {
43
+ setClassNames('text-orange-500');
44
+ }, SYNC_STALLED_TIMEOUT);
45
+ return () => clearTimeout(t);
46
+ }, [needsToUpload, needsToDownload]);
47
+
48
+ return (
49
+ <StatusBar.Item>
50
+ <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
+ />
65
+ </Popover.Trigger>
66
+ <Popover.Content sideOffset={16}>
67
+ <SyncStatusDetail state={state} summary={summary} debug={false} />
68
+ </Popover.Content>
69
+ </Popover.Root>
70
+ </StatusBar.Item>
71
+ );
72
+ };
73
+
74
+ export const SyncStatusDetail = ({
75
+ classNames,
76
+ state,
77
+ summary,
78
+ debug,
79
+ }: ThemedClassName<{
80
+ state: SpaceSyncStateMap;
81
+ summary: PeerSyncState;
82
+ debug?: boolean;
83
+ }>) => {
84
+ const { t } = useTranslation(SPACE_PLUGIN);
85
+ const entries = Object.entries(state).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
86
+
87
+ // TODO(burdon): Normalize to max document count?
88
+ 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
+ {entries.map(([spaceId, state]) => (
93
+ <SpaceRow key={spaceId} spaceId={spaceId} state={state} />
94
+ ))}
95
+ </div>
96
+ {debug && <SyntaxHighlighter language='json'>{JSON.stringify(summary, null, 2)}</SyntaxHighlighter>}
97
+ </div>
98
+ );
99
+ };
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
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './SyncStatus';