@noya-app/noya-multiplayer-react 0.1.66 → 0.1.68

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noya-app/noya-multiplayer-react",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "dev": "npm run build -- --watch"
13
13
  },
14
14
  "dependencies": {
15
- "@noya-app/state-manager": "0.1.52",
15
+ "@noya-app/state-manager": "0.1.54",
16
16
  "@noya-app/emitter": "0.1.0",
17
17
  "@noya-app/observable": "0.1.12",
18
18
  "@noya-app/task-runner": "0.1.6",
@@ -2,11 +2,14 @@
2
2
 
3
3
  import { MenuItem } from "@noya-app/noya-designsystem";
4
4
  import {
5
+ CreateResourceParametersWithoutAccessibleByFileId,
5
6
  diffResourceMaps,
6
7
  Input,
7
8
  MediaItem,
9
+ ModifiedResource,
8
10
  OutputTransform,
9
11
  Resource,
12
+ ResourceCreate,
10
13
  ResourceCreateMap,
11
14
  ResourceMap,
12
15
  } from "@noya-app/noya-schemas";
@@ -19,7 +22,6 @@ import {
19
22
  import { useJsonMemo, useStableCallback } from "@noya-app/react-utils";
20
23
  import {
21
24
  ActivityEventsManager,
22
- ConnectedUsersManager,
23
25
  CreateParams,
24
26
  EphemeralUserDataManager,
25
27
  MultiplayerPatchMetadata,
@@ -46,11 +48,112 @@ import React, {
46
48
  import {
47
49
  AppTheme,
48
50
  AppViewType,
49
- useNoyaState,
51
+ useNoyaStateInternal,
50
52
  UseNoyaStateOptions,
51
53
  } from "./noyaApp";
54
+ import { getNoyaManagerSingleton, getNoyaSingletonBindings } from "./singleton";
52
55
  import { useObservable } from "./useObservable";
53
56
 
57
+ export function resourceToCreateParameters(
58
+ resource: ResourceCreate
59
+ ): CreateResourceParametersWithoutAccessibleByFileId {
60
+ switch (resource.type) {
61
+ case "directory":
62
+ return {
63
+ path: resource.path,
64
+ type: "directory",
65
+ };
66
+ case "asset":
67
+ return {
68
+ path: resource.path,
69
+ type: "asset",
70
+ assetId: resource.assetId,
71
+ asset: resource.asset,
72
+ };
73
+ case "file":
74
+ return {
75
+ path: resource.path,
76
+ type: "file",
77
+ fileId: resource.fileId,
78
+ };
79
+ case "resource":
80
+ return {
81
+ path: resource.path,
82
+ type: "resource",
83
+ fileId: resource.fileId,
84
+ resourceId: resource.resourceId,
85
+ };
86
+ default:
87
+ throw new Error(`Unknown resource type: ${(resource as any).type}`);
88
+ }
89
+ }
90
+
91
+ type BuildCreateResourcePayloadParameters = {
92
+ addedResources: ResourceCreate[];
93
+ modifiedResources: ModifiedResource[];
94
+ newResourceMap: ResourceCreateMap;
95
+ resourceMap: ResourceMap;
96
+ };
97
+
98
+ type BuildCreateResourcePayloadResult = {
99
+ payload: CreateResourceParametersWithoutAccessibleByFileId[];
100
+ consumedModifiedResourceIds: string[];
101
+ };
102
+
103
+ export function buildCreateResourcePayload({
104
+ addedResources,
105
+ modifiedResources,
106
+ newResourceMap,
107
+ resourceMap,
108
+ }: BuildCreateResourcePayloadParameters): BuildCreateResourcePayloadResult {
109
+ const toCreateParameters = resourceToCreateParameters;
110
+
111
+ const resourcesById = new Map(
112
+ Object.values(resourceMap).map((resource) => [resource.id, resource])
113
+ );
114
+
115
+ const payload: CreateResourceParametersWithoutAccessibleByFileId[] = [
116
+ ...addedResources.map(toCreateParameters),
117
+ ];
118
+
119
+ const handledModifiedResourceIds: string[] = [];
120
+
121
+ const assetReplacements = modifiedResources
122
+ .filter(
123
+ (resource) =>
124
+ resource.assetId !== undefined || resource.asset !== undefined
125
+ )
126
+ .map((resource) => {
127
+ const previousResource = resourcesById.get(resource.id);
128
+ const path = resource.path ?? previousResource?.path;
129
+
130
+ if (!path) return undefined;
131
+
132
+ const updated = newResourceMap[path];
133
+
134
+ if (!updated || updated.type !== "asset") return undefined;
135
+
136
+ handledModifiedResourceIds.push(resource.id);
137
+
138
+ return toCreateParameters(updated);
139
+ })
140
+ .filter(
141
+ (
142
+ resource
143
+ ): resource is CreateResourceParametersWithoutAccessibleByFileId =>
144
+ resource !== undefined
145
+ );
146
+
147
+ const deduped = new Map(
148
+ [...payload, ...assetReplacements].map((item) => [item.path, item])
149
+ );
150
+
151
+ return {
152
+ payload: Array.from(deduped.values()),
153
+ consumedModifiedResourceIds: [...new Set(handledModifiedResourceIds)],
154
+ };
155
+ }
156
+
54
157
  export interface NoyaStateProviderProps<
55
158
  S,
56
159
  M extends object,
@@ -79,6 +182,16 @@ export function useAnyNoyaStateContext() {
79
182
  const value = useContext(AnyNoyaStateContext);
80
183
 
81
184
  if (!value) {
185
+ const bindings = getNoyaSingletonBindings();
186
+
187
+ if (bindings) {
188
+ return {
189
+ noyaManager: getNoyaManagerSingleton(),
190
+ theme: bindings.theme,
191
+ viewType: bindings.viewType,
192
+ } as AnyNoyaStateContextValue;
193
+ }
194
+
82
195
  throw new Error(
83
196
  "useNoyaStateContext must be used within a NoyaStateProvider"
84
197
  );
@@ -111,8 +224,10 @@ export function useAsset(id: string | undefined) {
111
224
  const { noyaManager } = useAnyNoyaStateContext();
112
225
  const observable = useMemo(
113
226
  () =>
114
- noyaManager.assetManager.assets$.map((assets) =>
115
- assets.find((a) => a.id === id)
227
+ noyaManager.assetManager.assets$.map(
228
+ (assets) =>
229
+ assets.find((a) => a.stableId === id) ??
230
+ assets.find((a) => a.id === id)
116
231
  ),
117
232
  [noyaManager, id]
118
233
  );
@@ -212,12 +327,9 @@ export function usePipelineManager() {
212
327
  return noyaManager.pipelineManager;
213
328
  }
214
329
 
215
- export const ConnectedUsersContext = createContext<
216
- ConnectedUsersManager | undefined
217
- >(undefined);
218
-
219
330
  export function useConnectedUsersManager() {
220
- return useContext(ConnectedUsersContext);
331
+ const { noyaManager } = useAnyNoyaStateContext();
332
+ return noyaManager.connectedUsersManager;
221
333
  }
222
334
 
223
335
  export function useConnectedUsers() {
@@ -241,12 +353,9 @@ export function useConnectedUser(userId?: string | null) {
241
353
  );
242
354
  }
243
355
 
244
- export const AnyEphemeralUserDataManagerContext = createContext<
245
- EphemeralUserDataManager<object> | undefined
246
- >(undefined);
247
-
248
356
  export function useAnyEphemeralUserData() {
249
- return useContext(AnyEphemeralUserDataManagerContext);
357
+ const { noyaManager } = useAnyNoyaStateContext();
358
+ return noyaManager.ephemeralUserDataManager;
250
359
  }
251
360
 
252
361
  export function useCurrentUserId() {
@@ -259,6 +368,22 @@ export function useIsProcessing() {
259
368
  return useObservable(noyaManager.isProcessing$);
260
369
  }
261
370
 
371
+ export function useFileName() {
372
+ const { noyaManager } = useAnyNoyaStateContext();
373
+ return useObservable(noyaManager.filePropertyManager.optimisticValue$);
374
+ }
375
+
376
+ export function useFilePropertyManager() {
377
+ const { noyaManager } = useAnyNoyaStateContext();
378
+ return noyaManager.filePropertyManager;
379
+ }
380
+
381
+ export function useSetFileNameState() {
382
+ const fileName = useFileName();
383
+ const filePropertyManager = useFilePropertyManager();
384
+ return [fileName, filePropertyManager.updateName] as const;
385
+ }
386
+
262
387
  export function useResourceManager(): ResourceManager {
263
388
  const { noyaManager } = useAnyNoyaStateContext();
264
389
  return noyaManager.resourceManager;
@@ -306,63 +431,45 @@ export function useResources({
306
431
  const { addedResources, removedResources, modifiedResources } =
307
432
  diffResourceMaps(resourceMap, newResourceMap, "id");
308
433
 
309
- if (addedResources.length > 0) {
310
- const payload = addedResources.map((resource) => {
311
- switch (resource.type) {
312
- case "directory":
313
- return {
314
- path: resource.path,
315
- type: "directory" as const,
316
- };
317
- case "asset":
318
- return {
319
- path: resource.path,
320
- type: "asset" as const,
321
- assetId: resource.assetId,
322
- asset: resource.asset,
323
- };
324
- case "file":
325
- return {
326
- path: resource.path,
327
- type: "file" as const,
328
- fileId: resource.fileId,
329
- };
330
- case "resource":
331
- return {
332
- path: resource.path,
333
- type: "resource" as const,
334
- fileId: resource.fileId,
335
- resourceId: resource.resourceId,
336
- };
337
- default:
338
- throw new Error(
339
- `Unknown resource type: ${(resource as any).type}`
340
- );
341
- }
434
+ const { payload: createPayload, consumedModifiedResourceIds } =
435
+ buildCreateResourcePayload({
436
+ addedResources,
437
+ modifiedResources,
438
+ newResourceMap,
439
+ resourceMap,
342
440
  });
343
441
 
344
- await resourceManager.createResource(payload);
442
+ if (createPayload.length > 0) {
443
+ await resourceManager.createResource(createPayload);
345
444
  }
346
445
 
347
- for (const resource of removedResources) {
348
- await resourceManager.deleteResource({ id: resource.id });
446
+ const consumedModifiedResourceIdsSet = new Set(
447
+ consumedModifiedResourceIds
448
+ );
349
449
 
350
- if (shouldDeleteAssets && resource.type === "asset") {
351
- try {
450
+ const remainingModifiedResources = modifiedResources.filter(
451
+ (resource) => !consumedModifiedResourceIdsSet.has(resource.id)
452
+ );
453
+
454
+ await resourceManager.deleteResource(
455
+ removedResources.map((resource) => ({ id: resource.id }))
456
+ );
457
+
458
+ if (shouldDeleteAssets) {
459
+ for (const resource of removedResources) {
460
+ if (resource.type === "asset") {
352
461
  await assetManager.delete(resource.assetId);
353
- } catch (error) {
354
- console.error("Failed to delete asset:", error);
355
462
  }
356
463
  }
357
464
  }
358
465
 
359
466
  await Promise.all(
360
- modifiedResources.map(({ id, path, assetId, asset }) =>
467
+ remainingModifiedResources.map(({ id, path, assetId, asset }) =>
361
468
  resourceManager.updateResource({
362
469
  id,
363
470
  path,
364
471
  assetId,
365
- asset,
472
+ ...(assetId ? {} : asset ? { asset } : {}),
366
473
  })
367
474
  )
368
475
  );
@@ -453,11 +560,6 @@ export function createNoyaContext<
453
560
  NoyaManager<S, M, E> | undefined
454
561
  >;
455
562
 
456
- const EphemeralUserDataManagerContext =
457
- AnyEphemeralUserDataManagerContext as React.Context<
458
- EphemeralUserDataManager<E> | undefined
459
- >;
460
-
461
563
  function NoyaContextProvider({
462
564
  children,
463
565
  contextValue,
@@ -467,15 +569,7 @@ export function createNoyaContext<
467
569
  }) {
468
570
  return (
469
571
  <AnyNoyaStateContext.Provider value={contextValue}>
470
- <ConnectedUsersContext.Provider
471
- value={contextValue.noyaManager.connectedUsersManager}
472
- >
473
- <EphemeralUserDataManagerContext.Provider
474
- value={contextValue.noyaManager.ephemeralUserDataManager}
475
- >
476
- {children}
477
- </EphemeralUserDataManagerContext.Provider>
478
- </ConnectedUsersContext.Provider>
572
+ {children}
479
573
  </AnyNoyaStateContext.Provider>
480
574
  );
481
575
  }
@@ -485,17 +579,19 @@ export function createNoyaContext<
485
579
  initialState,
486
580
  ...options
487
581
  }: NoyaStateProviderProps<S, M, E, MenuT>) {
488
- const [, , { noyaManager, theme, viewType }] = useNoyaState<S, M, E, MenuT>(
489
- initialState,
490
- {
491
- schema,
492
- mergeHistoryEntries,
493
- outputTransforms,
494
- inputs,
495
- safeEval,
496
- ...(options as any),
497
- }
498
- );
582
+ const [, , { noyaManager, theme, viewType }] = useNoyaStateInternal<
583
+ S,
584
+ M,
585
+ E,
586
+ MenuT
587
+ >(initialState, {
588
+ schema,
589
+ mergeHistoryEntries,
590
+ outputTransforms,
591
+ inputs,
592
+ safeEval,
593
+ ...(options as any),
594
+ });
499
595
 
500
596
  const contextValue = useMemo(
501
597
  () => ({
@@ -635,15 +731,8 @@ export function createNoyaContext<
635
731
  }
636
732
 
637
733
  function useEphemeralUserDataManager() {
638
- const ephemeralUserData = useContext(EphemeralUserDataManagerContext);
639
-
640
- if (!ephemeralUserData) {
641
- throw new Error(
642
- "useEphemeralUserData must be used within a NoyaStateProvider"
643
- );
644
- }
645
-
646
- return ephemeralUserData;
734
+ const { noyaManager } = useAnyNoyaStateContext();
735
+ return noyaManager.ephemeralUserDataManager as EphemeralUserDataManager<E>;
647
736
  }
648
737
 
649
738
  function useNoyaManager() {
@@ -13,7 +13,6 @@ it("typechecks", () => {
13
13
  });
14
14
 
15
15
  // For type checking
16
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
17
16
  function TestComponent() {
18
17
  const state = useValue();
19
18
  const count = useValue("count");
@@ -38,6 +37,8 @@ it("typechecks", () => {
38
37
  expectType<number>(count3);
39
38
  expectType<number>(count4);
40
39
  }
40
+
41
+ void TestComponent;
41
42
  });
42
43
 
43
- function expectType<T>(value: T): void {}
44
+ function expectType<T>(_value: T): void {}
@@ -0,0 +1,263 @@
1
+ import {
2
+ ModifiedResource,
3
+ Resource,
4
+ ResourceCreate,
5
+ ResourceCreateMap,
6
+ ResourceMap,
7
+ } from "@noya-app/noya-schemas";
8
+ import { describe, expect, it } from "bun:test";
9
+ import {
10
+ buildCreateResourcePayload,
11
+ resourceToCreateParameters,
12
+ } from "../NoyaStateContext";
13
+
14
+ const ISO_DATE = "2024-01-01T00:00:00.000Z";
15
+
16
+ type AssetResource = Extract<Resource, { type: "asset" }>;
17
+ type AssetResourceCreate = Extract<ResourceCreate, { type: "asset" }>;
18
+ type DirectoryResourceCreate = Extract<ResourceCreate, { type: "directory" }>;
19
+
20
+ function createAssetResource(
21
+ overrides: Partial<AssetResource> = {}
22
+ ): AssetResource {
23
+ const resource: AssetResource = {
24
+ id: "resource-id",
25
+ stableId: "stable-id",
26
+ path: "assets/default.png",
27
+ createdAt: ISO_DATE,
28
+ updatedAt: ISO_DATE,
29
+ accessibleByFileId: null,
30
+ accessibleByFileVersionId: null,
31
+ type: "asset",
32
+ assetId: "asset-id",
33
+ ...overrides,
34
+ };
35
+
36
+ return resource;
37
+ }
38
+
39
+ function createAssetResourceCreate(
40
+ overrides: Partial<AssetResourceCreate> = {}
41
+ ): AssetResourceCreate {
42
+ const resource: AssetResourceCreate = {
43
+ id: "resource-id",
44
+ stableId: "stable-id",
45
+ path: "assets/default.png",
46
+ createdAt: ISO_DATE,
47
+ updatedAt: ISO_DATE,
48
+ accessibleByFileId: null,
49
+ accessibleByFileVersionId: null,
50
+ type: "asset",
51
+ assetId: "asset-id",
52
+ asset: {
53
+ content: "ZGF0YQ==",
54
+ contentType: "image/png",
55
+ encoding: "base64" as const,
56
+ },
57
+ ...overrides,
58
+ };
59
+
60
+ return resource;
61
+ }
62
+
63
+ function createDirectoryResourceCreate(
64
+ overrides: Partial<DirectoryResourceCreate> = {}
65
+ ): DirectoryResourceCreate {
66
+ const resource: DirectoryResourceCreate = {
67
+ id: "directory-id",
68
+ stableId: "directory-stable",
69
+ path: "docs",
70
+ createdAt: ISO_DATE,
71
+ updatedAt: ISO_DATE,
72
+ accessibleByFileId: null,
73
+ accessibleByFileVersionId: null,
74
+ type: "directory",
75
+ ...overrides,
76
+ };
77
+
78
+ return resource;
79
+ }
80
+
81
+ describe("resource payload helpers", () => {
82
+ it("returns create parameters for added resources", () => {
83
+ const addedResource = createDirectoryResourceCreate();
84
+
85
+ const result = buildCreateResourcePayload({
86
+ addedResources: [addedResource],
87
+ modifiedResources: [],
88
+ newResourceMap: {
89
+ [addedResource.path]: addedResource,
90
+ } satisfies ResourceCreateMap,
91
+ resourceMap: {},
92
+ });
93
+
94
+ expect(result.payload).toEqual([
95
+ {
96
+ path: "docs",
97
+ type: "directory",
98
+ },
99
+ ]);
100
+ expect(result.consumedModifiedResourceIds).toEqual([]);
101
+ });
102
+
103
+ it("includes asset replacements when a modified resource swaps the asset", () => {
104
+ const existingResource = createAssetResource({
105
+ id: "existing-resource",
106
+ stableId: "existing-stable",
107
+ path: "images/logo.png",
108
+ assetId: "asset-old",
109
+ });
110
+
111
+ const updatedResource = createAssetResourceCreate({
112
+ id: "new-resource",
113
+ stableId: "new-stable",
114
+ path: "images/logo.png",
115
+ assetId: "asset-new",
116
+ });
117
+
118
+ const result = buildCreateResourcePayload({
119
+ addedResources: [],
120
+ modifiedResources: [
121
+ {
122
+ id: existingResource.id,
123
+ stableId: existingResource.stableId,
124
+ assetId: updatedResource.assetId,
125
+ } satisfies ModifiedResource,
126
+ ],
127
+ newResourceMap: {
128
+ [updatedResource.path]: updatedResource,
129
+ } satisfies ResourceCreateMap,
130
+ resourceMap: {
131
+ [existingResource.path]: existingResource,
132
+ } satisfies ResourceMap,
133
+ });
134
+
135
+ expect(result.payload).toEqual([
136
+ resourceToCreateParameters(updatedResource),
137
+ ]);
138
+ expect(result.consumedModifiedResourceIds).toEqual([
139
+ existingResource.id,
140
+ ]);
141
+ });
142
+
143
+ it("includes asset replacements when a modified resource provides inline asset data", () => {
144
+ const existingResource = createAssetResource({
145
+ id: "existing-resource",
146
+ stableId: "existing-stable",
147
+ path: "images/logo.png",
148
+ assetId: "asset-old",
149
+ });
150
+
151
+ const updatedResource = createAssetResourceCreate({
152
+ id: "existing-resource",
153
+ stableId: "existing-stable",
154
+ path: "images/logo.png",
155
+ assetId: "asset-old",
156
+ asset: {
157
+ content: "ZGF0YQ==",
158
+ contentType: "image/png",
159
+ encoding: "base64" as const,
160
+ },
161
+ });
162
+
163
+ const result = buildCreateResourcePayload({
164
+ addedResources: [],
165
+ modifiedResources: [
166
+ {
167
+ id: existingResource.id,
168
+ stableId: existingResource.stableId,
169
+ asset: updatedResource.asset,
170
+ } satisfies ModifiedResource,
171
+ ],
172
+ newResourceMap: {
173
+ [updatedResource.path]: updatedResource,
174
+ } satisfies ResourceCreateMap,
175
+ resourceMap: {
176
+ [existingResource.path]: existingResource,
177
+ } satisfies ResourceMap,
178
+ });
179
+
180
+ expect(result.payload).toEqual([
181
+ resourceToCreateParameters(updatedResource),
182
+ ]);
183
+ expect(result.consumedModifiedResourceIds).toEqual([
184
+ existingResource.id,
185
+ ]);
186
+ });
187
+
188
+ it("deduplicates payload entries by path", () => {
189
+ const existingResource = createAssetResource({
190
+ id: "existing-resource",
191
+ stableId: "existing-stable",
192
+ path: "shared/path",
193
+ assetId: "asset-old",
194
+ });
195
+
196
+ const updatedResource = createAssetResourceCreate({
197
+ id: "new-resource",
198
+ stableId: "new-stable",
199
+ path: "shared/path",
200
+ assetId: "asset-new",
201
+ });
202
+
203
+ const result = buildCreateResourcePayload({
204
+ addedResources: [updatedResource],
205
+ modifiedResources: [
206
+ {
207
+ id: existingResource.id,
208
+ stableId: existingResource.stableId,
209
+ assetId: updatedResource.assetId,
210
+ } satisfies ModifiedResource,
211
+ ],
212
+ newResourceMap: {
213
+ [updatedResource.path]: updatedResource,
214
+ } satisfies ResourceCreateMap,
215
+ resourceMap: {
216
+ [existingResource.path]: existingResource,
217
+ } satisfies ResourceMap,
218
+ });
219
+
220
+ expect(result.payload).toEqual([
221
+ resourceToCreateParameters(updatedResource),
222
+ ]);
223
+ expect(result.consumedModifiedResourceIds).toEqual([
224
+ existingResource.id,
225
+ ]);
226
+ });
227
+
228
+ it("excludes modified resources without asset changes from the consumed list", () => {
229
+ const existingResource = createAssetResource({
230
+ id: "existing-resource",
231
+ stableId: "existing-stable",
232
+ path: "images/logo.png",
233
+ assetId: "asset-old",
234
+ });
235
+
236
+ const updatedResource = createAssetResourceCreate({
237
+ id: "existing-resource",
238
+ stableId: "existing-stable",
239
+ path: "images/logo.png",
240
+ assetId: "asset-old",
241
+ });
242
+
243
+ const result = buildCreateResourcePayload({
244
+ addedResources: [],
245
+ modifiedResources: [
246
+ {
247
+ id: existingResource.id,
248
+ stableId: existingResource.stableId,
249
+ path: updatedResource.path,
250
+ } satisfies ModifiedResource,
251
+ ],
252
+ newResourceMap: {
253
+ [updatedResource.path]: updatedResource,
254
+ } satisfies ResourceCreateMap,
255
+ resourceMap: {
256
+ [existingResource.path]: existingResource,
257
+ } satisfies ResourceMap,
258
+ });
259
+
260
+ expect(result.payload).toEqual([]);
261
+ expect(result.consumedModifiedResourceIds).toEqual([]);
262
+ });
263
+ });