@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,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FollowTargetButton` — one button for everything an Oxy user can follow.
|
|
3
|
+
*
|
|
4
|
+
* Follows a TARGET of any registered kind: a person, a topic, a channel, a
|
|
5
|
+
* store, an artist. Nothing in this file branches on what the target is, which
|
|
6
|
+
* is the property that lets an application this package has never heard of use
|
|
7
|
+
* it without a release.
|
|
8
|
+
*
|
|
9
|
+
* ## Two controls, not one
|
|
10
|
+
*
|
|
11
|
+
* The main press does the obvious thing. The chevron beside it opens the
|
|
12
|
+
* choices that a single press cannot express — follow for a while, stop showing
|
|
13
|
+
* this here without giving it up everywhere — and those belong behind a
|
|
14
|
+
* disclosure precisely because they are not what most people want most of the
|
|
15
|
+
* time. Bloom's `Menu` renders as a bottom sheet on native and a dropdown on
|
|
16
|
+
* web, so this is one component and not a platform fork.
|
|
17
|
+
*
|
|
18
|
+
* ## Verbs
|
|
19
|
+
*
|
|
20
|
+
* "Follow" is wrong for half the things in the ecosystem: you subscribe to a
|
|
21
|
+
* channel, you join a community, you watch a listing. The verb is a prop with a
|
|
22
|
+
* small vocabulary of defaults and a full override, rather than a lookup keyed
|
|
23
|
+
* on kind — a kind→verb table in this package would need a release every time
|
|
24
|
+
* an application invents a kind, which is exactly the coupling #809 removes.
|
|
25
|
+
*
|
|
26
|
+
* ## Relationship to the legacy `FollowButton`
|
|
27
|
+
*
|
|
28
|
+
* `FollowButton` follows a USER through the Mongo-backed social graph and
|
|
29
|
+
* remains the correct component for that until the user graph is migrated onto
|
|
30
|
+
* `/v2/follows`. When it is, that component is deleted and this one takes the
|
|
31
|
+
* name — not aliased, not deprecated in place.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { memo, useCallback, useMemo } from 'react';
|
|
35
|
+
import type { FollowApplicationMode } from '@oxyhq/contracts';
|
|
36
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
37
|
+
import { View } from 'react-native';
|
|
38
|
+
import { Button } from '@oxyhq/bloom/button';
|
|
39
|
+
import { ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown } from '@oxyhq/bloom/icons';
|
|
40
|
+
import { Menu, MenuContent, MenuItem, MenuItemText, MenuTrigger } from '@oxyhq/bloom/menu';
|
|
41
|
+
import { toast } from '@oxyhq/bloom/toast';
|
|
42
|
+
import { useFollowTarget } from '../hooks/useFollowTarget';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The small vocabulary of verbs the ecosystem actually uses. An application
|
|
46
|
+
* whose verb is not here passes `labels` instead — the escape hatch exists so
|
|
47
|
+
* this union never has to grow to accommodate one product's wording.
|
|
48
|
+
*/
|
|
49
|
+
export type FollowVerb = 'follow' | 'subscribe' | 'join' | 'watch';
|
|
50
|
+
|
|
51
|
+
export interface FollowLabels {
|
|
52
|
+
/** Not following yet. */
|
|
53
|
+
idle: string;
|
|
54
|
+
/** Following, and this application acts on it. */
|
|
55
|
+
active: string;
|
|
56
|
+
/** Asked, waiting for the other side to accept. */
|
|
57
|
+
pending: string;
|
|
58
|
+
/** Following globally, but switched off in this application. */
|
|
59
|
+
disabled: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const VERB_LABELS: Record<FollowVerb, FollowLabels> = {
|
|
63
|
+
follow: { idle: 'Follow', active: 'Following', pending: 'Requested', disabled: 'Off here' },
|
|
64
|
+
subscribe: {
|
|
65
|
+
idle: 'Subscribe',
|
|
66
|
+
active: 'Subscribed',
|
|
67
|
+
pending: 'Requested',
|
|
68
|
+
disabled: 'Off here',
|
|
69
|
+
},
|
|
70
|
+
join: { idle: 'Join', active: 'Joined', pending: 'Requested', disabled: 'Off here' },
|
|
71
|
+
watch: { idle: 'Watch', active: 'Watching', pending: 'Requested', disabled: 'Off here' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A timed-follow choice offered in the menu. */
|
|
75
|
+
export interface FollowDuration {
|
|
76
|
+
label: string;
|
|
77
|
+
seconds: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const HOUR = 60 * 60;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Defaults chosen for the cases a timed follow is actually for: a live event
|
|
84
|
+
* tonight, a story running this week. Not a general-purpose duration picker —
|
|
85
|
+
* an application that needs one passes its own.
|
|
86
|
+
*/
|
|
87
|
+
const DEFAULT_DURATIONS: FollowDuration[] = [
|
|
88
|
+
{ label: '24 hours', seconds: 24 * HOUR },
|
|
89
|
+
{ label: '72 hours', seconds: 72 * HOUR },
|
|
90
|
+
{ label: 'A week', seconds: 7 * 24 * HOUR },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** One line in the disclosure menu. */
|
|
94
|
+
export interface FollowMenuItem {
|
|
95
|
+
key: string;
|
|
96
|
+
label: string;
|
|
97
|
+
/** What the component should call. Named so the table below stays pure. */
|
|
98
|
+
action:
|
|
99
|
+
| { type: 'follow-timed'; seconds: number; durationLabel: string }
|
|
100
|
+
| { type: 'enable-here' }
|
|
101
|
+
| { type: 'disable-here' }
|
|
102
|
+
| { type: 'unfollow' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Which choices exist, given the state.
|
|
107
|
+
*
|
|
108
|
+
* Pure and exported because this is the product decision, not a rendering
|
|
109
|
+
* detail: a timed follow is only offered before following, turning it off here
|
|
110
|
+
* is only offered while following, and NEITHER is offered before the server has
|
|
111
|
+
* answered — every one of them addresses a relationship that does not exist
|
|
112
|
+
* yet, so offering them mid-write would mean sending a guessed id.
|
|
113
|
+
*/
|
|
114
|
+
export function buildFollowMenuItems(input: {
|
|
115
|
+
following: boolean;
|
|
116
|
+
applicationMode: FollowApplicationMode;
|
|
117
|
+
hasRelationship: boolean;
|
|
118
|
+
isPending: boolean;
|
|
119
|
+
durations: FollowDuration[] | false;
|
|
120
|
+
idleVerb: string;
|
|
121
|
+
applicationName: string;
|
|
122
|
+
}): FollowMenuItem[] {
|
|
123
|
+
const items: FollowMenuItem[] = [];
|
|
124
|
+
|
|
125
|
+
if (!input.following) {
|
|
126
|
+
if (input.durations === false) return items;
|
|
127
|
+
for (const d of input.durations) {
|
|
128
|
+
items.push({
|
|
129
|
+
key: `for-${d.seconds}`,
|
|
130
|
+
label: `${input.idleVerb} for ${d.label.toLowerCase()}`,
|
|
131
|
+
action: { type: 'follow-timed', seconds: d.seconds, durationLabel: d.label },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return items;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!input.hasRelationship || input.isPending) return items;
|
|
138
|
+
|
|
139
|
+
items.push(
|
|
140
|
+
input.applicationMode === 'disabled'
|
|
141
|
+
? { key: 'enable-here', label: `Show in ${input.applicationName}`, action: { type: 'enable-here' } }
|
|
142
|
+
: {
|
|
143
|
+
key: 'disable-here',
|
|
144
|
+
label: `Don’t show in ${input.applicationName}`,
|
|
145
|
+
action: { type: 'disable-here' },
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
items.push({
|
|
149
|
+
// Named for what it does. "Unfollow" beside "don't show here" would read as
|
|
150
|
+
// the same action twice, and the user would pick the wrong one.
|
|
151
|
+
key: 'unfollow-everywhere',
|
|
152
|
+
label: 'Unfollow everywhere',
|
|
153
|
+
action: { type: 'unfollow' },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return items;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface FollowTargetButtonProps {
|
|
160
|
+
/** The registered target's id. Registration is separate — see the SDK. */
|
|
161
|
+
targetId: string;
|
|
162
|
+
verb?: FollowVerb;
|
|
163
|
+
/** Full override, for a verb the vocabulary above does not carry. */
|
|
164
|
+
labels?: Partial<FollowLabels>;
|
|
165
|
+
size?: 'small' | 'medium' | 'large';
|
|
166
|
+
style?: StyleProp<ViewStyle>;
|
|
167
|
+
disabled?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Hide the chevron. For a dense list row where the extra target is more
|
|
170
|
+
* likely to be hit by accident than used on purpose.
|
|
171
|
+
*/
|
|
172
|
+
showOptions?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* Offer timed follows. Pass `false` where a temporary follow makes no sense —
|
|
175
|
+
* a store you buy from, an account you know.
|
|
176
|
+
*/
|
|
177
|
+
durations?: FollowDuration[] | false;
|
|
178
|
+
/**
|
|
179
|
+
* This application's name, for the menu line that turns the follow off here.
|
|
180
|
+
* "Don't show in Mention" is a sentence the user can act on; "disable in this
|
|
181
|
+
* application" is not.
|
|
182
|
+
*/
|
|
183
|
+
applicationName?: string;
|
|
184
|
+
onChange?: (following: boolean) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const FollowTargetButton = memo(function FollowTargetButton({
|
|
188
|
+
targetId,
|
|
189
|
+
verb = 'follow',
|
|
190
|
+
labels,
|
|
191
|
+
size = 'medium',
|
|
192
|
+
style,
|
|
193
|
+
disabled = false,
|
|
194
|
+
showOptions = true,
|
|
195
|
+
durations = DEFAULT_DURATIONS,
|
|
196
|
+
applicationName,
|
|
197
|
+
onChange,
|
|
198
|
+
}: FollowTargetButtonProps) {
|
|
199
|
+
const { status, isFollowing, isUnknown, isPending, follow, unfollow, disableHere, enableHere } =
|
|
200
|
+
useFollowTarget(targetId);
|
|
201
|
+
|
|
202
|
+
const text = useMemo(() => ({ ...VERB_LABELS[verb], ...labels }), [verb, labels]);
|
|
203
|
+
|
|
204
|
+
const label = useMemo(() => {
|
|
205
|
+
if (status.globalState === 'requested') return text.pending;
|
|
206
|
+
if (!isFollowing) return text.idle;
|
|
207
|
+
// Followed globally but switched off here. `effectiveState` reports
|
|
208
|
+
// `not_following` for this, correctly — the question it answers is "does
|
|
209
|
+
// this act here" — so the label has to be derived from the other two.
|
|
210
|
+
return status.applicationMode === 'disabled' ? text.disabled : text.active;
|
|
211
|
+
}, [isFollowing, status.globalState, status.applicationMode, text]);
|
|
212
|
+
|
|
213
|
+
const handlePrimary = useCallback(async () => {
|
|
214
|
+
if (isFollowing) {
|
|
215
|
+
await unfollow();
|
|
216
|
+
onChange?.(false);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
await follow();
|
|
220
|
+
onChange?.(true);
|
|
221
|
+
}, [isFollowing, follow, unfollow, onChange]);
|
|
222
|
+
|
|
223
|
+
const handleTimed = useCallback(
|
|
224
|
+
async (seconds: number, durationLabel: string) => {
|
|
225
|
+
await follow({ expiresIn: seconds });
|
|
226
|
+
onChange?.(true);
|
|
227
|
+
// The confirmation is the point: a follow that ends on its own is a
|
|
228
|
+
// promise, and a user who does not see it made will not believe it.
|
|
229
|
+
toast.success(`Following for ${durationLabel.toLowerCase()}`);
|
|
230
|
+
},
|
|
231
|
+
[follow, onChange]
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const menuItems = useMemo(
|
|
235
|
+
() =>
|
|
236
|
+
buildFollowMenuItems({
|
|
237
|
+
following: isFollowing,
|
|
238
|
+
applicationMode: status.applicationMode,
|
|
239
|
+
hasRelationship: Boolean(status.relationshipId),
|
|
240
|
+
isPending,
|
|
241
|
+
durations,
|
|
242
|
+
idleVerb: text.idle,
|
|
243
|
+
applicationName: applicationName ?? 'this app',
|
|
244
|
+
}),
|
|
245
|
+
[
|
|
246
|
+
isFollowing,
|
|
247
|
+
status.applicationMode,
|
|
248
|
+
status.relationshipId,
|
|
249
|
+
isPending,
|
|
250
|
+
durations,
|
|
251
|
+
text.idle,
|
|
252
|
+
applicationName,
|
|
253
|
+
]
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const runItem = useCallback(
|
|
257
|
+
(item: FollowMenuItem) => {
|
|
258
|
+
switch (item.action.type) {
|
|
259
|
+
case 'follow-timed':
|
|
260
|
+
void handleTimed(item.action.seconds, item.action.durationLabel);
|
|
261
|
+
return;
|
|
262
|
+
case 'enable-here':
|
|
263
|
+
void enableHere();
|
|
264
|
+
return;
|
|
265
|
+
case 'disable-here':
|
|
266
|
+
void disableHere();
|
|
267
|
+
return;
|
|
268
|
+
case 'unfollow':
|
|
269
|
+
void unfollow();
|
|
270
|
+
onChange?.(false);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[handleTimed, enableHere, disableHere, unfollow, onChange]
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const primary = (
|
|
277
|
+
<Button
|
|
278
|
+
variant={isFollowing ? 'secondary' : 'primary'}
|
|
279
|
+
size={size}
|
|
280
|
+
// Unknown is not "not following": the button stays inert until the first
|
|
281
|
+
// read settles rather than inviting a follow that may already exist.
|
|
282
|
+
disabled={disabled || isUnknown}
|
|
283
|
+
loading={isPending}
|
|
284
|
+
onPress={() => void handlePrimary()}
|
|
285
|
+
accessibilityLabel={label}
|
|
286
|
+
>
|
|
287
|
+
{label}
|
|
288
|
+
</Button>
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (!showOptions || menuItems.length === 0) {
|
|
292
|
+
return <View style={style}>{primary}</View>;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<View style={[{ flexDirection: 'row', alignItems: 'center', gap: 4 }, style]}>
|
|
297
|
+
{primary}
|
|
298
|
+
<Menu>
|
|
299
|
+
<MenuTrigger label={`${label} options`} hint="Opens follow options">
|
|
300
|
+
{({ props }) => (
|
|
301
|
+
// The trigger's props are mapped rather than spread: `MenuTrigger`
|
|
302
|
+
// hands back a DOM-ish bag (focus handlers, an accessibility role)
|
|
303
|
+
// and `Button` accepts a different set, so a spread would pass
|
|
304
|
+
// props it silently drops.
|
|
305
|
+
<Button
|
|
306
|
+
variant="secondary"
|
|
307
|
+
size={size === 'large' ? 'large' : 'small'}
|
|
308
|
+
icon={<ChevronDown width={16} />}
|
|
309
|
+
disabled={disabled || isUnknown}
|
|
310
|
+
onPress={props.onPress}
|
|
311
|
+
accessibilityLabel={props.accessibilityLabel}
|
|
312
|
+
accessibilityHint={props.accessibilityHint}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
</MenuTrigger>
|
|
316
|
+
<MenuContent showCancel>
|
|
317
|
+
{menuItems.map((item) => (
|
|
318
|
+
<MenuItem key={item.key} label={item.label} onPress={() => runItem(item)}>
|
|
319
|
+
<MenuItemText>{item.label}</MenuItemText>
|
|
320
|
+
</MenuItem>
|
|
321
|
+
))}
|
|
322
|
+
</MenuContent>
|
|
323
|
+
</Menu>
|
|
324
|
+
</View>
|
|
325
|
+
);
|
|
326
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The follow button's product decisions, as a table.
|
|
3
|
+
*
|
|
4
|
+
* `buildFollowMenuItems` is pure and tested here rather than through a render
|
|
5
|
+
* because what matters is WHICH choices exist in which state — an assertion a
|
|
6
|
+
* rendering test would make about the DOM, one layout change away from being
|
|
7
|
+
* about nothing.
|
|
8
|
+
*
|
|
9
|
+
* `withApplicationMode` sits beside it because the two have to agree: the menu
|
|
10
|
+
* offers "don't show here", and the store computes what that does to the
|
|
11
|
+
* effective state. If they disagree the button offers something it then fails
|
|
12
|
+
* to reflect.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { buildFollowMenuItems } from '../FollowTargetButton';
|
|
16
|
+
import {
|
|
17
|
+
isFollowedGlobally,
|
|
18
|
+
UNKNOWN_FOLLOW_STATUS,
|
|
19
|
+
withApplicationMode,
|
|
20
|
+
} from '../../stores/followTargetStore';
|
|
21
|
+
|
|
22
|
+
const DURATIONS = [
|
|
23
|
+
{ label: '24 hours', seconds: 86400 },
|
|
24
|
+
{ label: 'A week', seconds: 604800 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const base = {
|
|
28
|
+
following: false,
|
|
29
|
+
applicationMode: 'inherit' as const,
|
|
30
|
+
hasRelationship: false,
|
|
31
|
+
isPending: false,
|
|
32
|
+
durations: DURATIONS,
|
|
33
|
+
idleVerb: 'Follow',
|
|
34
|
+
applicationName: 'Mention',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const keys = (items: ReturnType<typeof buildFollowMenuItems>) => items.map((i) => i.key);
|
|
38
|
+
|
|
39
|
+
describe('buildFollowMenuItems', () => {
|
|
40
|
+
describe('before following', () => {
|
|
41
|
+
it('offers the timed follows and nothing else', () => {
|
|
42
|
+
const items = buildFollowMenuItems(base);
|
|
43
|
+
expect(keys(items)).toEqual(['for-86400', 'for-604800']);
|
|
44
|
+
// Nothing that addresses a relationship, because there is none.
|
|
45
|
+
expect(keys(items).some((k) => k.includes('unfollow') || k.includes('here'))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('phrases the option with the application’s own verb', () => {
|
|
49
|
+
expect(buildFollowMenuItems({ ...base, idleVerb: 'Subscribe' })[0].label).toBe(
|
|
50
|
+
'Subscribe for 24 hours'
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('offers nothing when the application turned timed follows off', () => {
|
|
55
|
+
expect(buildFollowMenuItems({ ...base, durations: false })).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('while following', () => {
|
|
60
|
+
const following = { ...base, following: true, hasRelationship: true };
|
|
61
|
+
|
|
62
|
+
it('offers turning it off here, and unfollowing everywhere', () => {
|
|
63
|
+
expect(keys(buildFollowMenuItems(following))).toEqual([
|
|
64
|
+
'disable-here',
|
|
65
|
+
'unfollow-everywhere',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('names the application, so the line is a sentence the user can act on', () => {
|
|
70
|
+
expect(buildFollowMenuItems(following)[0].label).toBe('Don’t show in Mention');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('offers turning it back on once it is off here', () => {
|
|
74
|
+
const items = buildFollowMenuItems({ ...following, applicationMode: 'disabled' });
|
|
75
|
+
expect(keys(items)).toEqual(['enable-here', 'unfollow-everywhere']);
|
|
76
|
+
expect(items[0].label).toBe('Show in Mention');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('never offers a timed follow', () => {
|
|
80
|
+
// Extending a follow is a different operation from starting one, and
|
|
81
|
+
// offering "Follow for 24 hours" to somebody already following would read
|
|
82
|
+
// as shortening it.
|
|
83
|
+
expect(keys(buildFollowMenuItems(following)).some((k) => k.startsWith('for-'))).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('offers nothing while a write is in flight', () => {
|
|
87
|
+
// Every entry addresses the relationship id, which an optimistic follow
|
|
88
|
+
// does not have yet. Offering them here would mean sending a guess.
|
|
89
|
+
expect(buildFollowMenuItems({ ...following, isPending: true })).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('offers nothing when the relationship id is not known', () => {
|
|
93
|
+
expect(buildFollowMenuItems({ ...following, hasRelationship: false })).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('withApplicationMode', () => {
|
|
99
|
+
const active = { ...UNKNOWN_FOLLOW_STATUS, globalState: 'active' as const };
|
|
100
|
+
|
|
101
|
+
it('makes a disabled follow inactive without giving up the follow', () => {
|
|
102
|
+
const next = withApplicationMode(active, 'disabled');
|
|
103
|
+
expect(next.effectiveState).toBe('not_following');
|
|
104
|
+
// The distinction the whole design exists for, and the reason a client must
|
|
105
|
+
// never read `effectiveState` as "does the user follow this": the follow is
|
|
106
|
+
// still there globally.
|
|
107
|
+
expect(next.globalState).toBe('active');
|
|
108
|
+
expect(isFollowedGlobally(next)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('restores the global state when enabled again', () => {
|
|
112
|
+
expect(withApplicationMode(withApplicationMode(active, 'disabled'), 'enabled')).toMatchObject({
|
|
113
|
+
applicationMode: 'enabled',
|
|
114
|
+
effectiveState: 'following',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('keeps a requested follow requested rather than promoting it', () => {
|
|
119
|
+
const requested = { ...active, globalState: 'requested' as const };
|
|
120
|
+
expect(withApplicationMode(requested, 'enabled').effectiveState).toBe('requested');
|
|
121
|
+
// And a request in flight still counts as following, so the button offers
|
|
122
|
+
// to cancel rather than to ask again.
|
|
123
|
+
expect(isFollowedGlobally(requested)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('stays not-following when there is no global relationship to act on', () => {
|
|
127
|
+
expect(withApplicationMode(UNKNOWN_FOLLOW_STATUS, 'enabled').effectiveState).toBe(
|
|
128
|
+
'not_following'
|
|
129
|
+
);
|
|
130
|
+
expect(isFollowedGlobally(UNKNOWN_FOLLOW_STATUS)).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type React from 'react';
|
|
16
|
-
import { View } from 'react-native';
|
|
16
|
+
import { ActivityIndicator, View } from 'react-native';
|
|
17
17
|
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
|
|
18
18
|
import QRCode from 'react-native-qrcode-svg';
|
|
19
19
|
import { Button } from '@oxyhq/bloom/button';
|
|
20
|
-
import {
|
|
20
|
+
import { useTheme } from '@oxyhq/bloom/theme';
|
|
21
21
|
import { Text } from '@oxyhq/bloom/typography';
|
|
22
22
|
import type { CommonsDeliveryRoute, SignInProgress } from '@oxyhq/core';
|
|
23
23
|
import { authChooserStyles as styles } from './styles';
|
|
@@ -30,8 +30,16 @@ const QR_SIZE = 196;
|
|
|
30
30
|
/** The leading route glyph, sized to sit where the QR plate otherwise would. */
|
|
31
31
|
const ROUTE_GLYPH_SIZE = 44;
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The indeterminate leading visual: the request exists but has nothing to show yet.
|
|
35
|
+
* Uses RN's ActivityIndicator — `@oxyhq/bloom/loading` can tree-shake to `undefined`
|
|
36
|
+
* in rolldown-vite production bundles when co-imported with `@oxyhq/bloom/button`
|
|
37
|
+
* (auth.oxy.so/authorize blank screen, React #130).
|
|
38
|
+
*/
|
|
39
|
+
export const PreparingSurface: React.FC = () => {
|
|
40
|
+
const theme = useTheme();
|
|
41
|
+
return <ActivityIndicator size="large" color={theme.colors.primary} />;
|
|
42
|
+
};
|
|
35
43
|
|
|
36
44
|
/** The leading glyph for a route whose surface is elsewhere (the phone, Commons). */
|
|
37
45
|
export const RouteGlyph: React.FC<{
|
|
@@ -20,6 +20,11 @@ import { useOxy } from '../../context/OxyContext';
|
|
|
20
20
|
import { toast } from '@oxyhq/bloom/toast';
|
|
21
21
|
import { refreshAvatarInStore } from '../../utils/avatarUtils';
|
|
22
22
|
import { useAuthStore } from '../../stores/authStore';
|
|
23
|
+
import { useAccountStore } from '../../stores/accountStore';
|
|
24
|
+
import {
|
|
25
|
+
clearedFieldsFromProfileUpdate,
|
|
26
|
+
upsertCachedUser,
|
|
27
|
+
} from '../queries/userCache';
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Update user profile with optimistic updates and offline queue support
|
|
@@ -104,9 +109,21 @@ export const useUpdateProfile = () => {
|
|
|
104
109
|
// Update authStore so frontend components see the changes immediately
|
|
105
110
|
useAuthStore.getState().setUser(data);
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
const cleared = clearedFieldsFromProfileUpdate(updates);
|
|
113
|
+
upsertCachedUser(queryClient, data, data.id, {
|
|
114
|
+
cleared: cleared.length > 0 ? cleared : undefined,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Avatar add/remove: keep the account switcher in sync with profile writes
|
|
118
|
+
if (typeof updates.avatar === 'string' && activeSessionId) {
|
|
119
|
+
if (updates.avatar && oxyServices) {
|
|
120
|
+
refreshAvatarInStore(activeSessionId, updates.avatar, oxyServices);
|
|
121
|
+
} else {
|
|
122
|
+
useAccountStore.getState().updateAccount(activeSessionId, {
|
|
123
|
+
avatar: undefined,
|
|
124
|
+
avatarUrl: undefined,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
// Invalidate all related queries so every consumer (the account dialog,
|