@oxyhq/services 27.1.0 → 27.1.2

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 (53) hide show
  1. package/lib/commonjs/index.js +12 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +2 -2
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/FollowTargetButton.js +75 -15
  6. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/useFollow.js +6 -6
  8. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  9. package/lib/commonjs/ui/hooks/useFollowTarget.js +22 -11
  10. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
  11. package/lib/commonjs/ui/stores/followStore.js +2 -0
  12. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  13. package/lib/module/index.js +5 -1
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/module/ui/components/FollowButton.js +2 -2
  16. package/lib/module/ui/components/FollowButton.js.map +1 -1
  17. package/lib/module/ui/components/FollowTargetButton.js +73 -14
  18. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  19. package/lib/module/ui/hooks/useFollow.js +6 -6
  20. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  21. package/lib/module/ui/hooks/useFollowTarget.js +22 -11
  22. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
  23. package/lib/module/ui/stores/followStore.js +2 -0
  24. package/lib/module/ui/stores/followStore.js.map +1 -1
  25. package/lib/typescript/commonjs/index.d.ts +1 -1
  26. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +35 -1
  28. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +1 -1
  30. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +13 -4
  32. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +1 -1
  34. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  35. package/lib/typescript/module/index.d.ts +1 -1
  36. package/lib/typescript/module/index.d.ts.map +1 -1
  37. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +35 -1
  38. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
  39. package/lib/typescript/module/ui/hooks/useFollow.d.ts +1 -1
  40. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +13 -4
  42. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/stores/followStore.d.ts +1 -1
  44. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/index.ts +9 -1
  47. package/src/ui/components/FollowButton.tsx +2 -2
  48. package/src/ui/components/FollowTargetButton.tsx +89 -14
  49. package/src/ui/components/__tests__/followTargetButton.test.ts +79 -4
  50. package/src/ui/hooks/useFollow.ts +7 -7
  51. package/src/ui/hooks/useFollowTarget.ts +48 -27
  52. package/src/ui/stores/__tests__/followStore.test.ts +7 -4
  53. package/src/ui/stores/followStore.ts +3 -1
@@ -90,6 +90,29 @@ const DEFAULT_DURATIONS: FollowDuration[] = [
90
90
  { label: 'A week', seconds: 7 * 24 * HOUR },
91
91
  ];
92
92
 
93
+ /**
94
+ * Whether an action leaves the target ACTIVE in this application.
95
+ *
96
+ * One table, read by both the primary button and the menu, because the same
97
+ * action reached from two controls once reported differently — and a rule
98
+ * living in two switch statements is a rule that drifts. `Record` rather than
99
+ * a function with a default, so a new action is a compile error instead of a
100
+ * silent `false`.
101
+ */
102
+ export const FOLLOW_ACTION_LEAVES_ACTIVE: Record<
103
+ 'follow' | 'follow-timed' | 'enable-here' | 'disable-here' | 'unfollow',
104
+ boolean
105
+ > = {
106
+ follow: true,
107
+ 'follow-timed': true,
108
+ // Re-enabling here does not change the global follow — the user already had
109
+ // it — but it does change whether this application acts on it, which is what
110
+ // a mirror is asking about.
111
+ 'enable-here': true,
112
+ 'disable-here': false,
113
+ unfollow: false,
114
+ };
115
+
93
116
  /** One line in the disclosure menu. */
94
117
  export interface FollowMenuItem {
95
118
  key: string;
@@ -111,6 +134,22 @@ export interface FollowMenuItem {
111
134
  * answered — every one of them addresses a relationship that does not exist
112
135
  * yet, so offering them mid-write would mean sending a guessed id.
113
136
  */
137
+ /**
138
+ * What the main button should do for the current state.
139
+ *
140
+ * Exported because the product rule is not obvious from the label alone: a
141
+ * follow switched off here still reads as "following" globally, so the primary
142
+ * press must re-enable here — not unfollow everywhere.
143
+ */
144
+ export function resolveFollowPrimaryAction(input: {
145
+ isFollowing: boolean;
146
+ applicationMode: FollowApplicationMode;
147
+ }): 'follow' | 'unfollow' | 'enable-here' {
148
+ if (!input.isFollowing) return 'follow';
149
+ if (input.applicationMode === 'disabled') return 'enable-here';
150
+ return 'unfollow';
151
+ }
152
+
114
153
  export function buildFollowMenuItems(input: {
115
154
  following: boolean;
116
155
  applicationMode: FollowApplicationMode;
@@ -181,7 +220,20 @@ export interface FollowTargetButtonProps {
181
220
  * application" is not.
182
221
  */
183
222
  applicationName?: string;
184
- onChange?: (following: boolean) => void;
223
+ /**
224
+ * Fires when the server accepts a change, with whether this application
225
+ * should act on the follow NOW — the effective state, not the global one.
226
+ *
227
+ * That is the question an application mirroring the follow is actually
228
+ * asking: a user who picks "Don't show in Mercaria" still follows the shop
229
+ * everywhere else, but the shop must leave Mercaria's own shelf, which is
230
+ * the entire purpose of that menu item. Reporting the global state instead
231
+ * would leave it there.
232
+ *
233
+ * Never fires for a refused write — the mutations resolve to whether the
234
+ * server accepted, and only an accepted one gets here.
235
+ */
236
+ onChange?: (activeHere: boolean) => void;
185
237
  }
186
238
 
187
239
  export const FollowTargetButton = memo(function FollowTargetButton({
@@ -210,22 +262,35 @@ export const FollowTargetButton = memo(function FollowTargetButton({
210
262
  return status.applicationMode === 'disabled' ? text.disabled : text.active;
211
263
  }, [isFollowing, status.globalState, status.applicationMode, text]);
212
264
 
265
+ // `onChange` fires ONLY when the server accepted the write.
266
+ //
267
+ // The mutations never reject — a refusal becomes error state so the button
268
+ // can render it — so an unconditional `onChange` after `await` reports
269
+ // INTENT rather than outcome. A caller mirroring the follow somewhere else
270
+ // (a local shelf, a ranking signal) would then mirror failures too, and the
271
+ // button beside it would show the server's truth while the mirror showed a
272
+ // press that did not happen. Same shape of trap as reading `effectiveState`
273
+ // as "does the user follow this".
213
274
  const handlePrimary = useCallback(async () => {
214
- if (isFollowing) {
215
- await unfollow();
216
- onChange?.(false);
217
- return;
275
+ switch (resolveFollowPrimaryAction({ isFollowing, applicationMode: status.applicationMode })) {
276
+ case 'enable-here':
277
+ if (await enableHere()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
278
+ return;
279
+ case 'unfollow':
280
+ if (await unfollow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
281
+ return;
282
+ case 'follow':
283
+ if (await follow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.follow);
218
284
  }
219
- await follow();
220
- onChange?.(true);
221
- }, [isFollowing, follow, unfollow, onChange]);
285
+ }, [isFollowing, status.applicationMode, follow, unfollow, enableHere, onChange]);
222
286
 
223
287
  const handleTimed = useCallback(
224
288
  async (seconds: number, durationLabel: string) => {
225
- await follow({ expiresIn: seconds });
226
- onChange?.(true);
289
+ if (!(await follow({ expiresIn: seconds }))) return;
290
+ onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['follow-timed']);
227
291
  // The confirmation is the point: a follow that ends on its own is a
228
292
  // promise, and a user who does not see it made will not believe it.
293
+ // Which is also why it must not appear when the promise was not made.
229
294
  toast.success(`Following for ${durationLabel.toLowerCase()}`);
230
295
  },
231
296
  [follow, onChange]
@@ -259,15 +324,25 @@ export const FollowTargetButton = memo(function FollowTargetButton({
259
324
  case 'follow-timed':
260
325
  void handleTimed(item.action.seconds, item.action.durationLabel);
261
326
  return;
327
+ // Every branch reports through `onChange`, and they must agree with
328
+ // the primary button: the same action reached from two controls that
329
+ // reported differently was a real bug — a user picking "don't show
330
+ // here" kept the target on the app's own shelf, which is the state the
331
+ // menu item exists to end.
262
332
  case 'enable-here':
263
- void enableHere();
333
+ void enableHere().then((ok) => {
334
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
335
+ });
264
336
  return;
265
337
  case 'disable-here':
266
- void disableHere();
338
+ void disableHere().then((ok) => {
339
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['disable-here']);
340
+ });
267
341
  return;
268
342
  case 'unfollow':
269
- void unfollow();
270
- onChange?.(false);
343
+ void unfollow().then((ok) => {
344
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
345
+ });
271
346
  }
272
347
  },
273
348
  [handleTimed, enableHere, disableHere, unfollow, onChange]
@@ -12,7 +12,11 @@
12
12
  * to reflect.
13
13
  */
14
14
 
15
- import { buildFollowMenuItems } from '../FollowTargetButton';
15
+ import {
16
+ buildFollowMenuItems,
17
+ FOLLOW_ACTION_LEAVES_ACTIVE,
18
+ resolveFollowPrimaryAction,
19
+ } from '../FollowTargetButton';
16
20
  import {
17
21
  isFollowedGlobally,
18
22
  UNKNOWN_FOLLOW_STATUS,
@@ -36,6 +40,26 @@ const base = {
36
40
 
37
41
  const keys = (items: ReturnType<typeof buildFollowMenuItems>) => items.map((i) => i.key);
38
42
 
43
+ describe('resolveFollowPrimaryAction', () => {
44
+ it('follows when not following globally', () => {
45
+ expect(resolveFollowPrimaryAction({ isFollowing: false, applicationMode: 'inherit' })).toBe(
46
+ 'follow'
47
+ );
48
+ });
49
+
50
+ it('unfollows when following and active here', () => {
51
+ expect(resolveFollowPrimaryAction({ isFollowing: true, applicationMode: 'inherit' })).toBe(
52
+ 'unfollow'
53
+ );
54
+ });
55
+
56
+ it('re-enables here instead of unfollowing everywhere when switched off in this app', () => {
57
+ expect(resolveFollowPrimaryAction({ isFollowing: true, applicationMode: 'disabled' })).toBe(
58
+ 'enable-here'
59
+ );
60
+ });
61
+ });
62
+
39
63
  describe('buildFollowMenuItems', () => {
40
64
  describe('before following', () => {
41
65
  it('offers the timed follows and nothing else', () => {
@@ -108,9 +132,9 @@ describe('withApplicationMode', () => {
108
132
  expect(isFollowedGlobally(next)).toBe(true);
109
133
  });
110
134
 
111
- it('restores the global state when enabled again', () => {
112
- expect(withApplicationMode(withApplicationMode(active, 'disabled'), 'enabled')).toMatchObject({
113
- applicationMode: 'enabled',
135
+ it('restores inheritance when enabled again', () => {
136
+ expect(withApplicationMode(withApplicationMode(active, 'disabled'), 'inherit')).toMatchObject({
137
+ applicationMode: 'inherit',
114
138
  effectiveState: 'following',
115
139
  });
116
140
  });
@@ -130,3 +154,54 @@ describe('withApplicationMode', () => {
130
154
  expect(isFollowedGlobally(UNKNOWN_FOLLOW_STATUS)).toBe(false);
131
155
  });
132
156
  });
157
+
158
+ describe('what onChange reports', () => {
159
+ // The bug: the same action reached from the primary button and from the menu
160
+ // reported differently. Someone picking "Don't show in Syra" kept the artist
161
+ // on Syra's own shelf and kept feeding its taste signal — the exact state
162
+ // that menu item exists to end.
163
+ //
164
+ // Both controls now read one table, so the two cannot drift apart again.
165
+ // These tests pin what the table SAYS; the `Record` type is what stops a new
166
+ // action from being omitted.
167
+
168
+ it('reports the EFFECTIVE state — does this app act on it now', () => {
169
+ expect(FOLLOW_ACTION_LEAVES_ACTIVE).toEqual({
170
+ follow: true,
171
+ 'follow-timed': true,
172
+ 'enable-here': true,
173
+ 'disable-here': false,
174
+ unfollow: false,
175
+ });
176
+ });
177
+
178
+ it('treats enable-here as active even though the global follow did not change', () => {
179
+ // The distinction that decides the whole question: a mirror is asking
180
+ // "should this appear in MY app", not "does the user follow it anywhere".
181
+ expect(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']).toBe(true);
182
+ expect(FOLLOW_ACTION_LEAVES_ACTIVE['disable-here']).toBe(false);
183
+ });
184
+
185
+ it('covers every action either control can produce', () => {
186
+ // A menu item whose action is missing from the table would report
187
+ // `undefined` — falsy, so it would silently read as "not active here".
188
+ const fromMenu = new Set(
189
+ [
190
+ ...buildFollowMenuItems({ ...base }),
191
+ ...buildFollowMenuItems({ ...base, following: true, hasRelationship: true }),
192
+ ...buildFollowMenuItems({
193
+ ...base,
194
+ following: true,
195
+ hasRelationship: true,
196
+ applicationMode: 'disabled',
197
+ }),
198
+ ].map((item) => item.action.type)
199
+ );
200
+ // Plus the two the primary button can produce that the menu cannot.
201
+ for (const action of [...fromMenu, 'follow', 'unfollow']) {
202
+ expect(typeof FOLLOW_ACTION_LEAVES_ACTIVE[action as keyof typeof FOLLOW_ACTION_LEAVES_ACTIVE])
203
+ .toBe('boolean');
204
+ }
205
+ expect(fromMenu.size).toBeGreaterThanOrEqual(3);
206
+ });
207
+ });
@@ -101,8 +101,8 @@ export const useFollow = (userId?: string | string[]) => {
101
101
  if (!isSingleUser || !userId) throw new Error('toggleFollow is only available for single user mode');
102
102
  if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
103
103
  const currentlyFollowing = useFollowStore.getState().followingUsers[userId] ?? false;
104
- await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
105
- patchCachedUserRelationship(queryClient, userId, !currentlyFollowing);
104
+ const accepted = await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
105
+ if (accepted) patchCachedUserRelationship(queryClient, userId, !currentlyFollowing);
106
106
  }, [isSingleUser, userId, canUsePrivateApi, oxyServices, queryClient]);
107
107
 
108
108
  const setFollowStatus = useCallback((following: boolean) => {
@@ -157,15 +157,15 @@ export const useFollow = (userId?: string | string[]) => {
157
157
  following: state.followingCounts[singleUserId] ?? null,
158
158
  };
159
159
  },
160
- enabled: !!singleUserId && followerCount === null && followingCount === null,
160
+ enabled: !!singleUserId && (followerCount === null || followingCount === null),
161
161
  });
162
162
 
163
163
  // Multi-user callbacks
164
164
  const toggleFollowForUser = useCallback(async (targetUserId: string) => {
165
165
  if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
166
166
  const currentState = useFollowStore.getState().followingUsers[targetUserId] ?? false;
167
- await useFollowStore.getState().toggleFollowUser(targetUserId, oxyServices, currentState);
168
- patchCachedUserRelationship(queryClient, targetUserId, !currentState);
167
+ const accepted = await useFollowStore.getState().toggleFollowUser(targetUserId, oxyServices, currentState);
168
+ if (accepted) patchCachedUserRelationship(queryClient, targetUserId, !currentState);
169
169
  }, [canUsePrivateApi, oxyServices, queryClient]);
170
170
 
171
171
  const setFollowStatusForUser = useCallback((targetUserId: string, following: boolean) => {
@@ -280,9 +280,9 @@ export const useFollowForButton = (userId: string, oxyServices: OxyServices, ini
280
280
  useCallback((s) => s.errors[userId] ?? null, [userId])
281
281
  );
282
282
 
283
- const toggleFollow = useCallback(async () => {
283
+ const toggleFollow = useCallback(async (): Promise<boolean> => {
284
284
  const currentlyFollowing = useFollowStore.getState().followingUsers[userId] ?? false;
285
- await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
285
+ return useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
286
286
  }, [userId, oxyServices]);
287
287
 
288
288
  // Enqueue this id into the micro-batched resolver — every button that enqueues
@@ -36,11 +36,20 @@ export interface UseFollowTargetResult {
36
36
  isUnknown: boolean;
37
37
  isPending: boolean;
38
38
  error: string | undefined;
39
- follow: (options?: { expiresIn?: number }) => Promise<void>;
40
- unfollow: () => Promise<void>;
39
+ /**
40
+ * Every mutation resolves to whether the server ACCEPTED it.
41
+ *
42
+ * They never reject — a refusal becomes `error` in the store, so the button
43
+ * can render it — which means a caller that only awaits them cannot tell a
44
+ * write that happened from one that did not. Returning the outcome is what
45
+ * lets a caller mirror the change somewhere else without mirroring failures
46
+ * too.
47
+ */
48
+ follow: (options?: { expiresIn?: number }) => Promise<boolean>;
49
+ unfollow: () => Promise<boolean>;
41
50
  /** Stop acting on this follow HERE, without giving it up everywhere. */
42
- disableHere: () => Promise<void>;
43
- enableHere: () => Promise<void>;
51
+ disableHere: () => Promise<boolean>;
52
+ enableHere: () => Promise<boolean>;
44
53
  refresh: () => Promise<void>;
45
54
  }
46
55
 
@@ -101,10 +110,10 @@ export function useFollowTarget(
101
110
  const mutate = useCallback(
102
111
  async (
103
112
  optimistic: FollowStatus,
104
- run: () => Promise<FollowStatus | void>,
113
+ run: () => Promise<FollowStatus | undefined>,
105
114
  failureMessage: string
106
- ) => {
107
- if (!targetId) return;
115
+ ): Promise<boolean> => {
116
+ if (!targetId) return false;
108
117
  const store = useFollowTargetStore.getState();
109
118
  const previous = store.statuses[targetId];
110
119
  store.setStatus(targetId, optimistic);
@@ -113,12 +122,14 @@ export function useFollowTarget(
113
122
  try {
114
123
  const settled = await run();
115
124
  if (settled) useFollowTargetStore.getState().setStatus(targetId, settled);
125
+ return true;
116
126
  } catch (e) {
117
127
  const s = useFollowTargetStore.getState();
118
128
  // Back to what was true, not to a guess. Clearing the entry instead
119
129
  // would make the next render ask again and flash the wrong state.
120
130
  if (previous) s.setStatus(targetId, previous);
121
131
  s.setError(targetId, e instanceof Error ? e.message : failureMessage);
132
+ return false;
122
133
  } finally {
123
134
  useFollowTargetStore.getState().setPending(targetId, false);
124
135
  }
@@ -130,8 +141,8 @@ export function useFollowTarget(
130
141
 
131
142
  const follow = useCallback(
132
143
  async (opts?: { expiresIn?: number }) => {
133
- if (!targetId) return;
134
- await mutate(
144
+ if (!targetId) return false;
145
+ return mutate(
135
146
  {
136
147
  ...withApplicationMode({ ...current, globalState: 'active' }, current.applicationMode),
137
148
  ...(opts?.expiresIn
@@ -150,8 +161,8 @@ export function useFollowTarget(
150
161
 
151
162
  const unfollow = useCallback(async () => {
152
163
  const relationshipId = current.relationshipId;
153
- if (!targetId || !relationshipId) return;
154
- await mutate(
164
+ if (!targetId || !relationshipId) return false;
165
+ return mutate(
155
166
  { ...UNKNOWN_FOLLOW_STATUS },
156
167
  async () => {
157
168
  await oxyServices.unfollowTarget(relationshipId);
@@ -161,23 +172,33 @@ export function useFollowTarget(
161
172
  );
162
173
  }, [targetId, current.relationshipId, mutate, oxyServices]);
163
174
 
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
- );
175
+ const disableHere = useCallback(async () => {
176
+ const relationshipId = current.relationshipId;
177
+ if (!targetId || !relationshipId) return false;
178
+ const optimistic = withApplicationMode(current, 'disabled');
179
+ return mutate(
180
+ optimistic,
181
+ async () => {
182
+ await oxyServices.setFollowApplicationMode(relationshipId, 'disabled');
183
+ return optimistic;
184
+ },
185
+ 'Could not change this app’s setting for this follow'
186
+ );
187
+ }, [targetId, current, mutate, oxyServices]);
178
188
 
179
- const disableHere = useCallback(() => setMode('disabled'), [setMode]);
180
- const enableHere = useCallback(() => setMode('enabled'), [setMode]);
189
+ const enableHere = useCallback(async () => {
190
+ const relationshipId = current.relationshipId;
191
+ if (!targetId || !relationshipId) return false;
192
+ const optimistic = withApplicationMode(current, 'inherit');
193
+ return mutate(
194
+ optimistic,
195
+ async () => {
196
+ await oxyServices.restoreFollowInheritance(relationshipId);
197
+ return optimistic;
198
+ },
199
+ 'Could not change this app’s setting for this follow'
200
+ );
201
+ }, [targetId, current, mutate, oxyServices]);
181
202
 
182
203
  return {
183
204
  status: current,
@@ -116,7 +116,9 @@ describe('toggleFollowUser (optimistic)', () => {
116
116
  expect(useFollowStore.getState().loadingUsers.u1).toBe(true);
117
117
 
118
118
  resolveFollow({ message: 'ok', counts: { followers: 10, following: 3 } });
119
- await pending;
119
+ const accepted = await pending;
120
+
121
+ expect(accepted).toBe(true);
120
122
 
121
123
  expect(useFollowStore.getState().followingUsers.u1).toBe(true);
122
124
  expect(useFollowStore.getState().loadingUsers.u1).toBe(false);
@@ -132,9 +134,9 @@ describe('toggleFollowUser (optimistic)', () => {
132
134
  const store = useFollowStore.getState();
133
135
  store.setFollowStatuses({ u1: false });
134
136
 
135
- await store.toggleFollowUser('u1', services, false);
137
+ const accepted = await store.toggleFollowUser('u1', services, false);
136
138
 
137
- // Optimistic true was rolled back to the seeded false.
139
+ expect(accepted).toBe(false);
138
140
  expect(useFollowStore.getState().followingUsers.u1).toBe(false);
139
141
  expect(useFollowStore.getState().loadingUsers.u1).toBe(false);
140
142
  expect(useFollowStore.getState().errors.u1).toBe('boom');
@@ -145,8 +147,9 @@ describe('toggleFollowUser (optimistic)', () => {
145
147
  mock.followUser.mockRejectedValueOnce(new Error('nope'));
146
148
 
147
149
  const store = useFollowStore.getState();
148
- await store.toggleFollowUser('u1', services, false);
150
+ const accepted = await store.toggleFollowUser('u1', services, false);
149
151
 
152
+ expect(accepted).toBe(false);
150
153
  // The key is removed → back to UNKNOWN (tri-state), not a definite false.
151
154
  expect(Object.prototype.hasOwnProperty.call(useFollowStore.getState().followingUsers, 'u1')).toBe(false);
152
155
  });
@@ -27,7 +27,7 @@ interface FollowState {
27
27
  // requested within the same tick into ONE `getFollowStatuses` call. Ids that
28
28
  // are already known/seeded or already in flight are skipped.
29
29
  resolveFollowStatuses: (userIds: string[], oxyServices: OxyServices) => void;
30
- toggleFollowUser: (userId: string, oxyServices: OxyServices, isCurrentlyFollowing: boolean) => Promise<void>;
30
+ toggleFollowUser: (userId: string, oxyServices: OxyServices, isCurrentlyFollowing: boolean) => Promise<boolean>;
31
31
  // Bulk follow — follows MANY users in one network call; never unfollows.
32
32
  followManyUsers: (userIds: string[], oxyServices: OxyServices) => Promise<BulkFollowResult>;
33
33
  // Bulk unfollow — unfollows MANY users in one network call; idempotent, never follows.
@@ -237,6 +237,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
237
237
  return { followerCounts };
238
238
  });
239
239
  }
240
+ return true;
240
241
  } catch (error: unknown) {
241
242
  // Roll back to the prior value (or back to UNKNOWN if there was none).
242
243
  set((state) => {
@@ -252,6 +253,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
252
253
  errors: { ...state.errors, [userId]: (error instanceof Error ? error.message : null) || 'Failed to update follow status' },
253
254
  };
254
255
  });
256
+ return false;
255
257
  }
256
258
  },
257
259
  followManyUsers: async (userIds: string[], oxyServices: OxyServices): Promise<BulkFollowResult> => {