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