@oxyhq/services 26.2.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 +50 -1
- 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/components/authChooser/requestSurfaces.js +14 -6
- package/lib/commonjs/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js +16 -3
- package/lib/commonjs/ui/hooks/mutations/useAccountMutations.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/userCache.js +164 -10
- package/lib/commonjs/ui/hooks/queries/userCache.js.map +1 -1
- 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/screens/AccountSettingsScreen.js +6 -1
- package/lib/commonjs/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/CreateAccountScreen.js +73 -40
- package/lib/commonjs/ui/screens/CreateAccountScreen.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/commonjs/ui/utils/avatarUtils.js +5 -0
- package/lib/commonjs/ui/utils/avatarUtils.js.map +1 -1
- package/lib/module/index.js +8 -2
- 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/components/authChooser/requestSurfaces.js +15 -7
- package/lib/module/ui/components/authChooser/requestSurfaces.js.map +1 -1
- package/lib/module/ui/hooks/mutations/useAccountMutations.js +16 -3
- package/lib/module/ui/hooks/mutations/useAccountMutations.js.map +1 -1
- package/lib/module/ui/hooks/queries/userCache.js +161 -10
- package/lib/module/ui/hooks/queries/userCache.js.map +1 -1
- 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/screens/AccountSettingsScreen.js +6 -1
- package/lib/module/ui/screens/AccountSettingsScreen.js.map +1 -1
- package/lib/module/ui/screens/CreateAccountScreen.js +74 -41
- package/lib/module/ui/screens/CreateAccountScreen.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/module/ui/utils/avatarUtils.js +5 -0
- package/lib/module/ui/utils/avatarUtils.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +7 -2
- 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/components/authChooser/requestSurfaces.d.ts +6 -1
- package/lib/typescript/commonjs/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts +79 -2
- package/lib/typescript/commonjs/ui/hooks/queries/userCache.d.ts.map +1 -1
- 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/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/CreateAccountScreen.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/commonjs/ui/utils/avatarUtils.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +7 -2
- 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/components/authChooser/requestSurfaces.d.ts +6 -1
- package/lib/typescript/module/ui/components/authChooser/requestSurfaces.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/userCache.d.ts +79 -2
- package/lib/typescript/module/ui/hooks/queries/userCache.d.ts.map +1 -1
- 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/screens/AccountSettingsScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/CreateAccountScreen.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/lib/typescript/module/ui/utils/avatarUtils.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +26 -3
- 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/components/authChooser/requestSurfaces.tsx +12 -4
- package/src/ui/hooks/mutations/useAccountMutations.ts +20 -3
- package/src/ui/hooks/queries/__tests__/userCacheClear.test.ts +301 -0
- package/src/ui/hooks/queries/userCache.ts +211 -10
- package/src/ui/hooks/useFollowTarget.ts +194 -0
- package/src/ui/screens/AccountMembersScreen.tsx +62 -6
- package/src/ui/screens/AccountSettingsScreen.tsx +9 -1
- package/src/ui/screens/CreateAccountScreen.tsx +66 -36
- 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
- package/src/ui/utils/avatarUtils.ts +9 -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">
|
|
@@ -18,6 +18,10 @@ import { SettingsIcon } from '../components/SettingsIcon';
|
|
|
18
18
|
import { useOxy } from '../context/OxyContext';
|
|
19
19
|
import { useI18n } from '../hooks/useI18n';
|
|
20
20
|
import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
|
|
21
|
+
import {
|
|
22
|
+
clearedFieldsFromAccountUpdate,
|
|
23
|
+
upsertCachedUser,
|
|
24
|
+
} from '../hooks/queries/userCache';
|
|
21
25
|
|
|
22
26
|
const DISPLAY_NAME_MAX = MAX_DISPLAY_NAME_LENGTH;
|
|
23
27
|
const BIO_MAX = 160;
|
|
@@ -78,7 +82,11 @@ const AccountSettingsScreen: React.FC<BaseScreenProps> = ({ onClose, goBack, nav
|
|
|
78
82
|
const updateMutation = useMutation({
|
|
79
83
|
mutationKey: ['accounts', 'update', id],
|
|
80
84
|
mutationFn: (input: UpdateAccountInput) => oxyServices.updateAccount(id, input),
|
|
81
|
-
onSuccess: () => {
|
|
85
|
+
onSuccess: (updatedNode, input) => {
|
|
86
|
+
const cleared = clearedFieldsFromAccountUpdate(input);
|
|
87
|
+
upsertCachedUser(queryClient, updatedNode.account, user?.id, {
|
|
88
|
+
cleared: cleared.length > 0 ? cleared : undefined,
|
|
89
|
+
});
|
|
82
90
|
queryClient.invalidateQueries({ queryKey: ['accounts', 'detail', id] });
|
|
83
91
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
|
84
92
|
toast.success(t('accounts.settings.toasts.saved') || 'Account updated');
|
|
@@ -2,8 +2,8 @@ import type React from 'react';
|
|
|
2
2
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
3
3
|
import { View, ActivityIndicator } from 'react-native';
|
|
4
4
|
import Ionicons from '@expo/vector-icons/Ionicons';
|
|
5
|
-
import type { AccountKind, CreateAccountInput
|
|
6
|
-
import { DISPLAY_NAME_INVALID_MESSAGE, isValidDisplayName, MAX_DISPLAY_NAME_LENGTH,
|
|
5
|
+
import type { AccountCategoryId, AccountKind, CreateAccountInput } from '@oxyhq/core';
|
|
6
|
+
import { DISPLAY_NAME_INVALID_MESSAGE, isValidDisplayName, MAX_ACCOUNT_CATEGORIES, MAX_DISPLAY_NAME_LENGTH, SELECTABLE_ACCOUNT_CATEGORY_IDS } from '@oxyhq/core';
|
|
7
7
|
import type { BaseScreenProps } from '../types/navigation';
|
|
8
8
|
import { useI18n } from '../hooks/useI18n';
|
|
9
9
|
import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
|
|
@@ -83,23 +83,25 @@ const kindDescription = (
|
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* The visible label for a category id.
|
|
88
|
+
*
|
|
89
|
+
* A lookup, deliberately not a `switch` with a `default`: a `default` would let
|
|
90
|
+
* an id with no translation render as some OTHER category's label, silently and
|
|
91
|
+
* only in the languages that are missing it. Falling back to the raw id is ugly
|
|
92
|
+
* and correct — it is visibly wrong, and it names the missing key.
|
|
93
|
+
*/
|
|
94
|
+
const accountCategoryLabel = (
|
|
87
95
|
t: (key: string, vars?: Record<string, string | number>) => string,
|
|
88
|
-
category:
|
|
89
|
-
): string => {
|
|
90
|
-
switch (category) {
|
|
91
|
-
case 'agency':
|
|
92
|
-
return t('accounts.organizationCategory.agency');
|
|
93
|
-
case 'cooperative':
|
|
94
|
-
return t('accounts.organizationCategory.cooperative');
|
|
95
|
-
case 'landlord':
|
|
96
|
-
return t('accounts.organizationCategory.landlord');
|
|
97
|
-
default:
|
|
98
|
-
return t('accounts.organizationCategory.other');
|
|
99
|
-
}
|
|
100
|
-
};
|
|
96
|
+
category: AccountCategoryId,
|
|
97
|
+
): string => t(`accounts.accountCategory.${category}`) || category;
|
|
101
98
|
|
|
102
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Only the SELECTABLE ids are offered. The full `ACCOUNT_CATEGORY_IDS` still
|
|
101
|
+
* contains withdrawn ones so that accounts already carrying them keep working —
|
|
102
|
+
* offering them here would be how an account newly acquires one.
|
|
103
|
+
*/
|
|
104
|
+
const ACCOUNT_CATEGORY_OPTIONS: readonly AccountCategoryId[] = SELECTABLE_ACCOUNT_CATEGORY_IDS;
|
|
103
105
|
|
|
104
106
|
/**
|
|
105
107
|
* Create a new account in the unified account graph (an organization, project,
|
|
@@ -125,7 +127,12 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
125
127
|
const parentId = typeof parentAccountId === 'string' ? parentAccountId : undefined;
|
|
126
128
|
|
|
127
129
|
const [kind, setKind] = useState<CreatableAccountKind>('project');
|
|
128
|
-
|
|
130
|
+
/**
|
|
131
|
+
* ORDER IS THE DATA: index 0 is the primary category. Selecting appends,
|
|
132
|
+
* de-selecting removes, and neither sorts — so the list the user assembles is
|
|
133
|
+
* the list that is sent, and the first one they picked stays the primary.
|
|
134
|
+
*/
|
|
135
|
+
const [accountCategories, setAccountCategories] = useState<AccountCategoryId[]>([]);
|
|
129
136
|
const [username, setUsername] = useState('');
|
|
130
137
|
const [displayName, setDisplayName] = useState('');
|
|
131
138
|
const [displayNameError, setDisplayNameError] = useState('');
|
|
@@ -215,6 +222,23 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
215
222
|
);
|
|
216
223
|
}, [t]);
|
|
217
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Append on select, splice out on de-select. Never sorts — appending is what
|
|
227
|
+
* makes the FIRST category the user chose the primary one, and a sort would
|
|
228
|
+
* silently reassign that. De-selecting the primary promotes whatever the user
|
|
229
|
+
* picked next, which is the only interpretation that does not invent a choice
|
|
230
|
+
* on their behalf.
|
|
231
|
+
*/
|
|
232
|
+
const toggleAccountCategory = useCallback((category: AccountCategoryId) => {
|
|
233
|
+
setAccountCategories((current) => {
|
|
234
|
+
if (current.includes(category)) {
|
|
235
|
+
return current.filter((entry) => entry !== category);
|
|
236
|
+
}
|
|
237
|
+
if (current.length >= MAX_ACCOUNT_CATEGORIES) return current;
|
|
238
|
+
return [...current, category];
|
|
239
|
+
});
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
218
242
|
const canCreate = usernameStatus === 'available'
|
|
219
243
|
&& displayName.trim().length > 0
|
|
220
244
|
&& !displayNameError
|
|
@@ -225,17 +249,14 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
225
249
|
|
|
226
250
|
setIsCreating(true);
|
|
227
251
|
try {
|
|
228
|
-
// Split display name into first/last
|
|
229
|
-
const nameParts = displayName.trim().split(/\s+/);
|
|
230
|
-
const firstName = nameParts[0] || '';
|
|
231
|
-
const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined;
|
|
232
|
-
|
|
233
252
|
const input: CreateAccountInput = {
|
|
234
253
|
kind,
|
|
235
254
|
username,
|
|
236
|
-
name: {
|
|
255
|
+
name: { displayName: displayName.trim() },
|
|
237
256
|
bio: bio.trim() || undefined,
|
|
238
|
-
|
|
257
|
+
// Sent in the user's own order, or omitted entirely when empty — the
|
|
258
|
+
// API distinguishes "no categories" from "not stated" only by absence.
|
|
259
|
+
...(accountCategories.length > 0 ? { accountCategories } : null),
|
|
239
260
|
...(parentId ? { parentAccountId: parentId } : null),
|
|
240
261
|
};
|
|
241
262
|
const account = await createAccount(input);
|
|
@@ -265,7 +286,7 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
265
286
|
} finally {
|
|
266
287
|
setIsCreating(false);
|
|
267
288
|
}
|
|
268
|
-
}, [canCreate, kind,
|
|
289
|
+
}, [canCreate, kind, accountCategories, username, displayName, bio, parentId, createAccount, switchToAccount, onClose, t]);
|
|
269
290
|
|
|
270
291
|
// Status icon + color shown alongside the username field message
|
|
271
292
|
const usernameIsInvalid = usernameStatus === 'taken' || usernameStatus === 'invalid';
|
|
@@ -307,26 +328,35 @@ const CreateAccountScreen: React.FC<BaseScreenProps> = ({
|
|
|
307
328
|
})}
|
|
308
329
|
</SettingsListGroup>
|
|
309
330
|
|
|
310
|
-
{/*
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
331
|
+
{/* Categories — multi-select, shown for every kind this screen can create
|
|
332
|
+
(they are all non-personal). The badge on a selected row is its
|
|
333
|
+
POSITION, so the primary is legible as "1" rather than being a rule the
|
|
334
|
+
user has to be told. */}
|
|
335
|
+
<View className="gap-space-4">
|
|
336
|
+
<SettingsListGroup title={t('accounts.create.accountCategory.label')}>
|
|
337
|
+
{ACCOUNT_CATEGORY_OPTIONS.map((option) => {
|
|
338
|
+
const position = accountCategories.indexOf(option);
|
|
339
|
+
const selected = position >= 0;
|
|
340
|
+
const label = accountCategoryLabel(t, option);
|
|
315
341
|
return (
|
|
316
342
|
<SettingsListItem
|
|
317
343
|
key={option}
|
|
318
|
-
title={
|
|
319
|
-
|
|
344
|
+
title={label}
|
|
345
|
+
disabled={!selected && accountCategories.length >= MAX_ACCOUNT_CATEGORIES}
|
|
346
|
+
onPress={() => toggleAccountCategory(option)}
|
|
320
347
|
showChevron={false}
|
|
321
348
|
rightElement={selected ? (
|
|
322
|
-
<
|
|
349
|
+
<Text className="text-caption-1 font-semibold text-primary">{position + 1}</Text>
|
|
323
350
|
) : undefined}
|
|
324
|
-
accessibilityLabel={
|
|
351
|
+
accessibilityLabel={label}
|
|
325
352
|
/>
|
|
326
353
|
);
|
|
327
354
|
})}
|
|
328
355
|
</SettingsListGroup>
|
|
329
|
-
|
|
356
|
+
<Text className="text-caption font-caption text-text-tertiary px-space-4">
|
|
357
|
+
{t('accounts.create.accountCategory.hint', { max: MAX_ACCOUNT_CATEGORIES })}
|
|
358
|
+
</Text>
|
|
359
|
+
</View>
|
|
330
360
|
|
|
331
361
|
{/* Details — a grouped section card hosting the form fields */}
|
|
332
362
|
<SettingsListGroup title={t('accounts.create.detailsSection') || 'Details'}>
|
|
@@ -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
|
}
|
|
@@ -5,6 +5,10 @@ import { useAccountStore } from '../stores/accountStore';
|
|
|
5
5
|
import { useAuthStore } from '../stores/authStore';
|
|
6
6
|
import type { QueryClient } from '@tanstack/react-query';
|
|
7
7
|
import { queryKeys, invalidateUserQueries, invalidateAccountQueries } from '../hooks/queries/queryKeys';
|
|
8
|
+
import {
|
|
9
|
+
clearedFieldsFromProfileUpdate,
|
|
10
|
+
upsertCachedUser,
|
|
11
|
+
} from '../hooks/queries/userCache';
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Refreshes avatar in accountStore with cache-busted URL to force image reload.
|
|
@@ -60,6 +64,11 @@ export async function updateProfileWithAvatar(
|
|
|
60
64
|
// Update authStore so frontend components see the changes immediately
|
|
61
65
|
useAuthStore.getState().setUser(data);
|
|
62
66
|
|
|
67
|
+
const cleared = clearedFieldsFromProfileUpdate(updates);
|
|
68
|
+
upsertCachedUser(queryClient, data, data.id, {
|
|
69
|
+
cleared: cleared.length > 0 ? cleared : undefined,
|
|
70
|
+
});
|
|
71
|
+
|
|
63
72
|
// If avatar was updated, refresh accountStore with a cache-busted URL. An
|
|
64
73
|
// EMPTY avatar is a removal: clear both fields so the switcher falls back to
|
|
65
74
|
// initials instead of pointing at a download URL for the empty file id.
|