Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 302 lines 9.3 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 {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 = useMemo(() => { 52 return decodeURIComponent(tag) 53 }, [tag]) 54 55 const isCashtag = decodedTag.startsWith('$') 56 57 const fullTag = useMemo(() => { 58 // Cashtags already include the $ prefix, hashtags need # added 59 return isCashtag ? decodedTag : `#${decodedTag}` 60 }, [decodedTag, isCashtag]) 61 62 const headerTitle = 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 = useMemo(() => { 69 if (!author) return '' 70 return sanitizeHandle(author) 71 }, [author]) 72 73 const onShare = 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] = useState(0) 83 const setMinimalShellMode = useSetMinimalShellMode() 84 85 const enableSquareButtons = useEnableSquareButtons() 86 87 useFocusEffect( 88 useCallback(() => { 89 setMinimalShellMode(false) 90 }, [setMinimalShellMode]), 91 ) 92 93 const onPageSelected = useCallback( 94 (index: number) => { 95 setMinimalShellMode(false) 96 setActiveTab(index) 97 }, 98 [setMinimalShellMode], 99 ) 100 101 const sections = 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] = 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 = 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 = useMemo(() => { 210 return data?.pages.flatMap(page => page.posts) || [] 211 }, [data]) 212 213 const onRefresh = useCallback(async () => { 214 setIsPTR(true) 215 await refetch() 216 setIsPTR(false) 217 }, [refetch]) 218 219 const onEndReached = 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}