Improve reads article discovery, filtering, and shared caching#270
Improve reads article discovery, filtering, and shared caching#270
Conversation
Article discovery: - Extend lookback from 90 days to 365 days for deeper article pool - Dual fetch strategy: food-hashtag fetch (primary, for cover + Food/Farming) plus general fetch (secondary, for All/Bitcoin/Nostr/etc categories) - Remove Food Only toggle — cover is always food-editorial, feed shows all topics via category pills Filtering improvements (learnings from Highlighter): - Use NDKArticle typed wrapper for cleaner title/image/summary extraction - Add pubkey blacklist in quality filters for known spammers - Enforce image requirement for cover articles (hard filter) - Require real images for feed articles (no placeholder gradients in reads) - Remove 5-tag limit on getArticleTags — all tags available for category matching - Filter broken image URLs (Ooops patterns, placeholder domains) - Add preview/full article deduplication via 'full' tag - Add "test"/"testing" as forbidden words in article titles - Lower "All" tab min read time from 3min to 1min Ranking: - New "For You" sort option boosting followed authors with weekly decay - Defaults to "For You" when signed in, "Newest" when signed out - getFollowedPubkeys() helper in followListCache for the ranking function Shared article store: - New articleStore.ts with writable stores for articles and cover - Reads page syncs articles to shared store reactively - LongformFoodFeed on explore page consumes shared store, falls back to own food-hashtag fetch if store is empty - Both pages feed the same pool — visit either and both benefit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
frontend | 21dfbea | Mar 26 2026, 12:50 AM |
There was a problem hiding this comment.
Pull request overview
This PR upgrades the /reads experience by expanding article discovery depth, adding a “For You” ranking mode, tightening quality filtering (images/spam), and introducing a shared in-memory article pool intended to be reused across /reads and /explore.
Changes:
- Expanded discovery window to a 365-day lookback and added a dual-fetch strategy (food-tagged primary + general secondary).
- Added “For You” sorting backed by the follow-list cache and extended article utilities (image validation, preview/full dedup, spam/title filters).
- Introduced
articleStore.tsfor cross-page article sharing and updated reads/explore components to write/read shared data.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/routes/reads/+page.svelte | Dual-fetch strategy, removes food-only toggle, adds follow-based sorting inputs, writes into shared article store |
| src/lib/followListCache.ts | Adds getFollowedPubkeys() helper for “For You” ranking |
| src/lib/articleUtils.ts | Adds NDKArticle wrapper usage, image URL validation, tag-limit removal, preview/full dedup, “For You” sort option |
| src/lib/articleStore.ts | New shared Svelte stores for articles + cover |
| src/lib/articleOutbox.ts | Changes default article discovery window to 365 days |
| src/components/table/FilterBar.svelte | Adds optional “For You” sort option in the dropdown |
| src/components/table/FeedSection.svelte | Adds “For You” sort wiring, preview/full dedup, image requirement for feed cards |
| src/components/LongformFoodFeed.svelte | Uses shared store to avoid refetching when possible; pushes fetched articles into shared store |
| src/components/ArticleCard.svelte | Treats tiny loaded images as broken to reduce placeholder/error images |
| package.json | Version bump |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Always fetch all articles - feed shows all, cover always food-editorial | ||
| const cacheFilter = { kinds: [30023], hashtags: getFoodHashtags(), limit: 500 }; | ||
|
|
There was a problem hiding this comment.
cacheFilter is now constrained to the food hashtag set. In loadArticles, the “cache is fresh” fast-path can skip the network fetches entirely, which means the page may hydrate only food-tagged cached events and never run the general (no-hashtag) fetch. To avoid empty non-food categories when cache is used, consider either loading cache without a hashtag filter (or doing a second cache load for general), and/or ensuring the general fetch still runs even when cache is deemed fresh.
| // Sync local articles to shared store (for explore page reuse) | ||
| $: if (articles.length > 0) { | ||
| addToSharedStore(articles); | ||
| refreshSharedCover(); | ||
| } |
There was a problem hiding this comment.
The shared articleStore is only being written to here; +page.svelte never reads from it on load. That means “visit /explore first, then /reads — verify articles are shared between pages” (per PR description/test plan) likely won’t hold, because /reads will still cold-start from its cache/network path. Consider hydrating articles/cover from articleStore/coverStore when available (and skipping fetch if sufficiently populated).
| let selectedSort: SortOption = 'newest'; | ||
| let selectedCategory = 'All'; | ||
| let selectedSort: SortOption = isSignedIn ? 'foryou' : 'newest'; | ||
|
|
There was a problem hiding this comment.
selectedSort is initialized from isSignedIn, but won’t update if isSignedIn changes after mount (e.g., user logs in while on the page). This can prevent “For You” from becoming the default when a session becomes signed-in. Consider a reactive sync that sets the default to 'foryou' when isSignedIn transitions true (while preserving any user-chosen sort).
| // When the user signs in, default to "For You" if the sort is still the unsigned default. | |
| $: if (isSignedIn && selectedSort === 'newest') { | |
| selectedSort = 'foryou'; | |
| } |
| /** | ||
| * Get the set of followed pubkeys (without profile data). | ||
| * Returns empty set if not loaded yet. | ||
| */ | ||
| export function getFollowedPubkeys(): Set<string> { | ||
| return new Set(profileCache.keys()); | ||
| } |
There was a problem hiding this comment.
getFollowedPubkeys() currently returns profileCache.keys(), but profileCache can also be mutated via addToCache() (“from network search”). That means this set may include pubkeys that are not actually followed, which will skew “For You” ranking. Consider maintaining a separate followPubkeys Set derived from the kind:3 contact list tags and returning that instead (and keeping it independent from profile caching/search).
src/lib/articleUtils.ts
Outdated
| // Extract first image URL from content | ||
| const imageMatches = content.match(IMAGE_URL_REGEX); | ||
| if (imageMatches && imageMatches.length > 0) { | ||
| if (imageMatches && imageMatches.length > 0 && isValidImageUrl(imageMatches[0])) { | ||
| return imageMatches[0]; |
There was a problem hiding this comment.
extractImage only checks imageMatches[0]. If the first matched URL is a placeholder/broken URL (which you now explicitly filter), but a later match is valid, the function will return null and drop the image unnecessarily. Consider scanning imageMatches and returning the first URL that passes isValidImageUrl().
src/lib/articleStore.ts
Outdated
| @@ -0,0 +1,48 @@ | |||
| /** | |||
| * Shared article store — single source of truth for articles across reads and explore pages. | |||
| * Articles are fetched once and cached in this store + IndexedDB. | |||
There was a problem hiding this comment.
The header comment says articles are cached in this store + IndexedDB, but this module currently only maintains Svelte stores (no persistence). Consider either wiring it to eventStore/IndexedDB or adjusting the comment so future readers don’t assume persistence that isn’t implemented here.
| * Articles are fetched once and cached in this store + IndexedDB. | |
| * Articles are fetched once and kept in these Svelte stores (in-memory cache). |
src/routes/reads/+page.svelte
Outdated
|
|
||
| // Sync local articles to shared store (for explore page reuse) | ||
| $: if (articles.length > 0) { | ||
| addToSharedStore(articles); | ||
| refreshSharedCover(); |
There was a problem hiding this comment.
This reactive block runs on every articles update and passes the entire articles array into addToSharedStore, which then rebuilds a Set and filters/sorts again. With relay onEvent updates, this can become unnecessarily expensive. Consider only adding the newly appended articles (or tracking last-synced IDs), and only refreshing the shared cover when new food articles were actually added.
| // Sync local articles to shared store (for explore page reuse) | |
| $: if (articles.length > 0) { | |
| addToSharedStore(articles); | |
| refreshSharedCover(); | |
| let lastSyncedArticleCount = 0; | |
| // Sync local articles to shared store (for explore page reuse) | |
| $: { | |
| if (articles.length > lastSyncedArticleCount) { | |
| const newArticles = articles.slice(lastSyncedArticleCount); | |
| if (newArticles.length > 0) { | |
| addToSharedStore(newArticles); | |
| refreshSharedCover(); | |
| } | |
| lastSyncedArticleCount = articles.length; | |
| } else if (articles.length === 0) { | |
| // Reset sync counter when articles are cleared | |
| lastSyncedArticleCount = 0; | |
| } |
| import { | ||
| TOP_RELAY_FOOD_HASHTAGS, | ||
| isValidLongformArticle, | ||
| isValidLongformArticleNoFoodFilter, |
There was a problem hiding this comment.
isValidLongformArticleNoFoodFilter is imported but not used in this component. If ESLint is enforcing unused imports in .svelte files, this can fail CI; please remove it or use it.
| isValidLongformArticleNoFoodFilter, |
| eventToArticleData, | ||
| type ArticleData | ||
| } from '$lib/articleUtils'; | ||
| import { articleStore, foodArticles, addArticles } from '$lib/articleStore'; |
There was a problem hiding this comment.
articleStore is imported here but never referenced. Please remove the unused import (or use it) to avoid unused-import lint failures.
| import { articleStore, foodArticles, addArticles } from '$lib/articleStore'; | |
| import { foodArticles, addArticles } from '$lib/articleStore'; |
- Run general (no-hashtag) article fetch even when cache is fresh so non-food categories populate - Hydrate reads page from shared articleStore on load for cross-page caching between /reads and /explore - Optimize shared store sync to only add newly appended articles - Make selectedSort reactive to login state transitions - Separate follow pubkey set from profile cache to prevent non-followed pubkeys from skewing "For You" ranking - Scan all image regex matches in extractImage (not just first) - Fix misleading IndexedDB comment in articleStore - Remove unused imports in LongformFoodFeed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Major improvements to the reads section, applying patterns learned from pablof7z/highlighter:
articleStore.ts; visit either page and both have dataTest plan
🤖 Generated with Claude Code