@oxyhq/services 27.1.1 → 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 (42) hide show
  1. package/lib/commonjs/ui/components/FollowButton.js +2 -2
  2. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowTargetButton.js +37 -8
  4. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
  5. package/lib/commonjs/ui/hooks/useFollow.js +6 -6
  6. package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/useFollowTarget.js +6 -2
  8. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
  9. package/lib/commonjs/ui/stores/followStore.js +2 -0
  10. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  11. package/lib/module/ui/components/FollowButton.js +2 -2
  12. package/lib/module/ui/components/FollowButton.js.map +1 -1
  13. package/lib/module/ui/components/FollowTargetButton.js +36 -7
  14. package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
  15. package/lib/module/ui/hooks/useFollow.js +6 -6
  16. package/lib/module/ui/hooks/useFollow.js.map +1 -1
  17. package/lib/module/ui/hooks/useFollowTarget.js +6 -2
  18. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
  19. package/lib/module/ui/stores/followStore.js +2 -0
  20. package/lib/module/ui/stores/followStore.js.map +1 -1
  21. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +24 -1
  22. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +1 -1
  24. package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/stores/followStore.d.ts +1 -1
  27. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  28. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +24 -1
  29. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
  30. package/lib/typescript/module/ui/hooks/useFollow.d.ts +1 -1
  31. package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
  32. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
  33. package/lib/typescript/module/ui/stores/followStore.d.ts +1 -1
  34. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  35. package/package.json +2 -2
  36. package/src/ui/components/FollowButton.tsx +2 -2
  37. package/src/ui/components/FollowTargetButton.tsx +53 -8
  38. package/src/ui/components/__tests__/followTargetButton.test.ts +56 -1
  39. package/src/ui/hooks/useFollow.ts +7 -7
  40. package/src/ui/hooks/useFollowTarget.ts +7 -3
  41. package/src/ui/stores/__tests__/followStore.test.ts +7 -4
  42. package/src/ui/stores/followStore.ts +3 -1
@@ -88,8 +88,8 @@ const FollowButtonInner = memo(function FollowButtonInner({
88
88
  if (disabled || isLoading || !isKnown) return;
89
89
 
90
90
  try {
91
- await toggleFollow();
92
- onFollowChange?.(!isFollowing);
91
+ const accepted = await toggleFollow();
92
+ if (accepted) onFollowChange?.(!isFollowing);
93
93
  } catch (err: unknown) {
94
94
  const error = err instanceof Error ? err : new Error(String(err));
95
95
  toast.error(error.message || 'Failed to update follow status');
@@ -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;
@@ -197,7 +220,20 @@ export interface FollowTargetButtonProps {
197
220
  * application" is not.
198
221
  */
199
222
  applicationName?: string;
200
- 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;
201
237
  }
202
238
 
203
239
  export const FollowTargetButton = memo(function FollowTargetButton({
@@ -238,20 +274,20 @@ export const FollowTargetButton = memo(function FollowTargetButton({
238
274
  const handlePrimary = useCallback(async () => {
239
275
  switch (resolveFollowPrimaryAction({ isFollowing, applicationMode: status.applicationMode })) {
240
276
  case 'enable-here':
241
- if (await enableHere()) onChange?.(true);
277
+ if (await enableHere()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
242
278
  return;
243
279
  case 'unfollow':
244
- if (await unfollow()) onChange?.(false);
280
+ if (await unfollow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
245
281
  return;
246
282
  case 'follow':
247
- if (await follow()) onChange?.(true);
283
+ if (await follow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.follow);
248
284
  }
249
285
  }, [isFollowing, status.applicationMode, follow, unfollow, enableHere, onChange]);
250
286
 
251
287
  const handleTimed = useCallback(
252
288
  async (seconds: number, durationLabel: string) => {
253
289
  if (!(await follow({ expiresIn: seconds }))) return;
254
- onChange?.(true);
290
+ onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['follow-timed']);
255
291
  // The confirmation is the point: a follow that ends on its own is a
256
292
  // promise, and a user who does not see it made will not believe it.
257
293
  // Which is also why it must not appear when the promise was not made.
@@ -288,15 +324,24 @@ export const FollowTargetButton = memo(function FollowTargetButton({
288
324
  case 'follow-timed':
289
325
  void handleTimed(item.action.seconds, item.action.durationLabel);
290
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.
291
332
  case 'enable-here':
292
- void enableHere();
333
+ void enableHere().then((ok) => {
334
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
335
+ });
293
336
  return;
294
337
  case 'disable-here':
295
- void disableHere();
338
+ void disableHere().then((ok) => {
339
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['disable-here']);
340
+ });
296
341
  return;
297
342
  case 'unfollow':
298
343
  void unfollow().then((ok) => {
299
- if (ok) onChange?.(false);
344
+ if (ok) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
300
345
  });
301
346
  }
302
347
  },
@@ -12,7 +12,11 @@
12
12
  * to reflect.
13
13
  */
14
14
 
15
- import { buildFollowMenuItems, resolveFollowPrimaryAction } 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,
@@ -150,3 +154,54 @@ describe('withApplicationMode', () => {
150
154
  expect(isFollowedGlobally(UNKNOWN_FOLLOW_STATUS)).toBe(false);
151
155
  });
152
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
@@ -110,7 +110,7 @@ export function useFollowTarget(
110
110
  const mutate = useCallback(
111
111
  async (
112
112
  optimistic: FollowStatus,
113
- run: () => Promise<FollowStatus | void>,
113
+ run: () => Promise<FollowStatus | undefined>,
114
114
  failureMessage: string
115
115
  ): Promise<boolean> => {
116
116
  if (!targetId) return false;
@@ -175,10 +175,12 @@ export function useFollowTarget(
175
175
  const disableHere = useCallback(async () => {
176
176
  const relationshipId = current.relationshipId;
177
177
  if (!targetId || !relationshipId) return false;
178
+ const optimistic = withApplicationMode(current, 'disabled');
178
179
  return mutate(
179
- withApplicationMode(current, 'disabled'),
180
+ optimistic,
180
181
  async () => {
181
182
  await oxyServices.setFollowApplicationMode(relationshipId, 'disabled');
183
+ return optimistic;
182
184
  },
183
185
  'Could not change this app’s setting for this follow'
184
186
  );
@@ -187,10 +189,12 @@ export function useFollowTarget(
187
189
  const enableHere = useCallback(async () => {
188
190
  const relationshipId = current.relationshipId;
189
191
  if (!targetId || !relationshipId) return false;
192
+ const optimistic = withApplicationMode(current, 'inherit');
190
193
  return mutate(
191
- withApplicationMode(current, 'inherit'),
194
+ optimistic,
192
195
  async () => {
193
196
  await oxyServices.restoreFollowInheritance(relationshipId);
197
+ return optimistic;
194
198
  },
195
199
  'Could not change this app’s setting for this follow'
196
200
  );
@@ -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> => {