@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.
- package/lib/commonjs/index.js +29 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/FollowButton.js +3 -5
- package/lib/commonjs/ui/components/FollowButton.js.map +1 -1
- package/lib/commonjs/ui/components/FollowTargetButton.js +292 -0
- package/lib/commonjs/ui/components/FollowTargetButton.js.map +1 -0
- package/lib/commonjs/ui/hooks/useFollowTarget.js +140 -0
- package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -0
- package/lib/commonjs/ui/screens/AccountMembersScreen.js +49 -6
- package/lib/commonjs/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/commonjs/ui/stores/followTargetStore.js +113 -0
- package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -0
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js +2 -0
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -1
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/FollowButton.js +3 -5
- package/lib/module/ui/components/FollowButton.js.map +1 -1
- package/lib/module/ui/components/FollowTargetButton.js +287 -0
- package/lib/module/ui/components/FollowTargetButton.js.map +1 -0
- package/lib/module/ui/hooks/useFollowTarget.js +136 -0
- package/lib/module/ui/hooks/useFollowTarget.js.map +1 -0
- package/lib/module/ui/screens/AccountMembersScreen.js +49 -6
- package/lib/module/ui/screens/AccountMembersScreen.js.map +1 -1
- package/lib/module/ui/stores/followTargetStore.js +107 -0
- package/lib/module/ui/stores/followTargetStore.js.map +1 -0
- package/lib/module/ui/stores/resetSessionScopedStores.js +2 -0
- package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +5 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +119 -0
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts +41 -0
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +70 -0
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +5 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +119 -0
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts +41 -0
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/AccountMembersScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts +70 -0
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -0
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +11 -0
- package/src/ui/components/FollowButton.tsx +3 -5
- package/src/ui/components/FollowTargetButton.tsx +326 -0
- package/src/ui/components/__tests__/followTargetButton.test.ts +132 -0
- package/src/ui/hooks/useFollowTarget.ts +194 -0
- package/src/ui/screens/AccountMembersScreen.tsx +62 -6
- package/src/ui/stores/__tests__/resetSessionScopedStores.test.ts +23 -0
- package/src/ui/stores/followTargetStore.ts +119 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
}
|