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