Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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