@oxyhq/services 26.2.0 → 27.1.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 (109) hide show
  1. package/lib/commonjs/index.js +50 -1
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +3 -5
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/FollowTargetButton.js +292 -0
  6. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -0
  7. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js +14 -6
  8. package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
  9. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +16 -3
  10. package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/queries/userCache.js +164 -10
  12. package/lib/commonjs/ui/hooks/queries/userCache.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/useFollowTarget.js +140 -0
  14. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -0
  15. package/lib/commonjs/ui/screens/AccountMembersScreen.js +49 -6
  16. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  17. package/lib/commonjs/ui/screens/AccountSettingsScreen.js +6 -1
  18. package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
  19. package/lib/commonjs/ui/screens/CreateAccountScreen.js +73 -40
  20. package/lib/commonjs/ui/screens/CreateAccountScreen.js.map +1 -1
  21. package/lib/commonjs/ui/stores/followTargetStore.js +113 -0
  22. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -0
  23. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +2 -0
  24. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -1
  25. package/lib/commonjs/ui/utils/avatarUtils.js +5 -0
  26. package/lib/commonjs/ui/utils/avatarUtils.js.map +1 -1
  27. package/lib/module/index.js +8 -2
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/ui/components/FollowButton.js +3 -5
  30. package/lib/module/ui/components/FollowButton.js.map +1 -1
  31. package/lib/module/ui/components/FollowTargetButton.js +287 -0
  32. package/lib/module/ui/components/FollowTargetButton.js.map +1 -0
  33. package/lib/module/ui/components/authChooser/requestSurfaces.js +15 -7
  34. package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
  35. package/lib/module/ui/hooks/mutations/useAccountMutations.js +16 -3
  36. package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
  37. package/lib/module/ui/hooks/queries/userCache.js +161 -10
  38. package/lib/module/ui/hooks/queries/userCache.js.map +1 -1
  39. package/lib/module/ui/hooks/useFollowTarget.js +136 -0
  40. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -0
  41. package/lib/module/ui/screens/AccountMembersScreen.js +49 -6
  42. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  43. package/lib/module/ui/screens/AccountSettingsScreen.js +6 -1
  44. package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
  45. package/lib/module/ui/screens/CreateAccountScreen.js +74 -41
  46. package/lib/module/ui/screens/CreateAccountScreen.js.map +1 -1
  47. package/lib/module/ui/stores/followTargetStore.js +107 -0
  48. package/lib/module/ui/stores/followTargetStore.js.map +1 -0
  49. package/lib/module/ui/stores/resetSessionScopedStores.js +2 -0
  50. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -1
  51. package/lib/module/ui/utils/avatarUtils.js +5 -0
  52. package/lib/module/ui/utils/avatarUtils.js.map +1 -1
  53. package/lib/typescript/commonjs/index.d.ts +7 -2
  54. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +119 -0
  57. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts +6 -1
  59. package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
  61. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts +79 -2
  63. package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +41 -0
  65. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +70 -0
  70. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -0
  71. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/utils/avatarUtils.d.ts.map +1 -1
  73. package/lib/typescript/module/index.d.ts +7 -2
  74. package/lib/typescript/module/index.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  76. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +119 -0
  77. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -0
  78. package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts +6 -1
  79. package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
  81. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  82. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts +79 -2
  83. package/lib/typescript/module/ui/hooks/queries/userCache.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +41 -0
  85. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -0
  86. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/screens/AccountSettingsScreen.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/screens/CreateAccountScreen.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +70 -0
  90. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
  92. package/lib/typescript/module/ui/utils/avatarUtils.d.ts.map +1 -1
  93. package/package.json +4 -4
  94. package/src/index.ts +26 -3
  95. package/src/ui/components/FollowButton.tsx +3 -5
  96. package/src/ui/components/FollowTargetButton.tsx +326 -0
  97. package/src/ui/components/__tests__/followTargetButton.test.ts +132 -0
  98. package/src/ui/components/authChooser/requestSurfaces.tsx +12 -4
  99. package/src/ui/hooks/mutations/useAccountMutations.ts +20 -3
  100. package/src/ui/hooks/queries/__tests__/userCacheClear.test.ts +301 -0
  101. package/src/ui/hooks/queries/userCache.ts +211 -10
  102. package/src/ui/hooks/useFollowTarget.ts +194 -0
  103. package/src/ui/screens/AccountMembersScreen.tsx +62 -6
  104. package/src/ui/screens/AccountSettingsScreen.tsx +9 -1
  105. package/src/ui/screens/CreateAccountScreen.tsx +66 -36
  106. package/src/ui/stores/__tests__/resetSessionScopedStores.test.ts +23 -0
  107. package/src/ui/stores/followTargetStore.ts +119 -0
  108. package/src/ui/stores/resetSessionScopedStores.ts +2 -0
  109. package/src/ui/utils/avatarUtils.ts +9 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `useFollowTarget` — the one hook every application uses to follow anything.
3
+ *
4
+ * Wraps the `/v2/follows` SDK methods with the store above, an optimistic
5
+ * update, and a rollback. Nothing in here knows what kind of thing it is
6
+ * following, which is the property that lets an application the SDK has never
7
+ * heard of use it without a release of this package.
8
+ *
9
+ * ## Optimism, and its one limit
10
+ *
11
+ * A follow flips the button immediately and rolls back if the server refuses,
12
+ * because the round trip is long enough to feel like a bug otherwise. But an
13
+ * optimistic follow has no relationship id until the server answers, and every
14
+ * other operation addresses the relationship — so those stay disabled for the
15
+ * width of one request rather than being sent with a guessed id.
16
+ */
17
+
18
+ import { useCallback, useEffect } from 'react';
19
+ import type { FollowStatus } from '@oxyhq/contracts';
20
+ import { useOxy } from '../context/OxyContext';
21
+ import {
22
+ isFollowedGlobally,
23
+ UNKNOWN_FOLLOW_STATUS,
24
+ useFollowTargetStore,
25
+ withApplicationMode,
26
+ } from '../stores/followTargetStore';
27
+
28
+ export interface UseFollowTargetResult {
29
+ status: FollowStatus;
30
+ /**
31
+ * Whether the user follows this at all — NOT `effectiveState`, which reports
32
+ * `not_following` for a follow merely switched off in this application.
33
+ */
34
+ isFollowing: boolean;
35
+ /** True until the first read settles. Distinct from "not following". */
36
+ isUnknown: boolean;
37
+ isPending: boolean;
38
+ error: string | undefined;
39
+ follow: (options?: { expiresIn?: number }) => Promise<void>;
40
+ unfollow: () => Promise<void>;
41
+ /** Stop acting on this follow HERE, without giving it up everywhere. */
42
+ disableHere: () => Promise<void>;
43
+ enableHere: () => Promise<void>;
44
+ refresh: () => Promise<void>;
45
+ }
46
+
47
+ export function useFollowTarget(
48
+ targetId: string | undefined,
49
+ options?: { initialStatus?: FollowStatus }
50
+ ): UseFollowTargetResult {
51
+ const { oxyServices, canUsePrivateApi } = useOxy();
52
+
53
+ const status = useFollowTargetStore(
54
+ useCallback((s) => (targetId ? s.statuses[targetId] : undefined), [targetId])
55
+ );
56
+ const isPending = useFollowTargetStore(
57
+ useCallback((s) => (targetId ? (s.pending[targetId] ?? false) : false), [targetId])
58
+ );
59
+ const error = useFollowTargetStore(
60
+ useCallback((s) => (targetId ? s.errors[targetId] : undefined), [targetId])
61
+ );
62
+
63
+ const initialStatus = options?.initialStatus;
64
+
65
+ // A caller that already knows the status — a follow list, a feed payload —
66
+ // seeds it rather than making this hook re-ask once per rendered row.
67
+ useEffect(() => {
68
+ if (!targetId || !initialStatus) return;
69
+ const store = useFollowTargetStore.getState();
70
+ if (!store.statuses[targetId]) store.setStatus(targetId, initialStatus);
71
+ }, [targetId, initialStatus]);
72
+
73
+ const refresh = useCallback(async () => {
74
+ // Gated on `canUsePrivateApi`, not on `isAuthenticated`: during the session
75
+ // cold boot the second is true well before the first, and a read sent in
76
+ // that window 401s and would leave the button stuck reporting an error the
77
+ // user cannot act on.
78
+ if (!targetId || !canUsePrivateApi) return;
79
+ try {
80
+ const next = await oxyServices.getFollowTargetStatus(targetId);
81
+ useFollowTargetStore.getState().setStatus(targetId, next);
82
+ useFollowTargetStore.getState().setError(targetId, undefined);
83
+ } catch (e) {
84
+ useFollowTargetStore
85
+ .getState()
86
+ .setError(targetId, e instanceof Error ? e.message : 'Could not read follow status');
87
+ }
88
+ }, [targetId, canUsePrivateApi, oxyServices]);
89
+
90
+ useEffect(() => {
91
+ if (!targetId || !canUsePrivateApi) return;
92
+ if (useFollowTargetStore.getState().statuses[targetId]) return;
93
+ void refresh();
94
+ }, [targetId, canUsePrivateApi, refresh]);
95
+
96
+ /**
97
+ * Run a mutation optimistically: show `optimistic` at once, keep the previous
98
+ * value, and put it back if the server refuses. Every mutation below goes
99
+ * through this so the rollback cannot be forgotten in one of them.
100
+ */
101
+ const mutate = useCallback(
102
+ async (
103
+ optimistic: FollowStatus,
104
+ run: () => Promise<FollowStatus | void>,
105
+ failureMessage: string
106
+ ) => {
107
+ if (!targetId) return;
108
+ const store = useFollowTargetStore.getState();
109
+ const previous = store.statuses[targetId];
110
+ store.setStatus(targetId, optimistic);
111
+ store.setPending(targetId, true);
112
+ store.setError(targetId, undefined);
113
+ try {
114
+ const settled = await run();
115
+ if (settled) useFollowTargetStore.getState().setStatus(targetId, settled);
116
+ } catch (e) {
117
+ const s = useFollowTargetStore.getState();
118
+ // Back to what was true, not to a guess. Clearing the entry instead
119
+ // would make the next render ask again and flash the wrong state.
120
+ if (previous) s.setStatus(targetId, previous);
121
+ s.setError(targetId, e instanceof Error ? e.message : failureMessage);
122
+ } finally {
123
+ useFollowTargetStore.getState().setPending(targetId, false);
124
+ }
125
+ },
126
+ [targetId]
127
+ );
128
+
129
+ const current = status ?? UNKNOWN_FOLLOW_STATUS;
130
+
131
+ const follow = useCallback(
132
+ async (opts?: { expiresIn?: number }) => {
133
+ if (!targetId) return;
134
+ await mutate(
135
+ {
136
+ ...withApplicationMode({ ...current, globalState: 'active' }, current.applicationMode),
137
+ ...(opts?.expiresIn
138
+ ? { expiresAt: new Date(Date.now() + opts.expiresIn * 1000).toISOString() }
139
+ : {}),
140
+ },
141
+ // The server returns the whole resulting status, so store it rather
142
+ // than reconstructing one: `effectiveState`'s derivation lives there,
143
+ // and a client recomputing it is a second implementation of one rule.
144
+ async () => (await oxyServices.followTarget(targetId, opts)).status,
145
+ 'Could not follow'
146
+ );
147
+ },
148
+ [targetId, current, mutate, oxyServices]
149
+ );
150
+
151
+ const unfollow = useCallback(async () => {
152
+ const relationshipId = current.relationshipId;
153
+ if (!targetId || !relationshipId) return;
154
+ await mutate(
155
+ { ...UNKNOWN_FOLLOW_STATUS },
156
+ async () => {
157
+ await oxyServices.unfollowTarget(relationshipId);
158
+ return { ...UNKNOWN_FOLLOW_STATUS };
159
+ },
160
+ 'Could not unfollow'
161
+ );
162
+ }, [targetId, current.relationshipId, mutate, oxyServices]);
163
+
164
+ const setMode = useCallback(
165
+ async (mode: 'enabled' | 'disabled') => {
166
+ const relationshipId = current.relationshipId;
167
+ if (!targetId || !relationshipId) return;
168
+ await mutate(
169
+ withApplicationMode(current, mode),
170
+ async () => {
171
+ await oxyServices.setFollowApplicationMode(relationshipId, mode);
172
+ },
173
+ 'Could not change this app’s setting for this follow'
174
+ );
175
+ },
176
+ [targetId, current, mutate, oxyServices]
177
+ );
178
+
179
+ const disableHere = useCallback(() => setMode('disabled'), [setMode]);
180
+ const enableHere = useCallback(() => setMode('enabled'), [setMode]);
181
+
182
+ return {
183
+ status: current,
184
+ isFollowing: isFollowedGlobally(current),
185
+ isUnknown: status === undefined,
186
+ isPending,
187
+ error,
188
+ follow,
189
+ unfollow,
190
+ disableHere,
191
+ enableHere,
192
+ refresh,
193
+ };
194
+ }
@@ -15,6 +15,7 @@ import { Button } from '@oxyhq/bloom/button';
15
15
  import { TextField, TextFieldInput } from '@oxyhq/bloom/text-field';
16
16
  import { Divider } from '@oxyhq/bloom/divider';
17
17
  import type { AccountMember, AccountRole } from '@oxyhq/core';
18
+ import { getNormalizedUserHandle } from '@oxyhq/core';
18
19
  import type { BaseScreenProps } from '../types/navigation';
19
20
  import { useOxy } from '../context/OxyContext';
20
21
  import { useI18n } from '../hooks/useI18n';
@@ -86,10 +87,49 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
86
87
  enabled: canUsePrivateApi && id.length > 0 && canRead,
87
88
  });
88
89
  const members = useMemo<AccountMember[]>(() => membersQuery.data ?? [], [membersQuery.data]);
89
- const ownerCount = useMemo(() => members.filter((m) => m.role === 'owner').length, [members]);
90
+ const memberUserIds = useMemo(
91
+ () => [...new Set(members.map((member) => member.memberUserId).filter((id) => id.length > 0))],
92
+ [members],
93
+ );
94
+ const memberProfilesQuery = useQuery({
95
+ queryKey: ['users', 'by-ids', memberUserIds],
96
+ queryFn: () => oxyServices.getUsersByIds(memberUserIds),
97
+ enabled: canUsePrivateApi && memberUserIds.length > 0,
98
+ });
99
+ const memberLabelByUserId = useMemo(() => {
100
+ const labels = new Map<string, string>();
101
+ for (const profile of memberProfilesQuery.data ?? []) {
102
+ // `getNormalizedUserHandle` answers null for an actor with no resolvable
103
+ // handle. Leaving that entry out is what lets `memberDisplayLabel`'s own
104
+ // fallback run, rather than storing a null the map is not typed to hold.
105
+ const handle = getNormalizedUserHandle(profile);
106
+ if (handle) labels.set(profile.id, handle);
107
+ }
108
+ return labels;
109
+ }, [memberProfilesQuery.data]);
110
+ const memberDisplayLabel = useCallback(
111
+ (memberUserId: string): string =>
112
+ memberLabelByUserId.get(memberUserId) ?? memberUserId,
113
+ [memberLabelByUserId],
114
+ );
115
+ // DIRECT owners only. The list also carries members whose row lives on an
116
+ // ancestor, and the last-owner rule this count feeds is about the rows on THIS
117
+ // account — the server's guard counts those. Including an inherited owner
118
+ // would make a child that has no owner of its own look like it has exactly
119
+ // one, and silently disable removing anybody.
120
+ const ownerCount = useMemo(
121
+ () =>
122
+ members.filter(
123
+ (m) => m.role === 'owner' && (m.source ?? 'direct') === 'direct',
124
+ ).length,
125
+ [members],
126
+ );
90
127
 
91
128
  const invalidateMembers = useCallback(() => {
92
- queryClient.invalidateQueries({ queryKey: ['accounts', 'members', id] });
129
+ // Inherited rows on descendant rosters derive from ancestor membership
130
+ // tables — mirror the SDK's per-account prefix sweep so every cached member
131
+ // list refetches, not only the account that was mutated.
132
+ queryClient.invalidateQueries({ queryKey: ['accounts', 'members'] });
93
133
  queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
94
134
  }, [queryClient, id]);
95
135
 
@@ -308,16 +348,25 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
308
348
  {members.map((member, index) => {
309
349
  const isOwner = member.role === 'owner';
310
350
  const isLastOwner = isOwner && ownerCount <= 1;
311
- const canEditThisRole = canUpdate && !isOwner;
312
- const canRemoveThisMember = canRemove && !isLastOwner && (!isOwner || canTransfer);
313
- const canTransferToThis = canTransfer && !isOwner && member.status === 'active';
351
+ // An INHERITED entry's row lives on an ancestor account, and
352
+ // every member mutation is scoped to rows on the account in
353
+ // the path so offering to edit, remove or promote one would
354
+ // be offering a request the server answers 404. The row is
355
+ // changed where it lives, on that ancestor's own member
356
+ // screen.
357
+ const isEditableHere = (member.source ?? 'direct') === 'direct';
358
+ const canEditThisRole = canUpdate && isEditableHere && !isOwner;
359
+ const canRemoveThisMember =
360
+ canRemove && isEditableHere && !isLastOwner && (!isOwner || canTransfer);
361
+ const canTransferToThis =
362
+ canTransfer && isEditableHere && !isOwner && member.status === 'active';
314
363
  return (
315
364
  <View key={member._id}>
316
365
  {index > 0 ? <Divider color={colors.border} spacing={0} /> : null}
317
366
  <View className="p-space-16 gap-space-8">
318
367
  <View className="flex-row items-center gap-space-8">
319
368
  <Text className="text-body font-bodyBold text-text flex-1" numberOfLines={1}>
320
- {member.memberUserId}
369
+ {memberDisplayLabel(member.memberUserId)}
321
370
  </Text>
322
371
  {isOwner ? (
323
372
  <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.primarySubtle }}>
@@ -326,6 +375,13 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
326
375
  </Text>
327
376
  </View>
328
377
  ) : null}
378
+ {!isEditableHere ? (
379
+ <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.card }}>
380
+ <Text className="text-caption font-caption text-text-secondary">
381
+ {t('accounts.members.inherited') || 'Inherited'}
382
+ </Text>
383
+ </View>
384
+ ) : null}
329
385
  {member.status !== 'active' ? (
330
386
  <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.card }}>
331
387
  <Text className="text-caption font-caption capitalize text-text-secondary">
@@ -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';
@@ -83,23 +83,25 @@ const kindDescription = (
83
83
  }
84
84
  };
85
85
 
86
- 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 = (
87
95
  t: (key: string, vars?: Record<string, string | number>) => string,
88
- category: OrganizationCategory,
89
- ): string => {
90
- switch (category) {
91
- case 'agency':
92
- return t('accounts.organizationCategory.agency');
93
- case 'cooperative':
94
- return t('accounts.organizationCategory.cooperative');
95
- case 'landlord':
96
- return t('accounts.organizationCategory.landlord');
97
- default:
98
- return t('accounts.organizationCategory.other');
99
- }
100
- };
96
+ category: AccountCategoryId,
97
+ ): string => t(`accounts.accountCategory.${category}`) || category;
101
98
 
102
- 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;
103
105
 
104
106
  /**
105
107
  * Create a new account in the unified account graph (an organization, project,
@@ -125,7 +127,12 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
125
127
  const parentId = typeof parentAccountId === 'string' ? parentAccountId : undefined;
126
128
 
127
129
  const [kind, setKind] = useState<CreatableAccountKind>('project');
128
- 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[]>([]);
129
136
  const [username, setUsername] = useState('');
130
137
  const [displayName, setDisplayName] = useState('');
131
138
  const [displayNameError, setDisplayNameError] = useState('');
@@ -215,6 +222,23 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
215
222
  );
216
223
  }, [t]);
217
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
+
218
242
  const canCreate = usernameStatus === 'available'
219
243
  && displayName.trim().length > 0
220
244
  && !displayNameError
@@ -225,17 +249,14 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
225
249
 
226
250
  setIsCreating(true);
227
251
  try {
228
- // Split display name into first/last
229
- const nameParts = displayName.trim().split(/\s+/);
230
- const firstName = nameParts[0] || '';
231
- const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
232
-
233
252
  const input: CreateAccountInput = {
234
253
  kind,
235
254
  username,
236
- name: { first: firstName, last: lastName },
255
+ name: { displayName: displayName.trim() },
237
256
  bio: bio.trim() || undefined,
238
- ...(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),
239
260
  ...(parentId ? { parentAccountId: parentId } : null),
240
261
  };
241
262
  const account = await createAccount(input);
@@ -265,7 +286,7 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
265
286
  } finally {
266
287
  setIsCreating(false);
267
288
  }
268
- }, [canCreate, kind, organizationCategory, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
289
+ }, [canCreate, kind, accountCategories, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
269
290
 
270
291
  // Status icon + color shown alongside the username field message
271
292
  const usernameIsInvalid = usernameStatus === 'taken' || usernameStatus === 'invalid';
@@ -307,26 +328,35 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
307
328
  })}
308
329
  </SettingsListGroup>
309
330
 
310
- {/* Organization category grouped selection rows, shown only for organizations */}
311
- {kind === 'organization' ? (
312
- <SettingsListGroup title={t('accounts.create.organizationCategory.label')}>
313
- {ORGANIZATION_CATEGORY_OPTIONS.map((option) => {
314
- 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);
315
341
  return (
316
342
  <SettingsListItem
317
343
  key={option}
318
- title={organizationCategoryLabel(t, option)}
319
- onPress={() => setOrganizationCategory(option)}
344
+ title={label}
345
+ disabled={!selected && accountCategories.length >= MAX_ACCOUNT_CATEGORIES}
346
+ onPress={() => toggleAccountCategory(option)}
320
347
  showChevron={false}
321
348
  rightElement={selected ? (
322
- <Ionicons name="checkmark-circle" size={20} color={bloomTheme.colors.primary} />
349
+ <Text className="text-caption-1 font-semibold text-primary">{position + 1}</Text>
323
350
  ) : undefined}
324
- accessibilityLabel={organizationCategoryLabel(t, option)}
351
+ accessibilityLabel={label}
325
352
  />
326
353
  );
327
354
  })}
328
355
  </SettingsListGroup>
329
- ) : 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>
330
360
 
331
361
  {/* Details — a grouped section card hosting the form fields */}
332
362
  <SettingsListGroup title={t('accounts.create.detailsSection') || 'Details'}>
@@ -0,0 +1,23 @@
1
+ import { useFollowStore } from '../followStore';
2
+ import { UNKNOWN_FOLLOW_STATUS, useFollowTargetStore } from '../followTargetStore';
3
+ import { resetSessionScopedStores } from '../resetSessionScopedStores';
4
+
5
+ describe('resetSessionScopedStores', () => {
6
+ beforeEach(() => {
7
+ useFollowStore.getState().resetFollowState();
8
+ useFollowTargetStore.getState().reset();
9
+ });
10
+
11
+ it('clears legacy follow state and the v2 follow-target cache', () => {
12
+ useFollowStore.getState().setFollowingStatus('user-1', true);
13
+ useFollowTargetStore.getState().setStatus('target-1', {
14
+ ...UNKNOWN_FOLLOW_STATUS,
15
+ effectiveState: 'following',
16
+ });
17
+
18
+ resetSessionScopedStores();
19
+
20
+ expect(useFollowStore.getState().followingUsers['user-1']).toBeUndefined();
21
+ expect(useFollowTargetStore.getState().statuses['target-1']).toBeUndefined();
22
+ });
23
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The follow-graph store — the client's single cache authority for #809.
3
+ *
4
+ * The SDK caches none of these reads on purpose (a status cached across a write
5
+ * is the "follow reverts after navigating away and back" bug), which makes this
6
+ * store the one place a status lives between a write and the next read. If a
7
+ * second cache appears above it, the two will disagree the moment one is
8
+ * invalidated and the other is not.
9
+ *
10
+ * ## Keyed by target id, not by user id
11
+ *
12
+ * The legacy `followStore` is keyed by user id because the only followable
13
+ * thing was a user. Here a key is a target of any kind — a topic, a store, an
14
+ * artist, a channel — so nothing in this file may assume what it is looking at.
15
+ * That is the property that lets an application the SDK has never heard of use
16
+ * it without a release.
17
+ *
18
+ * ## Why the status is stored whole
19
+ *
20
+ * Not a boolean. `globalState`, `applicationMode` and `effectiveState` are
21
+ * three separate answers, and a UI that keeps only the last one cannot explain
22
+ * why a follow the user can see in their list does nothing in this app.
23
+ */
24
+
25
+ import { create } from 'zustand';
26
+ import type { FollowApplicationMode, FollowStatus } from '@oxyhq/contracts';
27
+
28
+ /** The status of one target, or `undefined` when it has never been read. */
29
+ type StatusMap = Record<string, FollowStatus | undefined>;
30
+
31
+ interface FollowTargetState {
32
+ statuses: StatusMap;
33
+ /** In-flight writes, per target. Reads do not set this — only mutations do. */
34
+ pending: Record<string, boolean>;
35
+ errors: Record<string, string | undefined>;
36
+
37
+ setStatus: (targetId: string, status: FollowStatus) => void;
38
+ /**
39
+ * Seed many statuses at once — from a follow list, a feed payload, anything
40
+ * that already knows. Saves one request per rendered row, which is the
41
+ * difference between a list that paints and a list that flickers.
42
+ */
43
+ seed: (entries: Record<string, FollowStatus>) => void;
44
+ setPending: (targetId: string, pending: boolean) => void;
45
+ setError: (targetId: string, error: string | undefined) => void;
46
+ /** Drop everything. Called on identity change — see the note below. */
47
+ reset: () => void;
48
+ }
49
+
50
+ /**
51
+ * The state one target is in before the server has been asked. Distinct from
52
+ * "not following": a button that renders a follow action while the answer is
53
+ * unknown invites a follow the user did not intend, so callers check
54
+ * `isUnknown` rather than reading this as an answer.
55
+ */
56
+ export const UNKNOWN_FOLLOW_STATUS: FollowStatus = {
57
+ globalState: 'none',
58
+ applicationMode: 'inherit',
59
+ effectiveState: 'not_following',
60
+ };
61
+
62
+ export const useFollowTargetStore = create<FollowTargetState>((set) => ({
63
+ statuses: {},
64
+ pending: {},
65
+ errors: {},
66
+
67
+ setStatus: (targetId, status) =>
68
+ set((s) => ({ statuses: { ...s.statuses, [targetId]: status } })),
69
+
70
+ seed: (entries) => set((s) => ({ statuses: { ...s.statuses, ...entries } })),
71
+
72
+ setPending: (targetId, pending) =>
73
+ set((s) => ({ pending: { ...s.pending, [targetId]: pending } })),
74
+
75
+ setError: (targetId, error) => set((s) => ({ errors: { ...s.errors, [targetId]: error } })),
76
+
77
+ // Every entry here is scoped to whoever was signed in when it was read. On an
78
+ // account switch the whole map is wrong, not stale — the next user's follows
79
+ // are a different set entirely, and showing one user their previous
80
+ // account's follow state is a privacy failure rather than a rendering one.
81
+ reset: () => set({ statuses: {}, pending: {}, errors: {} }),
82
+ }));
83
+
84
+ /**
85
+ * Whether the user follows this at all, anywhere.
86
+ *
87
+ * Deliberately NOT `effectiveState !== 'not_following'`: a follow switched off
88
+ * in this application reports `not_following` — correctly, since the question
89
+ * that field answers is "does this act here" — and a button that read it as
90
+ * "not followed" would offer to follow something already followed.
91
+ */
92
+ export function isFollowedGlobally(status: FollowStatus): boolean {
93
+ return status.globalState === 'active' || status.globalState === 'requested';
94
+ }
95
+
96
+ /**
97
+ * Apply a mode change to a cached status without a round trip.
98
+ *
99
+ * Exported because the optimistic path and the settled path must agree on what
100
+ * `effectiveState` becomes; deriving it in two places is how they drift. Mirrors
101
+ * the server's own derivation, which is the authority.
102
+ */
103
+ export function withApplicationMode(
104
+ status: FollowStatus,
105
+ mode: FollowApplicationMode
106
+ ): FollowStatus {
107
+ return {
108
+ ...status,
109
+ applicationMode: mode,
110
+ effectiveState:
111
+ mode === 'disabled'
112
+ ? 'not_following'
113
+ : status.globalState === 'active'
114
+ ? 'following'
115
+ : status.globalState === 'requested'
116
+ ? 'requested'
117
+ : 'not_following',
118
+ };
119
+ }
@@ -1,4 +1,5 @@
1
1
  import { useFollowStore } from './followStore';
2
+ import { useFollowTargetStore } from './followTargetStore';
2
3
 
3
4
  /**
4
5
  * Drop session-scoped Zustand slices so the next user/account never inherits
@@ -10,4 +11,5 @@ import { useFollowStore } from './followStore';
10
11
  */
11
12
  export function resetSessionScopedStores(): void {
12
13
  useFollowStore.getState().resetFollowState();
14
+ useFollowTargetStore.getState().reset();
13
15
  }
@@ -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.