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