Skip to content

Commit 14a5db3

Browse files
prazgaitisclaude
andcommitted
feat: pull-to-refresh on For You feed with all-feed backfill
Touch-based pull-to-refresh re-fetches the For You ranking. If no new activities appear, backfills with a few recent entries from the "all" feed so the refresh always feels productive. The "New activities" banner now triggers the same refresh instead of switching to the All tab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d9a352f commit 14a5db3

1 file changed

Lines changed: 127 additions & 19 deletions

File tree

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 127 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)