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

Configure Feed

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

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