From 340d41704c2ebd7f9e50261ab836761785c873b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Jan 2026 18:06:27 +0000 Subject: [PATCH 1/2] fix(ux): Reset timeline selection and scroll on refresh When pressing 'r' to refresh the timeline, automatically reset selection to index 0 and scroll to the top. This provides clear visual feedback that the refresh completed and shows the user the newest content. Detects refresh by tracking when the first post ID changes, indicating the posts array was replaced with new content. This also applies to tab switches in TimelineScreen, BookmarksScreen, and ProfileScreen. --- src/components/PostList.tsx | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/components/PostList.tsx b/src/components/PostList.tsx index 7ea2f0d..709edb5 100644 --- a/src/components/PostList.tsx +++ b/src/components/PostList.tsx @@ -5,7 +5,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { TweetData } from "@/api/types"; import type { TweetActionState } from "@/hooks/useActions"; @@ -63,6 +63,8 @@ export function PostList({ // Save scroll position so we can restore when refocused const savedScrollTop = useRef(0); const wasFocused = useRef(focused); + // Track first post ID to detect refresh (posts array replaced with new content) + const prevFirstPostId = useRef(null); // Restore scroll position when gaining focus useEffect(() => { @@ -77,7 +79,7 @@ export function PostList({ wasFocused.current = focused; }, [focused]); - const { selectedIndex } = useListNavigation({ + const { selectedIndex, setSelectedIndex } = useListNavigation({ itemCount: posts.length, enabled: focused, onSelect: (index) => { @@ -93,6 +95,32 @@ export function PostList({ }, }); + // Reset to top when posts are refreshed (first post ID changes) + // This provides visual feedback that refresh completed and shows newest content + const resetToTop = useCallback(() => { + setSelectedIndex(0); + if (scrollRef.current) { + scrollRef.current.scrollTo(0); + } + savedScrollTop.current = 0; + }, [setSelectedIndex]); + + useEffect(() => { + const currentFirstId = posts[0]?.id ?? null; + + // Detect refresh: we had posts before and the first post ID changed + // This indicates the posts array was replaced with new content + if ( + prevFirstPostId.current !== null && + currentFirstId !== null && + currentFirstId !== prevFirstPostId.current + ) { + resetToTop(); + } + + prevFirstPostId.current = currentFirstId; + }, [posts, resetToTop]); + // Notify parent of selection changes (e.g., for collapsible headers) useEffect(() => { onSelectedIndexChange?.(selectedIndex); From 0bbbebe81bd6bda6f942c392e97ee09cf8984828 Mon Sep 17 00:00:00 2001 From: Ali Ihsan Nergiz Date: Tue, 13 Jan 2026 00:15:30 +0000 Subject: [PATCH 2/2] fix(ux): Use explicit refreshKey instead of detecting first post ID change The previous approach only reset to top when the first post ID changed, which didn't work if no new tweets arrived. Now each screen tracks a refreshKey counter that increments on 'r' press, and PostList resets when this key changes - regardless of whether content changed. Co-Authored-By: Claude Opus 4.5 --- src/components/PostList.tsx | 41 ++++++++----------- .../TimelineScreenExperimental.tsx | 7 +++- src/screens/BookmarksScreen.tsx | 7 +++- src/screens/ProfileScreen.tsx | 5 +++ src/screens/TimelineScreen.tsx | 7 +++- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/src/components/PostList.tsx b/src/components/PostList.tsx index 709edb5..fbd9946 100644 --- a/src/components/PostList.tsx +++ b/src/components/PostList.tsx @@ -5,7 +5,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; -import { useCallback, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import type { TweetData } from "@/api/types"; import type { TweetActionState } from "@/hooks/useActions"; @@ -37,6 +37,8 @@ interface PostListProps { loadingMore?: boolean; /** Whether there are more posts available to load */ hasMore?: boolean; + /** Increment this to reset selection and scroll to top (e.g., on refresh) */ + refreshKey?: number; } /** @@ -58,13 +60,14 @@ export function PostList({ onLoadMore, loadingMore = false, hasMore = true, + refreshKey, }: PostListProps) { const scrollRef = useRef(null); // Save scroll position so we can restore when refocused const savedScrollTop = useRef(0); const wasFocused = useRef(focused); - // Track first post ID to detect refresh (posts array replaced with new content) - const prevFirstPostId = useRef(null); + // Track previous refreshKey to detect when refresh is triggered + const prevRefreshKey = useRef(refreshKey); // Restore scroll position when gaining focus useEffect(() => { @@ -95,31 +98,23 @@ export function PostList({ }, }); - // Reset to top when posts are refreshed (first post ID changes) - // This provides visual feedback that refresh completed and shows newest content - const resetToTop = useCallback(() => { - setSelectedIndex(0); - if (scrollRef.current) { - scrollRef.current.scrollTo(0); - } - savedScrollTop.current = 0; - }, [setSelectedIndex]); - + // Reset to top when refreshKey changes (user explicitly triggered refresh) useEffect(() => { - const currentFirstId = posts[0]?.id ?? null; - - // Detect refresh: we had posts before and the first post ID changed - // This indicates the posts array was replaced with new content + // Skip on initial mount (prevRefreshKey.current will equal refreshKey) if ( - prevFirstPostId.current !== null && - currentFirstId !== null && - currentFirstId !== prevFirstPostId.current + prevRefreshKey.current !== undefined && + refreshKey !== undefined && + refreshKey !== prevRefreshKey.current ) { - resetToTop(); + setSelectedIndex(0); + if (scrollRef.current) { + scrollRef.current.scrollTo(0); + } + savedScrollTop.current = 0; } - prevFirstPostId.current = currentFirstId; - }, [posts, resetToTop]); + prevRefreshKey.current = refreshKey; + }, [refreshKey, setSelectedIndex]); // Notify parent of selection changes (e.g., for collapsible headers) useEffect(() => { diff --git a/src/experiments/TimelineScreenExperimental.tsx b/src/experiments/TimelineScreenExperimental.tsx index 7d565d7..5fe7d41 100644 --- a/src/experiments/TimelineScreenExperimental.tsx +++ b/src/experiments/TimelineScreenExperimental.tsx @@ -7,7 +7,7 @@ */ import { useKeyboard } from "@opentui/react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { XClient } from "@/api/client"; import type { TweetData } from "@/api/types"; @@ -130,6 +130,9 @@ export function TimelineScreenExperimental({ initialTab: preferences.timeline.default_tab, }); + // Track refresh to reset PostList selection/scroll + const [refreshKey, setRefreshKey] = useState(0); + // Report post count to parent useEffect(() => { onPostCountChange?.(posts.length); @@ -161,6 +164,7 @@ export function TimelineScreenExperimental({ break; case "r": refresh(); + setRefreshKey((k) => k + 1); break; } }); @@ -216,6 +220,7 @@ export function TimelineScreenExperimental({ onLoadMore={fetchNextPage} loadingMore={isFetchingNextPage} hasMore={hasNextPage} + refreshKey={refreshKey} /> ); diff --git a/src/screens/BookmarksScreen.tsx b/src/screens/BookmarksScreen.tsx index b5fe476..51e7e19 100644 --- a/src/screens/BookmarksScreen.tsx +++ b/src/screens/BookmarksScreen.tsx @@ -5,7 +5,7 @@ */ import { useKeyboard } from "@opentui/react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import type { XClient } from "@/api/client"; import type { BookmarkFolder, TweetData } from "@/api/types"; @@ -101,6 +101,9 @@ export function BookmarksScreen({ fetchNextPage, } = useBookmarksQuery({ client, folderId: selectedFolder?.id }); + // Track refresh to reset PostList selection/scroll + const [refreshKey, setRefreshKey] = useState(0); + // Report post count to parent useEffect(() => { onPostCountChange?.(posts.length); @@ -117,6 +120,7 @@ export function BookmarksScreen({ if (key.name === "r") { refresh(); + setRefreshKey((k) => k + 1); } // Open folder picker with 'f' or Tab @@ -198,6 +202,7 @@ export function BookmarksScreen({ onLoadMore={fetchNextPage} loadingMore={isFetchingNextPage} hasMore={hasNextPage} + refreshKey={refreshKey} /> ); diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx index a3f3291..b32fc86 100644 --- a/src/screens/ProfileScreen.tsx +++ b/src/screens/ProfileScreen.tsx @@ -134,6 +134,9 @@ export function ProfileScreen({ // Tab state (only used when isSelf) const [activeTab, setActiveTab] = useState("tweets"); + // Track refresh to reset PostList selection/scroll + const [refreshKey, setRefreshKey] = useState(0); + // Track if header should be collapsed (when scrolled past first tweet) const [isCollapsed, setIsCollapsed] = useState(false); @@ -202,6 +205,7 @@ export function ProfileScreen({ break; case "r": refresh(); + setRefreshKey((k) => k + 1); break; case "a": // Open avatar/profile photo in Quick Look @@ -606,6 +610,7 @@ export function ProfileScreen({ onBookmark={onBookmark} getActionState={getActionState} initActionState={initActionState} + refreshKey={refreshKey} /> ) : ( diff --git a/src/screens/TimelineScreen.tsx b/src/screens/TimelineScreen.tsx index 3d92d13..6dac1fe 100644 --- a/src/screens/TimelineScreen.tsx +++ b/src/screens/TimelineScreen.tsx @@ -4,7 +4,7 @@ */ import { useKeyboard } from "@opentui/react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import type { XClient } from "@/api/client"; import type { TweetData } from "@/api/types"; @@ -87,6 +87,9 @@ export function TimelineScreen({ client, }); + // Track refresh to reset PostList selection/scroll + const [refreshKey, setRefreshKey] = useState(0); + // Report post count to parent useEffect(() => { onPostCountChange?.(posts.length); @@ -105,6 +108,7 @@ export function TimelineScreen({ break; case "r": refresh(); + setRefreshKey((k) => k + 1); break; } }); @@ -156,6 +160,7 @@ export function TimelineScreen({ onLoadMore={fetchNextPage} loadingMore={isFetchingNextPage} hasMore={hasNextPage} + refreshKey={refreshKey} /> );