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