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