@oxyhq/services 27.1.1 → 27.1.3

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 (60) hide show
  1. package/lib/commonjs/index.js +18 -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 +53 -13
  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 +17 -4
  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/commonjs/ui/stores/followTargetStore.js +12 -0
  14. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
  15. package/lib/module/index.js +7 -1
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/FollowButton.js +2 -2
  18. package/lib/module/ui/components/FollowButton.js.map +1 -1
  19. package/lib/module/ui/components/FollowTargetButton.js +52 -12
  20. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  21. package/lib/module/ui/hooks/useFollow.js +6 -6
  22. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  23. package/lib/module/ui/hooks/useFollowTarget.js +18 -5
  24. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
  25. package/lib/module/ui/stores/followStore.js +2 -0
  26. package/lib/module/ui/stores/followStore.js.map +1 -1
  27. package/lib/module/ui/stores/followTargetStore.js +11 -0
  28. package/lib/module/ui/stores/followTargetStore.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +1 -1
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +30 -2
  32. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +1 -1
  34. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +1 -1
  37. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +7 -0
  39. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
  40. package/lib/typescript/module/index.d.ts +1 -1
  41. package/lib/typescript/module/index.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +30 -2
  43. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
  44. package/lib/typescript/module/ui/hooks/useFollow.d.ts +1 -1
  45. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
  47. package/lib/typescript/module/ui/stores/followStore.d.ts +1 -1
  48. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +7 -0
  50. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
  51. package/package.json +2 -2
  52. package/src/index.ts +11 -1
  53. package/src/ui/components/FollowButton.tsx +2 -2
  54. package/src/ui/components/FollowTargetButton.tsx +81 -14
  55. package/src/ui/components/__tests__/followTargetButton.test.ts +68 -1
  56. package/src/ui/hooks/useFollow.ts +7 -7
  57. package/src/ui/hooks/useFollowTarget.ts +19 -7
  58. package/src/ui/stores/__tests__/followStore.test.ts +7 -4
  59. package/src/ui/stores/followStore.ts +3 -1
  60. package/src/ui/stores/followTargetStore.ts +11 -0
@@ -32,7 +32,7 @@
32
32
  */
33
33
 
34
34
  import { memo, useCallback, useMemo } from 'react';
35
- import type { FollowApplicationMode } from '@oxyhq/contracts';
35
+ import type { FollowApplicationMode, FollowStatus } from '@oxyhq/contracts';
36
36
  import type { StyleProp, ViewStyle } from 'react-native';
37
37
  import { View } from 'react-native';
38
38
  import { Button } from '@oxyhq/bloom/button';
@@ -40,6 +40,7 @@ import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown } from '@oxyhq/blo
40
40
  import { Menu, MenuContent, MenuItem, MenuItemText, MenuTrigger } from '@oxyhq/bloom/menu';
41
41
  import { toast } from '@oxyhq/bloom/toast';
42
42
  import { useFollowTarget } from '../hooks/useFollowTarget';
43
+ import { useFollowTargetStore } from '../stores/followTargetStore';
43
44
 
44
45
  /**
45
46
  * The small vocabulary of verbs the ecosystem actually uses. An application
@@ -90,6 +91,29 @@ const DEFAULT_DURATIONS: FollowDuration[] = [
90
91
  { label: 'A week', seconds: 7 * 24 * HOUR },
91
92
  ];
92
93
 
94
+ /**
95
+ * Whether an action leaves the target ACTIVE in this application.
96
+ *
97
+ * One table, read by both the primary button and the menu, because the same
98
+ * action reached from two controls once reported differently — and a rule
99
+ * living in two switch statements is a rule that drifts. `Record` rather than
100
+ * a function with a default, so a new action is a compile error instead of a
101
+ * silent `false`.
102
+ */
103
+ export const FOLLOW_ACTION_LEAVES_ACTIVE: Record<
104
+ 'follow' | 'follow-timed' | 'enable-here' | 'disable-here' | 'unfollow',
105
+ boolean
106
+ > = {
107
+ follow: true,
108
+ 'follow-timed': true,
109
+ // Re-enabling here does not change the global follow — the user already had
110
+ // it — but it does change whether this application acts on it, which is what
111
+ // a mirror is asking about.
112
+ 'enable-here': true,
113
+ 'disable-here': false,
114
+ unfollow: false,
115
+ };
116
+
93
117
  /** One line in the disclosure menu. */
94
118
  export interface FollowMenuItem {
95
119
  key: string;
@@ -197,7 +221,25 @@ export interface FollowTargetButtonProps {
197
221
  * application" is not.
198
222
  */
199
223
  applicationName?: string;
200
- onChange?: (following: boolean) => void;
224
+ /**
225
+ * Seed status from a list/feed payload. Incomplete rows (e.g. following without
226
+ * `relationshipId`) still trigger an authoritative refresh once private API is ready.
227
+ */
228
+ initialStatus?: FollowStatus;
229
+ /**
230
+ * Fires when the server accepts a change, with whether this application
231
+ * should act on the follow NOW — the effective state, not the global one.
232
+ *
233
+ * That is the question an application mirroring the follow is actually
234
+ * asking: a user who picks "Don't show in Mercaria" still follows the shop
235
+ * everywhere else, but the shop must leave Mercaria's own shelf, which is
236
+ * the entire purpose of that menu item. Reporting the global state instead
237
+ * would leave it there.
238
+ *
239
+ * Never fires for a refused write — the mutations resolve to whether the
240
+ * server accepted, and only an accepted one gets here.
241
+ */
242
+ onChange?: (activeHere: boolean) => void;
201
243
  }
202
244
 
203
245
  export const FollowTargetButton = memo(function FollowTargetButton({
@@ -210,10 +252,17 @@ export const FollowTargetButton = memo(function FollowTargetButton({
210
252
  showOptions = true,
211
253
  durations = DEFAULT_DURATIONS,
212
254
  applicationName,
255
+ initialStatus,
213
256
  onChange,
214
257
  }: FollowTargetButtonProps) {
215
258
  const { status, isFollowing, isUnknown, isPending, follow, unfollow, disableHere, enableHere } =
216
- useFollowTarget(targetId);
259
+ useFollowTarget(targetId, { initialStatus });
260
+
261
+ const reportMutationFailure = useCallback(() => {
262
+ const message =
263
+ useFollowTargetStore.getState().errors[targetId] ?? 'Could not update follow status';
264
+ toast.error(message);
265
+ }, [targetId]);
217
266
 
218
267
  const text = useMemo(() => ({ ...VERB_LABELS[verb], ...labels }), [verb, labels]);
219
268
 
@@ -238,26 +287,32 @@ export const FollowTargetButton = memo(function FollowTargetButton({
238
287
  const handlePrimary = useCallback(async () => {
239
288
  switch (resolveFollowPrimaryAction({ isFollowing, applicationMode: status.applicationMode })) {
240
289
  case 'enable-here':
241
- if (await enableHere()) onChange?.(true);
290
+ if (await enableHere()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
291
+ else reportMutationFailure();
242
292
  return;
243
293
  case 'unfollow':
244
- if (await unfollow()) onChange?.(false);
294
+ if (await unfollow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
295
+ else reportMutationFailure();
245
296
  return;
246
297
  case 'follow':
247
- if (await follow()) onChange?.(true);
298
+ if (await follow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.follow);
299
+ else reportMutationFailure();
248
300
  }
249
- }, [isFollowing, status.applicationMode, follow, unfollow, enableHere, onChange]);
301
+ }, [isFollowing, status.applicationMode, follow, unfollow, enableHere, onChange, reportMutationFailure]);
250
302
 
251
303
  const handleTimed = useCallback(
252
304
  async (seconds: number, durationLabel: string) => {
253
- if (!(await follow({ expiresIn: seconds }))) return;
254
- onChange?.(true);
305
+ if (!(await follow({ expiresIn: seconds }))) {
306
+ reportMutationFailure();
307
+ return;
308
+ }
309
+ onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['follow-timed']);
255
310
  // The confirmation is the point: a follow that ends on its own is a
256
311
  // promise, and a user who does not see it made will not believe it.
257
312
  // Which is also why it must not appear when the promise was not made.
258
313
  toast.success(`Following for ${durationLabel.toLowerCase()}`);
259
314
  },
260
- [follow, onChange]
315
+ [follow, onChange, reportMutationFailure]
261
316
  );
262
317
 
263
318
  const menuItems = useMemo(
@@ -288,19 +343,31 @@ export const FollowTargetButton = memo(function FollowTargetButton({
288
343
  case 'follow-timed':
289
344
  void handleTimed(item.action.seconds, item.action.durationLabel);
290
345
  return;
346
+ // Every branch reports through `onChange`, and they must agree with
347
+ // the primary button: the same action reached from two controls that
348
+ // reported differently was a real bug — a user picking "don't show
349
+ // here" kept the target on the app's own shelf, which is the state the
350
+ // menu item exists to end.
291
351
  case 'enable-here':
292
- void enableHere();
352
+ void enableHere().then((ok) => {
353
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
354
+ else reportMutationFailure();
355
+ });
293
356
  return;
294
357
  case 'disable-here':
295
- void disableHere();
358
+ void disableHere().then((ok) => {
359
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['disable-here']);
360
+ else reportMutationFailure();
361
+ });
296
362
  return;
297
363
  case 'unfollow':
298
364
  void unfollow().then((ok) => {
299
- if (ok) onChange?.(false);
365
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
366
+ else reportMutationFailure();
300
367
  });
301
368
  }
302
369
  },
303
- [handleTimed, enableHere, disableHere, unfollow, onChange]
370
+ [handleTimed, enableHere, disableHere, unfollow, onChange, reportMutationFailure]
304
371
  );
305
372
 
306
373
  const primary = (
@@ -12,8 +12,13 @@
12
12
  * to reflect.
13
13
  */
14
14
 
15
- import { buildFollowMenuItems, resolveFollowPrimaryAction } from '../FollowTargetButton';
16
15
  import {
16
+ buildFollowMenuItems,
17
+ FOLLOW_ACTION_LEAVES_ACTIVE,
18
+ resolveFollowPrimaryAction,
19
+ } from '../FollowTargetButton';
20
+ import {
21
+ isCompleteFollowStatus,
17
22
  isFollowedGlobally,
18
23
  UNKNOWN_FOLLOW_STATUS,
19
24
  withApplicationMode,
@@ -149,4 +154,66 @@ describe('withApplicationMode', () => {
149
154
  );
150
155
  expect(isFollowedGlobally(UNKNOWN_FOLLOW_STATUS)).toBe(false);
151
156
  });
157
+
158
+ it('treats a following row without relationshipId as incomplete', () => {
159
+ expect(
160
+ isCompleteFollowStatus({
161
+ globalState: 'active',
162
+ applicationMode: 'inherit',
163
+ effectiveState: 'following',
164
+ })
165
+ ).toBe(false);
166
+ expect(isCompleteFollowStatus(UNKNOWN_FOLLOW_STATUS)).toBe(true);
167
+ });
168
+ });
169
+
170
+ describe('what onChange reports', () => {
171
+ // The bug: the same action reached from the primary button and from the menu
172
+ // reported differently. Someone picking "Don't show in Syra" kept the artist
173
+ // on Syra's own shelf and kept feeding its taste signal — the exact state
174
+ // that menu item exists to end.
175
+ //
176
+ // Both controls now read one table, so the two cannot drift apart again.
177
+ // These tests pin what the table SAYS; the `Record` type is what stops a new
178
+ // action from being omitted.
179
+
180
+ it('reports the EFFECTIVE state — does this app act on it now', () => {
181
+ expect(FOLLOW_ACTION_LEAVES_ACTIVE).toEqual({
182
+ follow: true,
183
+ 'follow-timed': true,
184
+ 'enable-here': true,
185
+ 'disable-here': false,
186
+ unfollow: false,
187
+ });
188
+ });
189
+
190
+ it('treats enable-here as active even though the global follow did not change', () => {
191
+ // The distinction that decides the whole question: a mirror is asking
192
+ // "should this appear in MY app", not "does the user follow it anywhere".
193
+ expect(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']).toBe(true);
194
+ expect(FOLLOW_ACTION_LEAVES_ACTIVE['disable-here']).toBe(false);
195
+ });
196
+
197
+ it('covers every action either control can produce', () => {
198
+ // A menu item whose action is missing from the table would report
199
+ // `undefined` — falsy, so it would silently read as "not active here".
200
+ const fromMenu = new Set(
201
+ [
202
+ ...buildFollowMenuItems({ ...base }),
203
+ ...buildFollowMenuItems({ ...base, following: true, hasRelationship: true }),
204
+ ...buildFollowMenuItems({
205
+ ...base,
206
+ following: true,
207
+ hasRelationship: true,
208
+ applicationMode: 'disabled',
209
+ }),
210
+ ].map((item) => item.action.type)
211
+ );
212
+ // Plus the two the primary button can produce that the menu cannot.
213
+ for (const action of [...fromMenu, 'follow', 'unfollow']) {
214
+ expect(typeof FOLLOW_ACTION_LEAVES_ACTIVE[action as keyof typeof FOLLOW_ACTION_LEAVES_ACTIVE])
215
+ .toBe('boolean');
216
+ }
217
+ expect(fromMenu.size).toBeGreaterThanOrEqual(3);
218
+ });
152
219
  });
@@ -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
@@ -19,6 +19,7 @@ import { useCallback, useEffect } from 'react';
19
19
  import type { FollowStatus } from '@oxyhq/contracts';
20
20
  import { useOxy } from '../context/OxyContext';
21
21
  import {
22
+ isCompleteFollowStatus,
22
23
  isFollowedGlobally,
23
24
  UNKNOWN_FOLLOW_STATUS,
24
25
  useFollowTargetStore,
@@ -90,15 +91,22 @@ export function useFollowTarget(
90
91
  useFollowTargetStore.getState().setStatus(targetId, next);
91
92
  useFollowTargetStore.getState().setError(targetId, undefined);
92
93
  } catch (e) {
93
- useFollowTargetStore
94
- .getState()
95
- .setError(targetId, e instanceof Error ? e.message : 'Could not read follow status');
94
+ const store = useFollowTargetStore.getState();
95
+ const message = e instanceof Error ? e.message : 'Could not read follow status';
96
+ // Leave `isUnknown` false so the button stays interactive and can retry.
97
+ // A failed read is not the same as "never asked" — disabling forever would
98
+ // strand the user with no affordance when the network blips.
99
+ if (!store.statuses[targetId]) {
100
+ store.setStatus(targetId, UNKNOWN_FOLLOW_STATUS);
101
+ }
102
+ store.setError(targetId, message);
96
103
  }
97
104
  }, [targetId, canUsePrivateApi, oxyServices]);
98
105
 
99
106
  useEffect(() => {
100
107
  if (!targetId || !canUsePrivateApi) return;
101
- if (useFollowTargetStore.getState().statuses[targetId]) return;
108
+ const existing = useFollowTargetStore.getState().statuses[targetId];
109
+ if (existing && isCompleteFollowStatus(existing)) return;
102
110
  void refresh();
103
111
  }, [targetId, canUsePrivateApi, refresh]);
104
112
 
@@ -110,7 +118,7 @@ export function useFollowTarget(
110
118
  const mutate = useCallback(
111
119
  async (
112
120
  optimistic: FollowStatus,
113
- run: () => Promise<FollowStatus | void>,
121
+ run: () => Promise<FollowStatus | undefined>,
114
122
  failureMessage: string
115
123
  ): Promise<boolean> => {
116
124
  if (!targetId) return false;
@@ -175,10 +183,12 @@ export function useFollowTarget(
175
183
  const disableHere = useCallback(async () => {
176
184
  const relationshipId = current.relationshipId;
177
185
  if (!targetId || !relationshipId) return false;
186
+ const optimistic = withApplicationMode(current, 'disabled');
178
187
  return mutate(
179
- withApplicationMode(current, 'disabled'),
188
+ optimistic,
180
189
  async () => {
181
190
  await oxyServices.setFollowApplicationMode(relationshipId, 'disabled');
191
+ return optimistic;
182
192
  },
183
193
  'Could not change this app’s setting for this follow'
184
194
  );
@@ -187,10 +197,12 @@ export function useFollowTarget(
187
197
  const enableHere = useCallback(async () => {
188
198
  const relationshipId = current.relationshipId;
189
199
  if (!targetId || !relationshipId) return false;
200
+ const optimistic = withApplicationMode(current, 'inherit');
190
201
  return mutate(
191
- withApplicationMode(current, 'inherit'),
202
+ optimistic,
192
203
  async () => {
193
204
  await oxyServices.restoreFollowInheritance(relationshipId);
205
+ return optimistic;
194
206
  },
195
207
  'Could not change this app’s setting for this follow'
196
208
  );
@@ -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> => {
@@ -93,6 +93,17 @@ export function isFollowedGlobally(status: FollowStatus): boolean {
93
93
  return status.globalState === 'active' || status.globalState === 'requested';
94
94
  }
95
95
 
96
+ /**
97
+ * Whether a cached status is safe to act on without a round trip.
98
+ *
99
+ * List payloads often carry `globalState` without `relationshipId`. Treating
100
+ * that as authoritative would leave disable/unfollow as no-ops forever.
101
+ */
102
+ export function isCompleteFollowStatus(status: FollowStatus): boolean {
103
+ if (isFollowedGlobally(status)) return Boolean(status.relationshipId);
104
+ return true;
105
+ }
106
+
96
107
  /**
97
108
  * Apply a mode change to a cached status without a round trip.
98
109
  *