Bluesky app fork with some witchin' additions 馃挮
at feat/tealfm 301 lines 9.3 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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 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 = React.useMemo(() => { 51 return decodeURIComponent(tag) 52 }, [tag]) 53 54 const isCashtag = decodedTag.startsWith('$') 55 56 const fullTag = React.useMemo(() => { 57 // Cashtags already include the $ prefix, hashtags need # added 58 return isCashtag ? decodedTag : `#${decodedTag}` 59 }, [decodedTag, isCashtag]) 60 61 const headerTitle = React.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 = React.useMemo(() => { 68 if (!author) return 69 return sanitizeHandle(author) 70 }, [author]) 71 72 const onShare = React.useCallback(() => { 73 const url = new URL('https://witchsky.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] = React.useState(0) 82 const setMinimalShellMode = useSetMinimalShellMode() 83 84 const enableSquareButtons = useEnableSquareButtons() 85 86 useFocusEffect( 87 React.useCallback(() => { 88 setMinimalShellMode(false) 89 }, [setMinimalShellMode]), 90 ) 91 92 const onPageSelected = React.useCallback( 93 (index: number) => { 94 setMinimalShellMode(false) 95 setActiveTab(index) 96 }, 97 [setMinimalShellMode], 98 ) 99 100 const sections = React.useMemo(() => { 101 return [ 102 { 103 title: _(msg`Top`), 104 component: ( 105 <HashtagScreenTab 106 fullTag={fullTag} 107 author={author} 108 sort="top" 109 active={activeTab === 0} 110 /> 111 ), 112 }, 113 { 114 title: _(msg`Latest`), 115 component: ( 116 <HashtagScreenTab 117 fullTag={fullTag} 118 author={author} 119 sort="latest" 120 active={activeTab === 1} 121 /> 122 ), 123 }, 124 ] 125 }, [_, fullTag, author, activeTab]) 126 127 return ( 128 <Layout.Screen> 129 <Pager 130 onPageSelected={onPageSelected} 131 renderTabBar={props => ( 132 <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}> 133 <Layout.Header.Outer noBottomBorder> 134 <Layout.Header.BackButton /> 135 <Layout.Header.Content> 136 <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText> 137 {author && ( 138 <Layout.Header.SubtitleText> 139 {_(msg`From @${sanitizedAuthor}`)} 140 </Layout.Header.SubtitleText> 141 )} 142 </Layout.Header.Content> 143 <Layout.Header.Slot> 144 <Button 145 label={_(msg`Share`)} 146 size="small" 147 variant="ghost" 148 color="primary" 149 shape={enableSquareButtons ? 'square' : 'round'} 150 onPress={onShare} 151 hitSlop={HITSLOP_10} 152 style={[{right: -3}]}> 153 <ButtonIcon icon={Share} size="md" /> 154 </Button> 155 </Layout.Header.Slot> 156 </Layout.Header.Outer> 157 <TabBar items={sections.map(section => section.title)} {...props} /> 158 </Layout.Center> 159 )} 160 initialPage={0}> 161 {sections.map((section, i) => ( 162 <View key={i}>{section.component}</View> 163 ))} 164 </Pager> 165 </Layout.Screen> 166 ) 167} 168 169function HashtagScreenTab({ 170 fullTag, 171 author, 172 sort, 173 active, 174}: { 175 fullTag: string 176 author: string | undefined 177 sort: 'top' | 'latest' 178 active: boolean 179}) { 180 const {_} = useLingui() 181 const initialNumToRender = useInitialNumToRender() 182 const [isPTR, setIsPTR] = React.useState(false) 183 const t = useTheme() 184 const {hasSession} = useSession() 185 const trackPostView = usePostViewTracking('Hashtag') 186 187 const isCashtag = fullTag.startsWith('$') 188 189 const queryParam = React.useMemo(() => { 190 // Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author" 191 const searchTag = isCashtag ? `#${fullTag}` : fullTag 192 if (!author) return searchTag 193 return `${searchTag} from:${author}` 194 }, [fullTag, author, isCashtag]) 195 196 const { 197 data, 198 isFetched, 199 isFetchingNextPage, 200 isLoading, 201 isError, 202 error, 203 refetch, 204 fetchNextPage, 205 hasNextPage, 206 } = useSearchPostsQuery({query: queryParam, sort, enabled: active}) 207 208 const posts = React.useMemo(() => { 209 return data?.pages.flatMap(page => page.posts) || [] 210 }, [data]) 211 212 const onRefresh = React.useCallback(async () => { 213 setIsPTR(true) 214 await refetch() 215 setIsPTR(false) 216 }, [refetch]) 217 218 const onEndReached = React.useCallback(() => { 219 if (isFetchingNextPage || !hasNextPage || error) return 220 fetchNextPage() 221 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 222 223 const closeAllActiveElements = useCloseAllActiveElements() 224 const {requestSwitchToAccount} = useLoggedOutViewControls() 225 226 const showSignIn = () => { 227 closeAllActiveElements() 228 requestSwitchToAccount({requestedAccount: 'none'}) 229 } 230 231 const showCreateAccount = () => { 232 closeAllActiveElements() 233 requestSwitchToAccount({requestedAccount: 'new'}) 234 } 235 236 if (!hasSession) { 237 return ( 238 <SearchError 239 title={_(msg`Search is currently unavailable when logged out`)}> 240 <Text style={[a.text_md, a.text_center, a.leading_snug]}> 241 <Trans> 242 <InlineLinkText 243 label={_(msg`Sign in`)} 244 to={'#'} 245 onPress={showSignIn}> 246 Sign in 247 </InlineLinkText> 248 <Text style={t.atoms.text_contrast_medium}> or </Text> 249 <InlineLinkText 250 label={_(msg`Create an account`)} 251 to={'#'} 252 onPress={showCreateAccount}> 253 create an account 254 </InlineLinkText> 255 <Text> </Text> 256 <Text style={t.atoms.text_contrast_medium}> 257 to search for news, sports, politics, and everything else 258 happening on Bluesky. 259 </Text> 260 </Trans> 261 </Text> 262 </SearchError> 263 ) 264 } 265 266 return ( 267 <> 268 {posts.length < 1 ? ( 269 <ListMaybePlaceholder 270 isLoading={isLoading || !isFetched} 271 isError={isError} 272 onRetry={refetch} 273 emptyType="results" 274 emptyMessage={_(msg`We couldn't find any results for that tag.`)} 275 /> 276 ) : ( 277 <List 278 data={posts} 279 renderItem={renderItem} 280 keyExtractor={keyExtractor} 281 refreshing={isPTR} 282 onRefresh={onRefresh} 283 onEndReached={onEndReached} 284 onEndReachedThreshold={4} 285 onItemSeen={trackPostView} 286 // @ts-ignore web only -prf 287 desktopFixedHeight 288 ListFooterComponent={ 289 <ListFooter 290 isFetchingNextPage={isFetchingNextPage} 291 error={cleanError(error)} 292 onRetry={fetchNextPage} 293 /> 294 } 295 initialNumToRender={initialNumToRender} 296 windowSize={11} 297 /> 298 )} 299 </> 300 ) 301}