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