@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.
- package/lib/commonjs/ui/components/FollowButton.js +2 -2
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/components/FollowTargetButton.js +37 -8
- package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollow.js +6 -6
- package/lib/commonjs/ui/hooks/useFollow.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollowTarget.js +6 -2
- package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/commonjs/ui/stores/followStore.js +2 -0
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +2 -2
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/components/FollowTargetButton.js +36 -7
- package/lib/module/ui/components/FollowTargetButton.js.map +1 -1
- package/lib/module/ui/hooks/useFollow.js +6 -6
- package/lib/module/ui/hooks/useFollow.js.map +1 -1
- package/lib/module/ui/hooks/useFollowTarget.js +6 -2
- package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/module/ui/stores/followStore.js +2 -0
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +24 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +24 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/useFollow.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/ui/components/FollowButton.tsx +2 -2
- package/src/ui/components/FollowTargetButton.tsx +53 -8
- package/src/ui/components/__tests__/followTargetButton.test.ts +56 -1
- package/src/ui/hooks/useFollow.ts +7 -7
- package/src/ui/hooks/useFollowTarget.ts +7 -3
- package/src/ui/stores/__tests__/followStore.test.ts +7 -4
- 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
|
-
|
|
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?.(
|
|
277
|
+
if (await enableHere()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE['enable-here']);
|
|
242
278
|
return;
|
|
243
279
|
case 'unfollow':
|
|
244
|
-
if (await unfollow()) onChange?.(
|
|
280
|
+
if (await unfollow()) onChange?.(FOLLOW_ACTION_LEAVES_ACTIVE.unfollow);
|
|
245
281
|
return;
|
|
246
282
|
case 'follow':
|
|
247
|
-
if (await follow()) onChange?.(
|
|
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?.(
|
|
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?.(
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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> => {
|