Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {
2 type JSX,
3 useCallback,
4 useEffect,
5 useMemo,
6 useRef,
7 useState,
8} from 'react'
9import {View} from 'react-native'
10import {type AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
11import {msg} from '@lingui/core/macro'
12import {useLingui} from '@lingui/react'
13import {type NavigationProp, useNavigation} from '@react-navigation/native'
14import {useQueryClient} from '@tanstack/react-query'
15
16import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants'
17import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
18import {ComposeIcon2} from '#/lib/icons'
19import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
20import {type AllNavigatorParams} from '#/lib/routes/types'
21import {s} from '#/lib/styles'
22import {listenSoftReset} from '#/state/events'
23import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
24import {useSetHomeBadge} from '#/state/home-badge'
25import {type FeedSourceInfo} from '#/state/queries/feed'
26import {
27 type FeedDescriptor,
28 type FeedParams,
29 RQKEY as FEED_RQKEY,
30} from '#/state/queries/post-feed'
31import {truncateAndInvalidate} from '#/state/queries/util'
32import {useSession} from '#/state/session'
33import {useSetMinimalShellMode} from '#/state/shell'
34import {useHeaderOffset} from '#/components/hooks/useHeaderOffset'
35import {useAnalytics} from '#/analytics'
36import {IS_NATIVE} from '#/env'
37import {PostFeed} from '../posts/PostFeed'
38import {FAB} from '../util/fab/FAB'
39import {type ListMethods} from '../util/List'
40import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
41import {MainScrollProvider} from '../util/MainScrollProvider'
42
43const POLL_FREQ = 60e3 // 60sec
44
45export function FeedPage({
46 testID,
47 isPageFocused,
48 isPageAdjacent,
49 feed,
50 feedParams,
51 renderEmptyState,
52 renderEndOfFeed,
53 savedFeedConfig,
54 feedInfo,
55}: {
56 testID?: string
57 feed: FeedDescriptor
58 feedParams?: FeedParams
59 isPageFocused: boolean
60 isPageAdjacent: boolean
61 renderEmptyState: () => JSX.Element
62 renderEndOfFeed?: () => JSX.Element
63 savedFeedConfig?: AppBskyActorDefs.SavedFeed
64 feedInfo: FeedSourceInfo
65}) {
66 const ax = useAnalytics()
67 const {hasSession} = useSession()
68 const {_} = useLingui()
69 const navigation = useNavigation<NavigationProp<AllNavigatorParams>>()
70 const queryClient = useQueryClient()
71 const {openComposer} = useOpenComposer()
72 const [isScrolledDown, setIsScrolledDown] = useState(false)
73 const setMinimalShellMode = useSetMinimalShellMode()
74 const headerOffset = useHeaderOffset()
75 const feedFeedback = useFeedFeedback(feedInfo, hasSession)
76 const scrollElRef = useRef<ListMethods>(null)
77 const [hasNew, setHasNew] = useState(false)
78 const setHomeBadge = useSetHomeBadge()
79 const isVideoFeed = useMemo(() => {
80 const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
81 const feedIsVideoMode =
82 feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
83 const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
84 return IS_NATIVE && _isVideoFeed
85 }, [feedInfo])
86
87 useEffect(() => {
88 if (isPageFocused) {
89 setHomeBadge(hasNew)
90 }
91 }, [isPageFocused, hasNew, setHomeBadge])
92
93 const scrollToTop = useCallback(() => {
94 scrollElRef.current?.scrollToOffset({
95 animated: IS_NATIVE,
96 offset: -headerOffset,
97 })
98 setMinimalShellMode(false)
99 }, [headerOffset, setMinimalShellMode])
100
101 const onSoftReset = useCallback(() => {
102 const isScreenFocused =
103 getTabState(getRootNavigation(navigation).getState(), 'Home') ===
104 TabState.InsideAtRoot
105 if (isScreenFocused && isPageFocused) {
106 scrollToTop()
107 truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
108 setHasNew(false)
109 ax.metric('feed:refresh', {
110 feedType: feed.split('|')[0],
111 feedUrl: feed,
112 reason: 'soft-reset',
113 })
114 }
115 }, [ax, navigation, isPageFocused, scrollToTop, queryClient, feed])
116
117 // fires when page within screen is activated/deactivated
118 useEffect(() => {
119 if (!isPageFocused) {
120 return
121 }
122 return listenSoftReset(onSoftReset)
123 }, [onSoftReset, isPageFocused])
124
125 const onPressCompose = useCallback(() => {
126 openComposer({logContext: 'Fab'})
127 }, [openComposer])
128
129 const onPressLoadLatest = useCallback(() => {
130 scrollToTop()
131 truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
132 setHasNew(false)
133 ax.metric('feed:refresh', {
134 feedType: feed.split('|')[0],
135 feedUrl: feed,
136 reason: 'load-latest',
137 })
138 }, [ax, scrollToTop, feed, queryClient])
139
140 const shouldPrefetch = IS_NATIVE && isPageAdjacent
141 const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI
142 return (
143 <View
144 testID={testID}
145 // @ts-expect-error web only -sfn
146 dataSet={{nosnippet: isDiscoverFeed ? '' : undefined}}>
147 <MainScrollProvider>
148 <FeedFeedbackProvider value={feedFeedback}>
149 <PostFeed
150 testID={testID ? `${testID}-feed` : undefined}
151 enabled={isPageFocused || shouldPrefetch}
152 feed={feed}
153 feedParams={feedParams}
154 pollInterval={POLL_FREQ}
155 disablePoll={hasNew || !isPageFocused}
156 scrollElRef={scrollElRef}
157 onScrolledDownChange={setIsScrolledDown}
158 onHasNew={setHasNew}
159 renderEmptyState={renderEmptyState}
160 renderEndOfFeed={renderEndOfFeed}
161 headerOffset={headerOffset}
162 savedFeedConfig={savedFeedConfig}
163 isVideoFeed={isVideoFeed}
164 />
165 </FeedFeedbackProvider>
166 </MainScrollProvider>
167 {(isScrolledDown || hasNew) && (
168 <LoadLatestBtn
169 onPress={onPressLoadLatest}
170 label={_(msg`Load new posts`)}
171 showIndicator={hasNew}
172 />
173 )}
174
175 {hasSession && (
176 <FAB
177 testID="composeFAB"
178 onPress={onPressCompose}
179 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
180 accessibilityRole="button"
181 accessibilityLabel={_(msg({message: `New post`, context: 'action'}))}
182 accessibilityHint=""
183 />
184 )}
185 </View>
186 )
187}