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