From 234029436c891df8b60dfe9dad64241b9b416274 Mon Sep 17 00:00:00 2001 From: radial-agent Date: Thu, 14 May 2026 21:23:39 +0000 Subject: [PATCH] track feed selection by item key, not index When a refresh re-sorted the visible list (e.g. new articles arrive during auto-poll), the inline reader / expanded card was tied to the array index of the originally-selected item. New items pushed the selected item to a higher index, so the highlight, inline preview, and mark-as-read all latched onto the wrong card. Switch the source of truth for selection and expansion from a numeric index to FeedDisplayItem.key (already the keyed-each key, stable per item). selectedIndex / expandedIndex are kept as derived getters so existing call sites that need an index (scrollToCenter, j/k navigation) keep working. Co-Authored-By: Claude Opus 4.7 --- .../lib/components/feed/FeedListView.svelte | 36 +++++----- .../lib/components/feed/SavedListView.svelte | 8 +-- .../hooks/useFeedKeyboardShortcuts.svelte.ts | 66 ++++++++----------- frontend/src/lib/stores/feedView.svelte.ts | 52 ++++++++++----- 4 files changed, 87 insertions(+), 75 deletions(-) diff --git a/frontend/src/lib/components/feed/FeedListView.svelte b/frontend/src/lib/components/feed/FeedListView.svelte index 4cf8fedf..7036fc21 100644 --- a/frontend/src/lib/components/feed/FeedListView.svelte +++ b/frontend/src/lib/components/feed/FeedListView.svelte @@ -62,9 +62,9 @@ } export function openSelectedReader() { - const index = feedViewStore.selectedIndex; - if (index < 0) return; - const item = feedViewStore.currentItems[index]; + const key = feedViewStore.selectedKey; + if (key === null) return; + const item = feedViewStore.currentItems.find((i) => i.key === key); if (item) { openReader(item); } @@ -86,7 +86,8 @@ } async function handleExpand(index: number) { - if (feedViewStore.expandedIndex === index) { + const key = feedViewStore.currentItems[index]?.key ?? null; + if (key !== null && feedViewStore.expandedKey === key) { feedViewStore.collapse(); } else { feedViewStore.select(index); @@ -97,7 +98,8 @@ } function handleSelect(index: number) { - if (feedViewStore.selectedIndex === index) { + const key = feedViewStore.currentItems[index]?.key ?? null; + if (key !== null && feedViewStore.selectedKey === key) { feedViewStore.deselect(); } else { feedViewStore.select(index); @@ -225,9 +227,9 @@ isSaved={itemLabelsStore.isSaved(article.guid)} isShared={sharesStore.isShared(article.guid)} shareNote={sharesStore.getShareNote(article.guid)} - selected={preferences.expandAllItems || feedViewStore.selectedIndex === index} - expanded={feedViewStore.expandedIndex === index} - highlighted={feedViewStore.selectedIndex === index} + selected={preferences.expandAllItems || feedViewStore.selectedKey === displayItem.key} + expanded={feedViewStore.expandedKey === displayItem.key} + highlighted={feedViewStore.selectedKey === displayItem.key} onToggleSave={() => onToggleSave(article)} onToggleRead={() => handleToggleRead(article)} onShare={() => sub && onShare(article, sub)} @@ -250,9 +252,9 @@ {localArticle} isRead={itemLabelsStore.isSocialRead(share.recordUri)} isSaved={itemLabelsStore.isSaved(share.recordUri)} - selected={preferences.expandAllItems || feedViewStore.selectedIndex === index} - expanded={feedViewStore.expandedIndex === index} - highlighted={feedViewStore.selectedIndex === index} + selected={preferences.expandAllItems || feedViewStore.selectedKey === displayItem.key} + expanded={feedViewStore.expandedKey === displayItem.key} + highlighted={feedViewStore.selectedKey === displayItem.key} onToggleSave={() => itemLabelsStore.toggleSave(share.recordUri, 'share', share.itemUrl, share.itemTitle, { type: 'share', @@ -306,9 +308,9 @@ isShared={true} shareNote={share.note} reshareCount={share.reshareCount || 0} - selected={preferences.expandAllItems || feedViewStore.selectedIndex === index} - expanded={feedViewStore.expandedIndex === index} - highlighted={feedViewStore.selectedIndex === index} + selected={preferences.expandAllItems || feedViewStore.selectedKey === displayItem.key} + expanded={feedViewStore.expandedKey === displayItem.key} + highlighted={feedViewStore.selectedKey === displayItem.key} onToggleSave={() => onToggleSave(article)} onToggleRead={() => handleToggleRead(article)} onUnshare={() => onUnshare(share.articleGuid)} @@ -328,9 +330,9 @@ document={doc} isRead={itemLabelsStore.isSocialRead(doc.recordUri)} isSaved={itemLabelsStore.isSaved(doc.recordUri)} - selected={preferences.expandAllItems || feedViewStore.selectedIndex === index} - expanded={feedViewStore.expandedIndex === index} - highlighted={feedViewStore.selectedIndex === index} + selected={preferences.expandAllItems || feedViewStore.selectedKey === displayItem.key} + expanded={feedViewStore.expandedKey === displayItem.key} + highlighted={feedViewStore.selectedKey === displayItem.key} onToggleSave={() => itemLabelsStore.toggleSave( doc.recordUri, diff --git a/frontend/src/lib/components/feed/SavedListView.svelte b/frontend/src/lib/components/feed/SavedListView.svelte index dd02a506..b6686788 100644 --- a/frontend/src/lib/components/feed/SavedListView.svelte +++ b/frontend/src/lib/components/feed/SavedListView.svelte @@ -274,9 +274,9 @@ } export function openSelectedReader() { - const index = feedViewStore.selectedIndex; - if (index < 0) return; - const item = feedViewStore.currentItems[index]; + const key = feedViewStore.selectedKey; + if (key === null) return; + const item = feedViewStore.currentItems.find((i) => i.key === key); if (item) { openReader(item); } @@ -314,7 +314,7 @@
openReader(displayItem)} onHover={() => handleSelect(index)} onArchive={() => handleArchive(displayItem)} diff --git a/frontend/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts b/frontend/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts index 5f8ff7cc..7daf5c28 100644 --- a/frontend/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts +++ b/frontend/src/lib/hooks/useFeedKeyboardShortcuts.svelte.ts @@ -37,16 +37,20 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { return subscriptionsStore.subscriptions.find((s) => s.id === article.subscriptionId); } + // Helper to resolve the currently-selected item by key. Returns null when + // nothing is selected or the previously-selected item is no longer present. + function getSelectedItem(): FeedDisplayItem | null { + const key = feedViewStore.selectedKey; + if (key === null) return null; + return feedViewStore.currentItems.find((i) => i.key === key) ?? null; + } + // Helper to get selected article info function getSelectedArticle(): { article: Article; sub: Subscription; } | null { - const selectedIndex = feedViewStore.selectedIndex; - if (selectedIndex < 0) return null; - - const items = feedViewStore.currentItems; - const item = items[selectedIndex]; + const item = getSelectedItem(); if (!item) return null; const article = getArticleFromItem(item); @@ -77,11 +81,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { // Open selected item in new tab function openSelectedItem() { - const selectedIndex = feedViewStore.selectedIndex; - if (selectedIndex < 0) return; - - const items = feedViewStore.currentItems; - const item = items[selectedIndex]; + const item = getSelectedItem(); if (!item) return; let url: string; @@ -103,11 +103,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { // Toggle save on selected item (works for all item types) function toggleSelectedSave() { - const selectedIndex = feedViewStore.selectedIndex; - if (selectedIndex < 0) return; - - const items = feedViewStore.currentItems; - const item = items[selectedIndex]; + const item = getSelectedItem(); if (!item) return; if (item.type === 'article') { @@ -198,11 +194,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { // Toggle read/unread on selected item function toggleSelectedRead() { - const selectedIndex = feedViewStore.selectedIndex; - if (selectedIndex < 0) return; - - const items = feedViewStore.currentItems; - const item = items[selectedIndex]; + const item = getSelectedItem(); if (!item) return; if (item.type === 'article' || item.type === 'userShare') { @@ -249,10 +241,12 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { // Navigation actions async function selectNextItem() { const currentItems = feedViewStore.currentItems; - const selectedIndex = feedViewStore.selectedIndex; if (currentItems.length === 0) return; - const nextIndex = Math.min(selectedIndex + 1, currentItems.length - 1); + const selectedKey = feedViewStore.selectedKey; + const currentIndex = + selectedKey === null ? -1 : currentItems.findIndex((i) => i.key === selectedKey); + const nextIndex = Math.min(currentIndex + 1, currentItems.length - 1); feedViewStore.select(nextIndex); // If we're at the last item, try to load more @@ -266,10 +260,12 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { async function selectPreviousItem() { const currentItems = feedViewStore.currentItems; - const selectedIndex = feedViewStore.selectedIndex; if (currentItems.length === 0) return; - feedViewStore.select(Math.max(selectedIndex - 1, 0)); + const selectedKey = feedViewStore.selectedKey; + const currentIndex = + selectedKey === null ? -1 : currentItems.findIndex((i) => i.key === selectedKey); + feedViewStore.select(Math.max(currentIndex - 1, 0)); await tick(); params.scrollToCenter(); @@ -280,24 +276,24 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { } function hasSelected() { - return auth.isAuthenticated && feedViewStore.selectedIndex >= 0; + return auth.isAuthenticated && feedViewStore.selectedKey !== null; } // Toggle expand action (or open bookmark reader in bookmarks view) async function toggleExpand() { - const selectedIndex = feedViewStore.selectedIndex; - if (selectedIndex < 0) return; + const selectedKey = feedViewStore.selectedKey; + if (selectedKey === null) return; if (feedViewStore.savedFilter && params.openSavedReader) { params.openSavedReader(); return; } - const expandedIndex = feedViewStore.expandedIndex; - if (expandedIndex === selectedIndex) { + if (feedViewStore.expandedKey === selectedKey) { feedViewStore.collapse(); } else { - feedViewStore.expand(selectedIndex); + const idx = feedViewStore.currentItems.findIndex((i) => i.key === selectedKey); + if (idx >= 0) feedViewStore.expand(idx); } await tick(); params.scrollToCenter(); @@ -368,9 +364,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { description: 'Tag item', category: 'Article', action: () => { - const idx = feedViewStore.selectedIndex; - if (idx < 0) return; - const item = feedViewStore.currentItems[idx]; + const item = getSelectedItem(); if (!item) return; if (feedViewStore.tagMenuItemKey === item.key) { feedViewStore.closeTagMenu(); @@ -415,9 +409,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { description: 'Archive/unarchive saved item', category: 'Article', action: () => { - const idx = feedViewStore.selectedIndex; - if (idx < 0) return; - const item = feedViewStore.currentItems[idx]; + const item = getSelectedItem(); if (!item) return; const itemType = item.type === 'userShare' ? 'userShare' : item.type; itemLabelsStore.toggleArchive( @@ -434,7 +426,7 @@ export function useFeedKeyboardShortcuts(params: KeyboardShortcutsParams) { description: 'Toggle highlight on paragraph', category: 'Article', action: () => params.toggleHighlight?.(), - condition: () => hasSelected() && feedViewStore.expandedIndex >= 0, + condition: () => hasSelected() && feedViewStore.expandedKey !== null, }); // Full-screen reader for feed items (not in bookmarks view, which uses Enter) diff --git a/frontend/src/lib/stores/feedView.svelte.ts b/frontend/src/lib/stores/feedView.svelte.ts index ca1f7cd9..637cd543 100644 --- a/frontend/src/lib/stores/feedView.svelte.ts +++ b/frontend/src/lib/stores/feedView.svelte.ts @@ -192,8 +192,11 @@ export function matchesReadingLength(wc: number | null, bucket: ReadingLengthFil function createFeedViewStore() { // UI state let showOnlyUnread = $state(true); - let selectedIndex = $state(-1); - let expandedIndex = $state(-1); + // Track selection/expansion by item key (stable across refreshes) rather than + // by array index — list refreshes re-sort items so an index would point at a + // different item after a refresh. + let selectedKey = $state(null); + let expandedKey = $state(null); let loadedArticleCount = $state(DEFAULT_PAGE_SIZE); // Tag menu state (which item key should show the tag menu, null = closed) @@ -1056,16 +1059,20 @@ function createFeedViewStore() { } } - function select(index: number) { - if (index === selectedIndex) return; + function selectByKey(key: string | null) { + if (key === selectedKey) return; - const items = currentItems; - const item = items[index]; + if (key === null) { + selectedKey = null; + expandedKey = null; + return; + } + + const item = currentItems.find((i) => i.key === key); if (!item) return; - // Set selectedIndex first - selectedIndex = index; - expandedIndex = -1; + selectedKey = key; + expandedKey = null; // Track the item to keep it visible in unread filter for this session if (item.type === 'article') { @@ -1112,23 +1119,27 @@ function createFeedViewStore() { // userShare items don't auto-mark as read } + function select(index: number) { + selectByKey(currentItems[index]?.key ?? null); + } + function deselect() { - selectedIndex = -1; - expandedIndex = -1; + selectedKey = null; + expandedKey = null; // Don't clear session sets - items should stay visible until view changes } function expand(index: number) { - expandedIndex = index; + expandedKey = currentItems[index]?.key ?? null; } function collapse() { - expandedIndex = -1; + expandedKey = null; } function resetSelection() { - selectedIndex = -1; - expandedIndex = -1; + selectedKey = null; + expandedKey = null; // Clear session sets when switching views/feeds readArticleGuidsThisSession = new Set(); readShareUrisThisSession = new Set(); @@ -1248,11 +1259,17 @@ function createFeedViewStore() { get currentItems() { return currentItems; }, + get selectedKey() { + return selectedKey; + }, + get expandedKey() { + return expandedKey; + }, get selectedIndex() { - return selectedIndex; + return selectedKey === null ? -1 : currentItems.findIndex((i) => i.key === selectedKey); }, get expandedIndex() { - return expandedIndex; + return expandedKey === null ? -1 : currentItems.findIndex((i) => i.key === expandedKey); }, get showOnlyUnread() { return showOnlyUnread; @@ -1365,6 +1382,7 @@ function createFeedViewStore() { loadArticles, loadMore, select, + selectByKey, deselect, expand, collapse,