Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useCallback} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyGraphGetStarterPacksWithMembership,
5 AppBskyGraphStarterpack,
6} from '@atproto/api'
7import {msg} from '@lingui/core/macro'
8import {useLingui} from '@lingui/react'
9import {Plural, Trans} from '@lingui/react/macro'
10import {useNavigation} from '@react-navigation/native'
11
12import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
13import {type NavigationProp} from '#/lib/routes/types'
14import {isNetworkError} from '#/lib/strings/errors'
15import {logger} from '#/logger'
16import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
17import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
18import {
19 useListMembershipAddMutation,
20 useListMembershipRemoveMutation,
21} from '#/state/queries/list-memberships'
22import {useProfileQuery} from '#/state/queries/profile'
23import {atoms as a, native, platform, useTheme} from '#/alf'
24import {AvatarStack} from '#/components/AvatarStack'
25import {Button, ButtonIcon, ButtonText} from '#/components/Button'
26import * as Dialog from '#/components/Dialog'
27import {Divider} from '#/components/Divider'
28import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
29import {StarterPack} from '#/components/icons/StarterPack'
30import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
31import {Loader} from '#/components/Loader'
32import * as Toast from '#/components/Toast'
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 const {data: subject} = useProfileQuery({did: targetDid})
136
137 const enableSquareButtons = useEnableSquareButtons()
138
139 const {
140 data,
141 isError,
142 isLoading,
143 hasNextPage,
144 isFetchingNextPage,
145 fetchNextPage,
146 } = useActorStarterPacksWithMembershipsQuery({did: targetDid, enabled})
147
148 const membershipItems =
149 data?.pages.flatMap(page => page.starterPacksWithMembership) || []
150
151 const onEndReached = useCallback(async () => {
152 if (isFetchingNextPage || !hasNextPage || isError) return
153 try {
154 await fetchNextPage()
155 } catch (err) {
156 // Error handling is optional since this is just pagination
157 }
158 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
159
160 const renderItem = useCallback(
161 ({item}: {item: StarterPackWithMembership}) => (
162 <StarterPackItem
163 starterPackWithMembership={item}
164 targetDid={targetDid}
165 subject={subject}
166 />
167 ),
168 [targetDid, subject],
169 )
170
171 const onClose = useCallback(() => {
172 control.close()
173 }, [control])
174
175 const listHeader = (
176 <>
177 <View
178 style={[
179 a.justify_between,
180 a.align_center,
181 a.flex_row,
182 a.pb_lg,
183 native(a.pt_lg),
184 ]}>
185 <Text style={[a.text_lg, a.font_semi_bold]}>
186 <Trans>Add to starter packs</Trans>
187 </Text>
188 <Button
189 label={_(msg`Close`)}
190 onPress={onClose}
191 variant="ghost"
192 color="secondary"
193 size="small"
194 shape={enableSquareButtons ? 'square' : 'round'}
195 style={{margin: -8}}>
196 <ButtonIcon icon={XIcon} />
197 </Button>
198 </View>
199 {membershipItems.length > 0 && (
200 <>
201 <View
202 style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
203 <Text style={[a.text_md, a.font_semi_bold]}>
204 <Trans>New starter pack</Trans>
205 </Text>
206 <Button
207 label={_(msg`Create starter pack`)}
208 color="secondary_inverted"
209 size="small"
210 onPress={onStartWizard}>
211 <ButtonText>
212 <Trans comment="Text on button to create a new starter pack">
213 Create
214 </Trans>
215 </ButtonText>
216 <ButtonIcon icon={PlusIcon} />
217 </Button>
218 </View>
219 <Divider />
220 </>
221 )}
222 </>
223 )
224
225 return (
226 <Dialog.InnerFlatList
227 data={isLoading ? [{}] : membershipItems}
228 renderItem={
229 isLoading
230 ? () => (
231 <View style={[a.align_center, a.py_2xl]}>
232 <Loader size="xl" />
233 </View>
234 )
235 : renderItem
236 }
237 keyExtractor={
238 isLoading
239 ? () => 'starter_pack_dialog_loader'
240 : (item: StarterPackWithMembership) => item.starterPack.uri
241 }
242 onEndReached={onEndReached}
243 onEndReachedThreshold={0.1}
244 ListHeaderComponent={listHeader}
245 ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
246 style={platform({
247 web: [a.px_2xl, {minHeight: 500}],
248 native: [a.px_2xl, a.pt_lg],
249 })}
250 />
251 )
252}
253
254function StarterPackItem({
255 starterPackWithMembership,
256 targetDid,
257 subject,
258}: {
259 starterPackWithMembership: StarterPackWithMembership
260 targetDid: string
261 subject?: bsky.profile.AnyProfileView
262}) {
263 const t = useTheme()
264 const ax = useAnalytics()
265 const {_} = useLingui()
266
267 const starterPack = starterPackWithMembership.starterPack
268 const isInPack = !!starterPackWithMembership.listItem
269
270 const {mutate: addMembership, isPending: isPendingAdd} =
271 useListMembershipAddMutation({
272 subject,
273 onSuccess: () => {
274 Toast.show(_(msg`Added to starter pack`))
275 },
276 onError: err => {
277 if (!isNetworkError(err)) {
278 logger.error('Failed to add to starter pack', {safeMessage: err})
279 }
280 Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
281 },
282 })
283
284 const {mutate: removeMembership, isPending: isPendingRemove} =
285 useListMembershipRemoveMutation({
286 onSuccess: () => {
287 Toast.show(_(msg`Removed from starter pack`))
288 },
289 onError: err => {
290 if (!isNetworkError(err)) {
291 logger.error('Failed to remove from starter pack', {safeMessage: err})
292 }
293 Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
294 },
295 })
296
297 const isPending = isPendingAdd || isPendingRemove
298
299 const handleToggleMembership = () => {
300 if (!starterPack.list?.uri || isPending) return
301
302 const listUri = starterPack.list.uri
303 const starterPackUri = starterPack.uri
304
305 if (!isInPack) {
306 addMembership({
307 listUri: listUri,
308 actorDid: targetDid,
309 })
310 ax.metric('starterPack:addUser', {starterPack: starterPackUri})
311 } else {
312 if (!starterPackWithMembership.listItem?.uri) {
313 console.error('Cannot remove: missing membership URI')
314 return
315 }
316 removeMembership({
317 listUri: listUri,
318 actorDid: targetDid,
319 membershipUri: starterPackWithMembership.listItem.uri,
320 })
321 ax.metric('starterPack:removeUser', {starterPack: starterPackUri})
322 }
323 }
324
325 const {record} = starterPack
326
327 if (
328 !bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
329 record,
330 AppBskyGraphStarterpack.isRecord,
331 )
332 ) {
333 return null
334 }
335
336 return (
337 <View style={[a.flex_row, a.justify_between, a.align_center, a.py_md]}>
338 <View>
339 <Text emoji style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
340 {record.name}
341 </Text>
342
343 <View style={[a.flex_row, a.align_center, a.mt_xs]}>
344 {starterPack.listItemsSample &&
345 starterPack.listItemsSample.length > 0 && (
346 <>
347 <AvatarStack
348 size={24}
349 profiles={starterPack.listItemsSample
350 ?.slice(0, 4)
351 .map(p => p.subject)}
352 />
353
354 {starterPack.list?.listItemCount &&
355 starterPack.list.listItemCount > 4 && (
356 <Text
357 style={[
358 a.text_sm,
359 t.atoms.text_contrast_medium,
360 a.ml_xs,
361 ]}>
362 <Trans>
363 <Plural
364 value={starterPack.list.listItemCount - 4}
365 other="+# more"
366 />
367 </Trans>
368 </Text>
369 )}
370 </>
371 )}
372 </View>
373 </View>
374
375 <Button
376 label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
377 color={isInPack ? 'secondary' : 'primary_subtle'}
378 size="tiny"
379 disabled={isPending}
380 onPress={handleToggleMembership}>
381 {isPending && <ButtonIcon icon={Loader} />}
382 <ButtonText>
383 {isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
384 </ButtonText>
385 </Button>
386 </View>
387 )
388}