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