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 {logger} from '#/logger'
15import {isWeb} from '#/platform/detection'
16import {
17 invalidateActorStarterPacksWithMembershipQuery,
18 useActorStarterPacksWithMembershipsQuery,
19} from '#/state/queries/actor-starter-packs'
20import {
21 useListMembershipAddMutation,
22 useListMembershipRemoveMutation,
23} from '#/state/queries/list-memberships'
24import * as Toast from '#/view/com/util/Toast'
25import {atoms as a, useTheme} from '#/alf'
26import {AvatarStack} from '#/components/AvatarStack'
27import {Button, ButtonIcon, ButtonText} from '#/components/Button'
28import * as Dialog from '#/components/Dialog'
29import {Divider} from '#/components/Divider'
30import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
31import {StarterPack} from '#/components/icons/StarterPack'
32import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
33import {Loader} from '#/components/Loader'
34import {Text} from '#/components/Typography'
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: isWeb ? 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 isWeb ? 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={isWeb ? [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 {_} = useLingui()
248 const t = useTheme()
249 const queryClient = useQueryClient()
250
251 const starterPack = starterPackWithMembership.starterPack
252 const isInPack = !!starterPackWithMembership.listItem
253
254 const [isPendingRefresh, setIsPendingRefresh] = useState(false)
255
256 const {mutate: addMembership} = useListMembershipAddMutation({
257 onSuccess: () => {
258 Toast.show(_(msg`Added to starter pack`))
259 // Use a timeout to wait for the appview to update, matching the pattern
260 // in list-memberships.ts
261 setTimeout(() => {
262 invalidateActorStarterPacksWithMembershipQuery({
263 queryClient,
264 did: targetDid,
265 })
266 setIsPendingRefresh(false)
267 }, 1e3)
268 },
269 onError: () => {
270 Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
271 setIsPendingRefresh(false)
272 },
273 })
274
275 const {mutate: removeMembership} = useListMembershipRemoveMutation({
276 onSuccess: () => {
277 Toast.show(_(msg`Removed from starter pack`))
278 // Use a timeout to wait for the appview to update, matching the pattern
279 // in list-memberships.ts
280 setTimeout(() => {
281 invalidateActorStarterPacksWithMembershipQuery({
282 queryClient,
283 did: targetDid,
284 })
285 setIsPendingRefresh(false)
286 }, 1e3)
287 },
288 onError: () => {
289 Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
290 setIsPendingRefresh(false)
291 },
292 })
293
294 const handleToggleMembership = () => {
295 if (!starterPack.list?.uri || isPendingRefresh) return
296
297 const listUri = starterPack.list.uri
298 const starterPackUri = starterPack.uri
299
300 setIsPendingRefresh(true)
301
302 if (!isInPack) {
303 addMembership({
304 listUri: listUri,
305 actorDid: targetDid,
306 })
307 logger.metric('starterPack:addUser', {starterPack: starterPackUri})
308 } else {
309 if (!starterPackWithMembership.listItem?.uri) {
310 console.error('Cannot remove: missing membership URI')
311 setIsPendingRefresh(false)
312 return
313 }
314 removeMembership({
315 listUri: listUri,
316 actorDid: targetDid,
317 membershipUri: starterPackWithMembership.listItem.uri,
318 })
319 logger.metric('starterPack:removeUser', {starterPack: starterPackUri})
320 }
321 }
322
323 const {record} = starterPack
324
325 if (
326 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
327 record,
328 AppBskyGraphStarterpack.isRecord,
329 )
330 ) {
331 return null
332 }
333
334 return (
335 <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
336 <View>
337 <Text emoji style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
338 {record.name}
339 </Text>
340
341 <View style={[a.flex_row, a.align_center, a.mt_xs]}>
342 {starterPack.listItemsSample &&
343 starterPack.listItemsSample.length > 0 && (
344 <>
345 <AvatarStack
346 size={32}
347 profiles={starterPack.listItemsSample
348 ?.slice(0, 4)
349 .map(p => p.subject)}
350 />
351
352 {starterPack.list?.listItemCount &&
353 starterPack.list.listItemCount > 4 && (
354 <Text
355 style={[
356 a.text_sm,
357 t.atoms.text_contrast_medium,
358 a.ml_xs,
359 ]}>
360 <Trans>
361 <Plural
362 value={starterPack.list.listItemCount - 4}
363 other="+# more"
364 />
365 </Trans>
366 </Text>
367 )}
368 </>
369 )}
370 </View>
371 </View>
372
373 <Button
374 label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
375 color={isInPack ? 'secondary' : 'primary_subtle'}
376 size="tiny"
377 disabled={isPendingRefresh}
378 onPress={handleToggleMembership}>
379 <ButtonText>
380 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
381 </ButtonText>
382 </Button>
383 </View>
384 )
385}