@oxyhq/services 22.2.0 → 22.4.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.
- package/lib/commonjs/index.js +6 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/FollowButton.js +24 -19
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +74 -94
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +0 -19
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -1
- package/lib/commonjs/ui/context/useOxyAccountGraph.js +2 -0
- package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollow.js +53 -10
- package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -0
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/commonjs/ui/index.js +6 -0
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/stores/followStore.js +276 -92
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js +14 -0
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -0
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +24 -19
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +75 -95
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/oxyAuthChooserStyles.js +0 -19
- package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
- package/lib/module/ui/context/useOxyAccountGraph.js +2 -0
- package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
- package/lib/module/ui/hooks/useFollow.js +51 -9
- package/lib/module/ui/hooks/useFollow.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +2 -0
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/index.js +1 -1
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/stores/followStore.js +274 -91
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/module/ui/stores/resetSessionScopedStores.js +11 -0
- package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +0 -19
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +13 -2
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts +4 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +3 -0
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +0 -19
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +3 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts +13 -2
- package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts +1 -1
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts +4 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +3 -0
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +1 -1
- package/src/ui/components/FollowButton.tsx +22 -20
- package/src/ui/components/OxyAuthChooser.tsx +87 -75
- package/src/ui/components/oxyAuthChooserStyles.ts +0 -19
- package/src/ui/context/useOxyAccountGraph.ts +2 -0
- package/src/ui/hooks/useFollow.ts +51 -9
- package/src/ui/hooks/useSessionManagement.ts +2 -0
- package/src/ui/index.ts +1 -1
- package/src/ui/stores/__tests__/followStore.test.ts +178 -0
- package/src/ui/stores/followStore.ts +277 -106
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: () =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
107
|
-
const
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
229
|
-
const
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|