@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.
- package/lib/commonjs/index.js +12 -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 +75 -15
- 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 +22 -11
- 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/index.js +5 -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 +73 -14
- 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 +22 -11
- 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/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +35 -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 +13 -4
- 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/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +35 -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 +13 -4
- 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/index.ts +9 -1
- package/src/ui/components/FollowButton.tsx +2 -2
- package/src/ui/components/FollowTargetButton.tsx +89 -14
- package/src/ui/components/__tests__/followTargetButton.test.ts +79 -4
- package/src/ui/hooks/useFollow.ts +7 -7
- package/src/ui/hooks/useFollowTarget.ts +48 -27
- package/src/ui/stores/__tests__/followStore.test.ts +7 -4
- 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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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?.(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
112
|
-
expect(withApplicationMode(withApplicationMode(active, 'disabled'), '
|
|
113
|
-
applicationMode: '
|
|
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
|
|
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
|
|
@@ -36,11 +36,20 @@ export interface UseFollowTargetResult {
|
|
|
36
36
|
isUnknown: boolean;
|
|
37
37
|
isPending: boolean;
|
|
38
38
|
error: string | undefined;
|
|
39
|
-
|
|
40
|
-
|
|
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<
|
|
43
|
-
enableHere: () => Promise<
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
180
|
-
|
|
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
|
-
|
|
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> => {
|