@oxyhq/services 22.1.0 → 22.3.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 (100) hide show
  1. package/lib/commonjs/index.js +6 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +24 -19
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyAuthChooser.js +107 -106
  6. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
  7. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +0 -19
  8. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -1
  9. package/lib/commonjs/ui/components/passkeyHubPopup.js +33 -0
  10. package/lib/commonjs/ui/components/passkeyHubPopup.js.map +1 -0
  11. package/lib/commonjs/ui/context/OxyContext.js +7 -1
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/context/useOxyAccountGraph.js +2 -0
  14. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/useFollow.js +53 -10
  16. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -0
  18. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  19. package/lib/commonjs/ui/index.js +6 -0
  20. package/lib/commonjs/ui/index.js.map +1 -1
  21. package/lib/commonjs/ui/stores/followStore.js +276 -92
  22. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  23. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +14 -0
  24. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -0
  25. package/lib/module/index.js +1 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ui/components/FollowButton.js +24 -19
  28. package/lib/module/ui/components/FollowButton.js.map +1 -1
  29. package/lib/module/ui/components/OxyAuthChooser.js +108 -107
  30. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
  31. package/lib/module/ui/components/oxyAuthChooserStyles.js +0 -19
  32. package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
  33. package/lib/module/ui/components/passkeyHubPopup.js +29 -0
  34. package/lib/module/ui/components/passkeyHubPopup.js.map +1 -0
  35. package/lib/module/ui/context/OxyContext.js +7 -1
  36. package/lib/module/ui/context/OxyContext.js.map +1 -1
  37. package/lib/module/ui/context/useOxyAccountGraph.js +2 -0
  38. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  39. package/lib/module/ui/hooks/useFollow.js +51 -9
  40. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  41. package/lib/module/ui/hooks/useSessionManagement.js +2 -0
  42. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  43. package/lib/module/ui/index.js +1 -1
  44. package/lib/module/ui/index.js.map +1 -1
  45. package/lib/module/ui/stores/followStore.js +274 -91
  46. package/lib/module/ui/stores/followStore.js.map +1 -1
  47. package/lib/module/ui/stores/resetSessionScopedStores.js +11 -0
  48. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -0
  49. package/lib/typescript/commonjs/index.d.ts +1 -1
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  54. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts +23 -0
  56. package/lib/typescript/commonjs/ui/components/passkeyHubPopup.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +13 -2
  60. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/index.d.ts +1 -1
  63. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +4 -1
  65. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +3 -0
  67. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  68. package/lib/typescript/module/index.d.ts +1 -1
  69. package/lib/typescript/module/index.d.ts.map +1 -1
  70. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  71. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
  72. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +0 -19
  73. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts +23 -0
  75. package/lib/typescript/module/ui/components/passkeyHubPopup.d.ts.map +1 -0
  76. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  78. package/lib/typescript/module/ui/hooks/useFollow.d.ts +13 -2
  79. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/index.d.ts +1 -1
  82. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/stores/followStore.d.ts +4 -1
  84. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +3 -0
  86. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
  87. package/package.json +12 -12
  88. package/src/index.ts +1 -1
  89. package/src/ui/components/FollowButton.tsx +22 -20
  90. package/src/ui/components/OxyAuthChooser.tsx +125 -89
  91. package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
  92. package/src/ui/components/passkeyHubPopup.ts +28 -0
  93. package/src/ui/context/OxyContext.tsx +6 -0
  94. package/src/ui/context/useOxyAccountGraph.ts +2 -0
  95. package/src/ui/hooks/useFollow.ts +51 -9
  96. package/src/ui/hooks/useSessionManagement.ts +2 -0
  97. package/src/ui/index.ts +1 -1
  98. package/src/ui/stores/__tests__/followStore.test.ts +178 -0
  99. package/src/ui/stores/followStore.ts +277 -106
  100. package/src/ui/stores/resetSessionScopedStores.ts +8 -0
@@ -1,7 +1,11 @@
1
1
  import { create } from 'zustand';
2
- import type { OxyServices, BulkFollowResult, BulkUnfollowResult } from '@oxyhq/core';
2
+ import type { OxyServices, BulkFollowResult, BulkUnfollowResult, FollowMutationResult } from '@oxyhq/core';
3
3
 
4
4
  interface FollowState {
5
+ // Tri-state follow map: a MISSING key means UNKNOWN (status not yet resolved),
6
+ // a present `true`/`false` is a DEFINITE value. Read sites must NOT collapse a
7
+ // missing key to `false` when they need to distinguish "unknown" from "not
8
+ // following" (see `useFollowForButton`'s `isKnown`).
5
9
  followingUsers: Record<string, boolean>;
6
10
  loadingUsers: Record<string, boolean>;
7
11
  fetchingUsers: Record<string, boolean>;
@@ -12,9 +16,17 @@ interface FollowState {
12
16
  // Loading states for counts
13
17
  loadingCounts: Record<string, boolean>;
14
18
  setFollowingStatus: (userId: string, isFollowing: boolean) => void;
19
+ // Bulk-seed follow statuses (seed-only-if-absent — never clobbers a definite
20
+ // value the store already holds). Consumers call this to seed from
21
+ // `getViewerGraph().followingIds` so a page of FollowButtons paints correctly
22
+ // with ZERO network calls.
23
+ setFollowStatuses: (map: Record<string, boolean>) => void;
15
24
  clearFollowError: (userId: string) => void;
16
25
  resetFollowState: () => void;
17
- fetchFollowStatus: (userId: string, oxyServices: OxyServices) => Promise<void>;
26
+ // Micro-batched status resolver: enqueue UNKNOWN ids, coalesce every id
27
+ // requested within the same tick into ONE `getFollowStatuses` call. Ids that
28
+ // are already known/seeded or already in flight are skipped.
29
+ resolveFollowStatuses: (userIds: string[], oxyServices: OxyServices) => void;
18
30
  toggleFollowUser: (userId: string, oxyServices: OxyServices, isCurrentlyFollowing: boolean) => Promise<void>;
19
31
  // Bulk follow — follows MANY users in one network call; never unfollows.
20
32
  followManyUsers: (userIds: string[], oxyServices: OxyServices) => Promise<BulkFollowResult>;
@@ -27,7 +39,44 @@ interface FollowState {
27
39
  fetchUserCounts: (userId: string, oxyServices: OxyServices) => Promise<void>;
28
40
  }
29
41
 
30
- export const useFollowStore = create<FollowState>((set: any, get: any) => ({
42
+ const hasKey = (map: Record<string, unknown>, key: string): boolean =>
43
+ Object.prototype.hasOwnProperty.call(map, key);
44
+
45
+ const scheduleMicrotask = (cb: () => void): void => {
46
+ if (typeof queueMicrotask === 'function') {
47
+ queueMicrotask(cb);
48
+ } else {
49
+ setTimeout(cb, 0);
50
+ }
51
+ };
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Module-level micro-batch coordination for `resolveFollowStatuses`.
55
+ //
56
+ // Every UNKNOWN id requested within a single synchronous tick (e.g. a whole
57
+ // page of FollowButtons mounting in one commit) is collected in `pendingIds`
58
+ // and flushed by a SINGLE coalesced `getFollowStatuses` call on the next
59
+ // microtask. `inFlightIds` prevents an id already being fetched from being
60
+ // re-enqueued before its result lands. These are imperative coordination
61
+ // primitives read/written ONLY inside store actions — never in a render/memo
62
+ // position — so they are safe under the React Compiler.
63
+ // ---------------------------------------------------------------------------
64
+ let pendingIds = new Set<string>();
65
+ let pendingServices: OxyServices | null = null;
66
+ let flushScheduled = false;
67
+ const inFlightIds = new Set<string>();
68
+ let batchGeneration = 0;
69
+
70
+ /** Clears module-level micro-batch coordination state (test + sign-out hooks). */
71
+ export const resetFollowBatchState = (): void => {
72
+ pendingIds = new Set<string>();
73
+ pendingServices = null;
74
+ flushScheduled = false;
75
+ inFlightIds.clear();
76
+ batchGeneration += 1;
77
+ };
78
+
79
+ export const useFollowStore = create<FollowState>((set, get) => ({
31
80
  followingUsers: {},
32
81
  loadingUsers: {},
33
82
  fetchingUsers: {},
@@ -35,114 +84,200 @@ export const useFollowStore = create<FollowState>((set: any, get: any) => ({
35
84
  followerCounts: {},
36
85
  followingCounts: {},
37
86
  loadingCounts: {},
38
- setFollowingStatus: (userId: string, isFollowing: boolean) => set((state: FollowState) => ({
87
+ setFollowingStatus: (userId: string, isFollowing: boolean) => set((state) => ({
39
88
  followingUsers: { ...state.followingUsers, [userId]: isFollowing },
40
89
  errors: { ...state.errors, [userId]: null },
41
90
  })),
42
- clearFollowError: (userId: string) => set((state: FollowState) => ({
91
+ setFollowStatuses: (map: Record<string, boolean>) => set((state) => {
92
+ const followingUsers = { ...state.followingUsers };
93
+ let changed = false;
94
+ for (const [userId, isFollowing] of Object.entries(map)) {
95
+ // Seed only if absent — never overwrite a definite value the store already
96
+ // holds (e.g. one written optimistically). Idempotent and safe to call on
97
+ // every graph refresh.
98
+ if (!hasKey(followingUsers, userId)) {
99
+ followingUsers[userId] = isFollowing;
100
+ changed = true;
101
+ }
102
+ }
103
+ return changed ? { followingUsers } : {};
104
+ }),
105
+ clearFollowError: (userId: string) => set((state) => ({
43
106
  errors: { ...state.errors, [userId]: null },
44
107
  })),
45
- resetFollowState: () => set({
46
- followingUsers: {},
47
- loadingUsers: {},
48
- fetchingUsers: {},
49
- errors: {},
50
- followerCounts: {},
51
- followingCounts: {},
52
- loadingCounts: {},
53
- }),
54
- fetchFollowStatus: async (userId: string, oxyServices: OxyServices) => {
55
- set((state: FollowState) => ({
56
- fetchingUsers: { ...state.fetchingUsers, [userId]: true },
57
- errors: { ...state.errors, [userId]: null },
58
- }));
59
- try {
60
- const response = await oxyServices.getFollowStatus(userId);
61
- set((state: FollowState) => ({
62
- followingUsers: { ...state.followingUsers, [userId]: response.isFollowing },
63
- fetchingUsers: { ...state.fetchingUsers, [userId]: false },
64
- errors: { ...state.errors, [userId]: null },
65
- }));
66
- } catch (error: unknown) {
67
- set((state: FollowState) => ({
68
- fetchingUsers: { ...state.fetchingUsers, [userId]: false },
69
- errors: { ...state.errors, [userId]: (error instanceof Error ? error.message : null) || 'Failed to fetch follow status' },
70
- }));
108
+ resetFollowState: () => {
109
+ resetFollowBatchState();
110
+ set({
111
+ followingUsers: {},
112
+ loadingUsers: {},
113
+ fetchingUsers: {},
114
+ errors: {},
115
+ followerCounts: {},
116
+ followingCounts: {},
117
+ loadingCounts: {},
118
+ });
119
+ },
120
+ resolveFollowStatuses: (userIds: string[], oxyServices: OxyServices) => {
121
+ const { followingUsers } = get();
122
+ let enqueuedAny = false;
123
+ for (const rawId of userIds) {
124
+ if (typeof rawId !== 'string') continue;
125
+ const id = rawId.trim();
126
+ if (!id) continue;
127
+ // Skip ids with a known (seeded or fetched) status, already-in-flight ids,
128
+ // and ids already queued for this flush.
129
+ if (hasKey(followingUsers, id) || inFlightIds.has(id) || pendingIds.has(id)) continue;
130
+ pendingIds.add(id);
131
+ enqueuedAny = true;
71
132
  }
133
+
134
+ if (!enqueuedAny) return;
135
+
136
+ // The most recent instance wins the flush — they are the same client per app.
137
+ pendingServices = oxyServices;
138
+
139
+ if (flushScheduled) return;
140
+ flushScheduled = true;
141
+
142
+ scheduleMicrotask(() => {
143
+ flushScheduled = false;
144
+ const ids = Array.from(pendingIds);
145
+ pendingIds = new Set<string>();
146
+ const services = pendingServices;
147
+ pendingServices = null;
148
+ if (ids.length === 0 || !services) return;
149
+
150
+ const generation = batchGeneration;
151
+ for (const id of ids) inFlightIds.add(id);
152
+ set((state) => {
153
+ const fetchingUsers = { ...state.fetchingUsers };
154
+ for (const id of ids) fetchingUsers[id] = true;
155
+ return { fetchingUsers };
156
+ });
157
+
158
+ void services
159
+ .getFollowStatuses(ids)
160
+ .then((statuses) => {
161
+ if (generation !== batchGeneration) return;
162
+ set((state) => {
163
+ const followingUsersNext = { ...state.followingUsers };
164
+ const fetchingUsers = { ...state.fetchingUsers };
165
+ for (const id of ids) {
166
+ // Do not clobber a definite value written by a mutation that raced
167
+ // ahead of this batch — seed only if still unknown.
168
+ if (!hasKey(followingUsersNext, id)) {
169
+ followingUsersNext[id] = statuses[id] ?? false;
170
+ }
171
+ fetchingUsers[id] = false;
172
+ }
173
+ return { followingUsers: followingUsersNext, fetchingUsers };
174
+ });
175
+ })
176
+ .catch((error: unknown) => {
177
+ if (generation !== batchGeneration) return;
178
+ const message = (error instanceof Error ? error.message : null) || 'Failed to fetch follow status';
179
+ set((state) => {
180
+ const fetchingUsers = { ...state.fetchingUsers };
181
+ const errors = { ...state.errors };
182
+ for (const id of ids) {
183
+ fetchingUsers[id] = false;
184
+ errors[id] = message;
185
+ }
186
+ return { fetchingUsers, errors };
187
+ });
188
+ })
189
+ .finally(() => {
190
+ for (const id of ids) inFlightIds.delete(id);
191
+ });
192
+ });
72
193
  },
73
194
  toggleFollowUser: async (userId: string, oxyServices: OxyServices, isCurrentlyFollowing: boolean) => {
74
- set((state: FollowState) => ({
195
+ // Snapshot the prior value for rollback. `undefined` = was UNKNOWN.
196
+ const priorState = get().followingUsers;
197
+ const hadPrevious = hasKey(priorState, userId);
198
+ const previousValue = priorState[userId];
199
+ const optimisticValue = !isCurrentlyFollowing;
200
+
201
+ // Write the new value IMMEDIATELY (before the await) so the button flips
202
+ // instantly; mark loading; clear any prior error.
203
+ set((state) => ({
204
+ followingUsers: { ...state.followingUsers, [userId]: optimisticValue },
75
205
  loadingUsers: { ...state.loadingUsers, [userId]: true },
76
206
  errors: { ...state.errors, [userId]: null },
77
207
  }));
208
+
78
209
  try {
79
- let response: any;
80
- let newFollowState;
81
- if (isCurrentlyFollowing) {
82
- response = await oxyServices.unfollowUser(userId);
83
- newFollowState = false;
84
- } else {
85
- response = await oxyServices.followUser(userId);
86
- newFollowState = true;
87
- }
88
-
89
- // Update follow status
90
- set((state: FollowState) => ({
91
- followingUsers: { ...state.followingUsers, [userId]: newFollowState },
210
+ const response: FollowMutationResult = isCurrentlyFollowing
211
+ ? await oxyServices.unfollowUser(userId)
212
+ : await oxyServices.followUser(userId);
213
+
214
+ // Reconcile: confirm the optimistic value is authoritative, clear loading.
215
+ set((state) => ({
216
+ followingUsers: { ...state.followingUsers, [userId]: optimisticValue },
92
217
  loadingUsers: { ...state.loadingUsers, [userId]: false },
93
218
  errors: { ...state.errors, [userId]: null },
94
219
  }));
95
220
 
96
- // Update counts if the response includes them
221
+ // Update counts if the response includes them.
97
222
  // The API returns counts for both users:
98
223
  // - followers: target user's follower count (the user being followed)
99
224
  // - following: current user's following count (the user doing the following)
100
- if (response?.counts) {
225
+ if (response.counts) {
101
226
  const { counts } = response;
102
-
103
- // Get current user ID from oxyServices
104
227
  const currentUserId = oxyServices.getCurrentUserId();
105
-
106
- set((state: FollowState) => {
107
- const updates: any = {};
108
-
109
- // Update target user's follower count (the user being followed)
110
- updates.followerCounts = {
111
- ...state.followerCounts,
112
- [userId]: counts.followers
113
- };
114
-
115
- // Update current user's following count (the user doing the following)
228
+
229
+ set((state) => {
230
+ const followerCounts = { ...state.followerCounts, [userId]: counts.followers };
116
231
  if (currentUserId) {
117
- updates.followingCounts = {
118
- ...state.followingCounts,
119
- [currentUserId]: counts.following
232
+ return {
233
+ followerCounts,
234
+ followingCounts: { ...state.followingCounts, [currentUserId]: counts.following },
120
235
  };
121
236
  }
122
-
123
- return updates;
237
+ return { followerCounts };
124
238
  });
125
239
  }
126
240
  } catch (error: unknown) {
127
- set((state: FollowState) => ({
128
- loadingUsers: { ...state.loadingUsers, [userId]: false },
129
- errors: { ...state.errors, [userId]: (error instanceof Error ? error.message : null) || 'Failed to update follow status' },
130
- }));
241
+ // Roll back to the prior value (or back to UNKNOWN if there was none).
242
+ set((state) => {
243
+ const followingUsers = { ...state.followingUsers };
244
+ if (hadPrevious) {
245
+ followingUsers[userId] = previousValue;
246
+ } else {
247
+ delete followingUsers[userId];
248
+ }
249
+ return {
250
+ followingUsers,
251
+ loadingUsers: { ...state.loadingUsers, [userId]: false },
252
+ errors: { ...state.errors, [userId]: (error instanceof Error ? error.message : null) || 'Failed to update follow status' },
253
+ };
254
+ });
131
255
  }
132
256
  },
133
257
  followManyUsers: async (userIds: string[], oxyServices: OxyServices): Promise<BulkFollowResult> => {
134
- set((state: FollowState) => {
258
+ // Snapshot prior values for rollback (tri-state: undefined = unknown).
259
+ const prior = get().followingUsers;
260
+ const previous = new Map<string, boolean | undefined>();
261
+ for (const uid of userIds) {
262
+ previous.set(uid, hasKey(prior, uid) ? prior[uid] : undefined);
263
+ }
264
+
265
+ // Optimistically mark every target followed BEFORE the await.
266
+ set((state) => {
267
+ const followingUsers = { ...state.followingUsers };
135
268
  const loadingUsers = { ...state.loadingUsers };
136
269
  const errors = { ...state.errors };
137
270
  for (const uid of userIds) {
271
+ followingUsers[uid] = true;
138
272
  loadingUsers[uid] = true;
139
273
  errors[uid] = null;
140
274
  }
141
- return { loadingUsers, errors };
275
+ return { followingUsers, loadingUsers, errors };
142
276
  });
277
+
143
278
  try {
144
279
  const result = await oxyServices.followUsers(userIds);
145
- set((state: FollowState) => {
280
+ set((state) => {
146
281
  const followingUsers = { ...state.followingUsers };
147
282
  const loadingUsers = { ...state.loadingUsers };
148
283
  const errors = { ...state.errors };
@@ -154,6 +289,13 @@ export const useFollowStore = create<FollowState>((set: any, get: any) => ({
154
289
  followingUsers[entry.userId] = true;
155
290
  errors[entry.userId] = null;
156
291
  } else {
292
+ // This target failed — roll IT back to its prior value.
293
+ const prev = previous.get(entry.userId);
294
+ if (prev === undefined) {
295
+ delete followingUsers[entry.userId];
296
+ } else {
297
+ followingUsers[entry.userId] = prev;
298
+ }
157
299
  errors[entry.userId] = 'Failed to update follow status';
158
300
  }
159
301
  }
@@ -162,31 +304,50 @@ export const useFollowStore = create<FollowState>((set: any, get: any) => ({
162
304
  return result;
163
305
  } catch (error: unknown) {
164
306
  const message = (error instanceof Error ? error.message : null) || 'Failed to update follow status';
165
- set((state: FollowState) => {
307
+ // Whole batch failed — roll ALL targets back to their prior values.
308
+ set((state) => {
309
+ const followingUsers = { ...state.followingUsers };
166
310
  const loadingUsers = { ...state.loadingUsers };
167
311
  const errors = { ...state.errors };
168
312
  for (const uid of userIds) {
313
+ const prev = previous.get(uid);
314
+ if (prev === undefined) {
315
+ delete followingUsers[uid];
316
+ } else {
317
+ followingUsers[uid] = prev;
318
+ }
169
319
  loadingUsers[uid] = false;
170
320
  errors[uid] = message;
171
321
  }
172
- return { loadingUsers, errors };
322
+ return { followingUsers, loadingUsers, errors };
173
323
  });
174
324
  throw error;
175
325
  }
176
326
  },
177
327
  unfollowManyUsers: async (userIds: string[], oxyServices: OxyServices): Promise<BulkUnfollowResult> => {
178
- set((state: FollowState) => {
328
+ // Snapshot prior values for rollback (tri-state: undefined = unknown).
329
+ const prior = get().followingUsers;
330
+ const previous = new Map<string, boolean | undefined>();
331
+ for (const uid of userIds) {
332
+ previous.set(uid, hasKey(prior, uid) ? prior[uid] : undefined);
333
+ }
334
+
335
+ // Optimistically mark every target NOT followed BEFORE the await.
336
+ set((state) => {
337
+ const followingUsers = { ...state.followingUsers };
179
338
  const loadingUsers = { ...state.loadingUsers };
180
339
  const errors = { ...state.errors };
181
340
  for (const uid of userIds) {
341
+ followingUsers[uid] = false;
182
342
  loadingUsers[uid] = true;
183
343
  errors[uid] = null;
184
344
  }
185
- return { loadingUsers, errors };
345
+ return { followingUsers, loadingUsers, errors };
186
346
  });
347
+
187
348
  try {
188
349
  const result = await oxyServices.unfollowUsers(userIds);
189
- set((state: FollowState) => {
350
+ set((state) => {
190
351
  const followingUsers = { ...state.followingUsers };
191
352
  const loadingUsers = { ...state.loadingUsers };
192
353
  const errors = { ...state.errors };
@@ -198,6 +359,13 @@ export const useFollowStore = create<FollowState>((set: any, get: any) => ({
198
359
  followingUsers[entry.userId] = false;
199
360
  errors[entry.userId] = null;
200
361
  } else {
362
+ // This target failed — roll IT back to its prior value.
363
+ const prev = previous.get(entry.userId);
364
+ if (prev === undefined) {
365
+ delete followingUsers[entry.userId];
366
+ } else {
367
+ followingUsers[entry.userId] = prev;
368
+ }
201
369
  errors[entry.userId] = 'Failed to update follow status';
202
370
  }
203
371
  }
@@ -206,68 +374,71 @@ export const useFollowStore = create<FollowState>((set: any, get: any) => ({
206
374
  return result;
207
375
  } catch (error: unknown) {
208
376
  const message = (error instanceof Error ? error.message : null) || 'Failed to update follow status';
209
- set((state: FollowState) => {
377
+ // Whole batch failed — roll ALL targets back to their prior values.
378
+ set((state) => {
379
+ const followingUsers = { ...state.followingUsers };
210
380
  const loadingUsers = { ...state.loadingUsers };
211
381
  const errors = { ...state.errors };
212
382
  for (const uid of userIds) {
383
+ const prev = previous.get(uid);
384
+ if (prev === undefined) {
385
+ delete followingUsers[uid];
386
+ } else {
387
+ followingUsers[uid] = prev;
388
+ }
213
389
  loadingUsers[uid] = false;
214
390
  errors[uid] = message;
215
391
  }
216
- return { loadingUsers, errors };
392
+ return { followingUsers, loadingUsers, errors };
217
393
  });
218
394
  throw error;
219
395
  }
220
396
  },
221
- setFollowerCount: (userId: string, count: number) => set((state: FollowState) => ({
397
+ setFollowerCount: (userId: string, count: number) => set((state) => ({
222
398
  followerCounts: { ...state.followerCounts, [userId]: count },
223
399
  })),
224
- setFollowingCount: (userId: string, count: number) => set((state: FollowState) => ({
400
+ setFollowingCount: (userId: string, count: number) => set((state) => ({
225
401
  followingCounts: { ...state.followingCounts, [userId]: count },
226
402
  })),
227
403
  updateCountsFromFollowAction: (targetUserId: string, action: 'follow' | 'unfollow', counts: { followers: number; following: number }, currentUserId?: string) => {
228
- set((state: FollowState) => {
229
- const updates: any = {};
230
-
231
- // Update target user's follower count (the user being followed)
232
- updates.followerCounts = {
233
- ...state.followerCounts,
234
- [targetUserId]: counts.followers
235
- };
236
-
237
- // Update current user's following count (the user doing the following)
404
+ set((state) => {
405
+ const followerCounts = { ...state.followerCounts, [targetUserId]: counts.followers };
238
406
  if (currentUserId) {
239
- updates.followingCounts = {
240
- ...state.followingCounts,
241
- [currentUserId]: counts.following
407
+ return {
408
+ followerCounts,
409
+ followingCounts: { ...state.followingCounts, [currentUserId]: counts.following },
242
410
  };
243
411
  }
244
-
245
- return updates;
412
+ return { followerCounts };
246
413
  });
247
414
  },
248
415
  fetchUserCounts: async (userId: string, oxyServices: OxyServices) => {
249
- set((state: FollowState) => ({
416
+ set((state) => ({
250
417
  loadingCounts: { ...state.loadingCounts, [userId]: true },
251
418
  }));
252
419
  try {
253
420
  const user = await oxyServices.getUserById(userId);
254
421
  if (user?._count) {
255
- set((state: FollowState) => ({
256
- followerCounts: {
257
- ...state.followerCounts,
258
- [userId]: user._count?.followers || 0
422
+ set((state) => ({
423
+ followerCounts: {
424
+ ...state.followerCounts,
425
+ [userId]: user._count?.followers || 0,
259
426
  },
260
- followingCounts: {
261
- ...state.followingCounts,
262
- [userId]: user._count?.following || 0
427
+ followingCounts: {
428
+ ...state.followingCounts,
429
+ [userId]: user._count?.following || 0,
263
430
  },
264
431
  loadingCounts: { ...state.loadingCounts, [userId]: false },
265
432
  }));
433
+ } else {
434
+ set((state) => ({
435
+ loadingCounts: { ...state.loadingCounts, [userId]: false },
436
+ }));
266
437
  }
267
438
  } catch (error: unknown) {
268
- set((state: FollowState) => ({
439
+ set((state) => ({
269
440
  loadingCounts: { ...state.loadingCounts, [userId]: false },
270
441
  }));
271
442
  }
272
443
  },
273
- }));
444
+ }));
@@ -0,0 +1,8 @@
1
+ import { useFollowStore } from './followStore';
2
+ import { useFileStore } from './fileStore';
3
+
4
+ /** Drop session-scoped Zustand slices so the next user/account never inherits stale UI state. */
5
+ export function resetSessionScopedStores(): void {
6
+ useFollowStore.getState().resetFollowState();
7
+ useFileStore.getState().reset();
8
+ }