@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.
- package/lib/commonjs/index.js +18 -0
- package/lib/commonjs/index.js.map +1 -1
- 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 +53 -13
- 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 +17 -4
- 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/commonjs/ui/stores/followTargetStore.js +12 -0
- package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
- package/lib/module/index.js +7 -1
- package/lib/module/index.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 +52 -12
- 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 +18 -5
- 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/module/ui/stores/followTargetStore.js +11 -0
- package/lib/module/ui/stores/followTargetStore.js.map +1 -1
- 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/FollowTargetButton.d.ts +30 -2
- 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/commonjs/ui/stores/followTargetStore.d.ts +7 -0
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
- 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/FollowTargetButton.d.ts +30 -2
- 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/lib/typescript/module/ui/stores/followTargetStore.d.ts +7 -0
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +11 -1
- package/src/ui/components/FollowButton.tsx +2 -2
- package/src/ui/components/FollowTargetButton.tsx +81 -14
- package/src/ui/components/__tests__/followTargetButton.test.ts +68 -1
- package/src/ui/hooks/useFollow.ts +7 -7
- package/src/ui/hooks/useFollowTarget.ts +19 -7
- package/src/ui/stores/__tests__/followStore.test.ts +7 -4
- package/src/ui/stores/followStore.ts +3 -1
- 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
|
-
|
|
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?.(
|
|
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?.(
|
|
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?.(
|
|
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 })))
|
|
254
|
-
|
|
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?.(
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> => {
|
|
@@ -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
|
*
|