@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
@@ -36,10 +36,38 @@
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
68
 
42
- import type { UserNameResponse } from "@oxyhq/contracts";
69
+ import type { UserNameResponse, UserProfileUpdate } from "@oxyhq/contracts";
70
+ import type { UpdateAccountInput } from "@oxyhq/core";
43
71
  import type { QueryClient } from "@tanstack/react-query";
44
72
  import { useAuthStore } from "../../stores/authStore";
45
73
  import { queryKeys } from "./queryKeys";
@@ -78,6 +106,108 @@ export interface CacheableUser {
78
106
  /** The degraded display-name sentinel (ghost-author rule). */
79
107
  const DEGRADED_DISPLAY_NAME = "Unknown user";
80
108
 
109
+ /**
110
+ * The profile fields a user can genuinely EMPTY through a real Oxy write, and
111
+ * for which "empty" is a state every renderer already handles.
112
+ *
113
+ * Deliberately a closed list rather than "any field": a clear DELETES data from
114
+ * the cache, so the blast radius of a mistaken declaration is bounded here
115
+ * instead of resting on each call site. `username` is absent because an account
116
+ * always has one; `_count` and `relationship` are absent because they are
117
+ * server-derived, never user-emptied — and dropping a viewer `relationship` is
118
+ * the exact "Follows you vanishes" bug this module exists to prevent.
119
+ *
120
+ * Each entry is clearable through a shipped write path: `avatar`, `bio`,
121
+ * `description` and `name.displayName` via `UserProfileUpdate` (`''` clears) and
122
+ * `UpdateAccountInput` (`null` clears avatar/bio; `accountCategories: []`
123
+ * clears the ordered category list), and `color` via its nullable field.
124
+ */
125
+ export const CLEARABLE_USER_FIELDS = [
126
+ "avatar",
127
+ "bio",
128
+ "description",
129
+ "color",
130
+ "accountCategories",
131
+ "name.displayName",
132
+ ] as const;
133
+
134
+ /** A field nameable in {@link UpsertCachedUserOptions.cleared}. */
135
+ export type ClearableUserField = (typeof CLEARABLE_USER_FIELDS)[number];
136
+
137
+ /**
138
+ * Fields the caller deliberately emptied in a `PUT /users/me` patch. The wire
139
+ * response omits cleared scalars, so the cache needs this list to drop stale
140
+ * values immediately instead of waiting for a refetch that merges the same
141
+ * sparse payload.
142
+ */
143
+ export function clearedFieldsFromProfileUpdate(
144
+ updates: UserProfileUpdate,
145
+ ): ClearableUserField[] {
146
+ const cleared: ClearableUserField[] = [];
147
+ if ("avatar" in updates && !isMeaningful(updates.avatar)) {
148
+ cleared.push("avatar");
149
+ }
150
+ if ("bio" in updates && !isMeaningful(updates.bio)) {
151
+ cleared.push("bio");
152
+ }
153
+ if ("description" in updates && !isMeaningful(updates.description)) {
154
+ cleared.push("description");
155
+ }
156
+ if ("color" in updates && updates.color === null) {
157
+ cleared.push("color");
158
+ }
159
+ if (
160
+ updates.name !== undefined &&
161
+ "displayName" in updates.name &&
162
+ !isMeaningful(updates.name.displayName)
163
+ ) {
164
+ cleared.push("name.displayName");
165
+ }
166
+ return cleared;
167
+ }
168
+
169
+ /**
170
+ * Same contract as {@link clearedFieldsFromProfileUpdate} for managed-account
171
+ * `PATCH /accounts/:id` writes (`null` clears avatar/bio/category).
172
+ */
173
+ export function clearedFieldsFromAccountUpdate(
174
+ input: UpdateAccountInput,
175
+ ): ClearableUserField[] {
176
+ const cleared: ClearableUserField[] = [];
177
+ if ("avatar" in input && !isMeaningful(input.avatar)) {
178
+ cleared.push("avatar");
179
+ }
180
+ if ("bio" in input && !isMeaningful(input.bio)) {
181
+ cleared.push("bio");
182
+ }
183
+ if (
184
+ "accountCategories" in input &&
185
+ Array.isArray(input.accountCategories) &&
186
+ input.accountCategories.length === 0
187
+ ) {
188
+ cleared.push("accountCategories");
189
+ }
190
+ if (
191
+ input.name !== undefined &&
192
+ "displayName" in input.name &&
193
+ !isMeaningful(input.name.displayName)
194
+ ) {
195
+ cleared.push("name.displayName");
196
+ }
197
+ return cleared;
198
+ }
199
+
200
+ /** Options for {@link upsertCachedUser}. */
201
+ export interface UpsertCachedUserOptions {
202
+ /**
203
+ * Fields the write that produced this user DELIBERATELY emptied. For each,
204
+ * an incoming empty value stops meaning "this source does not carry it" and
205
+ * starts meaning "it is empty" — so the stale value is dropped rather than
206
+ * preserved. Everything not named here keeps the anti-degradation guard.
207
+ */
208
+ cleared?: readonly ClearableUserField[];
209
+ }
210
+
81
211
  /** A cache entry always carries a resolved string `id`. */
82
212
  type CachedUser = CacheableUser & { id: string; name?: UserNameResponse };
83
213
 
@@ -142,23 +272,55 @@ function normalizeIncoming(user: CacheableUser): CachedUser | null {
142
272
  return cached.id ? cached : null;
143
273
  }
144
274
 
145
- /** Merge two `name` objects field-by-field, with anti-degradation on `displayName`. */
275
+ /**
276
+ * Copy a name WITHOUT its `displayName`. The key must end up ABSENT rather than
277
+ * present-and-`undefined`: consumers render `name.displayName` directly and fall
278
+ * back to the handle when it is missing, and a present-but-undefined key also
279
+ * changes what a later merge sees.
280
+ */
281
+ function omitDisplayName(name: UserNameResponse): UserNameResponse {
282
+ const result: UserNameResponse = {};
283
+ for (const [key, value] of Object.entries(name)) {
284
+ if (key !== "displayName") result[key] = value;
285
+ }
286
+ return result;
287
+ }
288
+
289
+ /**
290
+ * Merge two `name` objects field-by-field, with anti-degradation on
291
+ * `displayName`.
292
+ *
293
+ * `clearDisplayName` is the declared-clear escape hatch: the incoming value
294
+ * still wins whenever it is meaningful (an account that clears its explicit
295
+ * display name gets the server-COMPOSED one back, which must not be discarded),
296
+ * and only a genuinely empty incoming value drops the stored one.
297
+ *
298
+ * Both incoming shapes reach the clear, and they are separate branches: the
299
+ * measured oxy-api response carries `name` as a PRESENT-but-empty object, while
300
+ * a caller passing a bare user object may carry no `name` key at all.
301
+ */
146
302
  function mergeName(
147
303
  existing: UserNameResponse | undefined,
148
304
  incoming: UserNameResponse | undefined,
305
+ clearDisplayName: boolean,
149
306
  ): UserNameResponse | undefined {
150
- if (incoming === undefined) return existing;
307
+ if (incoming === undefined) {
308
+ if (!clearDisplayName || existing === undefined) return existing;
309
+ return omitDisplayName(existing);
310
+ }
151
311
  if (existing === undefined) return incoming;
152
312
  const merged: UserNameResponse = { ...existing };
153
313
  for (const [key, value] of Object.entries(incoming)) {
154
314
  if (key === "displayName") continue;
155
315
  if (isMeaningful(value)) merged[key] = value;
156
316
  }
157
- // Never let an empty / `'Unknown user'` displayName overwrite a real one.
317
+ // Never let an empty / `'Unknown user'` displayName overwrite a real one
318
+ // unless the caller declared that the user cleared it.
158
319
  if (isMeaningfulDisplayName(incoming.displayName)) {
159
320
  merged.displayName = incoming.displayName;
321
+ return merged;
160
322
  }
161
- return merged;
323
+ return clearDisplayName ? omitDisplayName(merged) : merged;
162
324
  }
163
325
 
164
326
  /** Merge `_count` field-by-field so a partial count never replaces a fuller one. */
@@ -203,13 +365,22 @@ function mergeRelationship(
203
365
  * When `includeRelationship` is false (the viewer-independent by-id key), the
204
366
  * viewer-relative `relationship` field is never read, written, or preserved —
205
367
  * only the by-username key carries it (`useUserByUsername`).
368
+ *
369
+ * `cleared` names the fields the write deliberately emptied. It is applied
370
+ * AFTER the merge, because the merge loop can only ever COPY a meaningful value
371
+ * — an emptied field is absent from `incoming` (see the module docs: oxy-api
372
+ * omits it entirely) and would otherwise survive from `existing` untouched.
206
373
  */
207
374
  function mergeUsers(
208
375
  existing: CachedUser,
209
376
  incoming: CachedUser,
210
- options?: { includeRelationship?: boolean },
377
+ options?: {
378
+ includeRelationship?: boolean;
379
+ cleared?: readonly ClearableUserField[];
380
+ },
211
381
  ): CachedUser {
212
382
  const includeRelationship = options?.includeRelationship ?? true;
383
+ const cleared = options?.cleared;
213
384
  const merged: CachedUser = { ...existing };
214
385
  for (const [key, value] of Object.entries(incoming)) {
215
386
  if (key === "name" || key === "_count" || key === "relationship") continue;
@@ -219,7 +390,11 @@ function mergeUsers(
219
390
  }
220
391
  if (isMeaningful(value)) merged[key] = value;
221
392
  }
222
- const name = mergeName(existing.name, incoming.name);
393
+ const name = mergeName(
394
+ existing.name,
395
+ incoming.name,
396
+ cleared?.includes("name.displayName") ?? false,
397
+ );
223
398
  if (name !== undefined) merged.name = name;
224
399
  const count = mergeCount(existing._count, incoming._count);
225
400
  if (count !== undefined) merged._count = count;
@@ -232,6 +407,12 @@ function mergeUsers(
232
407
  } else {
233
408
  merged.relationship = undefined;
234
409
  }
410
+ if (cleared) {
411
+ for (const field of cleared) {
412
+ if (field === "name.displayName") continue; // handled by `mergeName`.
413
+ if (!isMeaningful(incoming[field])) delete merged[field];
414
+ }
415
+ }
235
416
  return merged;
236
417
  }
237
418
 
@@ -240,9 +421,15 @@ function upsertOneKey(
240
421
  queryClient: QueryClient,
241
422
  key: readonly unknown[],
242
423
  incoming: CachedUser,
243
- options: { includeRelationship: boolean },
424
+ options: {
425
+ includeRelationship: boolean;
426
+ cleared?: readonly ClearableUserField[];
427
+ },
244
428
  ): void {
245
- const mergeOpts = { includeRelationship: options.includeRelationship };
429
+ const mergeOpts = {
430
+ includeRelationship: options.includeRelationship,
431
+ cleared: options.cleared,
432
+ };
246
433
  const existing = queryClient.getQueryData<CacheableUser>(key);
247
434
  if (existing === undefined) {
248
435
  // Cold slot: seed the full incoming object, STALE, so react-query refetches
@@ -284,20 +471,27 @@ function resolveViewerId(viewerId?: string): string {
284
471
  * @param user A `User`-shaped object (may be sparse).
285
472
  * @param viewerId The active viewer id for the by-username key. Defaults to
286
473
  * the current auth-store user id.
474
+ * @param options `cleared` names the fields the write deliberately emptied
475
+ * — the ONLY way "remove my picture" can propagate, since a
476
+ * cleared field and an uncarried one are byte-identical on
477
+ * the wire (see the module docs).
287
478
  */
288
479
  export function upsertCachedUser(
289
480
  queryClient: QueryClient,
290
481
  user: CacheableUser,
291
482
  viewerId?: string,
483
+ options?: UpsertCachedUserOptions,
292
484
  ): void {
293
485
  const incoming = normalizeIncoming(user);
294
486
  if (!incoming) return;
487
+ const cleared = options?.cleared;
295
488
 
296
489
  // By-id identity entry (read by `useUserById`). Not viewer-scoped — never store
297
490
  // the viewer-relative `relationship` here or one viewer's follow state leaks
298
491
  // into every other viewer's by-id cache entry.
299
492
  upsertOneKey(queryClient, queryKeys.users.detail(incoming.id), incoming, {
300
493
  includeRelationship: false,
494
+ cleared,
301
495
  });
302
496
 
303
497
  const username = incoming.username;
@@ -307,7 +501,10 @@ export function upsertCachedUser(
307
501
  // the key through the SAME helper the hook uses so username normalization
308
502
  // (`trim().toLowerCase()`) matches byte-for-byte.
309
503
  const key = queryKeys.users.byUsername(username, resolveViewerId(viewerId));
310
- upsertOneKey(queryClient, key, incoming, { includeRelationship: true });
504
+ upsertOneKey(queryClient, key, incoming, {
505
+ includeRelationship: true,
506
+ cleared,
507
+ });
311
508
  }
312
509
  }
313
510
 
@@ -315,6 +512,10 @@ export function upsertCachedUser(
315
512
  * Batch merge-upsert many users at once (for a feed / list / search response).
316
513
  * Resolves the viewer id once and upserts each user cumulatively — a user that
317
514
  * appears twice merges both slices into the single cache entry.
515
+ *
516
+ * Takes NO `cleared`, deliberately: a batch is a multi-user projection, so it is
517
+ * exactly the sparse source the anti-degradation guard exists for, and one
518
+ * declaration could not be true of every user in the array anyway.
318
519
  */
319
520
  export function upsertCachedUsers(
320
521
  queryClient: QueryClient,
@@ -18,6 +18,10 @@ import { SettingsIcon } from '../components/SettingsIcon';
18
18
  import { useOxy } from '../context/OxyContext';
19
19
  import { useI18n } from '../hooks/useI18n';
20
20
  import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
21
+ import {
22
+ clearedFieldsFromAccountUpdate,
23
+ upsertCachedUser,
24
+ } from '../hooks/queries/userCache';
21
25
 
22
26
  const DISPLAY_NAME_MAX = MAX_DISPLAY_NAME_LENGTH;
23
27
  const BIO_MAX = 160;
@@ -78,7 +82,11 @@ const AccountSettingsScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, nav
78
82
  const updateMutation = useMutation({
79
83
  mutationKey: ['accounts', 'update', id],
80
84
  mutationFn: (input: UpdateAccountInput) => oxyServices.updateAccount(id, input),
81
- onSuccess: () => {
85
+ onSuccess: (updatedNode, input) => {
86
+ const cleared = clearedFieldsFromAccountUpdate(input);
87
+ upsertCachedUser(queryClient, updatedNode.account, user?.id, {
88
+ cleared: cleared.length > 0 ? cleared : undefined,
89
+ });
82
90
  queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
83
91
  queryClient.invalidateQueries({ queryKey: ['accounts'] });
84
92
  toast.success(t('accounts.settings.toasts.saved') || 'Account updated');
@@ -2,8 +2,8 @@ import type React from 'react';
2
2
  import { useState, useCallback, useRef, useEffect } from 'react';
3
3
  import { View, ActivityIndicator } from 'react-native';
4
4
  import Ionicons from '@expo/vector-icons/Ionicons';
5
- import type { AccountKind, CreateAccountInput, OrganizationCategory } from '@oxyhq/core';
6
- import { DISPLAY_NAME_INVALID_MESSAGE, isValidDisplayName, MAX_DISPLAY_NAME_LENGTH, ORGANIZATION_CATEGORIES } from '@oxyhq/core';
5
+ import type { AccountCategoryId, AccountKind, CreateAccountInput } from '@oxyhq/core';
6
+ import { DISPLAY_NAME_INVALID_MESSAGE, isValidDisplayName, MAX_ACCOUNT_CATEGORIES, MAX_DISPLAY_NAME_LENGTH, SELECTABLE_ACCOUNT_CATEGORY_IDS } from '@oxyhq/core';
7
7
  import type { BaseScreenProps } from '../types/navigation';
8
8
  import { useI18n } from '../hooks/useI18n';
9
9
  import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
@@ -21,12 +21,18 @@ type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
21
21
  * Kind of account this screen can create.
22
22
  *
23
23
  * A strict subset of what `POST /accounts` accepts, not `Exclude<AccountKind,
24
- * 'personal'>`: this screen SWITCHES INTO the account it just created, so it can
25
- * only offer kinds an operator may act as. `personal` is a signup-minted root,
26
- * and a `channel` is a content identity nobody occupies — creating one is
27
- * Mention's job, through the API. Spelling the subset out here is what keeps a
28
- * newly-added kind from silently inheriting the `project` label in
29
- * {@link kindLabel}'s fallback.
24
+ * 'personal'>`: this screen CREATES AND ENTERS in one gesture, so it can only
25
+ * offer kinds an operator may act as `isActAsEligibleKind` is the same
26
+ * predicate the server enforces on `POST /accounts/:id/switch`.
27
+ *
28
+ * So `channel` is absent here even though `POST /accounts` accepts it from any
29
+ * signed-in caller: a channel is a content identity nobody occupies, and
30
+ * offering it would create the account and then fail the switch. The reason is
31
+ * this screen's own shape, NOT a rule about who may create a channel — that rule
32
+ * changed once already, and a comment tied to it would now be false.
33
+ *
34
+ * Spelling the subset out here is what keeps a newly-added kind from silently
35
+ * inheriting the `project` label in {@link kindLabel}'s fallback.
30
36
  */
31
37
  type CreatableAccountKind = Extract<AccountKind, 'organization' | 'project' | 'bot'>;
32
38
 
@@ -77,23 +83,25 @@ const kindDescription = (
77
83
  }
78
84
  };
79
85
 
80
- const organizationCategoryLabel = (
86
+ /**
87
+ * The visible label for a category id.
88
+ *
89
+ * A lookup, deliberately not a `switch` with a `default`: a `default` would let
90
+ * an id with no translation render as some OTHER category's label, silently and
91
+ * only in the languages that are missing it. Falling back to the raw id is ugly
92
+ * and correct — it is visibly wrong, and it names the missing key.
93
+ */
94
+ const accountCategoryLabel = (
81
95
  t: (key: string, vars?: Record<string, string | number>) => string,
82
- category: OrganizationCategory,
83
- ): string => {
84
- switch (category) {
85
- case 'agency':
86
- return t('accounts.organizationCategory.agency');
87
- case 'cooperative':
88
- return t('accounts.organizationCategory.cooperative');
89
- case 'landlord':
90
- return t('accounts.organizationCategory.landlord');
91
- default:
92
- return t('accounts.organizationCategory.other');
93
- }
94
- };
96
+ category: AccountCategoryId,
97
+ ): string => t(`accounts.accountCategory.${category}`) || category;
95
98
 
96
- const ORGANIZATION_CATEGORY_OPTIONS: OrganizationCategory[] = [...ORGANIZATION_CATEGORIES];
99
+ /**
100
+ * Only the SELECTABLE ids are offered. The full `ACCOUNT_CATEGORY_IDS` still
101
+ * contains withdrawn ones so that accounts already carrying them keep working —
102
+ * offering them here would be how an account newly acquires one.
103
+ */
104
+ const ACCOUNT_CATEGORY_OPTIONS: readonly AccountCategoryId[] = SELECTABLE_ACCOUNT_CATEGORY_IDS;
97
105
 
98
106
  /**
99
107
  * Create a new account in the unified account graph (an organization, project,
@@ -119,7 +127,12 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
119
127
  const parentId = typeof parentAccountId === 'string' ? parentAccountId : undefined;
120
128
 
121
129
  const [kind, setKind] = useState<CreatableAccountKind>('project');
122
- const [organizationCategory, setOrganizationCategory] = useState<OrganizationCategory>('agency');
130
+ /**
131
+ * ORDER IS THE DATA: index 0 is the primary category. Selecting appends,
132
+ * de-selecting removes, and neither sorts — so the list the user assembles is
133
+ * the list that is sent, and the first one they picked stays the primary.
134
+ */
135
+ const [accountCategories, setAccountCategories] = useState<AccountCategoryId[]>([]);
123
136
  const [username, setUsername] = useState('');
124
137
  const [displayName, setDisplayName] = useState('');
125
138
  const [displayNameError, setDisplayNameError] = useState('');
@@ -209,6 +222,23 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
209
222
  );
210
223
  }, [t]);
211
224
 
225
+ /**
226
+ * Append on select, splice out on de-select. Never sorts — appending is what
227
+ * makes the FIRST category the user chose the primary one, and a sort would
228
+ * silently reassign that. De-selecting the primary promotes whatever the user
229
+ * picked next, which is the only interpretation that does not invent a choice
230
+ * on their behalf.
231
+ */
232
+ const toggleAccountCategory = useCallback((category: AccountCategoryId) => {
233
+ setAccountCategories((current) => {
234
+ if (current.includes(category)) {
235
+ return current.filter((entry) => entry !== category);
236
+ }
237
+ if (current.length >= MAX_ACCOUNT_CATEGORIES) return current;
238
+ return [...current, category];
239
+ });
240
+ }, []);
241
+
212
242
  const canCreate = usernameStatus === 'available'
213
243
  && displayName.trim().length > 0
214
244
  && !displayNameError
@@ -219,17 +249,14 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
219
249
 
220
250
  setIsCreating(true);
221
251
  try {
222
- // Split display name into first/last
223
- const nameParts = displayName.trim().split(/\s+/);
224
- const firstName = nameParts[0] || '';
225
- const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
226
-
227
252
  const input: CreateAccountInput = {
228
253
  kind,
229
254
  username,
230
- name: { first: firstName, last: lastName },
255
+ name: { displayName: displayName.trim() },
231
256
  bio: bio.trim() || undefined,
232
- ...(kind === 'organization' ? { organizationCategory } : null),
257
+ // Sent in the user's own order, or omitted entirely when empty — the
258
+ // API distinguishes "no categories" from "not stated" only by absence.
259
+ ...(accountCategories.length > 0 ? { accountCategories } : null),
233
260
  ...(parentId ? { parentAccountId: parentId } : null),
234
261
  };
235
262
  const account = await createAccount(input);
@@ -239,6 +266,13 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
239
266
  // Switch INTO the new account (real-session switch — the whole app becomes
240
267
  // it). Best-effort: creation already succeeded, so a switch hiccup should
241
268
  // not surface as a create failure.
269
+ //
270
+ // That trade holds for a TRANSIENT failure and only for one. A kind the
271
+ // server refuses outright fails DETERMINISTICALLY — `/switch` answers 403
272
+ // every time, never sometimes — and this swallow would turn it into a
273
+ // created account, no switch, and no error anywhere. Which is why
274
+ // `CreatableAccountKind` above is a subset of what `POST /accounts`
275
+ // accepts rather than of what it rejects.
242
276
  if (account.accountId) {
243
277
  await switchToAccount(account.accountId).catch(() => undefined);
244
278
  }
@@ -252,7 +286,7 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
252
286
  } finally {
253
287
  setIsCreating(false);
254
288
  }
255
- }, [canCreate, kind, organizationCategory, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
289
+ }, [canCreate, kind, accountCategories, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
256
290
 
257
291
  // Status icon + color shown alongside the username field message
258
292
  const usernameIsInvalid = usernameStatus === 'taken' || usernameStatus === 'invalid';
@@ -294,26 +328,35 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
294
328
  })}
295
329
  </SettingsListGroup>
296
330
 
297
- {/* Organization category grouped selection rows, shown only for organizations */}
298
- {kind === 'organization' ? (
299
- <SettingsListGroup title={t('accounts.create.organizationCategory.label')}>
300
- {ORGANIZATION_CATEGORY_OPTIONS.map((option) => {
301
- const selected = option === organizationCategory;
331
+ {/* Categoriesmulti-select, shown for every kind this screen can create
332
+ (they are all non-personal). The badge on a selected row is its
333
+ POSITION, so the primary is legible as "1" rather than being a rule the
334
+ user has to be told. */}
335
+ <View className="gap-space-4">
336
+ <SettingsListGroup title={t('accounts.create.accountCategory.label')}>
337
+ {ACCOUNT_CATEGORY_OPTIONS.map((option) => {
338
+ const position = accountCategories.indexOf(option);
339
+ const selected = position >= 0;
340
+ const label = accountCategoryLabel(t, option);
302
341
  return (
303
342
  <SettingsListItem
304
343
  key={option}
305
- title={organizationCategoryLabel(t, option)}
306
- onPress={() => setOrganizationCategory(option)}
344
+ title={label}
345
+ disabled={!selected && accountCategories.length >= MAX_ACCOUNT_CATEGORIES}
346
+ onPress={() => toggleAccountCategory(option)}
307
347
  showChevron={false}
308
348
  rightElement={selected ? (
309
- <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
349
+ <Text className="text-caption-1 font-semibold text-primary">{position + 1}</Text>
310
350
  ) : undefined}
311
- accessibilityLabel={organizationCategoryLabel(t, option)}
351
+ accessibilityLabel={label}
312
352
  />
313
353
  );
314
354
  })}
315
355
  </SettingsListGroup>
316
- ) : null}
356
+ <Text className="text-caption font-caption text-text-tertiary px-space-4">
357
+ {t('accounts.create.accountCategory.hint', { max: MAX_ACCOUNT_CATEGORIES })}
358
+ </Text>
359
+ </View>
317
360
 
318
361
  {/* Details — a grouped section card hosting the form fields */}
319
362
  <SettingsListGroup title={t('accounts.create.detailsSection') || 'Details'}>
@@ -5,6 +5,10 @@ import { useAccountStore } from '../stores/accountStore';
5
5
  import { useAuthStore } from '../stores/authStore';
6
6
  import type { QueryClient } from '@tanstack/react-query';
7
7
  import { queryKeys, invalidateUserQueries, invalidateAccountQueries } from '../hooks/queries/queryKeys';
8
+ import {
9
+ clearedFieldsFromProfileUpdate,
10
+ upsertCachedUser,
11
+ } from '../hooks/queries/userCache';
8
12
 
9
13
  /**
10
14
  * Refreshes avatar in accountStore with cache-busted URL to force image reload.
@@ -60,6 +64,11 @@ export async function updateProfileWithAvatar(
60
64
  // Update authStore so frontend components see the changes immediately
61
65
  useAuthStore.getState().setUser(data);
62
66
 
67
+ const cleared = clearedFieldsFromProfileUpdate(updates);
68
+ upsertCachedUser(queryClient, data, data.id, {
69
+ cleared: cleared.length > 0 ? cleared : undefined,
70
+ });
71
+
63
72
  // If avatar was updated, refresh accountStore with a cache-busted URL. An
64
73
  // EMPTY avatar is a removal: clear both fields so the switcher falls back to
65
74
  // initials instead of pointing at a download URL for the empty file id.