Bluesky app fork with some witchin' additions 馃挮
1import {useCallback} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyGraphGetStarterPacksWithMembership,
5 AppBskyGraphStarterpack,
6} from '@atproto/api'
7import {msg} from '@lingui/core/macro'
8import {useLingui} from '@lingui/react'
9import {Plural, Trans} from '@lingui/react/macro'
10import {useNavigation} from '@react-navigation/native'
11
12import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
13import {type NavigationProp} from '#/lib/routes/types'
14import {isNetworkError} from '#/lib/strings/errors'
15import {logger} from '#/logger'
16import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
17import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
18import {
19 useListMembershipAddMutation,
20 useListMembershipRemoveMutation,
21} from '#/state/queries/list-memberships'
22import {useProfileQuery} from '#/state/queries/profile'
23import {useSession} from '#/state/session'
24import {atoms as a, native, platform, useTheme} from '#/alf'
25import {AvatarStack} from '#/components/AvatarStack'
26import {Button, ButtonIcon, ButtonText} from '#/components/Button'
27import * as Dialog from '#/components/Dialog'
28import {Divider} from '#/components/Divider'
29import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
30import {StarterPack} from '#/components/icons/StarterPack'
31import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
32import {Loader} from '#/components/Loader'
33import * as Toast from '#/components/Toast'
34import {Text} from '#/components/Typography'
35import {useAnalytics} from '#/analytics'
36import {IS_WEB} from '#/env'
37import * as bsky from '#/types/bsky'
38
39type StarterPackWithMembership =
40 AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership
41
42export type StarterPackDialogProps = {
43 control: Dialog.DialogControlProps
44 targetDid: string
45 enabled?: boolean
46}
47
48export function StarterPackDialog({
49 control,
50 targetDid,
51 enabled,
52}: StarterPackDialogProps) {
53 const navigation = useNavigation<NavigationProp>()
54 const requireEmailVerification = useRequireEmailVerification()
55
56 const navToWizard = useCallback(() => {
57 control.close()
58 navigation.navigate('StarterPackWizard', {
59 fromDialog: true,
60 targetDid: targetDid,
61 onSuccess: () => {
62 setTimeout(() => {
63 if (!control.isOpen) {
64 control.open()
65 }
66 }, 0)
67 },
68 })
69 }, [navigation, control, targetDid])
70
71 const wrappedNavToWizard = requireEmailVerification(navToWizard, {
72 instructions: [
73 <Trans key="nav">
74 Before creating a starter pack, you must first verify your email.
75 </Trans>,
76 ],
77 })
78
79 return (
80 <Dialog.Outer control={control} nativeOptions={{fullHeight: true}}>
81 <Dialog.Handle />
82 <StarterPackList
83 onStartWizard={wrappedNavToWizard}
84 targetDid={targetDid}
85 enabled={enabled}
86 />
87 </Dialog.Outer>
88 )
89}
90
91function Empty({onStartWizard}: {onStartWizard: () => void}) {
92 const {_} = useLingui()
93 const t = useTheme()
94
95 return (
96 <View style={[a.gap_2xl, {paddingTop: IS_WEB ? 100 : 64}]}>
97 <View style={[a.gap_xs, a.align_center]}>
98 <StarterPack
99 width={48}
100 fill={t.atoms.border_contrast_medium.borderColor}
101 />
102 <Text style={[a.text_center]}>
103 <Trans>You have no starter packs.</Trans>
104 </Text>
105 </View>
106
107 <View style={[a.align_center]}>
108 <Button
109 label={_(msg`Create starter pack`)}
110 color="secondary_inverted"
111 size="small"
112 onPress={onStartWizard}>
113 <ButtonText>
114 <Trans comment="Text on button to create a new starter pack">
115 Create
116 </Trans>
117 </ButtonText>
118 <ButtonIcon icon={PlusIcon} />
119 </Button>
120 </View>
121 </View>
122 )
123}
124
125function StarterPackList({
126 onStartWizard,
127 targetDid,
128 enabled,
129}: {
130 onStartWizard: () => void
131 targetDid: string
132 enabled?: boolean
133}) {
134 const control = Dialog.useDialogContext()
135 const {_} = useLingui()
136 const {data: subject} = useProfileQuery({did: targetDid})
137
138 const enableSquareButtons = useEnableSquareButtons()
139
140 const {
141 data,
142 isError,
143 isLoading,
144 hasNextPage,
145 isFetchingNextPage,
146 fetchNextPage,
147 } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled})
148
149 const membershipItems =
150 data?.pages.flatMap(page => page.starterPacksWithMembership) || []
151
152 const onEndReached = useCallback(async () => {
153 if (isFetchingNextPage || !hasNextPage || isError) return
154 try {
155 await fetchNextPage()
156 } catch (err) {
157 // Error handling is optional since this is just pagination
158 }
159 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
160
161 const renderItem = useCallback(
162 ({item}: {item: StarterPackWithMembership}) => (
163 <StarterPackItem
164 starterPackWithMembership={item}
165 targetDid={targetDid}
166 subject={subject}
167 />
168 ),
169 [targetDid, subject],
170 )
171
172 const onClose = useCallback(() => {
173 control.close()
174 }, [control])
175
176 const listHeader = (
177 <>
178 <View
179 style={[
180 a.justify_between,
181 a.align_center,
182 a.flex_row,
183 a.pb_lg,
184 native(a.pt_lg),
185 ]}>
186 <Text style={[a.text_lg, a.font_semi_bold]}>
187 <Trans>Add to starter packs</Trans>
188 </Text>
189 <Button
190 label={_(msg`Close`)}
191 onPress={onClose}
192 variant="ghost"
193 color="secondary"
194 size="small"
195 shape={enableSquareButtons ? 'square' : 'round'}
196 style={{margin: -8}}>
197 <ButtonIcon icon={XIcon} />
198 </Button>
199 </View>
200 {membershipItems.length > 0 && (
201 <>
202 <View
203 style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
204 <Text style={[a.text_md, a.font_semi_bold]}>
205 <Trans>New starter pack</Trans>
206 </Text>
207 <Button
208 label={_(msg`Create starter pack`)}
209 color="secondary_inverted"
210 size="small"
211 onPress={onStartWizard}>
212 <ButtonText>
213 <Trans comment="Text on button to create a new starter pack">
214 Create
215 </Trans>
216 </ButtonText>
217 <ButtonIcon icon={PlusIcon} />
218 </Button>
219 </View>
220 <Divider />
221 </>
222 )}
223 </>
224 )
225
226 return (
227 <Dialog.InnerFlatList
228 data={isLoading ? [{}] : membershipItems}
229 renderItem={
230 isLoading
231 ? () => (
232 <View style={[a.align_center, a.py_2xl]}>
233 <Loader size="xl" />
234 </View>
235 )
236 : renderItem
237 }
238 keyExtractor={
239 isLoading
240 ? () => 'starter_pack_dialog_loader'
241 : (item: StarterPackWithMembership) => item.starterPack.uri
242 }
243 onEndReached={onEndReached}
244 onEndReachedThreshold={0.1}
245 ListHeaderComponent={listHeader}
246 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
247 style={platform({
248 web: [a.px_2xl, {minHeight: 500}],
249 native: [a.px_2xl, a.pt_lg],
250 })}
251 />
252 )
253}
254
255function StarterPackItem({
256 starterPackWithMembership,
257 targetDid,
258 subject,
259}: {
260 starterPackWithMembership: StarterPackWithMembership
261 targetDid: string
262 subject?: bsky.profile.AnyProfileView
263}) {
264 const t = useTheme()
265 const ax = useAnalytics()
266 const {_} = useLingui()
267 const {currentAccount} = useSession()
268 const isSelf = subject?.did === currentAccount?.did
269
270 const starterPack = starterPackWithMembership.starterPack
271 const isInPack = !!starterPackWithMembership.listItem
272
273 const {mutate: addMembership, isPending: isPendingAdd} =
274 useListMembershipAddMutation({
275 subject,
276 onSuccess: () => {
277 Toast.show(_(msg`Added to starter pack`))
278 },
279 onError: err => {
280 if (!isNetworkError(err)) {
281 logger.error('Failed to add to starter pack', {safeMessage: err})
282 }
283 Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
284 },
285 })
286
287 const {mutate: removeMembership, isPending: isPendingRemove} =
288 useListMembershipRemoveMutation({
289 onSuccess: () => {
290 Toast.show(_(msg`Removed from starter pack`))
291 },
292 onError: err => {
293 if (!isNetworkError(err)) {
294 logger.error('Failed to remove from starter pack', {safeMessage: err})
295 }
296 Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
297 },
298 })
299
300 const isPending = isPendingAdd || isPendingRemove
301
302 const handleToggleMembership = () => {
303 if (!starterPack.list?.uri || isPending) return
304
305 const listUri = starterPack.list.uri
306 const starterPackUri = starterPack.uri
307
308 if (!isInPack) {
309 addMembership({
310 listUri: listUri,
311 actorDid: targetDid,
312 })
313 ax.metric('starterPack:addUser', {starterPack: starterPackUri})
314 } else {
315 if (!starterPackWithMembership.listItem?.uri) {
316 console.error('Cannot remove: missing membership URI')
317 return
318 }
319 removeMembership({
320 listUri: listUri,
321 actorDid: targetDid,
322 membershipUri: starterPackWithMembership.listItem.uri,
323 })
324 ax.metric('starterPack:removeUser', {starterPack: starterPackUri})
325 }
326 }
327
328 const {record} = starterPack
329
330 if (
331 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
332 record,
333 AppBskyGraphStarterpack.isRecord,
334 )
335 ) {
336 return null
337 }
338
339 return (
340 <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
341 <View>
342 <Text emoji style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
343 {record.name}
344 </Text>
345
346 <View style={[a.flex_row, a.align_center, a.mt_xs]}>
347 {starterPack.listItemsSample &&
348 starterPack.listItemsSample.length > 0 && (
349 <>
350 <AvatarStack
351 size={24}
352 profiles={starterPack.listItemsSample
353 ?.slice(0, 4)
354 .map(p => p.subject)}
355 />
356
357 {starterPack.list?.listItemCount &&
358 starterPack.list.listItemCount > 4 && (
359 <Text
360 style={[
361 a.text_sm,
362 t.atoms.text_contrast_medium,
363 a.ml_xs,
364 ]}>
365 <Trans>
366 <Plural
367 value={starterPack.list.listItemCount - 4}
368 other="+# more"
369 />
370 </Trans>
371 </Text>
372 )}
373 </>
374 )}
375 </View>
376 </View>
377
378 <Button
379 label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
380 color={isInPack ? 'secondary' : 'primary_subtle'}
381 size="tiny"
382 disabled={isPending || isSelf}
383 onPress={handleToggleMembership}>
384 {isPending && <ButtonIcon icon={Loader} />}
385 <ButtonText>
386 {isSelf ? (
387 <Trans>Owner</Trans>
388 ) : isInPack ? (
389 <Trans>Remove</Trans>
390 ) : (
391 <Trans>Add</Trans>
392 )}
393 </ButtonText>
394 </Button>
395 </View>
396 )
397}