Skip to content

Improve reads article discovery, filtering, and shared caching#270

Merged
spe1020 merged 2 commits intomainfrom
feat/reads-article-improvements
Mar 26, 2026
Merged

Improve reads article discovery, filtering, and shared caching#270
spe1020 merged 2 commits intomainfrom
feat/reads-article-improvements

Conversation

@spe1020
Copy link
Copy Markdown
Contributor

@spe1020 spe1020 commented Mar 25, 2026

Summary

Major improvements to the reads section, applying patterns learned from pablof7z/highlighter:

  • 365-day lookback (was 90 days) — much deeper article pool, especially for niche categories like Food and Farming
  • Dual fetch strategy: food-hashtag relay query populates cover + Food/Farming instantly, general query fills Bitcoin/Nostr/Travel/etc in parallel
  • Removed Food Only toggle — cover is always food-editorial, feed section shows all topics via category pills
  • "For You" sort — boosts articles from followed authors with weekly decay, defaults when signed in
  • Shared article store — reads and explore pages share the same article pool via articleStore.ts; visit either page and both have data
  • NDKArticle typed wrapper for cleaner property access (title, image, summary, published_at)
  • Quality filters: pubkey blacklist, broken image URL filtering, preview/full dedup, image requirement for feed articles, "test"/"testing" forbidden in titles
  • Tag limit removed — all article tags available for category matching (was capped at 5, causing Food/Farming to show empty)

Test plan

  • Load /reads — verify food articles appear in cover section with images
  • Click category pills (All, Food, Farming, Bitcoin, etc.) — all should show articles
  • Verify "For You" sort appears when signed in and boosts followed authors
  • Verify no articles with placeholder/broken images appear in feed
  • Load /explore — verify "Food Stories & Articles" section shows articles
  • Visit /explore first, then /reads — verify articles are shared between pages
  • Verify no "test" or "testing" titled articles appear
  • Check mobile layout — category pills scroll horizontally, sort dropdown works

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 25, 2026

Deploying frontend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 21dfbea
Status:⚡️  Build in progress...

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 25, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
frontend 21dfbea Mar 26 2026, 12:50 AM

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts for 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.

Comment on lines +155 to 157
// Always fetch all articles - feed shows all, cover always food-editorial
const cacheFilter = { kinds: [30023], hashtags: getFoodHashtags(), limit: 500 };

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +57
// Sync local articles to shared store (for explore page reuse)
$: if (articles.length > 0) {
addToSharedStore(articles);
refreshSharedCover();
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
let selectedSort: SortOption = 'newest';
let selectedCategory = 'All';
let selectedSort: SortOption = isSignedIn ? 'foryou' : 'newest';

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// When the user signs in, default to "For You" if the sort is still the unsigned default.
$: if (isSignedIn && selectedSort === 'newest') {
selectedSort = 'foryou';
}

Copilot uses AI. Check for mistakes.
Comment on lines +200 to +206
/**
* 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());
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 304 to 307
// 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];
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
@@ -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.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* Articles are fetched once and cached in this store + IndexedDB.
* Articles are fetched once and kept in these Svelte stores (in-memory cache).

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +56

// Sync local articles to shared store (for explore page reuse)
$: if (articles.length > 0) {
addToSharedStore(articles);
refreshSharedCover();
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;
}

Copilot uses AI. Check for mistakes.
import {
TOP_RELAY_FOOD_HASHTAGS,
isValidLongformArticle,
isValidLongformArticleNoFoodFilter,
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
isValidLongformArticleNoFoodFilter,

Copilot uses AI. Check for mistakes.
eventToArticleData,
type ArticleData
} from '$lib/articleUtils';
import { articleStore, foodArticles, addArticles } from '$lib/articleStore';
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

articleStore is imported here but never referenced. Please remove the unused import (or use it) to avoid unused-import lint failures.

Suggested change
import { articleStore, foodArticles, addArticles } from '$lib/articleStore';
import { foodArticles, addArticles } from '$lib/articleStore';

Copilot uses AI. Check for mistakes.
- 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>
@spe1020 spe1020 merged commit f1a6eb3 into main Mar 26, 2026
1 of 3 checks passed
@spe1020 spe1020 deleted the feat/reads-article-improvements branch March 26, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants