@oxyhq/services 27.0.0 → 27.1.0

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 +29 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/FollowButton.js +3 -5
  4. package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
  5. package/lib/commonjs/ui/components/FollowTargetButton.js +292 -0
  6. package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -0
  7. package/lib/commonjs/ui/hooks/useFollowTarget.js +140 -0
  8. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -0
  9. package/lib/commonjs/ui/screens/AccountMembersScreen.js +49 -6
  10. package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
  11. package/lib/commonjs/ui/stores/followTargetStore.js +113 -0
  12. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -0
  13. package/lib/commonjs/ui/stores/resetSessionScopedStores.js +2 -0
  14. package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -1
  15. package/lib/module/index.js +4 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/FollowButton.js +3 -5
  18. package/lib/module/ui/components/FollowButton.js.map +1 -1
  19. package/lib/module/ui/components/FollowTargetButton.js +287 -0
  20. package/lib/module/ui/components/FollowTargetButton.js.map +1 -0
  21. package/lib/module/ui/hooks/useFollowTarget.js +136 -0
  22. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -0
  23. package/lib/module/ui/screens/AccountMembersScreen.js +49 -6
  24. package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
  25. package/lib/module/ui/stores/followTargetStore.js +107 -0
  26. package/lib/module/ui/stores/followTargetStore.js.map +1 -0
  27. package/lib/module/ui/stores/resetSessionScopedStores.js +2 -0
  28. package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +5 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +119 -0
  33. package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -0
  34. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +41 -0
  35. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +70 -0
  38. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
  40. package/lib/typescript/module/index.d.ts +5 -0
  41. package/lib/typescript/module/index.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +119 -0
  44. package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -0
  45. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +41 -0
  46. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -0
  47. package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +70 -0
  49. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -0
  50. package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
  51. package/package.json +3 -3
  52. package/src/index.ts +11 -0
  53. package/src/ui/components/FollowButton.tsx +3 -5
  54. package/src/ui/components/FollowTargetButton.tsx +326 -0
  55. package/src/ui/components/__tests__/followTargetButton.test.ts +132 -0
  56. package/src/ui/hooks/useFollowTarget.ts +194 -0
  57. package/src/ui/screens/AccountMembersScreen.tsx +62 -6
  58. package/src/ui/stores/__tests__/resetSessionScopedStores.test.ts +23 -0
  59. package/src/ui/stores/followTargetStore.ts +119 -0
  60. package/src/ui/stores/resetSessionScopedStores.ts +2 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `useFollowTarget` — the one hook every application uses to follow anything.
3
+ *
4
+ * Wraps the `/v2/follows` SDK methods with the store above, an optimistic
5
+ * update, and a rollback. Nothing in here knows what kind of thing it is
6
+ * following, which is the property that lets an application the SDK has never
7
+ * heard of use it without a release of this package.
8
+ *
9
+ * ## Optimism, and its one limit
10
+ *
11
+ * A follow flips the button immediately and rolls back if the server refuses,
12
+ * because the round trip is long enough to feel like a bug otherwise. But an
13
+ * optimistic follow has no relationship id until the server answers, and every
14
+ * other operation addresses the relationship — so those stay disabled for the
15
+ * width of one request rather than being sent with a guessed id.
16
+ */
17
+
18
+ import { useCallback, useEffect } from 'react';
19
+ import type { FollowStatus } from '@oxyhq/contracts';
20
+ import { useOxy } from '../context/OxyContext';
21
+ import {
22
+ isFollowedGlobally,
23
+ UNKNOWN_FOLLOW_STATUS,
24
+ useFollowTargetStore,
25
+ withApplicationMode,
26
+ } from '../stores/followTargetStore';
27
+
28
+ export interface UseFollowTargetResult {
29
+ status: FollowStatus;
30
+ /**
31
+ * Whether the user follows this at all — NOT `effectiveState`, which reports
32
+ * `not_following` for a follow merely switched off in this application.
33
+ */
34
+ isFollowing: boolean;
35
+ /** True until the first read settles. Distinct from "not following". */
36
+ isUnknown: boolean;
37
+ isPending: boolean;
38
+ error: string | undefined;
39
+ follow: (options?: { expiresIn?: number }) => Promise<void>;
40
+ unfollow: () => Promise<void>;
41
+ /** Stop acting on this follow HERE, without giving it up everywhere. */
42
+ disableHere: () => Promise<void>;
43
+ enableHere: () => Promise<void>;
44
+ refresh: () => Promise<void>;
45
+ }
46
+
47
+ export function useFollowTarget(
48
+ targetId: string | undefined,
49
+ options?: { initialStatus?: FollowStatus }
50
+ ): UseFollowTargetResult {
51
+ const { oxyServices, canUsePrivateApi } = useOxy();
52
+
53
+ const status = useFollowTargetStore(
54
+ useCallback((s) => (targetId ? s.statuses[targetId] : undefined), [targetId])
55
+ );
56
+ const isPending = useFollowTargetStore(
57
+ useCallback((s) => (targetId ? (s.pending[targetId] ?? false) : false), [targetId])
58
+ );
59
+ const error = useFollowTargetStore(
60
+ useCallback((s) => (targetId ? s.errors[targetId] : undefined), [targetId])
61
+ );
62
+
63
+ const initialStatus = options?.initialStatus;
64
+
65
+ // A caller that already knows the status — a follow list, a feed payload —
66
+ // seeds it rather than making this hook re-ask once per rendered row.
67
+ useEffect(() => {
68
+ if (!targetId || !initialStatus) return;
69
+ const store = useFollowTargetStore.getState();
70
+ if (!store.statuses[targetId]) store.setStatus(targetId, initialStatus);
71
+ }, [targetId, initialStatus]);
72
+
73
+ const refresh = useCallback(async () => {
74
+ // Gated on `canUsePrivateApi`, not on `isAuthenticated`: during the session
75
+ // cold boot the second is true well before the first, and a read sent in
76
+ // that window 401s and would leave the button stuck reporting an error the
77
+ // user cannot act on.
78
+ if (!targetId || !canUsePrivateApi) return;
79
+ try {
80
+ const next = await oxyServices.getFollowTargetStatus(targetId);
81
+ useFollowTargetStore.getState().setStatus(targetId, next);
82
+ useFollowTargetStore.getState().setError(targetId, undefined);
83
+ } catch (e) {
84
+ useFollowTargetStore
85
+ .getState()
86
+ .setError(targetId, e instanceof Error ? e.message : 'Could not read follow status');
87
+ }
88
+ }, [targetId, canUsePrivateApi, oxyServices]);
89
+
90
+ useEffect(() => {
91
+ if (!targetId || !canUsePrivateApi) return;
92
+ if (useFollowTargetStore.getState().statuses[targetId]) return;
93
+ void refresh();
94
+ }, [targetId, canUsePrivateApi, refresh]);
95
+
96
+ /**
97
+ * Run a mutation optimistically: show `optimistic` at once, keep the previous
98
+ * value, and put it back if the server refuses. Every mutation below goes
99
+ * through this so the rollback cannot be forgotten in one of them.
100
+ */
101
+ const mutate = useCallback(
102
+ async (
103
+ optimistic: FollowStatus,
104
+ run: () => Promise<FollowStatus | void>,
105
+ failureMessage: string
106
+ ) => {
107
+ if (!targetId) return;
108
+ const store = useFollowTargetStore.getState();
109
+ const previous = store.statuses[targetId];
110
+ store.setStatus(targetId, optimistic);
111
+ store.setPending(targetId, true);
112
+ store.setError(targetId, undefined);
113
+ try {
114
+ const settled = await run();
115
+ if (settled) useFollowTargetStore.getState().setStatus(targetId, settled);
116
+ } catch (e) {
117
+ const s = useFollowTargetStore.getState();
118
+ // Back to what was true, not to a guess. Clearing the entry instead
119
+ // would make the next render ask again and flash the wrong state.
120
+ if (previous) s.setStatus(targetId, previous);
121
+ s.setError(targetId, e instanceof Error ? e.message : failureMessage);
122
+ } finally {
123
+ useFollowTargetStore.getState().setPending(targetId, false);
124
+ }
125
+ },
126
+ [targetId]
127
+ );
128
+
129
+ const current = status ?? UNKNOWN_FOLLOW_STATUS;
130
+
131
+ const follow = useCallback(
132
+ async (opts?: { expiresIn?: number }) => {
133
+ if (!targetId) return;
134
+ await mutate(
135
+ {
136
+ ...withApplicationMode({ ...current, globalState: 'active' }, current.applicationMode),
137
+ ...(opts?.expiresIn
138
+ ? { expiresAt: new Date(Date.now() + opts.expiresIn * 1000).toISOString() }
139
+ : {}),
140
+ },
141
+ // The server returns the whole resulting status, so store it rather
142
+ // than reconstructing one: `effectiveState`'s derivation lives there,
143
+ // and a client recomputing it is a second implementation of one rule.
144
+ async () => (await oxyServices.followTarget(targetId, opts)).status,
145
+ 'Could not follow'
146
+ );
147
+ },
148
+ [targetId, current, mutate, oxyServices]
149
+ );
150
+
151
+ const unfollow = useCallback(async () => {
152
+ const relationshipId = current.relationshipId;
153
+ if (!targetId || !relationshipId) return;
154
+ await mutate(
155
+ { ...UNKNOWN_FOLLOW_STATUS },
156
+ async () => {
157
+ await oxyServices.unfollowTarget(relationshipId);
158
+ return { ...UNKNOWN_FOLLOW_STATUS };
159
+ },
160
+ 'Could not unfollow'
161
+ );
162
+ }, [targetId, current.relationshipId, mutate, oxyServices]);
163
+
164
+ const setMode = useCallback(
165
+ async (mode: 'enabled' | 'disabled') => {
166
+ const relationshipId = current.relationshipId;
167
+ if (!targetId || !relationshipId) return;
168
+ await mutate(
169
+ withApplicationMode(current, mode),
170
+ async () => {
171
+ await oxyServices.setFollowApplicationMode(relationshipId, mode);
172
+ },
173
+ 'Could not change this app’s setting for this follow'
174
+ );
175
+ },
176
+ [targetId, current, mutate, oxyServices]
177
+ );
178
+
179
+ const disableHere = useCallback(() => setMode('disabled'), [setMode]);
180
+ const enableHere = useCallback(() => setMode('enabled'), [setMode]);
181
+
182
+ return {
183
+ status: current,
184
+ isFollowing: isFollowedGlobally(current),
185
+ isUnknown: status === undefined,
186
+ isPending,
187
+ error,
188
+ follow,
189
+ unfollow,
190
+ disableHere,
191
+ enableHere,
192
+ refresh,
193
+ };
194
+ }
@@ -15,6 +15,7 @@ import { Button } from '@oxyhq/bloom/button';
15
15
  import { TextField, TextFieldInput } from '@oxyhq/bloom/text-field';
16
16
  import { Divider } from '@oxyhq/bloom/divider';
17
17
  import type { AccountMember, AccountRole } from '@oxyhq/core';
18
+ import { getNormalizedUserHandle } from '@oxyhq/core';
18
19
  import type { BaseScreenProps } from '../types/navigation';
19
20
  import { useOxy } from '../context/OxyContext';
20
21
  import { useI18n } from '../hooks/useI18n';
@@ -86,10 +87,49 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
86
87
  enabled: canUsePrivateApi && id.length > 0 && canRead,
87
88
  });
88
89
  const members = useMemo<AccountMember[]>(() => membersQuery.data ?? [], [membersQuery.data]);
89
- const ownerCount = useMemo(() => members.filter((m) => m.role === 'owner').length, [members]);
90
+ const memberUserIds = useMemo(
91
+ () => [...new Set(members.map((member) => member.memberUserId).filter((id) => id.length > 0))],
92
+ [members],
93
+ );
94
+ const memberProfilesQuery = useQuery({
95
+ queryKey: ['users', 'by-ids', memberUserIds],
96
+ queryFn: () => oxyServices.getUsersByIds(memberUserIds),
97
+ enabled: canUsePrivateApi && memberUserIds.length > 0,
98
+ });
99
+ const memberLabelByUserId = useMemo(() => {
100
+ const labels = new Map<string, string>();
101
+ for (const profile of memberProfilesQuery.data ?? []) {
102
+ // `getNormalizedUserHandle` answers null for an actor with no resolvable
103
+ // handle. Leaving that entry out is what lets `memberDisplayLabel`'s own
104
+ // fallback run, rather than storing a null the map is not typed to hold.
105
+ const handle = getNormalizedUserHandle(profile);
106
+ if (handle) labels.set(profile.id, handle);
107
+ }
108
+ return labels;
109
+ }, [memberProfilesQuery.data]);
110
+ const memberDisplayLabel = useCallback(
111
+ (memberUserId: string): string =>
112
+ memberLabelByUserId.get(memberUserId) ?? memberUserId,
113
+ [memberLabelByUserId],
114
+ );
115
+ // DIRECT owners only. The list also carries members whose row lives on an
116
+ // ancestor, and the last-owner rule this count feeds is about the rows on THIS
117
+ // account — the server's guard counts those. Including an inherited owner
118
+ // would make a child that has no owner of its own look like it has exactly
119
+ // one, and silently disable removing anybody.
120
+ const ownerCount = useMemo(
121
+ () =>
122
+ members.filter(
123
+ (m) => m.role === 'owner' && (m.source ?? 'direct') === 'direct',
124
+ ).length,
125
+ [members],
126
+ );
90
127
 
91
128
  const invalidateMembers = useCallback(() => {
92
- queryClient.invalidateQueries({ queryKey: ['accounts', 'members', id] });
129
+ // Inherited rows on descendant rosters derive from ancestor membership
130
+ // tables — mirror the SDK's per-account prefix sweep so every cached member
131
+ // list refetches, not only the account that was mutated.
132
+ queryClient.invalidateQueries({ queryKey: ['accounts', 'members'] });
93
133
  queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
94
134
  }, [queryClient, id]);
95
135
 
@@ -308,16 +348,25 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
308
348
  {members.map((member, index) => {
309
349
  const isOwner = member.role === 'owner';
310
350
  const isLastOwner = isOwner && ownerCount <= 1;
311
- const canEditThisRole = canUpdate && !isOwner;
312
- const canRemoveThisMember = canRemove && !isLastOwner && (!isOwner || canTransfer);
313
- const canTransferToThis = canTransfer && !isOwner && member.status === 'active';
351
+ // An INHERITED entry's row lives on an ancestor account, and
352
+ // every member mutation is scoped to rows on the account in
353
+ // the path so offering to edit, remove or promote one would
354
+ // be offering a request the server answers 404. The row is
355
+ // changed where it lives, on that ancestor's own member
356
+ // screen.
357
+ const isEditableHere = (member.source ?? 'direct') === 'direct';
358
+ const canEditThisRole = canUpdate && isEditableHere && !isOwner;
359
+ const canRemoveThisMember =
360
+ canRemove && isEditableHere && !isLastOwner && (!isOwner || canTransfer);
361
+ const canTransferToThis =
362
+ canTransfer && isEditableHere && !isOwner && member.status === 'active';
314
363
  return (
315
364
  <View key={member._id}>
316
365
  {index > 0 ? <Divider color={colors.border} spacing={0} /> : null}
317
366
  <View className="p-space-16 gap-space-8">
318
367
  <View className="flex-row items-center gap-space-8">
319
368
  <Text className="text-body font-bodyBold text-text flex-1" numberOfLines={1}>
320
- {member.memberUserId}
369
+ {memberDisplayLabel(member.memberUserId)}
321
370
  </Text>
322
371
  {isOwner ? (
323
372
  <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.primarySubtle }}>
@@ -326,6 +375,13 @@ const AccountMembersScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, acco
326
375
  </Text>
327
376
  </View>
328
377
  ) : null}
378
+ {!isEditableHere ? (
379
+ <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.card }}>
380
+ <Text className="text-caption font-caption text-text-secondary">
381
+ {t('accounts.members.inherited') || 'Inherited'}
382
+ </Text>
383
+ </View>
384
+ ) : null}
329
385
  {member.status !== 'active' ? (
330
386
  <View className="px-space-8 py-space-4 rounded-radius-full" style={{ backgroundColor: colors.card }}>
331
387
  <Text className="text-caption font-caption capitalize text-text-secondary">
@@ -0,0 +1,23 @@
1
+ import { useFollowStore } from '../followStore';
2
+ import { UNKNOWN_FOLLOW_STATUS, useFollowTargetStore } from '../followTargetStore';
3
+ import { resetSessionScopedStores } from '../resetSessionScopedStores';
4
+
5
+ describe('resetSessionScopedStores', () => {
6
+ beforeEach(() => {
7
+ useFollowStore.getState().resetFollowState();
8
+ useFollowTargetStore.getState().reset();
9
+ });
10
+
11
+ it('clears legacy follow state and the v2 follow-target cache', () => {
12
+ useFollowStore.getState().setFollowingStatus('user-1', true);
13
+ useFollowTargetStore.getState().setStatus('target-1', {
14
+ ...UNKNOWN_FOLLOW_STATUS,
15
+ effectiveState: 'following',
16
+ });
17
+
18
+ resetSessionScopedStores();
19
+
20
+ expect(useFollowStore.getState().followingUsers['user-1']).toBeUndefined();
21
+ expect(useFollowTargetStore.getState().statuses['target-1']).toBeUndefined();
22
+ });
23
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The follow-graph store — the client's single cache authority for #809.
3
+ *
4
+ * The SDK caches none of these reads on purpose (a status cached across a write
5
+ * is the "follow reverts after navigating away and back" bug), which makes this
6
+ * store the one place a status lives between a write and the next read. If a
7
+ * second cache appears above it, the two will disagree the moment one is
8
+ * invalidated and the other is not.
9
+ *
10
+ * ## Keyed by target id, not by user id
11
+ *
12
+ * The legacy `followStore` is keyed by user id because the only followable
13
+ * thing was a user. Here a key is a target of any kind — a topic, a store, an
14
+ * artist, a channel — so nothing in this file may assume what it is looking at.
15
+ * That is the property that lets an application the SDK has never heard of use
16
+ * it without a release.
17
+ *
18
+ * ## Why the status is stored whole
19
+ *
20
+ * Not a boolean. `globalState`, `applicationMode` and `effectiveState` are
21
+ * three separate answers, and a UI that keeps only the last one cannot explain
22
+ * why a follow the user can see in their list does nothing in this app.
23
+ */
24
+
25
+ import { create } from 'zustand';
26
+ import type { FollowApplicationMode, FollowStatus } from '@oxyhq/contracts';
27
+
28
+ /** The status of one target, or `undefined` when it has never been read. */
29
+ type StatusMap = Record<string, FollowStatus | undefined>;
30
+
31
+ interface FollowTargetState {
32
+ statuses: StatusMap;
33
+ /** In-flight writes, per target. Reads do not set this — only mutations do. */
34
+ pending: Record<string, boolean>;
35
+ errors: Record<string, string | undefined>;
36
+
37
+ setStatus: (targetId: string, status: FollowStatus) => void;
38
+ /**
39
+ * Seed many statuses at once — from a follow list, a feed payload, anything
40
+ * that already knows. Saves one request per rendered row, which is the
41
+ * difference between a list that paints and a list that flickers.
42
+ */
43
+ seed: (entries: Record<string, FollowStatus>) => void;
44
+ setPending: (targetId: string, pending: boolean) => void;
45
+ setError: (targetId: string, error: string | undefined) => void;
46
+ /** Drop everything. Called on identity change — see the note below. */
47
+ reset: () => void;
48
+ }
49
+
50
+ /**
51
+ * The state one target is in before the server has been asked. Distinct from
52
+ * "not following": a button that renders a follow action while the answer is
53
+ * unknown invites a follow the user did not intend, so callers check
54
+ * `isUnknown` rather than reading this as an answer.
55
+ */
56
+ export const UNKNOWN_FOLLOW_STATUS: FollowStatus = {
57
+ globalState: 'none',
58
+ applicationMode: 'inherit',
59
+ effectiveState: 'not_following',
60
+ };
61
+
62
+ export const useFollowTargetStore = create<FollowTargetState>((set) => ({
63
+ statuses: {},
64
+ pending: {},
65
+ errors: {},
66
+
67
+ setStatus: (targetId, status) =>
68
+ set((s) => ({ statuses: { ...s.statuses, [targetId]: status } })),
69
+
70
+ seed: (entries) => set((s) => ({ statuses: { ...s.statuses, ...entries } })),
71
+
72
+ setPending: (targetId, pending) =>
73
+ set((s) => ({ pending: { ...s.pending, [targetId]: pending } })),
74
+
75
+ setError: (targetId, error) => set((s) => ({ errors: { ...s.errors, [targetId]: error } })),
76
+
77
+ // Every entry here is scoped to whoever was signed in when it was read. On an
78
+ // account switch the whole map is wrong, not stale — the next user's follows
79
+ // are a different set entirely, and showing one user their previous
80
+ // account's follow state is a privacy failure rather than a rendering one.
81
+ reset: () => set({ statuses: {}, pending: {}, errors: {} }),
82
+ }));
83
+
84
+ /**
85
+ * Whether the user follows this at all, anywhere.
86
+ *
87
+ * Deliberately NOT `effectiveState !== 'not_following'`: a follow switched off
88
+ * in this application reports `not_following` — correctly, since the question
89
+ * that field answers is "does this act here" — and a button that read it as
90
+ * "not followed" would offer to follow something already followed.
91
+ */
92
+ export function isFollowedGlobally(status: FollowStatus): boolean {
93
+ return status.globalState === 'active' || status.globalState === 'requested';
94
+ }
95
+
96
+ /**
97
+ * Apply a mode change to a cached status without a round trip.
98
+ *
99
+ * Exported because the optimistic path and the settled path must agree on what
100
+ * `effectiveState` becomes; deriving it in two places is how they drift. Mirrors
101
+ * the server's own derivation, which is the authority.
102
+ */
103
+ export function withApplicationMode(
104
+ status: FollowStatus,
105
+ mode: FollowApplicationMode
106
+ ): FollowStatus {
107
+ return {
108
+ ...status,
109
+ applicationMode: mode,
110
+ effectiveState:
111
+ mode === 'disabled'
112
+ ? 'not_following'
113
+ : status.globalState === 'active'
114
+ ? 'following'
115
+ : status.globalState === 'requested'
116
+ ? 'requested'
117
+ : 'not_following',
118
+ };
119
+ }
@@ -1,4 +1,5 @@
1
1
  import { useFollowStore } from './followStore';
2
+ import { useFollowTargetStore } from './followTargetStore';
2
3
 
3
4
  /**
4
5
  * Drop session-scoped Zustand slices so the next user/account never inherits
@@ -10,4 +11,5 @@ import { useFollowStore } from './followStore';
10
11
  */
11
12
  export function resetSessionScopedStores(): void {
12
13
  useFollowStore.getState().resetFollowState();
14
+ useFollowTargetStore.getState().reset();
13
15
  }