@@ -5,6 +5,11 @@ import {msg} from '@lingui/macro'
55import { useLingui } from '@lingui/react'
66import { useFocusEffect } from '@react-navigation/native'
77import { type NativeStackScreenProps } from '@react-navigation/native-stack'
8+ import {
9+ type InfiniteData ,
10+ type QueryClient ,
11+ useInfiniteQuery ,
12+ } from '@tanstack/react-query'
813
914import { HITSLOP_10 } from '#/lib/constants'
1015import { useInitialNumToRender } from '#/lib/hooks/useInitialNumToRender'
@@ -14,6 +19,7 @@ import {shareUrl} from '#/lib/sharing'
1419import { cleanError } from '#/lib/strings/errors'
1520import { enforceLen } from '#/lib/strings/helpers'
1621import { useSearchPostsQuery } from '#/state/queries/search-posts'
22+ import { useAgent } from '#/state/session'
1723import { useSetMinimalShellMode } from '#/state/shell'
1824import { Pager } from '#/view/com/pager/Pager'
1925import { TabBar } from '#/view/com/pager/TabBar'
@@ -25,6 +31,9 @@ import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/componen
2531import * as Layout from '#/components/Layout'
2632import { ListFooter , ListMaybePlaceholder } from '#/components/Lists'
2733
34+ const TOPICS_API = 'https://api.blacksky.community'
35+ const PAGE_SIZE = 25
36+
2837const renderItem = ( { item} : ListRenderItemInfo < AppBskyFeedDefs . PostView > ) => {
2938 return < Post post = { item } />
3039}
@@ -36,20 +45,25 @@ const keyExtractor = (item: AppBskyFeedDefs.PostView, index: number) => {
3645export default function TopicScreen ( {
3746 route,
3847} : NativeStackScreenProps < CommonNavigatorParams , 'Topic' > ) {
39- const { topic} = route . params
48+ const { topic : topicParam } = route . params
4049 const { _} = useLingui ( )
4150
51+ const isTopicId = / ^ \d + $ / . test ( topicParam )
52+
53+ const [ topicName , setTopicName ] = React . useState (
54+ isTopicId ? '' : decodeURIComponent ( topicParam ) ,
55+ )
56+
4257 const headerTitle = React . useMemo ( ( ) => {
43- return enforceLen ( decodeURIComponent ( topic ) , 24 , true , 'middle' )
44- } , [ topic ] )
58+ return topicName ? enforceLen ( topicName , 30 , true , 'middle' ) : _ ( msg `Topic` )
59+ } , [ topicName , _ ] )
4560
4661 const onShare = React . useCallback ( ( ) => {
4762 const url = new URL ( 'https://blacksky.community' )
48- url . pathname = `/topic/${ topic } `
63+ url . pathname = `/topic/${ topicParam } `
4964 shareUrl ( url . toString ( ) )
50- } , [ topic ] )
65+ } , [ topicParam ] )
5166
52- const [ activeTab , setActiveTab ] = React . useState ( 0 )
5367 const setMinimalShellMode = useSetMinimalShellMode ( )
5468
5569 useFocusEffect (
@@ -58,6 +72,183 @@ export default function TopicScreen({
5872 } , [ setMinimalShellMode ] ) ,
5973 )
6074
75+ if ( isTopicId ) {
76+ return (
77+ < Layout . Screen >
78+ < Layout . Center style = { [ a . z_10 , web ( [ a . sticky , { top : 0 } ] ) ] } >
79+ < Layout . Header . Outer >
80+ < Layout . Header . BackButton />
81+ < Layout . Header . Content >
82+ < Layout . Header . TitleText > { headerTitle } </ Layout . Header . TitleText >
83+ </ Layout . Header . Content >
84+ < Layout . Header . Slot >
85+ < Button
86+ label = { _ ( msg `Share` ) }
87+ size = "small"
88+ variant = "ghost"
89+ color = "primary"
90+ shape = "round"
91+ onPress = { onShare }
92+ hitSlop = { HITSLOP_10 }
93+ style = { [ { right : - 3 } ] } >
94+ < ButtonIcon icon = { Share } size = "md" />
95+ </ Button >
96+ </ Layout . Header . Slot >
97+ </ Layout . Header . Outer >
98+ </ Layout . Center >
99+ < CuratedTopicFeed topicId = { topicParam } onTopicName = { setTopicName } />
100+ </ Layout . Screen >
101+ )
102+ }
103+
104+ // Legacy: search-based topic view (non-numeric topic param)
105+ return < LegacyTopicScreen topicParam = { topicParam } headerTitle = { headerTitle } onShare = { onShare } />
106+ }
107+
108+ function CuratedTopicFeed ( {
109+ topicId,
110+ onTopicName,
111+ } : {
112+ topicId : string
113+ onTopicName : ( name : string ) => void
114+ } ) {
115+ const { _} = useLingui ( )
116+ const initialNumToRender = useInitialNumToRender ( )
117+ const [ isPTR , setIsPTR ] = React . useState ( false )
118+ const trackPostView = usePostViewTracking ( 'Topic' )
119+ const agent = useAgent ( )
120+
121+ const {
122+ data,
123+ isLoading,
124+ isFetched,
125+ isError,
126+ error,
127+ refetch,
128+ fetchNextPage,
129+ hasNextPage,
130+ isFetchingNextPage,
131+ } = useInfiniteQuery ( {
132+ queryKey : [ 'topic-feed' , topicId ] ,
133+ initialPageParam : undefined as string | undefined ,
134+ queryFn : async ( { pageParam} ) => {
135+ const params = new URLSearchParams ( { topicId, limit : String ( PAGE_SIZE ) } )
136+ if ( pageParam ) params . set ( 'cursor' , pageParam )
137+
138+ const res = await fetch (
139+ `${ TOPICS_API } /xrpc/app.bsky.unspecced.getTopicFeed?${ params } ` ,
140+ )
141+ if ( ! res . ok ) throw new Error ( `getTopicFeed failed: ${ res . status } ` )
142+ const json = ( await res . json ( ) ) as {
143+ posts : string [ ]
144+ topic : { name : string }
145+ cursor : string | null
146+ }
147+
148+ if ( json . topic ?. name ) {
149+ onTopicName ( json . topic . name )
150+ }
151+
152+ if ( ! json . posts ?. length ) {
153+ return { posts : [ ] as AppBskyFeedDefs . PostView [ ] , cursor : null }
154+ }
155+
156+ // Hydrate post URIs through the appview (max 25 per call)
157+ const hydrated = await agent . getPosts ( { uris : json . posts . slice ( 0 , 25 ) } )
158+ return {
159+ posts : hydrated . data . posts ,
160+ cursor : json . cursor ,
161+ }
162+ } ,
163+ getNextPageParam : lastPage => lastPage ?. cursor ?? undefined ,
164+ } )
165+
166+ const posts = React . useMemo ( ( ) => {
167+ return data ?. pages . flatMap ( page => page . posts ) ?? [ ]
168+ } , [ data ] )
169+
170+ const onRefresh = React . useCallback ( async ( ) => {
171+ setIsPTR ( true )
172+ await refetch ( )
173+ setIsPTR ( false )
174+ } , [ refetch ] )
175+
176+ const onEndReached = React . useCallback ( ( ) => {
177+ if ( isFetchingNextPage || ! hasNextPage || error ) return
178+ fetchNextPage ( )
179+ } , [ isFetchingNextPage , hasNextPage , error , fetchNextPage ] )
180+
181+ return (
182+ < >
183+ { posts . length < 1 ? (
184+ < ListMaybePlaceholder
185+ isLoading = { isLoading || ! isFetched }
186+ isError = { isError }
187+ onRetry = { refetch }
188+ emptyType = "results"
189+ emptyMessage = { _ ( msg `We couldn't find any results for that topic.` ) }
190+ />
191+ ) : (
192+ < List
193+ data = { posts }
194+ renderItem = { renderItem }
195+ keyExtractor = { keyExtractor }
196+ refreshing = { isPTR }
197+ onRefresh = { onRefresh }
198+ onEndReached = { onEndReached }
199+ onEndReachedThreshold = { 4 }
200+ onItemSeen = { trackPostView }
201+ // @ts -ignore web only -prf
202+ desktopFixedHeight
203+ ListFooterComponent = {
204+ < ListFooter
205+ isFetchingNextPage = { isFetchingNextPage }
206+ error = { cleanError ( error ) }
207+ onRetry = { fetchNextPage }
208+ />
209+ }
210+ initialNumToRender = { initialNumToRender }
211+ windowSize = { 11 }
212+ />
213+ ) }
214+ </ >
215+ )
216+ }
217+
218+ export function * findAllPostsInTopicQueryData (
219+ queryClient : QueryClient ,
220+ uri : string ,
221+ ) : Generator < AppBskyFeedDefs . PostView , undefined > {
222+ type PageData = { posts : AppBskyFeedDefs . PostView [ ] ; cursor : string | null }
223+ const queryDatas = queryClient . getQueriesData < InfiniteData < PageData > > ( {
224+ queryKey : [ 'topic-feed' ] ,
225+ } )
226+ for ( const [ _queryKey , queryData ] of queryDatas ) {
227+ if ( ! queryData ?. pages ) continue
228+ for ( const page of queryData . pages ) {
229+ for ( const post of page . posts ) {
230+ if ( post . uri === uri ) {
231+ yield post
232+ }
233+ }
234+ }
235+ }
236+ }
237+
238+ // Legacy search-based topic view for non-numeric topic params
239+ function LegacyTopicScreen ( {
240+ topicParam,
241+ headerTitle,
242+ onShare,
243+ } : {
244+ topicParam : string
245+ headerTitle : string
246+ onShare : ( ) => void
247+ } ) {
248+ const { _} = useLingui ( )
249+ const [ activeTab , setActiveTab ] = React . useState ( 0 )
250+ const setMinimalShellMode = useSetMinimalShellMode ( )
251+
61252 const onPageSelected = React . useCallback (
62253 ( index : number ) => {
63254 setMinimalShellMode ( false )
@@ -71,21 +262,25 @@ export default function TopicScreen({
71262 {
72263 title : _ ( msg `Top` ) ,
73264 component : (
74- < TopicScreenTab topic = { topic } sort = "top" active = { activeTab === 0 } />
265+ < TopicScreenTab
266+ topic = { topicParam }
267+ sort = "top"
268+ active = { activeTab === 0 }
269+ />
75270 ) ,
76271 } ,
77272 {
78273 title : _ ( msg `Latest` ) ,
79274 component : (
80275 < TopicScreenTab
81- topic = { topic }
276+ topic = { topicParam }
82277 sort = "latest"
83278 active = { activeTab === 1 }
84279 />
85280 ) ,
86281 } ,
87282 ]
88- } , [ _ , topic , activeTab ] )
283+ } , [ _ , topicParam , activeTab ] )
89284
90285 return (
91286 < Layout . Screen >
0 commit comments