@oxyhq/services 26.1.0 → 27.0.0

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 (59) hide show
  1. package/lib/commonjs/index.js +21 -1
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +14 -6
  4. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
  5. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +16 -3
  6. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/queries/userCache.js +164 -10
  8. package/lib/commonjs/ui/hooks/queries/userCache.js.map +1 -1
  9. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +6 -1
  10. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  11. package/lib/commonjs/ui/screens/CreateAccountScreen.js +92 -46
  12. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  13. package/lib/commonjs/ui/utils/avatarUtils.js +5 -0
  14. package/lib/commonjs/ui/utils/avatarUtils.js.map +1 -1
  15. package/lib/module/index.js +4 -2
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/authChooser/requestSurfaces.js +15 -7
  18. package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
  19. package/lib/module/ui/hooks/mutations/useAccountMutations.js +16 -3
  20. package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  21. package/lib/module/ui/hooks/queries/userCache.js +161 -10
  22. package/lib/module/ui/hooks/queries/userCache.js.map +1 -1
  23. package/lib/module/ui/screens/AccountSettingsScreen.js +6 -1
  24. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  25. package/lib/module/ui/screens/CreateAccountScreen.js +93 -47
  26. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  27. package/lib/module/ui/utils/avatarUtils.js +5 -0
  28. package/lib/module/ui/utils/avatarUtils.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +2 -2
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts +6 -1
  32. package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
  34. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts +79 -2
  36. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/utils/avatarUtils.d.ts.map +1 -1
  40. package/lib/typescript/module/index.d.ts +2 -2
  41. package/lib/typescript/module/index.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts +6 -1
  43. package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
  44. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
  45. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts +79 -2
  47. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  50. package/lib/typescript/module/ui/utils/avatarUtils.d.ts.map +1 -1
  51. package/package.json +4 -4
  52. package/src/index.ts +15 -3
  53. package/src/ui/components/authChooser/requestSurfaces.tsx +12 -4
  54. package/src/ui/hooks/mutations/useAccountMutations.ts +20 -3
  55. package/src/ui/hooks/queries/__tests__/userCacheClear.test.ts +301 -0
  56. package/src/ui/hooks/queries/userCache.ts +211 -10
  57. package/src/ui/screens/AccountSettingsScreen.tsx +9 -1
  58. package/src/ui/screens/CreateAccountScreen.tsx +85 -42
  59. package/src/ui/utils/avatarUtils.ts +9 -0
@@ -208,7 +208,7 @@ export declare const useUpdateAccountSettings: () => {
208
208
  };
209
209
  isManagedAccount?: boolean;
210
210
  managedBy?: string;
211
- organizationCategory?: import("@oxyhq/contracts").OrganizationCategory;
211
+ accountCategories?: import("@oxyhq/contracts").AccountCategoryId[];
212
212
  languages?: string[];
213
213
  notificationPreferences?: NotificationPreferences;
214
214
  userPreferences?: UserPreferences;
@@ -1 +1 @@
1
- {"version":3,"file":"useAccountMutations.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/hooks/mutations/useAccountMutations.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useAccountMutations.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/hooks/mutations/useAccountMutations.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,gBAAgB,EAChB,uBAAuB,EACvB,eAAe,EACf,IAAI,EACJ,eAAe,EAChB,MAAM,aAAa,CAAC;AAoBrB;;GAEG;AACH,eAAO,MAAM,gBAAgB;;;;;;aAioBrB,CAAC;YAAkC,CAAC;mBACtC,CAAF;;aAMc,CAAC;YAAkC,CAAC;mBAE7B,CAAC;;;;;;;;;;;;;;;;;;;;;mBAwBsN,CAAC;UAAgC,CAAC;aAAmC,CAAC;aAAmC,CAAC;;;mBAAuE,CAAC;UAAgC,CAAC;aAAmC,CAAC;aAAmC,CAAC;;;;;;;;;;EAxjB1gB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,eAAe;SAMQ,MAAM;WAAS,MAAM;WAAS,MAAM;WAAS,MAAM;;;EA4EtF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,UAAU,8BAA8B;IACtC,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC;IAClC;;;;OAIG;IACH,WAAW,EAAE,IAAI,CAAC;CACnB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB;sBAoFf,OAAO,CAAC,eAAe,CAAC,KAAG,IAAI;2BAQpB,OAAO,CAAC,eAAe,CAAC,KAAG,OAAO,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;sBARnD,OAAO,CAAC,eAAe,CAAC,KAAG,IAAI;2BAQpB,OAAO,CAAC,eAAe,CAAC,KAAG,OAAO,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;sBARnD,OAAO,CAAC,eAAe,CAAC,KAAG,IAAI;2BAQpB,OAAO,CAAC,eAAe,CAAC,KAAG,OAAO,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;sBARnD,OAAO,CAAC,eAAe,CAAC,KAAG,IAAI;2BAQpB,OAAO,CAAC,eAAe,CAAC,KAAG,OAAO,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;iBA7O9D,CAAC;uBAA6B,CAAC;iBAAuB,CAAC;cAC/C,CAAC;;;qBACK,CAAC;qBAA2B,CAAC;;;;;;;;;;;oBA+BR,CAAC;kBAAwB,CAAC;mBACnD,CAAC;;;mBAEG,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;CAiNzB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,wBAAwB;cAMoB,OAAO,CAAC,eAAe,CAAC;aAAW,MAAM;;;;cAgJjG,CAAC;AAEF,4CAA4C;AAC5C,UAAU,YAAY;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,QAAQ,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;CACxI;AAED,4EAA4E;AAC5E,UAAU,YAAY;IACpB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,KAAK,CAAC,EAAE,YAAY,EAAE,CAAC;IACvB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,eAAO,MAAM,gCAAgC;;EAmD5C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;;EA+CpC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,uFAoBjC,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,aAAa;UAWd,gBAAgB;iBACT,SAAS,GAAG,QAAQ,GAAG,UAAU;eACnC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;iBACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI;WAS5C,CAAC"}
@@ -36,9 +36,37 @@
36
36
  * never overwritten by a degraded/empty one (empty username, the
37
37
  * `'Unknown user'` ghost-author sentinel, `null` avatar).
38
38
  *
39
+ * EXPRESSING A DELIBERATE CLEAR ("remove my picture")
40
+ * ---------------------------------------------------
41
+ * The anti-degradation rule above is right for a sparse source and wrong for a
42
+ * user who just emptied the field — and the two are NOT distinguishable from the
43
+ * payload. Measured against oxy-api's canonical serializer (`formatUserResponse`
44
+ * in `packages/api/src/utils/userTransform.ts`, which passes every field through
45
+ * `typeof value === 'string' ? value : undefined`): an account whose avatar,
46
+ * bio and display name were all just CLEARED serializes to
47
+ * `{"id":…,"publicKey":…,"username":…,"name":{},"languages":[]}` — byte-identical
48
+ * to the same account read as a sparse projection. There is no `null` and no
49
+ * `''` on the wire to key on. The information that a field was deliberately
50
+ * emptied exists ONLY at the call site that performed the write.
51
+ *
52
+ * So the caller declares it: `upsertCachedUser(qc, user, viewerId, { cleared:
53
+ * ['avatar'] })`. For a declared field an incoming EMPTY value means the field
54
+ * IS empty and the stale value is dropped; a MEANINGFUL incoming value still
55
+ * wins as usual (so `{ cleared: ['name.displayName'] }` on a personal account,
56
+ * where clearing the explicit name makes the server return the COMPOSED one,
57
+ * keeps the composed name rather than blanking it).
58
+ *
59
+ * A blanket "this source is authoritative, treat every absent field as cleared"
60
+ * flag was considered and rejected: because the two payloads are byte-identical,
61
+ * such a flag is an unverifiable promise about provenance, and the failure mode
62
+ * of getting it wrong is blanking real identity data in every Oxy app. Naming
63
+ * the fields states something the caller actually observed — which fields the
64
+ * user emptied — and bounds the damage to exactly those.
65
+ *
39
66
  * It is a cache write only — zero network, one `setQueryData` per key.
40
67
  */
41
- import type { UserNameResponse } from "@oxyhq/contracts";
68
+ import type { UserNameResponse, UserProfileUpdate } from "@oxyhq/contracts";
69
+ import type { UpdateAccountInput } from "@oxyhq/core";
42
70
  import type { QueryClient } from "@tanstack/react-query";
43
71
  /**
44
72
  * A user-shaped object that can be upserted into the cache. Intentionally
@@ -76,6 +104,47 @@ export interface CacheableUser {
76
104
  } | null;
77
105
  [key: string]: unknown;
78
106
  }
107
+ /**
108
+ * The profile fields a user can genuinely EMPTY through a real Oxy write, and
109
+ * for which "empty" is a state every renderer already handles.
110
+ *
111
+ * Deliberately a closed list rather than "any field": a clear DELETES data from
112
+ * the cache, so the blast radius of a mistaken declaration is bounded here
113
+ * instead of resting on each call site. `username` is absent because an account
114
+ * always has one; `_count` and `relationship` are absent because they are
115
+ * server-derived, never user-emptied — and dropping a viewer `relationship` is
116
+ * the exact "Follows you vanishes" bug this module exists to prevent.
117
+ *
118
+ * Each entry is clearable through a shipped write path: `avatar`, `bio`,
119
+ * `description` and `name.displayName` via `UserProfileUpdate` (`''` clears) and
120
+ * `UpdateAccountInput` (`null` clears avatar/bio; `accountCategories: []`
121
+ * clears the ordered category list), and `color` via its nullable field.
122
+ */
123
+ export declare const CLEARABLE_USER_FIELDS: readonly ["avatar", "bio", "description", "color", "accountCategories", "name.displayName"];
124
+ /** A field nameable in {@link UpsertCachedUserOptions.cleared}. */
125
+ export type ClearableUserField = (typeof CLEARABLE_USER_FIELDS)[number];
126
+ /**
127
+ * Fields the caller deliberately emptied in a `PUT /users/me` patch. The wire
128
+ * response omits cleared scalars, so the cache needs this list to drop stale
129
+ * values immediately instead of waiting for a refetch that merges the same
130
+ * sparse payload.
131
+ */
132
+ export declare function clearedFieldsFromProfileUpdate(updates: UserProfileUpdate): ClearableUserField[];
133
+ /**
134
+ * Same contract as {@link clearedFieldsFromProfileUpdate} for managed-account
135
+ * `PATCH /accounts/:id` writes (`null` clears avatar/bio/category).
136
+ */
137
+ export declare function clearedFieldsFromAccountUpdate(input: UpdateAccountInput): ClearableUserField[];
138
+ /** Options for {@link upsertCachedUser}. */
139
+ export interface UpsertCachedUserOptions {
140
+ /**
141
+ * Fields the write that produced this user DELIBERATELY emptied. For each,
142
+ * an incoming empty value stops meaning "this source does not carry it" and
143
+ * starts meaning "it is empty" — so the stale value is dropped rather than
144
+ * preserved. Everything not named here keeps the anti-degradation guard.
145
+ */
146
+ cleared?: readonly ClearableUserField[];
147
+ }
79
148
  /**
80
149
  * Merge-upsert a (possibly partial) user into the SDK's user query cache under
81
150
  * both the by-id key and, when a username is present, the viewer-scoped
@@ -85,12 +154,20 @@ export interface CacheableUser {
85
154
  * @param user A `User`-shaped object (may be sparse).
86
155
  * @param viewerId The active viewer id for the by-username key. Defaults to
87
156
  * the current auth-store user id.
157
+ * @param options `cleared` names the fields the write deliberately emptied
158
+ * — the ONLY way "remove my picture" can propagate, since a
159
+ * cleared field and an uncarried one are byte-identical on
160
+ * the wire (see the module docs).
88
161
  */
89
- export declare function upsertCachedUser(queryClient: QueryClient, user: CacheableUser, viewerId?: string): void;
162
+ export declare function upsertCachedUser(queryClient: QueryClient, user: CacheableUser, viewerId?: string, options?: UpsertCachedUserOptions): void;
90
163
  /**
91
164
  * Batch merge-upsert many users at once (for a feed / list / search response).
92
165
  * Resolves the viewer id once and upserts each user cumulatively — a user that
93
166
  * appears twice merges both slices into the single cache entry.
167
+ *
168
+ * Takes NO `cleared`, deliberately: a batch is a multi-user projection, so it is
169
+ * exactly the sparse source the anti-degradation guard exists for, and one
170
+ * declaration could not be true of every user in the array anyway.
94
171
  */
95
172
  export declare function upsertCachedUsers(queryClient: QueryClient, users: readonly CacheableUser[] | null | undefined, viewerId?: string): void;
96
173
  //# sourceMappingURL=userCache.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"userCache.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/hooks/queries/userCache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC7B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAAC;IACjC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,qEAAqE;IACrE,MAAM,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3D;;;;OAIG;IACH,YAAY,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC;IACtE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AA0MD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC/B,WAAW,EAAE,WAAW,EACxB,IAAI,EAAE,aAAa,EACnB,QAAQ,CAAC,EAAE,MAAM,GACf,IAAI,CAoBN;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAChC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,GAAG,SAAS,EAClD,QAAQ,CAAC,EAAE,MAAM,GACf,IAAI,CAMN"}
1
+ {"version":3,"file":"userCache.d.ts","sourceRoot":"","sources":["../../../../../../src/ui/hooks/queries/userCache.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAIzD;;;;;;;GAOG;AACH,MAAM,WAAW,aAAa;IAC7B,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,gBAAgB,CAAC;IACjC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,qEAAqE;IACrE,MAAM,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3D;;;;OAIG;IACH,YAAY,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC;IACtE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAKD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB,6FAOxB,CAAC;AAEX,mEAAmE;AACnE,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,qBAAqB,CAAC,CAAC,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE,iBAAiB,GACxB,kBAAkB,EAAE,CAsBtB;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC7C,KAAK,EAAE,kBAAkB,GACvB,kBAAkB,EAAE,CAuBtB;AAED,4CAA4C;AAC5C,MAAM,WAAW,uBAAuB;IACvC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAC;CACxC;AAgQD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAC/B,WAAW,EAAE,WAAW,EACxB,IAAI,EAAE,aAAa,EACnB,QAAQ,CAAC,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,uBAAuB,GAC/B,IAAI,CAyBN;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAChC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI,GAAG,SAAS,EAClD,QAAQ,CAAC,EAAE,MAAM,GACf,IAAI,CAMN"}
@@ -1 +1 @@
1
- {"version":3,"file":"AccountSettingsScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/AccountSettingsScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAe/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAY3D;;;;;;GAMG;AACH,QAAA,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAyOpD,CAAC;AAEF,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"AccountSettingsScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/AccountSettingsScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAe/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAgB3D;;;;;;GAMG;AACH,QAAA,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CA6OpD,CAAC;AAEF,eAAe,qBAAqB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"CreateAccountScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/CreateAccountScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAM/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AA2F3D;;;;;GAKG;AACH,QAAA,MAAM,mBAAmB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CA+SlD,CAAC;AAEF,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"CreateAccountScreen.d.ts","sourceRoot":"","sources":["../../../../../src/ui/screens/CreateAccountScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAM/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAqB,CAAC;AAmG3D;;;;;GAKG;AACH,QAAA,MAAM,mBAAmB,EAAE,KAAK,CAAC,EAAE,CAAC,eAAe,CAkVlD,CAAC;AAEF,eAAe,mBAAmB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"avatarUtils.d.ts","sourceRoot":"","sources":["../../../../../src/ui/utils/avatarUtils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAGzD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,WAAW,GACvB,IAAI,CAON;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,WAAW,EACxB,eAAe,EAAE,MAAM,GAAG,IAAI,EAC9B,WAAW,EAAE,WAAW,EACxB,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CAoCf"}
1
+ {"version":3,"file":"avatarUtils.d.ts","sourceRoot":"","sources":["../../../../../src/ui/utils/avatarUtils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAOzD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,WAAW,GACvB,IAAI,CAON;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,iBAAiB,EAC1B,WAAW,EAAE,WAAW,EACxB,eAAe,EAAE,MAAM,GAAG,IAAI,EAC9B,WAAW,EAAE,WAAW,EACxB,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CAyCf"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "26.1.0",
3
+ "version": "27.0.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -138,7 +138,7 @@
138
138
  }
139
139
  },
140
140
  "dependencies": {
141
- "@oxyhq/contracts": "0.22.0",
141
+ "@oxyhq/contracts": "0.23.0",
142
142
  "@simplewebauthn/browser": "^13.3.0",
143
143
  "color": "^4.2.3"
144
144
  },
@@ -147,7 +147,7 @@
147
147
  "@commitlint/cli": "^21.2.1",
148
148
  "@commitlint/config-conventional": "^21.2.0",
149
149
  "@expo/vector-icons": "^15.0.3",
150
- "@oxyhq/core": "17.1.0",
150
+ "@oxyhq/core": "19.0.0",
151
151
  "@react-native-async-storage/async-storage": "^2.0.0",
152
152
  "@react-native-community/netinfo": "^11.4.1",
153
153
  "@react-native/babel-preset": "^0.86.0",
@@ -190,7 +190,7 @@
190
190
  "peerDependencies": {
191
191
  "@expo/vector-icons": "^15.0.3",
192
192
  "@oxyhq/bloom": ">=0.59.0",
193
- "@oxyhq/core": "^17.0.3",
193
+ "@oxyhq/core": "^19.0.0",
194
194
  "@react-native-async-storage/async-storage": "^2.0.0",
195
195
  "@react-native-community/netinfo": ">=11.4.1",
196
196
  "@tanstack/query-async-storage-persister": "^5.101",
package/src/index.ts CHANGED
@@ -173,9 +173,21 @@ export {
173
173
  // SDK's user query cache under both keys it owns (by-id + viewer-scoped
174
174
  // by-username). Consumers route ALL cache seeds (feed / list / search / profile
175
175
  // hydration) through this so a sparse source can never strip a field an
176
- // authoritative fetch already stored.
177
- export { upsertCachedUser, upsertCachedUsers } from './ui/hooks/queries/userCache';
178
- export type { CacheableUser } from './ui/hooks/queries/userCache';
176
+ // authoritative fetch already stored. A write that DELIBERATELY empties a field
177
+ // ("remove my picture") says so with `{ cleared: [...] }` the payload cannot
178
+ // express it, see the module docs.
179
+ export {
180
+ upsertCachedUser,
181
+ upsertCachedUsers,
182
+ CLEARABLE_USER_FIELDS,
183
+ clearedFieldsFromProfileUpdate,
184
+ clearedFieldsFromAccountUpdate,
185
+ } from './ui/hooks/queries/userCache';
186
+ export type {
187
+ CacheableUser,
188
+ ClearableUserField,
189
+ UpsertCachedUserOptions,
190
+ } from './ui/hooks/queries/userCache';
179
191
 
180
192
  // Mutation status aggregator (for "Syncing..." indicators)
181
193
  export { useMutationStatus } from './ui/hooks/useMutationStatus';
@@ -13,11 +13,11 @@
13
13
  */
14
14
 
15
15
  import type React from 'react';
16
- import { View } from 'react-native';
16
+ import { ActivityIndicator, View } from 'react-native';
17
17
  import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
18
18
  import QRCode from 'react-native-qrcode-svg';
19
19
  import { Button } from '@oxyhq/bloom/button';
20
- import { Loading } from '@oxyhq/bloom/loading';
20
+ import { useTheme } from '@oxyhq/bloom/theme';
21
21
  import { Text } from '@oxyhq/bloom/typography';
22
22
  import type { CommonsDeliveryRoute, SignInProgress } from '@oxyhq/core';
23
23
  import { authChooserStyles as styles } from './styles';
@@ -30,8 +30,16 @@ const QR_SIZE = 196;
30
30
  /** The leading route glyph, sized to sit where the QR plate otherwise would. */
31
31
  const ROUTE_GLYPH_SIZE = 44;
32
32
 
33
- /** The indeterminate leading visual: the request exists but has nothing to show yet. */
34
- export const PreparingSurface: React.FC = () => <Loading variant="spinner" size="large" />;
33
+ /**
34
+ * The indeterminate leading visual: the request exists but has nothing to show yet.
35
+ * Uses RN's ActivityIndicator — `@oxyhq/bloom/loading` can tree-shake to `undefined`
36
+ * in rolldown-vite production bundles when co-imported with `@oxyhq/bloom/button`
37
+ * (auth.oxy.so/authorize blank screen, React #130).
38
+ */
39
+ export const PreparingSurface: React.FC = () => {
40
+ const theme = useTheme();
41
+ return <ActivityIndicator size="large" color={theme.colors.primary} />;
42
+ };
35
43
 
36
44
  /** The leading glyph for a route whose surface is elsewhere (the phone, Commons). */
37
45
  export const RouteGlyph: React.FC<{
@@ -20,6 +20,11 @@ import { useOxy } from '../../context/OxyContext';
20
20
  import { toast } from '@oxyhq/bloom/toast';
21
21
  import { refreshAvatarInStore } from '../../utils/avatarUtils';
22
22
  import { useAuthStore } from '../../stores/authStore';
23
+ import { useAccountStore } from '../../stores/accountStore';
24
+ import {
25
+ clearedFieldsFromProfileUpdate,
26
+ upsertCachedUser,
27
+ } from '../queries/userCache';
23
28
 
24
29
  /**
25
30
  * Update user profile with optimistic updates and offline queue support
@@ -104,9 +109,21 @@ export const useUpdateProfile = () => {
104
109
  // Update authStore so frontend components see the changes immediately
105
110
  useAuthStore.getState().setUser(data);
106
111
 
107
- // If avatar was updated, refresh accountStore with cache-busted URL
108
- if (updates.avatar && activeSessionId && oxyServices) {
109
- refreshAvatarInStore(activeSessionId, updates.avatar, oxyServices);
112
+ const cleared = clearedFieldsFromProfileUpdate(updates);
113
+ upsertCachedUser(queryClient, data, data.id, {
114
+ cleared: cleared.length > 0 ? cleared : undefined,
115
+ });
116
+
117
+ // Avatar add/remove: keep the account switcher in sync with profile writes
118
+ if (typeof updates.avatar === 'string' && activeSessionId) {
119
+ if (updates.avatar && oxyServices) {
120
+ refreshAvatarInStore(activeSessionId, updates.avatar, oxyServices);
121
+ } else {
122
+ useAccountStore.getState().updateAccount(activeSessionId, {
123
+ avatar: undefined,
124
+ avatarUrl: undefined,
125
+ });
126
+ }
110
127
  }
111
128
 
112
129
  // Invalidate all related queries so every consumer (the account dialog,
@@ -0,0 +1,301 @@
1
+ /**
2
+ * userCache — DELIBERATE CLEARS vs sparse sources.
3
+ *
4
+ * `upsertCachedUser` refuses an empty incoming value so a sparse feed/post/list
5
+ * author can never blank a field the authoritative profile fetch stored. That is
6
+ * right for a projection and wrong for a user who just removed their picture,
7
+ * and the payload cannot tell the two apart: oxy-api's `formatUserResponse`
8
+ * emits a cleared field as `undefined`, which `JSON.stringify` drops, so a
9
+ * cleared avatar and an uncarried one arrive byte-identical. The caller declares
10
+ * the difference with `{ cleared: [...] }`.
11
+ *
12
+ * WHAT MAKES THIS SUITE NON-VACUOUS
13
+ * ---------------------------------
14
+ * Every assertion here is paired: the SAME incoming payload is upserted once
15
+ * WITHOUT `cleared` and once WITH it, and the two must disagree. A suite whose
16
+ * fixtures only ever passed `cleared` could not tell "the guard was removed"
17
+ * from "the clear works", which is the whole risk — dropping the guard would
18
+ * blank real avatars across every Oxy app. The `does not` half of each pair is
19
+ * what pins the guard; `userCache.test.ts` keeps its own anti-degradation
20
+ * coverage independently.
21
+ */
22
+
23
+ import { QueryClient } from '@tanstack/react-query';
24
+ import {
25
+ CLEARABLE_USER_FIELDS,
26
+ clearedFieldsFromAccountUpdate,
27
+ clearedFieldsFromProfileUpdate,
28
+ upsertCachedUser,
29
+ upsertCachedUsers,
30
+ } from '../userCache';
31
+ import type { CacheableUser, ClearableUserField } from '../userCache';
32
+ import { queryKeys } from '../queryKeys';
33
+ import { useAuthStore } from '../../../stores/authStore';
34
+
35
+ function makeClient(): QueryClient {
36
+ return new QueryClient({ defaultOptions: { queries: { retry: false } } });
37
+ }
38
+
39
+ function readById(qc: QueryClient, id: string): CacheableUser | undefined {
40
+ return qc.getQueryData<CacheableUser>(queryKeys.users.detail(id));
41
+ }
42
+
43
+ function readByUsername(
44
+ qc: QueryClient,
45
+ username: string,
46
+ viewerId: string,
47
+ ): CacheableUser | undefined {
48
+ return qc.getQueryData<CacheableUser>(
49
+ queryKeys.users.byUsername(username, viewerId),
50
+ );
51
+ }
52
+
53
+ /** A warm, fully-populated entry — what an authoritative profile fetch stored. */
54
+ function seedFullEntry(qc: QueryClient, viewerId = ''): void {
55
+ const full = {
56
+ id: 'u1',
57
+ username: 'alice',
58
+ name: { displayName: 'Alice A', first: 'Alice' },
59
+ avatar: 'file_old',
60
+ bio: 'old bio',
61
+ description: 'old description',
62
+ color: 'teal',
63
+ accountCategories: ['agency'],
64
+ _count: { followers: 10, following: 5 },
65
+ };
66
+ qc.setQueryData(queryKeys.users.detail('u1'), full);
67
+ qc.setQueryData(queryKeys.users.byUsername('alice', viewerId), {
68
+ ...full,
69
+ relationship: { isFollowing: true, followsYou: true },
70
+ });
71
+ }
72
+
73
+ /**
74
+ * The write response for an account that just emptied everything, in the shape
75
+ * oxy-api's `formatUserResponse` actually produces — MEASURED, not assumed:
76
+ *
77
+ * {"id":"acc1","publicKey":"pk","username":"chan","name":{},"languages":[]}
78
+ *
79
+ * The two details that matter and are easy to get wrong: every cleared scalar is
80
+ * OMITTED (serialized as `undefined`, which `JSON.stringify` drops), while
81
+ * `name` survives as a PRESENT-but-EMPTY object. Writing this fixture as
82
+ * `{ id, username }` instead — with no `name` key — routes `mergeName` down its
83
+ * "incoming absent" branch and leaves the "incoming present, displayName empty"
84
+ * branch untested; mutation-testing caught exactly that. Nothing in this object
85
+ * says "cleared", which is the whole point.
86
+ */
87
+ const clearedResponse: CacheableUser = { id: 'u1', username: 'alice', name: {} };
88
+
89
+ /**
90
+ * The same clear expressed the way a caller echoing its own REQUEST would send
91
+ * it: `UserProfileUpdate` clears a display name with `''`. It must land on the
92
+ * same result as the response shape above.
93
+ */
94
+ const clearedByEmptyString: CacheableUser = {
95
+ id: 'u1',
96
+ username: 'alice',
97
+ name: { displayName: '' },
98
+ };
99
+
100
+ beforeEach(() => {
101
+ useAuthStore.setState({ user: null });
102
+ });
103
+
104
+ describe('upsertCachedUser — a declared clear empties the field', () => {
105
+ it.each(
106
+ CLEARABLE_USER_FIELDS.filter(
107
+ (field): field is Exclude<ClearableUserField, 'name.displayName'> =>
108
+ field !== 'name.displayName',
109
+ ),
110
+ )('drops a stale `%s` only when the caller declares it cleared', (field) => {
111
+ // WITHOUT the declaration: the guard holds, the stored value survives.
112
+ const guarded = makeClient();
113
+ seedFullEntry(guarded);
114
+ upsertCachedUser(guarded, clearedResponse, '');
115
+ expect(readById(guarded, 'u1')?.[field]).toBeDefined();
116
+
117
+ // WITH it: the same payload now empties exactly that field.
118
+ const cleared = makeClient();
119
+ seedFullEntry(cleared);
120
+ upsertCachedUser(cleared, clearedResponse, '', { cleared: [field] });
121
+ expect(readById(cleared, 'u1')?.[field]).toBeUndefined();
122
+ });
123
+
124
+ // Three `name` shapes, because `mergeName` branches on them and a single
125
+ // fixture leaves one branch untested (mutation-verified): the measured
126
+ // response carries `name: {}`, a request echo carries `name: {displayName:''}`,
127
+ // and a caller passing a bare object carries no `name` key at all.
128
+ it.each([
129
+ ['present but empty (the measured oxy-api shape)', clearedResponse],
130
+ ['an explicit empty displayName (the request shape)', clearedByEmptyString],
131
+ ['no `name` key at all', { id: 'u1', username: 'alice' } as CacheableUser],
132
+ ])(
133
+ 'drops a stale display name only when declared — incoming name %s',
134
+ (_label, incoming) => {
135
+ const guarded = makeClient();
136
+ seedFullEntry(guarded);
137
+ upsertCachedUser(guarded, incoming, '');
138
+ expect(readById(guarded, 'u1')?.name).toEqual({
139
+ displayName: 'Alice A',
140
+ first: 'Alice',
141
+ });
142
+
143
+ const cleared = makeClient();
144
+ seedFullEntry(cleared);
145
+ upsertCachedUser(cleared, incoming, '', {
146
+ cleared: ['name.displayName'],
147
+ });
148
+ // `first` is not what was cleared and must survive — a clear is per-field,
149
+ // not "drop the whole `name` object".
150
+ expect(readById(cleared, 'u1')?.name).toEqual({ first: 'Alice' });
151
+ },
152
+ );
153
+
154
+ it('never lets the "Unknown user" sentinel survive a declared display-name clear', () => {
155
+ // The ghost-author sentinel is not a real value, so it must not block the
156
+ // clear either — it is "empty" for this purpose, exactly as it is for the
157
+ // anti-degradation guard.
158
+ const qc = makeClient();
159
+ seedFullEntry(qc);
160
+ upsertCachedUser(
161
+ qc,
162
+ { id: 'u1', username: 'alice', name: { displayName: 'Unknown user' } },
163
+ '',
164
+ { cleared: ['name.displayName'] },
165
+ );
166
+ expect(readById(qc, 'u1')?.name).toEqual({ first: 'Alice' });
167
+ });
168
+
169
+ it('clears under the by-username key too, not just by-id', () => {
170
+ const qc = makeClient();
171
+ seedFullEntry(qc, 'viewer-1');
172
+ upsertCachedUser(qc, clearedResponse, 'viewer-1', { cleared: ['avatar'] });
173
+
174
+ expect(readByUsername(qc, 'alice', 'viewer-1')?.avatar).toBeUndefined();
175
+ expect(readById(qc, 'u1')?.avatar).toBeUndefined();
176
+ });
177
+
178
+ it('clears ONLY the declared fields, leaving every other one intact', () => {
179
+ const qc = makeClient();
180
+ seedFullEntry(qc);
181
+ upsertCachedUser(qc, clearedResponse, '', { cleared: ['avatar'] });
182
+
183
+ const entry = readById(qc, 'u1');
184
+ expect(entry?.avatar).toBeUndefined();
185
+ // Vacuity floor: if a clear were implemented as "drop everything absent
186
+ // from the payload", all of these would be gone too and the test above
187
+ // would still pass.
188
+ expect(entry?.bio).toBe('old bio');
189
+ expect(entry?.description).toBe('old description');
190
+ expect(entry?.color).toBe('teal');
191
+ expect(entry?.name).toEqual({ displayName: 'Alice A', first: 'Alice' });
192
+ expect(entry?._count).toEqual({ followers: 10, following: 5 });
193
+ });
194
+
195
+ it('never drops the viewer relationship, whatever is declared', () => {
196
+ // `relationship` is not declarable (it is absent from CLEARABLE_USER_FIELDS)
197
+ // and is server-derived, never user-emptied. Dropping it is the
198
+ // "Follows you tag vanishes" bug this module exists to prevent.
199
+ const qc = makeClient();
200
+ seedFullEntry(qc, 'viewer-1');
201
+ upsertCachedUser(qc, clearedResponse, 'viewer-1', {
202
+ cleared: [...CLEARABLE_USER_FIELDS],
203
+ });
204
+
205
+ expect(readByUsername(qc, 'alice', 'viewer-1')?.relationship).toEqual({
206
+ isFollowing: true,
207
+ followsYou: true,
208
+ });
209
+ expect(readByUsername(qc, 'alice', 'viewer-1')?.username).toBe('alice');
210
+ });
211
+ });
212
+
213
+ describe('upsertCachedUser — a declared clear never beats a real value', () => {
214
+ it('keeps a meaningful incoming value for a field declared cleared', () => {
215
+ // The server-COMPOSED display name case: clearing the EXPLICIT name makes
216
+ // oxy-api return the composed one, which must be stored rather than
217
+ // blanked. Same rule for every field: `cleared` lowers the guard, it does
218
+ // not force an erasure.
219
+ const qc = makeClient();
220
+ seedFullEntry(qc);
221
+
222
+ upsertCachedUser(
223
+ qc,
224
+ {
225
+ id: 'u1',
226
+ username: 'alice',
227
+ name: { displayName: 'Alice Anderson' },
228
+ avatar: 'file_new',
229
+ },
230
+ '',
231
+ { cleared: ['name.displayName', 'avatar'] },
232
+ );
233
+
234
+ expect(readById(qc, 'u1')?.name).toEqual({
235
+ displayName: 'Alice Anderson',
236
+ first: 'Alice',
237
+ });
238
+ expect(readById(qc, 'u1')?.avatar).toBe('file_new');
239
+ });
240
+
241
+ it('treats an explicit null and an empty string as cleared, not as values', () => {
242
+ // oxy-api omits a cleared field, but `UpdateAccountInput` clears with `null`
243
+ // and `UserProfileUpdate` clears with `''`, so a caller echoing its own
244
+ // request must land on the same result as one passing the response.
245
+ for (const empty of [null, '', ' ']) {
246
+ const qc = makeClient();
247
+ seedFullEntry(qc);
248
+ upsertCachedUser(qc, { id: 'u1', username: 'alice', avatar: empty }, '', {
249
+ cleared: ['avatar'],
250
+ });
251
+ expect(readById(qc, 'u1')?.avatar).toBeUndefined();
252
+ }
253
+ });
254
+
255
+ it('is a no-op on a cold slot (nothing stale to drop)', () => {
256
+ const qc = makeClient();
257
+ upsertCachedUser(qc, clearedResponse, '', { cleared: ['avatar'] });
258
+
259
+ expect(readById(qc, 'u1')).toMatchObject({ id: 'u1', username: 'alice' });
260
+ expect(readById(qc, 'u1')?.avatar).toBeUndefined();
261
+ // The cold-slot stale-seed contract still holds.
262
+ expect(qc.getQueryState(queryKeys.users.detail('u1'))?.dataUpdatedAt).toBe(0);
263
+ });
264
+ });
265
+
266
+ describe('upsertCachedUsers — the batch path stays guarded', () => {
267
+ it('takes no clear declaration and never empties a field', () => {
268
+ // A batch is a multi-user projection: exactly the sparse source the guard
269
+ // exists for. If it ever grows a `cleared` option this test should fail.
270
+ const qc = makeClient();
271
+ seedFullEntry(qc);
272
+
273
+ upsertCachedUsers(qc, [{ id: 'u1', username: 'alice', avatar: null }], '');
274
+
275
+ expect(readById(qc, 'u1')?.avatar).toBe('file_old');
276
+ });
277
+ });
278
+
279
+ describe('clearedFieldsFromProfileUpdate', () => {
280
+ it('names only fields the patch deliberately emptied', () => {
281
+ expect(
282
+ clearedFieldsFromProfileUpdate({ avatar: '', bio: '', name: { displayName: '' } }),
283
+ ).toEqual(['avatar', 'bio', 'name.displayName']);
284
+ expect(clearedFieldsFromProfileUpdate({ avatar: 'file_new', bio: 'hello' })).toEqual([]);
285
+ expect(clearedFieldsFromProfileUpdate({ color: null })).toEqual(['color']);
286
+ });
287
+ });
288
+
289
+ describe('clearedFieldsFromAccountUpdate', () => {
290
+ it('treats null clears on managed accounts like profile clears', () => {
291
+ expect(
292
+ clearedFieldsFromAccountUpdate({
293
+ avatar: null,
294
+ bio: null,
295
+ accountCategories: [],
296
+ name: { displayName: '' },
297
+ }),
298
+ ).toEqual(['avatar', 'bio', 'accountCategories', 'name.displayName']);
299
+ expect(clearedFieldsFromAccountUpdate({ bio: 'still here' })).toEqual([]);
300
+ });
301
+ });