@@ -261,29 +261,79 @@ export function ActivityFeed({
261261 type FeedEntry = { id : Id < "activities" > ; repostedBy ?: string } ;
262262 const [ rankedEntries , setRankedEntries ] = useState < FeedEntry [ ] | undefined > ( undefined ) ;
263263 const algoFetchIdRef = useRef ( 0 ) ;
264+
265+ const parseRankedEntries = useCallback (
266+ ( raw : Array < Id < "activities" > | { id : Id < "activities" > ; repostedBy : string } > ) : FeedEntry [ ] =>
267+ raw . map ( ( entry ) : FeedEntry =>
268+ typeof entry === "string"
269+ ? { id : entry }
270+ : { id : entry . id as Id < "activities" > , repostedBy : entry . repostedBy } ,
271+ ) ,
272+ [ ] ,
273+ ) ;
274+
275+ const fetchForYou = useCallback ( async ( ) => {
276+ const fetchId = ++ algoFetchIdRef . current ;
277+ const raw = await convexClient . query (
278+ api . queries . algorithmicFeed . getRankedActivityIds ,
279+ { challengeId : challengeId as Id < "challenges" > } ,
280+ ) ;
281+ if ( fetchId !== algoFetchIdRef . current ) return ; // stale
282+ const entries = parseRankedEntries (
283+ raw as Array < Id < "activities" > | { id : Id < "activities" > ; repostedBy : string } > ,
284+ ) ;
285+ setRankedEntries ( entries ) ;
286+ return entries ;
287+ } , [ convexClient , challengeId , parseRankedEntries ] ) ;
288+
264289 useEffect ( ( ) => {
265290 if ( feedFilter !== "for_you" ) {
266291 setRankedEntries ( undefined ) ;
267292 return ;
268293 }
269- const fetchId = ++ algoFetchIdRef . current ;
270- convexClient
271- . query ( api . queries . algorithmicFeed . getRankedActivityIds , {
272- challengeId : challengeId as Id < "challenges" > ,
273- } )
274- . then ( ( raw ) => {
275- if ( fetchId !== algoFetchIdRef . current ) return ; // stale
276- const entries = ( raw as Array < Id < "activities" > | { id : Id < "activities" > ; repostedBy : string } > ) . map (
277- ( entry ) : FeedEntry =>
278- typeof entry === "string"
279- ? { id : entry }
280- : { id : entry . id as Id < "activities" > , repostedBy : entry . repostedBy } ,
281- ) ;
282- setRankedEntries ( entries ) ;
283- } ) ;
284- } , [ feedFilter , challengeId , convexClient ] ) ;
294+ fetchForYou ( ) ;
295+ } , [ feedFilter , fetchForYou ] ) ;
285296
286297 const [ algoVisibleCount , setAlgoVisibleCount ] = useState ( ALGO_PAGE_SIZE ) ;
298+ const [ isRefreshing , setIsRefreshing ] = useState ( false ) ;
299+
300+ // Pull-to-refresh: re-fetch the For You ranking. If no new activities
301+ // appear, backfill with a few recent entries from the "all" feed.
302+ const handlePullRefresh = useCallback ( async ( ) => {
303+ setIsRefreshing ( true ) ;
304+ try {
305+ const prevIds = new Set ( ( rankedEntries ?? [ ] ) . map ( ( e ) => e . id ) ) ;
306+ const fresh = await fetchForYou ( ) ;
307+ if ( ! fresh ) return ;
308+
309+ const hasNew = fresh . some ( ( e ) => ! prevIds . has ( e . id ) ) ;
310+ if ( ! hasNew && fresh . length > 0 ) {
311+ // No new For You activities — pull a few recent ones from the "all" feed
312+ const allRaw = await convexClient . query (
313+ api . queries . activities . getChallengeFeed ,
314+ {
315+ challengeId : challengeId as Id < "challenges" > ,
316+ followingOnly : false ,
317+ includeEngagementCounts : true ,
318+ includeMediaUrls : true ,
319+ paginationOpts : { numItems : 5 , cursor : null } ,
320+ } ,
321+ ) ;
322+ const existingIds = new Set ( fresh . map ( ( e ) => e . id ) ) ;
323+ const backfill : FeedEntry [ ] = ( allRaw as any ) . page
324+ ?. filter ( ( item : any ) => ! existingIds . has ( item . activity . _id ) )
325+ . slice ( 0 , 3 )
326+ . map ( ( item : any ) : FeedEntry => ( { id : item . activity . _id } ) ) ?? [ ] ;
327+
328+ if ( backfill . length > 0 ) {
329+ setRankedEntries ( [ ...backfill , ...fresh ] ) ;
330+ }
331+ }
332+ window . scrollTo ( { top : 0 , behavior : "smooth" } ) ;
333+ } finally {
334+ setIsRefreshing ( false ) ;
335+ }
336+ } , [ fetchForYou , rankedEntries , convexClient , challengeId ] ) ;
287337
288338 const visibleAlgoEntries = useMemo (
289339 ( ) => ( rankedEntries ?? [ ] ) . slice ( 0 , algoVisibleCount ) ,
@@ -557,8 +607,67 @@ export function ActivityFeed({
557607 return effectiveIsLoading && ! hasInitialFeed ;
558608 } , [ displayResults , effectiveIsLoading , feedFilter , visibleAlgoIds . length ] ) ;
559609
610+ // Pull-to-refresh touch gesture
611+ const pullStartY = useRef < number | null > ( null ) ;
612+ const [ pullDistance , setPullDistance ] = useState ( 0 ) ;
613+ const pullThreshold = 80 ;
614+
615+ const handleTouchStart = useCallback (
616+ ( e : React . TouchEvent ) => {
617+ if ( feedFilter !== "for_you" || isRefreshing ) return ;
618+ if ( window . scrollY <= 0 ) {
619+ pullStartY . current = e . touches [ 0 ] . clientY ;
620+ }
621+ } ,
622+ [ feedFilter , isRefreshing ] ,
623+ ) ;
624+
625+ const handleTouchMove = useCallback (
626+ ( e : React . TouchEvent ) => {
627+ if ( pullStartY . current === null ) return ;
628+ const delta = e . touches [ 0 ] . clientY - pullStartY . current ;
629+ if ( delta > 0 ) {
630+ // Dampen the pull distance for a natural feel
631+ setPullDistance ( Math . min ( delta * 0.4 , pullThreshold * 1.5 ) ) ;
632+ }
633+ } ,
634+ [ ] ,
635+ ) ;
636+
637+ const handleTouchEnd = useCallback ( ( ) => {
638+ if ( pullStartY . current === null ) return ;
639+ if ( pullDistance >= pullThreshold ) {
640+ handlePullRefresh ( ) ;
641+ }
642+ pullStartY . current = null ;
643+ setPullDistance ( 0 ) ;
644+ } , [ pullDistance , handlePullRefresh ] ) ;
645+
560646 return (
561- < div >
647+ < div
648+ onTouchStart = { handleTouchStart }
649+ onTouchMove = { handleTouchMove }
650+ onTouchEnd = { handleTouchEnd }
651+ >
652+ { /* Pull-to-refresh indicator */ }
653+ { feedFilter === "for_you" && ( pullDistance > 0 || isRefreshing ) && (
654+ < div
655+ className = "flex items-center justify-center overflow-hidden transition-all"
656+ style = { { height : isRefreshing ? 48 : pullDistance } }
657+ >
658+ < Loader2
659+ className = { cn (
660+ "h-5 w-5 text-zinc-400 transition-opacity" ,
661+ isRefreshing
662+ ? "animate-spin opacity-100"
663+ : pullDistance >= pullThreshold
664+ ? "opacity-100"
665+ : "opacity-40" ,
666+ ) }
667+ />
668+ </ div >
669+ ) }
670+
562671 { /* Twitter-like Feed Filter Tabs */ }
563672 < div className = "sticky top-[env(safe-area-inset-top)] z-10 -mx-4 border-b border-zinc-800 bg-black/80 backdrop-blur" >
564673 < div className = "flex" >
@@ -606,8 +715,7 @@ export function ActivityFeed({
606715 < button
607716 onClick = { ( ) => {
608717 acknowledgeActivity ( ) ;
609- setFeedFilter ( "all" ) ;
610- window . scrollTo ( { top : 0 , behavior : "smooth" } ) ;
718+ handlePullRefresh ( ) ;
611719 } }
612720 className = "flex items-center gap-1.5 rounded-full bg-indigo-500 px-4 py-2 text-sm font-medium text-white shadow-lg transition-transform hover:scale-105 active:scale-95"
613721 >
0 commit comments