this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at e28f6d2f370b4e882ed6f23d08ca0f8d94dbac5f 299 lines 9.1 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {type ListRenderItemInfo, 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 {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 10import {HITSLOP_10} from '#/lib/constants' 11import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 12import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 13import {type CommonNavigatorParams} from '#/lib/routes/types' 14import {shareUrl} from '#/lib/sharing' 15import {cleanError} from '#/lib/strings/errors' 16import {sanitizeHandle} from '#/lib/strings/handles' 17import {enforceLen} from '#/lib/strings/helpers' 18import {useSearchPostsQuery} from '#/state/queries/search-posts' 19import {useSession} from '#/state/session' 20import {useSetMinimalShellMode} from '#/state/shell' 21import {useLoggedOutViewControls} from '#/state/shell/logged-out' 22import {useCloseAllActiveElements} from '#/state/util' 23import {Pager} from '#/view/com/pager/Pager' 24import {TabBar} from '#/view/com/pager/TabBar' 25import {Post} from '#/view/com/post/Post' 26import {List} from '#/view/com/util/List' 27import {atoms as a, useTheme, web} from '#/alf' 28import {Button, ButtonIcon} from '#/components/Button' 29import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 30import * as Layout from '#/components/Layout' 31import {InlineLinkText} from '#/components/Link' 32import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 33import {SearchError} from '#/components/SearchError' 34import {Text} from '#/components/Typography' 35 36const renderItem = ({item}: ListRenderItemInfo<AppBskyFeedDefs.PostView>) => { 37 return <Post post={item} /> 38} 39 40const keyExtractor = (item: AppBskyFeedDefs.PostView, index: number) => { 41 return `${item.uri}-${index}` 42} 43 44export default function HashtagScreen({ 45 route, 46}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) { 47 const {tag, author} = route.params 48 const {_} = useLingui() 49 50 const decodedTag = useMemo(() => { 51 return decodeURIComponent(tag) 52 }, [tag]) 53 54 const isCashtag = decodedTag.startsWith('$') 55 56 const fullTag = useMemo(() => { 57 // Cashtags already include the $ prefix, hashtags need # added 58 return isCashtag ? decodedTag : `#${decodedTag}` 59 }, [decodedTag, isCashtag]) 60 61 const headerTitle = useMemo(() => { 62 // Keep cashtags uppercase, lowercase hashtags 63 const displayTag = isCashtag ? fullTag.toUpperCase() : fullTag.toLowerCase() 64 return enforceLen(displayTag, 24, true, 'middle') 65 }, [fullTag, isCashtag]) 66 67 const sanitizedAuthor = useMemo(() => { 68 if (!author) return '' 69 return sanitizeHandle(author) 70 }, [author]) 71 72 const onShare = useCallback(() => { 73 const url = new URL('https://bsky.app') 74 url.pathname = `/hashtag/${decodeURIComponent(tag)}` 75 if (author) { 76 url.searchParams.set('author', author) 77 } 78 shareUrl(url.toString()) 79 }, [tag, author]) 80 81 const [activeTab, setActiveTab] = useState(0) 82 const setMinimalShellMode = useSetMinimalShellMode() 83 84 useFocusEffect( 85 useCallback(() => { 86 setMinimalShellMode(false) 87 }, [setMinimalShellMode]), 88 ) 89 90 const onPageSelected = useCallback( 91 (index: number) => { 92 setMinimalShellMode(false) 93 setActiveTab(index) 94 }, 95 [setMinimalShellMode], 96 ) 97 98 const sections = useMemo(() => { 99 return [ 100 { 101 title: _(msg`Top`), 102 component: ( 103 <HashtagScreenTab 104 fullTag={fullTag} 105 author={author} 106 sort="top" 107 active={activeTab === 0} 108 /> 109 ), 110 }, 111 { 112 title: _(msg`Latest`), 113 component: ( 114 <HashtagScreenTab 115 fullTag={fullTag} 116 author={author} 117 sort="latest" 118 active={activeTab === 1} 119 /> 120 ), 121 }, 122 ] 123 }, [_, fullTag, author, activeTab]) 124 125 return ( 126 <Layout.Screen> 127 <Pager 128 onPageSelected={onPageSelected} 129 renderTabBar={props => ( 130 <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}> 131 <Layout.Header.Outer noBottomBorder> 132 <Layout.Header.BackButton /> 133 <Layout.Header.Content> 134 <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText> 135 {author && ( 136 <Layout.Header.SubtitleText> 137 {_(msg`From @${sanitizedAuthor}`)} 138 </Layout.Header.SubtitleText> 139 )} 140 </Layout.Header.Content> 141 <Layout.Header.Slot> 142 <Button 143 label={_(msg`Share`)} 144 size="small" 145 variant="ghost" 146 color="primary" 147 shape="round" 148 onPress={onShare} 149 hitSlop={HITSLOP_10} 150 style={[{right: -3}]}> 151 <ButtonIcon icon={Share} size="md" /> 152 </Button> 153 </Layout.Header.Slot> 154 </Layout.Header.Outer> 155 <TabBar items={sections.map(section => section.title)} {...props} /> 156 </Layout.Center> 157 )} 158 initialPage={0}> 159 {sections.map((section, i) => ( 160 <View key={i}>{section.component}</View> 161 ))} 162 </Pager> 163 </Layout.Screen> 164 ) 165} 166 167function HashtagScreenTab({ 168 fullTag, 169 author, 170 sort, 171 active, 172}: { 173 fullTag: string 174 author: string | undefined 175 sort: 'top' | 'latest' 176 active: boolean 177}) { 178 const {_} = useLingui() 179 const initialNumToRender = useInitialNumToRender() 180 const [isPTR, setIsPTR] = useState(false) 181 const t = useTheme() 182 const {hasSession} = useSession() 183 const trackPostView = usePostViewTracking('Hashtag') 184 185 const isCashtag = fullTag.startsWith('$') 186 187 const queryParam = useMemo(() => { 188 // Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author" 189 const searchTag = isCashtag ? `#${fullTag}` : fullTag 190 if (!author) return searchTag 191 return `${searchTag} from:${author}` 192 }, [fullTag, author, isCashtag]) 193 194 const { 195 data, 196 isFetched, 197 isFetchingNextPage, 198 isLoading, 199 isError, 200 error, 201 refetch, 202 fetchNextPage, 203 hasNextPage, 204 } = useSearchPostsQuery({query: queryParam, sort, enabled: active}) 205 206 const posts = useMemo(() => { 207 return data?.pages.flatMap(page => page.posts) || [] 208 }, [data]) 209 210 const onRefresh = useCallback(async () => { 211 setIsPTR(true) 212 await refetch() 213 setIsPTR(false) 214 }, [refetch]) 215 216 const onEndReached = useCallback(() => { 217 if (isFetchingNextPage || !hasNextPage || error) return 218 fetchNextPage() 219 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 220 221 const closeAllActiveElements = useCloseAllActiveElements() 222 const {requestSwitchToAccount} = useLoggedOutViewControls() 223 224 const showSignIn = () => { 225 closeAllActiveElements() 226 requestSwitchToAccount({requestedAccount: 'none'}) 227 } 228 229 const showCreateAccount = () => { 230 closeAllActiveElements() 231 requestSwitchToAccount({requestedAccount: 'new'}) 232 } 233 234 if (!hasSession) { 235 return ( 236 <SearchError 237 title={_(msg`Search is currently unavailable when logged out`)}> 238 <Text style={[a.text_md, a.text_center, a.leading_snug]}> 239 <Trans> 240 <InlineLinkText 241 label={_(msg`Sign in`)} 242 to={'#'} 243 onPress={showSignIn}> 244 Sign in 245 </InlineLinkText> 246 <Text style={t.atoms.text_contrast_medium}> or </Text> 247 <InlineLinkText 248 label={_(msg`Create an account`)} 249 to={'#'} 250 onPress={showCreateAccount}> 251 create an account 252 </InlineLinkText> 253 <Text> </Text> 254 <Text style={t.atoms.text_contrast_medium}> 255 to search for news, sports, politics, and everything else 256 happening on Bluesky. 257 </Text> 258 </Trans> 259 </Text> 260 </SearchError> 261 ) 262 } 263 264 return ( 265 <> 266 {posts.length < 1 ? ( 267 <ListMaybePlaceholder 268 isLoading={isLoading || !isFetched} 269 isError={isError} 270 onRetry={refetch} 271 emptyType="results" 272 emptyMessage={_(msg`We couldn't find any results for that tag.`)} 273 /> 274 ) : ( 275 <List 276 data={posts} 277 renderItem={renderItem} 278 keyExtractor={keyExtractor} 279 refreshing={isPTR} 280 onRefresh={onRefresh} 281 onEndReached={onEndReached} 282 onEndReachedThreshold={4} 283 onItemSeen={trackPostView} 284 // @ts-ignore web only -prf 285 desktopFixedHeight 286 ListFooterComponent={ 287 <ListFooter 288 isFetchingNextPage={isFetchingNextPage} 289 error={cleanError(error)} 290 onRetry={fetchNextPage} 291 /> 292 } 293 initialNumToRender={initialNumToRender} 294 windowSize={11} 295 /> 296 )} 297 </> 298 ) 299}