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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
16import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
17import {
18 useListMembershipAddMutation,
19 useListMembershipRemoveMutation,
20} from '#/state/queries/list-memberships'
21import {useProfileQuery} from '#/state/queries/profile'
22import {atoms as a, native, platform, useTheme} from '#/alf'
23import {AvatarStack} from '#/components/AvatarStack'
24import {Button, ButtonIcon, ButtonText} from '#/components/Button'
25import * as Dialog from '#/components/Dialog'
26import {Divider} from '#/components/Divider'
27import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
28import {StarterPack} from '#/components/icons/StarterPack'
29import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
30import {Loader} from '#/components/Loader'
31import * as Toast from '#/components/Toast'
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 const {data: subject} = useProfileQuery({did: targetDid})
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
162 starterPackWithMembership={item}
163 targetDid={targetDid}
164 subject={subject}
165 />
166 ),
167 [targetDid, subject],
168 )
169
170 const onClose = useCallback(() => {
171 control.close()
172 }, [control])
173
174 const listHeader = (
175 <>
176 <View
177 style={[
178 a.justify_between,
179 a.align_center,
180 a.flex_row,
181 a.pb_lg,
182 native(a.pt_lg),
183 ]}>
184 <Text style={[a.text_lg, a.font_semi_bold]}>
185 <Trans>Add to starter packs</Trans>
186 </Text>
187 <Button
188 label={_(msg`Close`)}
189 onPress={onClose}
190 variant="ghost"
191 color="secondary"
192 size="small"
193 shape={enableSquareButtons ? 'square' : 'round'}
194 style={{margin: -8}}>
195 <ButtonIcon icon={XIcon} />
196 </Button>
197 </View>
198 {membershipItems.length > 0 && (
199 <>
200 <View
201 style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
202 <Text style={[a.text_md, a.font_semi_bold]}>
203 <Trans>New starter pack</Trans>
204 </Text>
205 <Button
206 label={_(msg`Create starter pack`)}
207 color="secondary_inverted"
208 size="small"
209 onPress={onStartWizard}>
210 <ButtonText>
211 <Trans comment="Text on button to create a new starter pack">
212 Create
213 </Trans>
214 </ButtonText>
215 <ButtonIcon icon={PlusIcon} />
216 </Button>
217 </View>
218 <Divider />
219 </>
220 )}
221 </>
222 )
223
224 return (
225 <Dialog.InnerFlatList
226 data={isLoading ? [{}] : membershipItems}
227 renderItem={
228 isLoading
229 ? () => (
230 <View style={[a.align_center, a.py_2xl]}>
231 <Loader size="xl" />
232 </View>
233 )
234 : renderItem
235 }
236 keyExtractor={
237 isLoading
238 ? () => 'starter_pack_dialog_loader'
239 : (item: StarterPackWithMembership) => item.starterPack.uri
240 }
241 onEndReached={onEndReached}
242 onEndReachedThreshold={0.1}
243 ListHeaderComponent={listHeader}
244 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
245 style={platform({
246 web: [a.px_2xl, {minHeight: 500}],
247 native: [a.px_2xl, a.pt_lg],
248 })}
249 />
250 )
251}
252
253function StarterPackItem({
254 starterPackWithMembership,
255 targetDid,
256 subject,
257}: {
258 starterPackWithMembership: StarterPackWithMembership
259 targetDid: string
260 subject?: bsky.profile.AnyProfileView
261}) {
262 const t = useTheme()
263 const ax = useAnalytics()
264 const {_} = useLingui()
265
266 const starterPack = starterPackWithMembership.starterPack
267 const isInPack = !!starterPackWithMembership.listItem
268
269 const {mutate: addMembership, isPending: isPendingAdd} =
270 useListMembershipAddMutation({
271 subject,
272 onSuccess: () => {
273 Toast.show(_(msg`Added to starter pack`))
274 },
275 onError: err => {
276 if (!isNetworkError(err)) {
277 logger.error('Failed to add to starter pack', {safeMessage: err})
278 }
279 Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
280 },
281 })
282
283 const {mutate: removeMembership, isPending: isPendingRemove} =
284 useListMembershipRemoveMutation({
285 onSuccess: () => {
286 Toast.show(_(msg`Removed from starter pack`))
287 },
288 onError: err => {
289 if (!isNetworkError(err)) {
290 logger.error('Failed to remove from starter pack', {safeMessage: err})
291 }
292 Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
293 },
294 })
295
296 const isPending = isPendingAdd || isPendingRemove
297
298 const handleToggleMembership = () => {
299 if (!starterPack.list?.uri || isPending) return
300
301 const listUri = starterPack.list.uri
302 const starterPackUri = starterPack.uri
303
304 if (!isInPack) {
305 addMembership({
306 listUri: listUri,
307 actorDid: targetDid,
308 })
309 ax.metric('starterPack:addUser', {starterPack: starterPackUri})
310 } else {
311 if (!starterPackWithMembership.listItem?.uri) {
312 console.error('Cannot remove: missing membership URI')
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={24}
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={isPending}
379 onPress={handleToggleMembership}>
380 {isPending && <ButtonIcon icon={Loader} />}
381 <ButtonText>
382 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
383 </ButtonText>
384 </Button>
385 </View>
386 )
387}