diff options
| author | Fuwn <[email protected]> | 2026-02-10 01:29:36 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-10 01:29:36 -0800 |
| commit | eb4674514acaaa57885057e0446aacc8660b6708 (patch) | |
| tree | 9e86312d1d03723090fb5416a47de7c74d5baa52 /apps | |
| parent | feat: scoped mark-all-read, share enhancements, notification z-index (diff) | |
| download | asa.news-eb4674514acaaa57885057e0446aacc8660b6708.tar.xz asa.news-eb4674514acaaa57885057e0446aacc8660b6708.zip | |
feat: add automatic timeline refresh with scroll position preservation
New appearance setting (disabled by default) that silently refreshes
the entry list when new entries arrive, provided the user is scrolled
to the top. Falls back to notification when scrolled down to avoid
disrupting reading position.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/(marketing)/_components/feature-grid.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/entry-list.tsx | 18 | ||||
| -rw-r--r-- | apps/web/app/reader/settings/_components/appearance-settings.tsx | 25 | ||||
| -rw-r--r-- | apps/web/lib/hooks/use-realtime-entries.ts | 22 | ||||
| -rw-r--r-- | apps/web/lib/stores/user-interface-store.ts | 12 |
5 files changed, 75 insertions, 4 deletions
diff --git a/apps/web/app/(marketing)/_components/feature-grid.tsx b/apps/web/app/(marketing)/_components/feature-grid.tsx index 445d6c3..640b5d0 100644 --- a/apps/web/app/(marketing)/_components/feature-grid.tsx +++ b/apps/web/app/(marketing)/_components/feature-grid.tsx @@ -27,7 +27,7 @@ const FEATURES = [ { title: "real-time updates", description: - "get notified when new entries arrive as feeds are refreshed. one click to load.", + "new entries appear automatically or via notification \u2014 your choice. scroll position is always preserved.", }, ] diff --git a/apps/web/app/reader/_components/entry-list.tsx b/apps/web/app/reader/_components/entry-list.tsx index 7513d18..cf75620 100644 --- a/apps/web/app/reader/_components/entry-list.tsx +++ b/apps/web/app/reader/_components/entry-list.tsx @@ -129,6 +129,24 @@ export function EntryList({ fetchNextPage, ]) + const setIsEntryListAtTop = useUserInterfaceStore( + (state) => state.setIsEntryListAtTop + ) + + useEffect(() => { + const scrollElement = scrollContainerReference.current + if (!scrollElement) return + + function handleScroll() { + if (!scrollElement) return + useUserInterfaceStore.getState().setIsEntryListAtTop(scrollElement.scrollTop < 50) + } + + handleScroll() + scrollElement.addEventListener("scroll", handleScroll, { passive: true }) + return () => scrollElement.removeEventListener("scroll", handleScroll) + }, [setIsEntryListAtTop]) + const allEntriesReference = useRef(allEntries) allEntriesReference.current = allEntries diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx index 1476190..9d2d146 100644 --- a/apps/web/app/reader/settings/_components/appearance-settings.tsx +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -80,6 +80,12 @@ export function AppearanceSettings() { const setShowFoldersAboveFeeds = useUserInterfaceStore( (state) => state.setShowFoldersAboveFeeds ) + const autoRefreshTimeline = useUserInterfaceStore( + (state) => state.autoRefreshTimeline + ) + const setAutoRefreshTimeline = useUserInterfaceStore( + (state) => state.setAutoRefreshTimeline + ) const toolbarPosition = useUserInterfaceStore( (state) => state.toolbarPosition ) @@ -280,6 +286,25 @@ export function AppearanceSettings() { <SettingsSection title="behaviour"> <div> + <h3 className="mb-2 text-text-primary">automatic refresh</h3> + <p className="mb-3 text-text-dim"> + automatically load new entries when they arrive, instead of showing + a notification. only refreshes when scrolled to the top of the entry + list to preserve your reading position. + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={autoRefreshTimeline} + onChange={(event) => + setAutoRefreshTimeline(event.target.checked) + } + className="accent-text-primary" + /> + <span>enable automatic refresh</span> + </label> + </div> + <div> <h3 className="mb-2 text-text-primary">focus follows interaction</h3> <p className="mb-3 text-text-dim"> automatically move keyboard panel focus to the last pane you diff --git a/apps/web/lib/hooks/use-realtime-entries.ts b/apps/web/lib/hooks/use-realtime-entries.ts index 3fec9f6..22551f9 100644 --- a/apps/web/lib/hooks/use-realtime-entries.ts +++ b/apps/web/lib/hooks/use-realtime-entries.ts @@ -6,6 +6,7 @@ import { createSupabaseBrowserClient } from "@/lib/supabase/client" import { queryKeys } from "@/lib/queries/query-keys" import { toast } from "sonner" import { useNotificationStore } from "@/lib/stores/notification-store" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" const DEBOUNCE_MILLISECONDS = 3000 @@ -16,6 +17,15 @@ export function useRealtimeEntries() { const debounceTimerReference = useRef<ReturnType<typeof setTimeout> | null>(null) useEffect(() => { + function invalidateTimelineQueries() { + queryClient.invalidateQueries({ + queryKey: queryKeys.timeline.all, + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.unreadCounts.all, + }) + } + function flushPendingNotifications() { const count = pendingCountReference.current if (count === 0) return @@ -23,6 +33,14 @@ export function useRealtimeEntries() { pendingCountReference.current = 0 debounceTimerReference.current = null + const autoRefresh = useUserInterfaceStore.getState().autoRefreshTimeline + const isAtTop = useUserInterfaceStore.getState().isEntryListAtTop + + if (autoRefresh && isAtTop) { + invalidateTimelineQueries() + return + } + const message = count === 1 ? "1 new entry" : `${count} new entries` @@ -31,9 +49,7 @@ export function useRealtimeEntries() { action: { label: "refresh", onClick: () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.timeline.all, - }) + invalidateTimelineQueries() }, }, }) diff --git a/apps/web/lib/stores/user-interface-store.ts b/apps/web/lib/stores/user-interface-store.ts index 4c17735..814b0d6 100644 --- a/apps/web/lib/stores/user-interface-store.ts +++ b/apps/web/lib/stores/user-interface-store.ts @@ -45,7 +45,9 @@ interface UserInterfaceState { showReadingTime: boolean showFoldersAboveFeeds: boolean showEntryFavicons: boolean + autoRefreshTimeline: boolean toolbarPosition: ToolbarPosition + isEntryListAtTop: boolean isShortcutsDialogOpen: boolean expandedFolderIdentifiers: string[] currentFeedIdentifier: string | null @@ -76,7 +78,9 @@ interface UserInterfaceState { setShowReadingTime: (show: boolean) => void setShowFoldersAboveFeeds: (show: boolean) => void setShowEntryFavicons: (show: boolean) => void + setAutoRefreshTimeline: (enabled: boolean) => void setToolbarPosition: (position: ToolbarPosition) => void + setIsEntryListAtTop: (isAtTop: boolean) => void setShortcutsDialogOpen: (isOpen: boolean) => void toggleShortcutsDialog: () => void toggleFolderExpansion: (folderIdentifier: string) => void @@ -107,7 +111,9 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( showReadingTime: true, showFoldersAboveFeeds: false, showEntryFavicons: false, + autoRefreshTimeline: false, toolbarPosition: "top", + isEntryListAtTop: true, isShortcutsDialogOpen: false, expandedFolderIdentifiers: [], currentFeedIdentifier: null, @@ -167,8 +173,13 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( setShowEntryFavicons: (show) => set({ showEntryFavicons: show }), + setAutoRefreshTimeline: (enabled) => + set({ autoRefreshTimeline: enabled }), + setToolbarPosition: (position) => set({ toolbarPosition: position }), + setIsEntryListAtTop: (isAtTop) => set({ isEntryListAtTop: isAtTop }), + setShortcutsDialogOpen: (isOpen) => set({ isShortcutsDialogOpen: isOpen }), @@ -221,6 +232,7 @@ export const useUserInterfaceStore = create<UserInterfaceState>()( showReadingTime: state.showReadingTime, showFoldersAboveFeeds: state.showFoldersAboveFeeds, showEntryFavicons: state.showEntryFavicons, + autoRefreshTimeline: state.autoRefreshTimeline, toolbarPosition: state.toolbarPosition, }), } |