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