forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {ActivityIndicator, StyleSheet, View} from 'react-native'
3import {type AppBskyFeedDefs} from '@atproto/api'
4import {msg, Trans} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6import {useFocusEffect} from '@react-navigation/native'
7import debounce from 'lodash.debounce'
8
9import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
10import {usePalette} from '#/lib/hooks/usePalette'
11import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
12import {ComposeIcon2} from '#/lib/icons'
13import {
14 type CommonNavigatorParams,
15 type NativeStackScreenProps,
16} from '#/lib/routes/types'
17import {cleanError} from '#/lib/strings/errors'
18import {s} from '#/lib/styles'
19import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
20import {
21 type SavedFeedItem,
22 useGetPopularFeedsQuery,
23 useSavedFeeds,
24 useSearchPopularFeedsMutation,
25} from '#/state/queries/feed'
26import {useSession} from '#/state/session'
27import {useSetMinimalShellMode} from '#/state/shell'
28import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
29import {FAB} from '#/view/com/util/fab/FAB'
30import {List, type ListMethods} from '#/view/com/util/List'
31import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
32import {Text} from '#/view/com/util/text/Text'
33import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed'
34import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType'
35import {atoms as a, useTheme} from '#/alf'
36import {ButtonIcon} from '#/components/Button'
37import {Divider} from '#/components/Divider'
38import * as FeedCard from '#/components/FeedCard'
39import {SearchInput} from '#/components/forms/SearchInput'
40import {IconCircle} from '#/components/IconCircle'
41import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
42import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
43import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass'
44import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle'
45import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
46import * as Layout from '#/components/Layout'
47import {Link} from '#/components/Link'
48import * as ListCard from '#/components/ListCard'
49import {IS_NATIVE, IS_WEB} from '#/env'
50
51type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
52
53type FlatlistSlice =
54 | {
55 type: 'error'
56 key: string
57 error: string
58 }
59 | {
60 type: 'savedFeedsHeader'
61 key: string
62 }
63 | {
64 type: 'savedFeedPlaceholder'
65 key: string
66 }
67 | {
68 type: 'savedFeedNoResults'
69 key: string
70 }
71 | {
72 type: 'savedFeed'
73 key: string
74 savedFeed: SavedFeedItem
75 }
76 | {
77 type: 'savedFeedsLoadMore'
78 key: string
79 }
80 | {
81 type: 'popularFeedsHeader'
82 key: string
83 }
84 | {
85 type: 'popularFeedsLoading'
86 key: string
87 }
88 | {
89 type: 'popularFeedsNoResults'
90 key: string
91 }
92 | {
93 type: 'popularFeed'
94 key: string
95 feedUri: string
96 feed: AppBskyFeedDefs.GeneratorView
97 }
98 | {
99 type: 'popularFeedsLoadingMore'
100 key: string
101 }
102 | {
103 type: 'noFollowingFeed'
104 key: string
105 }
106
107export function FeedsScreen(_props: Props) {
108 const pal = usePalette('default')
109 const t = useTheme()
110 const {openComposer} = useOpenComposer()
111 const {isMobile} = useWebMediaQueries()
112 const [query, setQuery] = React.useState('')
113 const [isPTR, setIsPTR] = React.useState(false)
114 const {
115 data: savedFeeds,
116 isPlaceholderData: isSavedFeedsPlaceholder,
117 error: savedFeedsError,
118 refetch: refetchSavedFeeds,
119 } = useSavedFeeds()
120 const {
121 data: popularFeeds,
122 isFetching: isPopularFeedsFetching,
123 error: popularFeedsError,
124 refetch: refetchPopularFeeds,
125 fetchNextPage: fetchNextPopularFeedsPage,
126 isFetchingNextPage: isPopularFeedsFetchingNextPage,
127 hasNextPage: hasNextPopularFeedsPage,
128 } = useGetPopularFeedsQuery()
129 const {_} = useLingui()
130 const setMinimalShellMode = useSetMinimalShellMode()
131 const {
132 data: searchResults,
133 mutate: search,
134 reset: resetSearch,
135 isPending: isSearchPending,
136 error: searchError,
137 } = useSearchPopularFeedsMutation()
138 const {hasSession} = useSession()
139 const listRef = React.useRef<ListMethods>(null)
140
141 const enableSquareButtons = useEnableSquareButtons()
142
143 /**
144 * A search query is present. We may not have search results yet.
145 */
146 const isUserSearching = query.length > 1
147 const debouncedSearch = React.useMemo(
148 () => debounce(q => search(q), 500), // debounce for 500ms
149 [search],
150 )
151 const onPressCompose = React.useCallback(() => {
152 openComposer({})
153 }, [openComposer])
154 const onChangeQuery = React.useCallback(
155 (text: string) => {
156 setQuery(text)
157 if (text.length > 1) {
158 debouncedSearch(text)
159 } else {
160 refetchPopularFeeds()
161 resetSearch()
162 }
163 },
164 [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch],
165 )
166 const onPressCancelSearch = React.useCallback(() => {
167 setQuery('')
168 refetchPopularFeeds()
169 resetSearch()
170 }, [refetchPopularFeeds, setQuery, resetSearch])
171 const onSubmitQuery = React.useCallback(() => {
172 debouncedSearch(query)
173 }, [query, debouncedSearch])
174 const onPullToRefresh = React.useCallback(async () => {
175 setIsPTR(true)
176 await Promise.all([
177 refetchSavedFeeds().catch(_e => undefined),
178 refetchPopularFeeds().catch(_e => undefined),
179 ])
180 setIsPTR(false)
181 }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds])
182 const onEndReached = React.useCallback(() => {
183 if (
184 isPopularFeedsFetching ||
185 isUserSearching ||
186 !hasNextPopularFeedsPage ||
187 popularFeedsError
188 )
189 return
190 fetchNextPopularFeedsPage()
191 }, [
192 isPopularFeedsFetching,
193 isUserSearching,
194 popularFeedsError,
195 hasNextPopularFeedsPage,
196 fetchNextPopularFeedsPage,
197 ])
198
199 useFocusEffect(
200 React.useCallback(() => {
201 setMinimalShellMode(false)
202 }, [setMinimalShellMode]),
203 )
204
205 const items = React.useMemo(() => {
206 let slices: FlatlistSlice[] = []
207 const hasActualSavedCount =
208 !isSavedFeedsPlaceholder ||
209 (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0)
210 const canShowDiscoverSection =
211 !hasSession || (hasSession && hasActualSavedCount)
212
213 if (hasSession) {
214 slices.push({
215 key: 'savedFeedsHeader',
216 type: 'savedFeedsHeader',
217 })
218
219 if (savedFeedsError) {
220 slices.push({
221 key: 'savedFeedsError',
222 type: 'error',
223 error: cleanError(savedFeedsError.toString()),
224 })
225 } else {
226 if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) {
227 /*
228 * Initial render in placeholder state is 0 on a cold page load,
229 * because preferences haven't loaded yet.
230 *
231 * In practice, `savedFeeds` is always defined, but we check for TS
232 * and for safety.
233 *
234 * In both cases, we show 4 as the the loading state.
235 */
236 const min = 8
237 const count = savedFeeds
238 ? savedFeeds.count === 0
239 ? min
240 : savedFeeds.count
241 : min
242 Array(count)
243 .fill(0)
244 .forEach((_, i) => {
245 slices.push({
246 key: 'savedFeedPlaceholder' + i,
247 type: 'savedFeedPlaceholder',
248 })
249 })
250 } else {
251 if (savedFeeds?.feeds?.length) {
252 const noFollowingFeed = savedFeeds.feeds.every(
253 f => f.type !== 'timeline',
254 )
255
256 slices = slices.concat(
257 savedFeeds.feeds
258 .filter(s => {
259 return s.config.pinned
260 })
261 .map(s => ({
262 key: `savedFeed:${s.view?.uri}:${s.config.id}`,
263 type: 'savedFeed',
264 savedFeed: s,
265 })),
266 )
267 slices = slices.concat(
268 savedFeeds.feeds
269 .filter(s => {
270 return !s.config.pinned
271 })
272 .map(s => ({
273 key: `savedFeed:${s.view?.uri}:${s.config.id}`,
274 type: 'savedFeed',
275 savedFeed: s,
276 })),
277 )
278
279 if (noFollowingFeed) {
280 slices.push({
281 key: 'noFollowingFeed',
282 type: 'noFollowingFeed',
283 })
284 }
285 } else {
286 slices.push({
287 key: 'savedFeedNoResults',
288 type: 'savedFeedNoResults',
289 })
290 }
291 }
292 }
293 }
294
295 if (!hasSession || (hasSession && canShowDiscoverSection)) {
296 slices.push({
297 key: 'popularFeedsHeader',
298 type: 'popularFeedsHeader',
299 })
300
301 if (popularFeedsError || searchError) {
302 slices.push({
303 key: 'popularFeedsError',
304 type: 'error',
305 error: cleanError(
306 popularFeedsError?.toString() ?? searchError?.toString() ?? '',
307 ),
308 })
309 } else {
310 if (isUserSearching) {
311 if (isSearchPending || !searchResults) {
312 slices.push({
313 key: 'popularFeedsLoading',
314 type: 'popularFeedsLoading',
315 })
316 } else {
317 if (!searchResults || searchResults?.length === 0) {
318 slices.push({
319 key: 'popularFeedsNoResults',
320 type: 'popularFeedsNoResults',
321 })
322 } else {
323 slices = slices.concat(
324 searchResults.map(feed => ({
325 key: `popularFeed:${feed.uri}`,
326 type: 'popularFeed',
327 feedUri: feed.uri,
328 feed,
329 })),
330 )
331 }
332 }
333 } else {
334 if (isPopularFeedsFetching && !popularFeeds?.pages) {
335 slices.push({
336 key: 'popularFeedsLoading',
337 type: 'popularFeedsLoading',
338 })
339 } else {
340 if (!popularFeeds?.pages) {
341 slices.push({
342 key: 'popularFeedsNoResults',
343 type: 'popularFeedsNoResults',
344 })
345 } else {
346 for (const page of popularFeeds.pages || []) {
347 slices = slices.concat(
348 page.feeds.map(feed => ({
349 key: `popularFeed:${feed.uri}`,
350 type: 'popularFeed',
351 feedUri: feed.uri,
352 feed,
353 })),
354 )
355 }
356
357 if (isPopularFeedsFetchingNextPage) {
358 slices.push({
359 key: 'popularFeedsLoadingMore',
360 type: 'popularFeedsLoadingMore',
361 })
362 }
363 }
364 }
365 }
366 }
367 }
368
369 return slices
370 }, [
371 hasSession,
372 savedFeeds,
373 isSavedFeedsPlaceholder,
374 savedFeedsError,
375 popularFeeds,
376 isPopularFeedsFetching,
377 popularFeedsError,
378 isPopularFeedsFetchingNextPage,
379 searchResults,
380 isSearchPending,
381 searchError,
382 isUserSearching,
383 ])
384
385 const searchBarIndex = items.findIndex(
386 item => item.type === 'popularFeedsHeader',
387 )
388
389 const onChangeSearchFocus = React.useCallback(
390 (focus: boolean) => {
391 if (focus && searchBarIndex > -1) {
392 if (IS_NATIVE) {
393 // scrollToIndex scrolls the exact right amount, so use if available
394 listRef.current?.scrollToIndex({
395 index: searchBarIndex,
396 animated: true,
397 })
398 } else {
399 // web implementation only supports scrollToOffset
400 // thus, we calculate the offset based on the index
401 // pixel values are estimates, I wasn't able to get it pixel perfect :(
402 const headerHeight = isMobile ? 43 : 53
403 const feedItemHeight = isMobile ? 49 : 58
404 listRef.current?.scrollToOffset({
405 offset: searchBarIndex * feedItemHeight - headerHeight,
406 animated: true,
407 })
408 }
409 }
410 },
411 [searchBarIndex, isMobile],
412 )
413
414 const renderItem = React.useCallback(
415 ({item}: {item: FlatlistSlice}) => {
416 if (item.type === 'error') {
417 return <ErrorMessage message={item.error} />
418 } else if (item.type === 'popularFeedsLoadingMore') {
419 return (
420 <View style={s.p10}>
421 <ActivityIndicator size="large" color={t.palette.primary_500} />
422 </View>
423 )
424 } else if (item.type === 'savedFeedsHeader') {
425 return <FeedsSavedHeader />
426 } else if (item.type === 'savedFeedNoResults') {
427 return (
428 <View
429 style={[
430 pal.border,
431 {
432 borderBottomWidth: 1,
433 },
434 ]}>
435 <NoSavedFeedsOfAnyType />
436 </View>
437 )
438 } else if (item.type === 'savedFeedPlaceholder') {
439 return <SavedFeedPlaceholder />
440 } else if (item.type === 'savedFeed') {
441 return <FeedOrFollowing savedFeed={item.savedFeed} />
442 } else if (item.type === 'popularFeedsHeader') {
443 return (
444 <>
445 <FeedsAboutHeader />
446 <View style={{paddingHorizontal: 12, paddingBottom: 4}}>
447 <SearchInput
448 placeholder={_(msg`Search feeds`)}
449 value={query}
450 onChangeText={onChangeQuery}
451 onClearText={onPressCancelSearch}
452 onSubmitEditing={onSubmitQuery}
453 onFocus={() => onChangeSearchFocus(true)}
454 onBlur={() => onChangeSearchFocus(false)}
455 />
456 </View>
457 </>
458 )
459 } else if (item.type === 'popularFeedsLoading') {
460 return <FeedFeedLoadingPlaceholder />
461 } else if (item.type === 'popularFeed') {
462 return (
463 <View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
464 <FeedCard.Default view={item.feed} />
465 <Divider />
466 </View>
467 )
468 } else if (item.type === 'popularFeedsNoResults') {
469 return (
470 <View
471 style={{
472 paddingHorizontal: 16,
473 paddingTop: 10,
474 paddingBottom: '150%',
475 }}>
476 <Text type="lg" style={pal.textLight}>
477 <Trans>No results found for "{query}"</Trans>
478 </Text>
479 </View>
480 )
481 } else if (item.type === 'noFollowingFeed') {
482 return (
483 <View
484 style={[
485 pal.border,
486 {
487 borderBottomWidth: 1,
488 },
489 ]}>
490 <NoFollowingFeed />
491 </View>
492 )
493 }
494 return null
495 },
496 [
497 _,
498 t.palette.primary_500,
499 pal.border,
500 pal.textLight,
501 query,
502 onChangeQuery,
503 onPressCancelSearch,
504 onSubmitQuery,
505 onChangeSearchFocus,
506 ],
507 )
508
509 return (
510 <Layout.Screen testID="FeedsScreen">
511 <Layout.Center>
512 <Layout.Header.Outer>
513 <Layout.Header.BackButton />
514 <Layout.Header.Content>
515 <Layout.Header.TitleText>
516 <Trans>Feeds</Trans>
517 </Layout.Header.TitleText>
518 </Layout.Header.Content>
519 <Layout.Header.Slot>
520 <Link
521 testID="editFeedsBtn"
522 to="/settings/saved-feeds"
523 label={_(msg`Edit My Feeds`)}
524 size="small"
525 variant="ghost"
526 color="secondary"
527 shape={enableSquareButtons ? 'square' : 'round'}
528 style={[a.justify_center, {right: -3}]}>
529 <ButtonIcon icon={Gear} size="lg" />
530 </Link>
531 </Layout.Header.Slot>
532 </Layout.Header.Outer>
533
534 <List
535 ref={listRef}
536 data={items}
537 keyExtractor={item => item.key}
538 contentContainerStyle={styles.contentContainer}
539 renderItem={renderItem}
540 refreshing={isPTR}
541 onRefresh={isUserSearching ? undefined : onPullToRefresh}
542 initialNumToRender={10}
543 onEndReached={onEndReached}
544 desktopFixedHeight
545 keyboardShouldPersistTaps="handled"
546 keyboardDismissMode="on-drag"
547 sideBorders={false}
548 />
549 </Layout.Center>
550
551 {hasSession && (
552 <FAB
553 testID="composeFAB"
554 onPress={onPressCompose}
555 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
556 accessibilityRole="button"
557 accessibilityLabel={_(msg`New post`)}
558 accessibilityHint=""
559 />
560 )}
561 </Layout.Screen>
562 )
563}
564
565function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
566 return savedFeed.type === 'timeline' ? (
567 <FollowingFeed />
568 ) : (
569 <SavedFeed savedFeed={savedFeed} />
570 )
571}
572
573function FollowingFeed() {
574 const t = useTheme()
575 const {_} = useLingui()
576 return (
577 <View
578 style={[
579 a.flex_1,
580 a.px_lg,
581 a.py_md,
582 a.border_b,
583 t.atoms.border_contrast_low,
584 ]}>
585 <FeedCard.Header>
586 <View
587 style={[
588 a.align_center,
589 a.justify_center,
590 {
591 width: 28,
592 height: 28,
593 borderRadius: 3,
594 backgroundColor: t.palette.primary_500,
595 },
596 ]}>
597 <FilterTimeline
598 style={[
599 {
600 width: 18,
601 height: 18,
602 },
603 ]}
604 fill={t.palette.white}
605 />
606 </View>
607 <FeedCard.TitleAndByline
608 title={_(msg({message: 'Following', context: 'feed-name'}))}
609 />
610 </FeedCard.Header>
611 </View>
612 )
613}
614
615function SavedFeed({
616 savedFeed,
617}: {
618 savedFeed: SavedFeedItem & {type: 'feed' | 'list'}
619}) {
620 const t = useTheme()
621
622 const commonStyle = [
623 a.w_full,
624 a.flex_1,
625 a.px_lg,
626 a.py_md,
627 a.border_b,
628 t.atoms.border_contrast_low,
629 ]
630
631 return savedFeed.type === 'feed' ? (
632 <FeedCard.Link
633 testID={`saved-feed-${savedFeed.view.displayName}`}
634 {...savedFeed}>
635 {({hovered, pressed}) => (
636 <View
637 style={[commonStyle, (hovered || pressed) && t.atoms.bg_contrast_25]}>
638 <FeedCard.Header>
639 <FeedCard.Avatar src={savedFeed.view.avatar} size={28} />
640 <FeedCard.TitleAndByline title={savedFeed.view.displayName} />
641
642 <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
643 </FeedCard.Header>
644 </View>
645 )}
646 </FeedCard.Link>
647 ) : (
648 <ListCard.Link testID={`saved-feed-${savedFeed.view.name}`} {...savedFeed}>
649 {({hovered, pressed}) => (
650 <View
651 style={[commonStyle, (hovered || pressed) && t.atoms.bg_contrast_25]}>
652 <ListCard.Header>
653 <ListCard.Avatar src={savedFeed.view.avatar} size={28} />
654 <ListCard.TitleAndByline title={savedFeed.view.name} />
655
656 <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} />
657 </ListCard.Header>
658 </View>
659 )}
660 </ListCard.Link>
661 )
662}
663
664function SavedFeedPlaceholder() {
665 const t = useTheme()
666 return (
667 <View
668 style={[
669 a.flex_1,
670 a.px_lg,
671 a.py_md,
672 a.border_b,
673 t.atoms.border_contrast_low,
674 ]}>
675 <FeedCard.Header>
676 <FeedCard.AvatarPlaceholder size={28} />
677 <FeedCard.TitleAndBylinePlaceholder />
678 </FeedCard.Header>
679 </View>
680 )
681}
682
683function FeedsSavedHeader() {
684 const t = useTheme()
685
686 return (
687 <View
688 style={
689 IS_WEB
690 ? [
691 a.flex_row,
692 a.px_md,
693 a.py_lg,
694 a.gap_md,
695 a.border_b,
696 t.atoms.border_contrast_low,
697 ]
698 : [
699 {flexDirection: 'row-reverse'},
700 a.p_lg,
701 a.gap_md,
702 a.border_b,
703 t.atoms.border_contrast_low,
704 ]
705 }>
706 <IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
707 <View style={[a.flex_1, a.gap_xs]}>
708 <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
709 <Trans>My Feeds</Trans>
710 </Text>
711 <Text style={[t.atoms.text_contrast_high]}>
712 <Trans>All the feeds you've saved, right in one place.</Trans>
713 </Text>
714 </View>
715 </View>
716 )
717}
718
719function FeedsAboutHeader() {
720 const t = useTheme()
721
722 return (
723 <View
724 style={
725 IS_WEB
726 ? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md]
727 : [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md]
728 }>
729 <IconCircle
730 icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded}
731 size="lg"
732 />
733 <View style={[a.flex_1, a.gap_sm]}>
734 <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
735 <Trans>Discover New Feeds</Trans>
736 </Text>
737 <Text style={[t.atoms.text_contrast_high]}>
738 <Trans>
739 Choose your own timeline! Feeds built by the community help you find
740 content you love.
741 </Trans>
742 </Text>
743 </View>
744 </View>
745 )
746}
747
748const styles = StyleSheet.create({
749 contentContainer: {
750 paddingBottom: 100,
751 },
752
753 header: {
754 flexDirection: 'row',
755 alignItems: 'center',
756 justifyContent: 'space-between',
757 gap: 16,
758 paddingHorizontal: 18,
759 paddingVertical: 12,
760 },
761
762 savedFeed: {
763 flexDirection: 'row',
764 alignItems: 'center',
765 paddingHorizontal: 16,
766 paddingVertical: 14,
767 gap: 12,
768 borderBottomWidth: StyleSheet.hairlineWidth,
769 },
770 savedFeedMobile: {
771 paddingVertical: 10,
772 },
773 offlineSlug: {
774 borderWidth: StyleSheet.hairlineWidth,
775 borderRadius: 4,
776 paddingHorizontal: 4,
777 paddingVertical: 2,
778 },
779 headerBtnGroup: {
780 flexDirection: 'row',
781 gap: 15,
782 alignItems: 'center',
783 },
784})