summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 01:29:36 -0800
committerFuwn <[email protected]>2026-02-10 01:29:36 -0800
commiteb4674514acaaa57885057e0446aacc8660b6708 (patch)
tree9e86312d1d03723090fb5416a47de7c74d5baa52 /apps
parentfeat: scoped mark-all-read, share enhancements, notification z-index (diff)
downloadasa.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.tsx2
-rw-r--r--apps/web/app/reader/_components/entry-list.tsx18
-rw-r--r--apps/web/app/reader/settings/_components/appearance-settings.tsx25
-rw-r--r--apps/web/lib/hooks/use-realtime-entries.ts22
-rw-r--r--apps/web/lib/stores/user-interface-store.ts12
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,
}),
}