forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useState} from 'react'
2import {View} from 'react-native'
3import type Animated from 'react-native-reanimated'
4import {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated'
5import {type AppBskyActorDefs} from '@atproto/api'
6import {TID} from '@atproto/common-web'
7import {msg} from '@lingui/core/macro'
8import {useLingui} from '@lingui/react'
9import {Trans} from '@lingui/react/macro'
10import {useFocusEffect, useNavigation} from '@react-navigation/native'
11import {type NativeStackScreenProps} from '@react-navigation/native-stack'
12
13import {RECOMMENDED_SAVED_FEEDS, TIMELINE_SAVED_FEED} from '#/lib/constants'
14import {useHaptics} from '#/lib/haptics'
15import {
16 type CommonNavigatorParams,
17 type NavigationProp,
18} from '#/lib/routes/types'
19import {logger} from '#/logger'
20import {useA11y} from '#/state/a11y'
21import {
22 useOverwriteSavedFeedsMutation,
23 usePreferencesQuery,
24} from '#/state/queries/preferences'
25import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
26import {useSetMinimalShellMode} from '#/state/shell'
27import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
28import * as Toast from '#/view/com/util/Toast'
29import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
30import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
31import {atoms as a, useBreakpoints, useTheme} from '#/alf'
32import {Admonition} from '#/components/Admonition'
33import {Button, ButtonIcon, ButtonText} from '#/components/Button'
34import {SortableList} from '#/components/DraggableList'
35import {
36 ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon,
37 ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon,
38} from '#/components/icons/Arrow'
39import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
40import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk'
41import {Pin_Filled_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
42import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
43import * as Layout from '#/components/Layout'
44import {InlineLinkText} from '#/components/Link'
45import {Loader} from '#/components/Loader'
46import {Text} from '#/components/Typography'
47
48type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
49export function SavedFeeds({}: Props) {
50 const {data: preferences} = usePreferencesQuery()
51 const {screenReaderEnabled} = useA11y()
52 if (!preferences) {
53 return <View />
54 }
55 if (screenReaderEnabled) {
56 return <SavedFeedsA11y preferences={preferences} />
57 }
58 return <SavedFeedsInner preferences={preferences} />
59}
60
61function SavedFeedsInner({
62 preferences,
63}: {
64 preferences: UsePreferencesQueryResponse
65}) {
66 const t = useTheme()
67 const {_} = useLingui()
68 const {gtMobile} = useBreakpoints()
69 const setMinimalShellMode = useSetMinimalShellMode()
70 const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
71 useOverwriteSavedFeedsMutation()
72 const navigation = useNavigation<NavigationProp>()
73 const scrollRef = useAnimatedRef<Animated.ScrollView>()
74 const scrollOffset = useScrollViewOffset(scrollRef)
75
76 /*
77 * Use optimistic data if exists and no error, otherwise fallback to remote
78 * data
79 */
80 const [currentFeeds, setCurrentFeeds] = useState(
81 () => preferences.savedFeeds || [],
82 )
83 const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds
84 const pinnedFeeds = currentFeeds.filter(f => f.pinned)
85 const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
86 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
87 const noFollowingFeed =
88 currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
89 const [isDragging, setIsDragging] = useState(false)
90
91 useFocusEffect(
92 useCallback(() => {
93 setMinimalShellMode(false)
94 }, [setMinimalShellMode]),
95 )
96
97 const onSaveChanges = async () => {
98 try {
99 await overwriteSavedFeeds(currentFeeds)
100 Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
101 if (navigation.canGoBack()) {
102 navigation.goBack()
103 } else {
104 navigation.navigate('Feeds')
105 }
106 } catch (e) {
107 Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
108 logger.error('Failed to toggle pinned feed', {message: e})
109 }
110 }
111
112 return (
113 <Layout.Screen>
114 <Layout.Header.Outer>
115 <Layout.Header.BackButton />
116 <Layout.Header.Content align="left">
117 <Layout.Header.TitleText>
118 <Trans>Feeds</Trans>
119 </Layout.Header.TitleText>
120 </Layout.Header.Content>
121 <Button
122 testID="saveChangesBtn"
123 size="small"
124 color={hasUnsavedChanges ? 'primary' : 'secondary'}
125 onPress={onSaveChanges}
126 label={_(msg`Save changes`)}
127 disabled={isOverwritePending || !hasUnsavedChanges}>
128 <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} />
129 <ButtonText>
130 {gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>}
131 </ButtonText>
132 </Button>
133 </Layout.Header.Outer>
134
135 <Layout.Content ref={scrollRef} scrollEnabled={!isDragging}>
136 {noSavedFeedsOfAnyType && (
137 <View style={[t.atoms.border_contrast_low, a.border_b]}>
138 <NoSavedFeedsOfAnyType
139 onAddRecommendedFeeds={() =>
140 setCurrentFeeds(
141 RECOMMENDED_SAVED_FEEDS.map(f => ({
142 ...f,
143 id: TID.nextStr(),
144 })),
145 )
146 }
147 />
148 </View>
149 )}
150
151 <SectionHeaderText>
152 <Trans>Pinned Feeds</Trans>
153 </SectionHeaderText>
154
155 {preferences ? (
156 !pinnedFeeds.length ? (
157 <View style={[a.flex_1, a.p_lg]}>
158 <Admonition type="info">
159 <Trans>You don't have any pinned feeds.</Trans>
160 </Admonition>
161 </View>
162 ) : (
163 <SortableList
164 data={pinnedFeeds}
165 keyExtractor={f => f.id}
166 itemHeight={68}
167 scrollRef={scrollRef}
168 scrollOffset={scrollOffset}
169 onDragStart={() => setIsDragging(true)}
170 onDragEnd={() => setIsDragging(false)}
171 onReorder={reordered => {
172 setCurrentFeeds([...reordered, ...unpinnedFeeds])
173 }}
174 renderItem={(feed, dragHandle) => (
175 <PinnedFeedItem
176 feed={feed}
177 currentFeeds={currentFeeds}
178 setCurrentFeeds={setCurrentFeeds}
179 dragHandle={dragHandle}
180 />
181 )}
182 />
183 )
184 ) : (
185 <View style={[a.w_full, a.py_2xl, a.align_center]}>
186 <Loader size="xl" />
187 </View>
188 )}
189
190 {noFollowingFeed && (
191 <View style={[t.atoms.border_contrast_low, a.border_b]}>
192 <NoFollowingFeed
193 onAddFeed={() =>
194 setCurrentFeeds(feeds => [
195 ...feeds,
196 {...TIMELINE_SAVED_FEED, id: TID.next().toString()},
197 ])
198 }
199 />
200 </View>
201 )}
202
203 <SectionHeaderText>
204 <Trans>Saved Feeds</Trans>
205 </SectionHeaderText>
206
207 {preferences ? (
208 !unpinnedFeeds.length ? (
209 <View style={[a.flex_1, a.p_lg]}>
210 <Admonition type="info">
211 <Trans>You don't have any saved feeds.</Trans>
212 </Admonition>
213 </View>
214 ) : (
215 unpinnedFeeds.map(f => (
216 <UnpinnedFeedItem
217 key={f.id}
218 feed={f}
219 currentFeeds={currentFeeds}
220 setCurrentFeeds={setCurrentFeeds}
221 />
222 ))
223 )
224 ) : (
225 <View style={[a.w_full, a.py_2xl, a.align_center]}>
226 <Loader size="xl" />
227 </View>
228 )}
229
230 <View style={[a.px_lg, a.py_xl]}>
231 <Text
232 style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
233 <Trans>
234 Feeds are custom algorithms that users build with a little coding
235 expertise.{' '}
236 <InlineLinkText
237 to="https://github.com/bluesky-social/feed-generator"
238 label={_(msg`See this guide`)}
239 disableMismatchWarning
240 style={[a.leading_snug]}>
241 See this guide
242 </InlineLinkText>{' '}
243 for more information.
244 </Trans>
245 </Text>
246 </View>
247 </Layout.Content>
248 </Layout.Screen>
249 )
250}
251
252function SavedFeedsA11y({
253 preferences,
254}: {
255 preferences: UsePreferencesQueryResponse
256}) {
257 const t = useTheme()
258 const {_} = useLingui()
259 const {gtMobile} = useBreakpoints()
260 const setMinimalShellMode = useSetMinimalShellMode()
261 const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} =
262 useOverwriteSavedFeedsMutation()
263 const navigation = useNavigation<NavigationProp>()
264
265 const [currentFeeds, setCurrentFeeds] = useState(
266 () => preferences.savedFeeds || [],
267 )
268 const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds
269 const pinnedFeeds = currentFeeds.filter(f => f.pinned)
270 const unpinnedFeeds = currentFeeds.filter(f => !f.pinned)
271 const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0
272 const noFollowingFeed =
273 currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType
274
275 useFocusEffect(
276 useCallback(() => {
277 setMinimalShellMode(false)
278 }, [setMinimalShellMode]),
279 )
280
281 const onSaveChanges = async () => {
282 try {
283 await overwriteSavedFeeds(currentFeeds)
284 Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
285 if (navigation.canGoBack()) {
286 navigation.goBack()
287 } else {
288 navigation.navigate('Feeds')
289 }
290 } catch (e) {
291 Toast.show(_(msg`There was an issue contacting the server`), 'xmark')
292 logger.error('Failed to toggle pinned feed', {message: e})
293 }
294 }
295
296 const onMoveUp = (index: number) => {
297 const pinned = [...pinnedFeeds]
298 ;[pinned[index - 1], pinned[index]] = [pinned[index], pinned[index - 1]]
299 setCurrentFeeds([...pinned, ...unpinnedFeeds])
300 }
301
302 const onMoveDown = (index: number) => {
303 const pinned = [...pinnedFeeds]
304 ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]]
305 setCurrentFeeds([...pinned, ...unpinnedFeeds])
306 }
307
308 return (
309 <Layout.Screen>
310 <Layout.Header.Outer>
311 <Layout.Header.BackButton />
312 <Layout.Header.Content align="left">
313 <Layout.Header.TitleText>
314 <Trans>Feeds</Trans>
315 </Layout.Header.TitleText>
316 </Layout.Header.Content>
317 <Button
318 testID="saveChangesBtn"
319 size="small"
320 color={hasUnsavedChanges ? 'primary' : 'secondary'}
321 onPress={onSaveChanges}
322 label={_(msg`Save changes`)}
323 disabled={isOverwritePending || !hasUnsavedChanges}>
324 <ButtonIcon icon={isOverwritePending ? Loader : SaveIcon} />
325 <ButtonText>
326 {gtMobile ? <Trans>Save changes</Trans> : <Trans>Save</Trans>}
327 </ButtonText>
328 </Button>
329 </Layout.Header.Outer>
330
331 <Layout.Content>
332 {noSavedFeedsOfAnyType && (
333 <View style={[t.atoms.border_contrast_low, a.border_b]}>
334 <NoSavedFeedsOfAnyType
335 onAddRecommendedFeeds={() =>
336 setCurrentFeeds(
337 RECOMMENDED_SAVED_FEEDS.map(f => ({
338 ...f,
339 id: TID.nextStr(),
340 })),
341 )
342 }
343 />
344 </View>
345 )}
346
347 <SectionHeaderText>
348 <Trans>Pinned Feeds</Trans>
349 </SectionHeaderText>
350
351 {!pinnedFeeds.length ? (
352 <View style={[a.flex_1, a.p_lg]}>
353 <Admonition type="info">
354 <Trans>You don't have any pinned feeds.</Trans>
355 </Admonition>
356 </View>
357 ) : (
358 pinnedFeeds.map((feed, i) => (
359 <PinnedFeedItem
360 key={feed.id}
361 feed={feed}
362 currentFeeds={currentFeeds}
363 setCurrentFeeds={setCurrentFeeds}
364 index={i}
365 total={pinnedFeeds.length}
366 onMoveUp={() => onMoveUp(i)}
367 onMoveDown={() => onMoveDown(i)}
368 />
369 ))
370 )}
371
372 {noFollowingFeed && (
373 <View style={[t.atoms.border_contrast_low, a.border_b]}>
374 <NoFollowingFeed
375 onAddFeed={() =>
376 setCurrentFeeds(feeds => [
377 ...feeds,
378 {...TIMELINE_SAVED_FEED, id: TID.next().toString()},
379 ])
380 }
381 />
382 </View>
383 )}
384
385 <SectionHeaderText>
386 <Trans>Saved Feeds</Trans>
387 </SectionHeaderText>
388
389 {!unpinnedFeeds.length ? (
390 <View style={[a.flex_1, a.p_lg]}>
391 <Admonition type="info">
392 <Trans>You don't have any saved feeds.</Trans>
393 </Admonition>
394 </View>
395 ) : (
396 unpinnedFeeds.map(f => (
397 <UnpinnedFeedItem
398 key={f.id}
399 feed={f}
400 currentFeeds={currentFeeds}
401 setCurrentFeeds={setCurrentFeeds}
402 />
403 ))
404 )}
405
406 <View style={[a.px_lg, a.py_xl]}>
407 <Text
408 style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
409 <Trans>
410 Feeds are custom algorithms that users build with a little coding
411 expertise.{' '}
412 <InlineLinkText
413 to="https://github.com/bluesky-social/feed-generator"
414 label={_(msg`See this guide`)}
415 disableMismatchWarning
416 style={[a.leading_snug]}>
417 See this guide
418 </InlineLinkText>{' '}
419 for more information.
420 </Trans>
421 </Text>
422 </View>
423 </Layout.Content>
424 </Layout.Screen>
425 )
426}
427
428function PinnedFeedItem({
429 feed,
430 currentFeeds,
431 setCurrentFeeds,
432 dragHandle,
433 index,
434 total,
435 onMoveUp,
436 onMoveDown,
437}: {
438 feed: AppBskyActorDefs.SavedFeed
439 currentFeeds: AppBskyActorDefs.SavedFeed[]
440 setCurrentFeeds: React.Dispatch<
441 React.SetStateAction<AppBskyActorDefs.SavedFeed[]>
442 >
443 dragHandle?: React.ReactNode
444 index?: number
445 total?: number
446 onMoveUp?: () => void
447 onMoveDown?: () => void
448}) {
449 const {_} = useLingui()
450 const t = useTheme()
451 const playHaptic = useHaptics()
452 const feedUri = feed.value
453
454 const onTogglePinned = () => {
455 playHaptic()
456 setCurrentFeeds(
457 currentFeeds.map(f =>
458 f.id === feed.id ? {...feed, pinned: !feed.pinned} : f,
459 ),
460 )
461 }
462
463 return (
464 <View style={[a.flex_row, t.atoms.bg]}>
465 {feed.type === 'timeline' ? (
466 <FollowingFeedCard />
467 ) : (
468 <FeedSourceCard
469 feedUri={feedUri}
470 style={[a.pr_sm]}
471 showMinimalPlaceholder
472 hideTopBorder={true}
473 />
474 )}
475 <View style={[a.pr_sm, a.flex_row, a.align_center, a.gap_sm]}>
476 <Button
477 testID={`feed-${feed.type}-togglePin`}
478 label={_(msg`Unpin feed`)}
479 onPress={onTogglePinned}
480 size="small"
481 color="primary_subtle"
482 shape="square">
483 <ButtonIcon icon={PinIcon} />
484 </Button>
485 {onMoveUp !== undefined ? (
486 <>
487 <Button
488 testID={`feed-${feed.type}-moveUp`}
489 label={_(msg`Move feed up`)}
490 onPress={onMoveUp}
491 disabled={index === 0}
492 size="small"
493 color="secondary"
494 shape="square">
495 <ButtonIcon icon={ArrowUpIcon} />
496 </Button>
497 <Button
498 testID={`feed-${feed.type}-moveDown`}
499 label={_(msg`Move feed down`)}
500 onPress={onMoveDown}
501 disabled={index === total! - 1}
502 size="small"
503 color="secondary"
504 shape="square">
505 <ButtonIcon icon={ArrowDownIcon} />
506 </Button>
507 </>
508 ) : (
509 dragHandle
510 )}
511 </View>
512 </View>
513 )
514}
515
516function UnpinnedFeedItem({
517 feed,
518 currentFeeds,
519 setCurrentFeeds,
520}: {
521 feed: AppBskyActorDefs.SavedFeed
522 currentFeeds: AppBskyActorDefs.SavedFeed[]
523 setCurrentFeeds: React.Dispatch<
524 React.SetStateAction<AppBskyActorDefs.SavedFeed[]>
525 >
526}) {
527 const {_} = useLingui()
528 const t = useTheme()
529 const playHaptic = useHaptics()
530 const feedUri = feed.value
531
532 const onTogglePinned = () => {
533 playHaptic()
534 setCurrentFeeds(
535 currentFeeds.map(f =>
536 f.id === feed.id ? {...feed, pinned: !feed.pinned} : f,
537 ),
538 )
539 }
540
541 const onPressRemove = () => {
542 playHaptic()
543 setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id))
544 }
545
546 return (
547 <View style={[a.flex_row, a.border_b, t.atoms.border_contrast_low]}>
548 {feed.type === 'timeline' ? (
549 <FollowingFeedCard />
550 ) : (
551 <FeedSourceCard
552 feedUri={feedUri}
553 showMinimalPlaceholder
554 hideTopBorder={true}
555 />
556 )}
557 <View style={[a.pr_lg, a.flex_row, a.align_center, a.gap_sm]}>
558 <Button
559 testID={`feed-${feedUri}-toggleSave`}
560 label={_(msg`Remove from my feeds`)}
561 onPress={onPressRemove}
562 size="small"
563 color="secondary"
564 variant="ghost"
565 shape="square">
566 <ButtonIcon icon={TrashIcon} />
567 </Button>
568 <Button
569 testID={`feed-${feed.type}-togglePin`}
570 label={_(msg`Pin feed`)}
571 onPress={onTogglePinned}
572 size="small"
573 color="secondary"
574 shape="square">
575 <ButtonIcon icon={PinIcon} />
576 </Button>
577 </View>
578 </View>
579 )
580}
581
582function SectionHeaderText({children}: {children: React.ReactNode}) {
583 const t = useTheme()
584 // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
585 return (
586 <View
587 style={[
588 a.flex_row,
589 a.flex_1,
590 a.px_lg,
591 a.pt_2xl,
592 a.pb_md,
593 a.border_b,
594 t.atoms.border_contrast_low,
595 ]}>
596 <Text style={[a.text_xl, a.font_bold, a.leading_snug]}>{children}</Text>
597 </View>
598 )
599}
600
601function FollowingFeedCard() {
602 const t = useTheme()
603 return (
604 <View style={[a.flex_row, a.align_center, a.flex_1, a.p_lg]}>
605 <View
606 style={[
607 a.align_center,
608 a.justify_center,
609 a.rounded_sm,
610 a.mr_md,
611 {
612 width: 36,
613 height: 36,
614 backgroundColor: t.palette.primary_500,
615 },
616 ]}>
617 <FilterTimeline
618 style={[
619 {
620 width: 22,
621 height: 22,
622 },
623 ]}
624 fill={t.palette.white}
625 />
626 </View>
627 <View style={[a.flex_1, a.flex_row, a.gap_sm, a.align_center]}>
628 <Text style={[a.text_sm, a.font_semi_bold, a.leading_snug]}>
629 <Trans context="feed-name">Following</Trans>
630 </Text>
631 </View>
632 </View>
633 )
634}