From fddf79e0c6168949e339a9c00376d32bbcc2cd8a Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 08:27:10 +0200 Subject: [PATCH 01/19] feat: add named kind constants, streaming updates, and fix reads/links tabs - Create src/config/kinds.ts with named Nostr kind constants - Add streaming support to fetchAllReads and fetchLinks with onItem callbacks - Update all services to use KINDS constants instead of magic numbers - Add mergeReadItem utility for DRY state management - Add fallbackTitleFromUrl for external links without titles - Relax validation to allow external items without titles - Update Me.tsx to use streaming with Map-based state for reads/links - Fix refresh to merge new data instead of clearing state - Fix empty states for Reads and Links tabs (no more infinite skeletons) - Services updated: readsService, linksService, libraryService, bookmarkService, exploreService, highlights/fetchByAuthor --- src/components/Me.tsx | 59 +++++++++++++------ src/config/kinds.ts | 15 +++++ src/services/bookmarkService.ts | 5 +- src/services/exploreService.ts | 3 +- src/services/highlights/fetchByAuthor.ts | 5 +- src/services/libraryService.ts | 9 +-- src/services/linksService.ts | 41 +++++++++++-- src/services/readingDataProcessor.ts | 39 ++++++++----- src/services/readsService.ts | 70 ++++++++++++---------- src/utils/readItemMerge.ts | 74 ++++++++++++++++++++++++ 10 files changed, 241 insertions(+), 79 deletions(-) create mode 100644 src/config/kinds.ts create mode 100644 src/utils/readItemMerge.ts diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 2d987e9d..91f54cdd 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -49,7 +49,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const [highlights, setHighlights] = useState([]) const [bookmarks, setBookmarks] = useState([]) const [reads, setReads] = useState([]) + const [readsMap, setReadsMap] = useState>(new Map()) const [links, setLinks] = useState([]) + const [linksMap, setLinksMap] = useState>(new Map()) const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) const [loadedTabs, setLoadedTabs] = useState>(new Set()) @@ -144,9 +146,14 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr fetchedBookmarks = [] } - // Fetch all reads - const userReads = await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks) - setReads(userReads) + // Fetch all reads with streaming + const tempMap = new Map(readsMap) + await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { + tempMap.set(item.id, item) + setReadsMap(new Map(tempMap)) + setReads(Array.from(tempMap.values())) + }) + setLoadedTabs(prev => new Set(prev).add('reads')) } catch (err) { console.error('Failed to load reads:', err) @@ -163,9 +170,14 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr try { if (!hasBeenLoaded) setLoading(true) - // Fetch links (external URLs with reading progress) - const userLinks = await fetchLinks(relayPool, viewingPubkey) - setLinks(userLinks) + // Fetch links with streaming + const tempMap = new Map(linksMap) + await fetchLinks(relayPool, viewingPubkey, (item) => { + tempMap.set(item.id, item) + setLinksMap(new Map(tempMap)) + setLinks(Array.from(tempMap.values())) + }) + setLoadedTabs(prev => new Set(prev).add('links')) } catch (err) { console.error('Failed to load links:', err) @@ -214,15 +226,10 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr }, [activeTab, viewingPubkey, refreshTrigger]) - // Pull-to-refresh - only reload active tab + // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { - // Clear the loaded state for current tab to force refresh - setLoadedTabs(prev => { - const newSet = new Set(prev) - newSet.delete(activeTab) - return newSet - }) + // Just trigger refresh - loaders will merge new data setRefreshTrigger(prev => prev + 1) }, maximumPullLength: 240, @@ -449,8 +456,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'reads': - // Show loading skeletons while fetching or if no data - if (reads.length === 0 || (loading && !loadedTabs.has('reads'))) { + // Show loading skeletons only while initially loading + if (loading && !loadedTabs.has('reads')) { return (
{Array.from({ length: 6 }).map((_, i) => ( @@ -460,6 +467,15 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) } + // Show empty state if loaded but no reads + if (reads.length === 0 && loadedTabs.has('reads')) { + return ( +
+ No articles read yet. +
+ ) + } + // Show reads with filters return ( <> @@ -487,8 +503,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'links': - // Show loading skeletons while fetching or if no data - if (links.length === 0 || (loading && !loadedTabs.has('links'))) { + // Show loading skeletons only while initially loading + if (loading && !loadedTabs.has('links')) { return (
{Array.from({ length: 6 }).map((_, i) => ( @@ -498,6 +514,15 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) } + // Show empty state if loaded but no links + if (links.length === 0 && loadedTabs.has('links')) { + return ( +
+ No links with reading progress yet. +
+ ) + } + // Show links with filters return ( <> diff --git a/src/config/kinds.ts b/src/config/kinds.ts new file mode 100644 index 00000000..4221a07d --- /dev/null +++ b/src/config/kinds.ts @@ -0,0 +1,15 @@ +// Nostr event kinds used throughout the application +export const KINDS = { + Highlights: 9802, // NIP-?? user highlights + BlogPost: 30023, // NIP-23 long-form article + AppData: 30078, // NIP-78 application data (reading positions) + List: 30001, // NIP-51 list (addressable) + ListReplaceable: 30003, // NIP-51 replaceable list + ListSimple: 10003, // NIP-51 simple list + WebBookmark: 39701, // NIP-B0 web bookmark + ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read) + ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read) +} as const + +export type KindValue = typeof KINDS[keyof typeof KINDS] + diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index f53b630b..9d8a594d 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -15,6 +15,7 @@ import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' @@ -34,7 +35,7 @@ export const fetchBookmarks = async ( const rawEvents = await queryEvents( relayPool, - { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }, + { kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] }, {} ) console.log('📊 Raw events fetched:', rawEvents.length, 'events') @@ -71,7 +72,7 @@ export const fetchBookmarks = async ( }) // Check specifically for Primal's "reads" list - const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads')) + const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads')) if (primalReads) { console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8)) } else { diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index b4b4c122..ff486c7a 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async ( await queryEvents( relayPool, - { kinds: [30023], authors: pubkeys, limit: 100 }, + { kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 }, { relayUrls, onEvent: (event: NostrEvent) => { diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts index 7c6b71c6..011d02eb 100644 --- a/src/services/highlights/fetchByAuthor.ts +++ b/src/services/highlights/fetchByAuthor.ts @@ -6,6 +6,7 @@ import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' +import { KINDS } from '../../config/kinds' export const fetchHighlights = async ( relayPool: RelayPool, @@ -21,7 +22,7 @@ export const fetchHighlights = async ( const seenIds = new Set() const local$ = localRelays.length > 0 ? relayPool - .req(localRelays, { kinds: [9802], authors: [pubkey] }) + .req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] }) .pipe( onlyEvents(), tap((event: NostrEvent) => { @@ -36,7 +37,7 @@ export const fetchHighlights = async ( : new Observable((sub) => sub.complete()) const remote$ = remoteRelays.length > 0 ? relayPool - .req(remoteRelays, { kinds: [9802], authors: [pubkey] }) + .req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] }) .pipe( onlyEvents(), tap((event: NostrEvent) => { diff --git a/src/services/libraryService.ts b/src/services/libraryService.ts index 8818b818..d07d0d4f 100644 --- a/src/services/libraryService.ts +++ b/src/services/libraryService.ts @@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' +import { KINDS } from '../config/kinds' import { MARK_AS_READ_EMOJI } from './reactionService' import { BlogPostPreview } from './exploreService' import { queryEvents } from './dataFetch' @@ -29,8 +30,8 @@ export async function fetchReadArticles( try { // Fetch kind:7 and kind:17 reactions in parallel const [kind7Events, kind17Events] = await Promise.all([ - queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }), - queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS }) + queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }), + queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS }) ]) const readArticles: ReadArticle[] = [] @@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData( // Filter to only nostr-native articles (kind 30023) const nostrArticles = readArticles.filter( - article => article.eventKind === 30023 && article.eventId + article => article.eventKind === KINDS.BlogPost && article.eventId ) if (nostrArticles.length === 0) { @@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData( const articleEvents = await queryEvents( relayPool, - { kinds: [30023], ids: eventIds }, + { kinds: [KINDS.BlogPost], ids: eventIds }, { relayUrls: RELAYS } ) diff --git a/src/services/linksService.ts b/src/services/linksService.ts index ec7620cc..401cec12 100644 --- a/src/services/linksService.ts +++ b/src/services/linksService.ts @@ -2,10 +2,10 @@ import { RelayPool } from 'applesauce-relay' import { fetchReadArticles } from './libraryService' import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' +import { KINDS } from '../config/kinds' import { ReadItem } from './readsService' import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' - -const APP_DATA_KIND = 30078 // NIP-78 Application Data +import { mergeReadItem } from '../utils/readItemMerge' /** * Fetches external URL links with reading progress from: @@ -14,14 +14,26 @@ const APP_DATA_KIND = 30078 // NIP-78 Application Data */ export async function fetchLinks( relayPool: RelayPool, - userPubkey: string + userPubkey: string, + onItem?: (item: ReadItem) => void ): Promise { console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8)) + const linksMap = new Map() + + // Helper to emit items as they're added/updated + const emitItem = (item: ReadItem) => { + if (onItem && mergeReadItem(linksMap, item)) { + onItem(linksMap.get(item.id)!) + } else if (!onItem) { + linksMap.set(item.id, item) + } + } + try { // Fetch all data sources in parallel const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ - queryEvents(relayPool, { kinds: [APP_DATA_KIND], authors: [userPubkey] }, { relayUrls: RELAYS }), + queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) @@ -30,10 +42,27 @@ export async function fetchLinks( markedAsRead: markedAsReadArticles.length }) - // Process data using shared utilities - const linksMap = new Map() + // Process reading positions and emit external items processReadingPositions(readingPositionEvents, linksMap) + if (onItem) { + linksMap.forEach(item => { + if (item.type === 'external') { + const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead + if (hasProgress) emitItem(item) + } + }) + } + + // Process marked-as-read and emit external items processMarkedAsRead(markedAsReadArticles, linksMap) + if (onItem) { + linksMap.forEach(item => { + if (item.type === 'external') { + const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead + if (hasProgress) emitItem(item) + } + }) + } // Filter for external URLs only with reading progress const links = Array.from(linksMap.values()) diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index 81ebcc98..a61c54ef 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -1,5 +1,6 @@ import { NostrEvent } from 'nostr-tools' import { ReadItem } from './readsService' +import { fallbackTitleFromUrl } from '../utils/readItemMerge' const READING_POSITION_PREFIX = 'boris:reading-position:' @@ -117,24 +118,30 @@ export function sortByReadingActivity(items: ReadItem[]): ReadItem[] { } /** - * Filters out items without timestamps or proper titles + * Filters out items without timestamps and enriches external items with fallback titles */ export function filterValidItems(items: ReadItem[]): ReadItem[] { - return items.filter(item => { - // Only include items that have a timestamp - const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) || - (item.markedAt && item.markedAt > 0) - if (!hasTimestamp) return false - - // Filter out items without titles - if (!item.title || item.title === 'Untitled') { - // For Nostr articles, we need the title from the event + return items + .filter(item => { + // Only include items that have a timestamp + const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) || + (item.markedAt && item.markedAt > 0) + if (!hasTimestamp) return false + + // For Nostr articles, we need the event to be valid if (item.type === 'article' && !item.event) return false - // For external URLs, we need a proper title - if (item.type === 'external' && !item.title) return false - } - - return true - }) + + // For external URLs, we need at least a URL + if (item.type === 'external' && !item.url) return false + + return true + }) + .map(item => { + // Add fallback title for external URLs without titles + if (item.type === 'external' && !item.title && item.url) { + return { ...item, title: fallbackTitleFromUrl(item.url) } + } + return item + }) } diff --git a/src/services/readsService.ts b/src/services/readsService.ts index eb631cc7..988b783f 100644 --- a/src/services/readsService.ts +++ b/src/services/readsService.ts @@ -5,14 +5,14 @@ import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { fetchReadArticles } from './libraryService' import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' +import { KINDS } from '../config/kinds' import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier' import { nip19 } from 'nostr-tools' import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' +import { mergeReadItem } from '../utils/readItemMerge' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers -const APP_DATA_KIND = 30078 // NIP-78 Application Data - export interface ReadItem { id: string // event ID or URL or coordinate source: 'bookmark' | 'reading-progress' | 'marked-as-read' @@ -43,14 +43,26 @@ export interface ReadItem { export async function fetchAllReads( relayPool: RelayPool, userPubkey: string, - bookmarks: Bookmark[] + bookmarks: Bookmark[], + onItem?: (item: ReadItem) => void ): Promise { console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8)) + const readsMap = new Map() + + // Helper to emit items as they're added/updated + const emitItem = (item: ReadItem) => { + if (onItem && mergeReadItem(readsMap, item)) { + onItem(readsMap.get(item.id)!) + } else if (!onItem) { + readsMap.set(item.id, item) + } + } + try { // Fetch all data sources in parallel const [readingPositionEvents, markedAsReadArticles] = await Promise.all([ - queryEvents(relayPool, { kinds: [APP_DATA_KIND], authors: [userPubkey] }, { relayUrls: RELAYS }), + queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }), fetchReadArticles(relayPool, userPubkey) ]) @@ -60,10 +72,21 @@ export async function fetchAllReads( bookmarks: bookmarks.length }) - // Process data using shared utilities - const readsMap = new Map() + // Process reading positions and emit items processReadingPositions(readingPositionEvents, readsMap) + if (onItem) { + readsMap.forEach(item => { + if (item.type === 'article') emitItem(item) + }) + } + + // Process marked-as-read and emit items processMarkedAsRead(markedAsReadArticles, readsMap) + if (onItem) { + readsMap.forEach(item => { + if (item.type === 'article') emitItem(item) + }) + } // 3. Process bookmarked articles and article/website URLs const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) @@ -71,38 +94,22 @@ export async function fetchAllReads( for (const bookmark of allBookmarks) { const bookmarkType = classifyBookmarkType(bookmark) - // Only include articles and external article/website bookmarks + // Only include articles if (bookmarkType === 'article') { // Kind:30023 nostr article const coordinate = bookmark.id // Already in coordinate format const existing = readsMap.get(coordinate) if (!existing) { - readsMap.set(coordinate, { + const item: ReadItem = { id: coordinate, source: 'bookmark', type: 'article', readingProgress: 0, readingTimestamp: bookmark.added_at || bookmark.created_at - }) - } - } else if (bookmarkType === 'external') { - // External article URL - const urls = extractUrlFromBookmark(bookmark) - if (urls.length > 0) { - const url = urls[0] - const existing = readsMap.get(url) - - if (!existing) { - readsMap.set(url, { - id: url, - source: 'bookmark', - type: 'external', - url, - readingProgress: 0, - readingTimestamp: bookmark.added_at || bookmark.created_at - }) } + readsMap.set(coordinate, item) + if (onItem) emitItem(item) } } } @@ -123,7 +130,7 @@ export async function fetchAllReads( // Try to decode as naddr if (coord.startsWith('naddr1')) { const decoded = nip19.decode(coord) - if (decoded.type === 'naddr' && decoded.data.kind === 30023) { + if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) { articlesToFetch.push({ pubkey: decoded.data.pubkey, identifier: decoded.data.identifier || '' @@ -132,7 +139,7 @@ export async function fetchAllReads( } else { // Try coordinate format (kind:pubkey:identifier) const parts = coord.split(':') - if (parts.length === 3 && parts[0] === '30023') { + if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) { articlesToFetch.push({ pubkey: parts[1], identifier: parts[2] @@ -150,14 +157,14 @@ export async function fetchAllReads( const events = await queryEvents( relayPool, - { kinds: [30023], authors, '#d': identifiers }, + { kinds: [KINDS.BlogPost], authors, '#d': identifiers }, { relayUrls: RELAYS } ) - // Merge event data into ReadItems + // Merge event data into ReadItems and emit for (const event of events) { const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' - const coordinate = `30023:${event.pubkey}:${dTag}` + const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}` const item = readsMap.get(coordinate) || readsMap.get(event.id) if (item) { @@ -167,6 +174,7 @@ export async function fetchAllReads( item.image = getArticleImage(event) item.published = getArticlePublished(event) item.author = event.pubkey + if (onItem) emitItem(item) } } } diff --git a/src/utils/readItemMerge.ts b/src/utils/readItemMerge.ts new file mode 100644 index 00000000..4e849e62 --- /dev/null +++ b/src/utils/readItemMerge.ts @@ -0,0 +1,74 @@ +import { ReadItem } from '../services/readsService' + +/** + * Merges a ReadItem into a state map, returning whether the state changed. + * Uses most recent reading activity to determine precedence. + */ +export function mergeReadItem( + stateMap: Map, + incoming: ReadItem +): boolean { + const existing = stateMap.get(incoming.id) + + if (!existing) { + stateMap.set(incoming.id, incoming) + return true + } + + // Merge by taking the most recent reading activity + const existingTime = existing.readingTimestamp || existing.markedAt || 0 + const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0 + + if (incomingTime > existingTime) { + // Keep existing data, but update with newer reading metadata + stateMap.set(incoming.id, { + ...existing, + ...incoming, + // Preserve event data if incoming doesn't have it + event: incoming.event || existing.event, + title: incoming.title || existing.title, + summary: incoming.summary || existing.summary, + image: incoming.image || existing.image, + published: incoming.published || existing.published, + author: incoming.author || existing.author + }) + return true + } + + // If timestamps are equal but incoming has additional data, merge it + if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) { + stateMap.set(incoming.id, { + ...existing, + ...incoming, + event: incoming.event || existing.event, + title: incoming.title || existing.title, + summary: incoming.summary || existing.summary, + image: incoming.image || existing.image, + published: incoming.published || existing.published, + author: incoming.author || existing.author + }) + return true + } + + return false +} + +/** + * Extracts a readable title from a URL when no title is available. + * Removes protocol, www, and shows domain + path. + */ +export function fallbackTitleFromUrl(url: string): string { + try { + const parsed = new URL(url) + let title = parsed.hostname.replace(/^www\./, '') + if (parsed.pathname && parsed.pathname !== '/') { + const path = parsed.pathname.slice(0, 40) + title += path.length < parsed.pathname.length ? path + '...' : path + } + return title + } catch { + // If URL parsing fails, just return the URL truncated + return url.length > 50 ? url.slice(0, 47) + '...' : url + } +} + From 7e2b4b46c96cc44672062db1f08e0f8ea6aeae0d Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 08:45:31 +0200 Subject: [PATCH 02/19] feat(me): populate reads/links from bookmarks instantly - Add deriveReadsFromBookmarks helper to convert 30023 bookmarks to ReadItems - Add deriveLinksFromBookmarks helper for web bookmarks (39701) and URLs - Update loadReadsTab to show bookmarked articles immediately, enrich in background - Update loadLinksTab to show bookmarked links immediately, enrich in background - Background enrichment merges reading progress only for displayed items - Preserve existing pull-to-refresh and empty state logic --- src/components/Me.tsx | 89 ++++++++++++++++++++++----------- src/utils/linksFromBookmarks.ts | 69 +++++++++++++++++++++++++ src/utils/readsFromBookmarks.ts | 47 +++++++++++++++++ 3 files changed, 177 insertions(+), 28 deletions(-) create mode 100644 src/utils/linksFromBookmarks.ts create mode 100644 src/utils/readsFromBookmarks.ts diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 91f54cdd..52d5acc9 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -29,6 +29,9 @@ import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters' import { filterByReadingProgress } from '../utils/readingProgressUtils' +import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' +import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' +import { mergeReadItem } from '../utils/readItemMerge' interface MeProps { relayPool: RelayPool @@ -134,30 +137,39 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr try { if (!hasBeenLoaded) setLoading(true) - // Fetch bookmarks first (needed for reads) - let fetchedBookmarks: Bookmark[] = [] - try { - await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { - fetchedBookmarks = newBookmarks - setBookmarks(newBookmarks) - }) - } catch (err) { - console.warn('Failed to load bookmarks:', err) - fetchedBookmarks = [] + // Ensure bookmarks are loaded + let fetchedBookmarks: Bookmark[] = bookmarks + if (bookmarks.length === 0) { + try { + await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { + fetchedBookmarks = newBookmarks + setBookmarks(newBookmarks) + }) + } catch (err) { + console.warn('Failed to load bookmarks:', err) + fetchedBookmarks = [] + } } - // Fetch all reads with streaming - const tempMap = new Map(readsMap) - await fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { - tempMap.set(item.id, item) - setReadsMap(new Map(tempMap)) - setReads(Array.from(tempMap.values())) - }) - + // Derive reads from bookmarks immediately + const initialReads = deriveReadsFromBookmarks(fetchedBookmarks) + const tempMap = new Map(initialReads.map(item => [item.id, item])) + setReadsMap(tempMap) + setReads(initialReads) setLoadedTabs(prev => new Set(prev).add('reads')) + if (!hasBeenLoaded) setLoading(false) + + // Background enrichment: merge reading progress and mark-as-read + // Only update items that are already in our map + fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { + if (tempMap.has(item.id) && mergeReadItem(tempMap, item)) { + setReadsMap(new Map(tempMap)) + setReads(Array.from(tempMap.values())) + } + }).catch(err => console.warn('Failed to enrich reads:', err)) + } catch (err) { console.error('Failed to load reads:', err) - } finally { if (!hasBeenLoaded) setLoading(false) } } @@ -170,18 +182,39 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr try { if (!hasBeenLoaded) setLoading(true) - // Fetch links with streaming - const tempMap = new Map(linksMap) - await fetchLinks(relayPool, viewingPubkey, (item) => { - tempMap.set(item.id, item) - setLinksMap(new Map(tempMap)) - setLinks(Array.from(tempMap.values())) - }) - + // Ensure bookmarks are loaded + let fetchedBookmarks: Bookmark[] = bookmarks + if (bookmarks.length === 0) { + try { + await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => { + fetchedBookmarks = newBookmarks + setBookmarks(newBookmarks) + }) + } catch (err) { + console.warn('Failed to load bookmarks:', err) + fetchedBookmarks = [] + } + } + + // Derive links from bookmarks immediately + const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks) + const tempMap = new Map(initialLinks.map(item => [item.id, item])) + setLinksMap(tempMap) + setLinks(initialLinks) setLoadedTabs(prev => new Set(prev).add('links')) + if (!hasBeenLoaded) setLoading(false) + + // Background enrichment: merge reading progress and mark-as-read + // Only update items that are already in our map + fetchLinks(relayPool, viewingPubkey, (item) => { + if (tempMap.has(item.id) && mergeReadItem(tempMap, item)) { + setLinksMap(new Map(tempMap)) + setLinks(Array.from(tempMap.values())) + } + }).catch(err => console.warn('Failed to enrich links:', err)) + } catch (err) { console.error('Failed to load links:', err) - } finally { if (!hasBeenLoaded) setLoading(false) } } diff --git a/src/utils/linksFromBookmarks.ts b/src/utils/linksFromBookmarks.ts new file mode 100644 index 00000000..156f5794 --- /dev/null +++ b/src/utils/linksFromBookmarks.ts @@ -0,0 +1,69 @@ +import { Bookmark } from '../types/bookmarks' +import { ReadItem } from '../services/readsService' +import { KINDS } from '../config/kinds' +import { fallbackTitleFromUrl } from './readItemMerge' + +/** + * Derives ReadItems from bookmarks for external URLs: + * - Web bookmarks (kind:39701) + * - Any bookmark with http(s) URLs in content or urlReferences + */ +export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { + const linksMap = new Map() + + const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) + + for (const bookmark of allBookmarks) { + const urls: string[] = [] + + // Web bookmarks (kind:39701) - extract from 'd' tag + if (bookmark.kind === KINDS.WebBookmark) { + const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] + if (dTag) { + const url = dTag.startsWith('http') ? dTag : `https://${dTag}` + urls.push(url) + } + } + + // Extract URLs from urlReferences (pre-extracted by bookmarkService) + if (bookmark.urlReferences && bookmark.urlReferences.length > 0) { + urls.push(...bookmark.urlReferences) + } + + // Extract URLs from content if not already captured + if (bookmark.content) { + const urlRegex = /(https?:\/\/[^\s]+)/g + const matches = bookmark.content.match(urlRegex) + if (matches) { + urls.push(...matches) + } + } + + // Create ReadItem for each unique URL + for (const url of [...new Set(urls)]) { + if (!linksMap.has(url)) { + const item: ReadItem = { + id: url, + source: 'bookmark', + type: 'external', + url, + title: bookmark.title || fallbackTitleFromUrl(url), + summary: bookmark.summary, + image: bookmark.image, + readingProgress: 0, + readingTimestamp: bookmark.added_at || bookmark.created_at + } + + linksMap.set(url, item) + } + } + } + + // Sort by most recent bookmark activity + return Array.from(linksMap.values()).sort((a, b) => { + const timeA = a.readingTimestamp || 0 + const timeB = b.readingTimestamp || 0 + return timeB - timeA + }) +} + diff --git a/src/utils/readsFromBookmarks.ts b/src/utils/readsFromBookmarks.ts new file mode 100644 index 00000000..033ef479 --- /dev/null +++ b/src/utils/readsFromBookmarks.ts @@ -0,0 +1,47 @@ +import { Bookmark, IndividualBookmark } from '../types/bookmarks' +import { ReadItem } from '../services/readsService' +import { classifyBookmarkType } from './bookmarkTypeClassifier' +import { KINDS } from '../config/kinds' + +/** + * Derives ReadItems from bookmarks for Nostr articles (kind:30023). + * Returns items with type='article', using hydrated event data when available. + */ +export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { + const readsMap = new Map() + + const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) + + for (const bookmark of allBookmarks) { + const bookmarkType = classifyBookmarkType(bookmark) + + // Only include articles (kind:30023) + if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) { + const coordinate = bookmark.id // Already in coordinate format + + const item: ReadItem = { + id: coordinate, + source: 'bookmark', + type: 'article', + readingProgress: 0, + readingTimestamp: bookmark.added_at || bookmark.created_at, + event: bookmark.event, + title: bookmark.title, + summary: bookmark.summary, + image: bookmark.image, + author: bookmark.pubkey, + url: bookmark.url + } + + readsMap.set(coordinate, item) + } + } + + // Sort by most recent bookmark activity + return Array.from(readsMap.values()).sort((a, b) => { + const timeA = a.readingTimestamp || 0 + const timeB = b.readingTimestamp || 0 + return timeB - timeA + }) +} + From e73d89739b43b0be3df4fa9a7bd53fe02f873e9c Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:01:51 +0200 Subject: [PATCH 03/19] fix(reads): extract article titles from events using applesauce helpers - Use getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished from Helpers - Extract metadata from bookmark.event when available - Fallback to bookmark fields if event not hydrated - Fixes 'Untitled' articles in Reads tab --- src/utils/readsFromBookmarks.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/utils/readsFromBookmarks.ts b/src/utils/readsFromBookmarks.ts index 033ef479..2fc493c4 100644 --- a/src/utils/readsFromBookmarks.ts +++ b/src/utils/readsFromBookmarks.ts @@ -2,6 +2,9 @@ import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { ReadItem } from '../services/readsService' import { classifyBookmarkType } from './bookmarkTypeClassifier' import { KINDS } from '../config/kinds' +import { Helpers } from 'applesauce-core' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers /** * Derives ReadItems from bookmarks for Nostr articles (kind:30023). @@ -19,6 +22,12 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) { const coordinate = bookmark.id // Already in coordinate format + // Extract metadata from event if available + const title = bookmark.event ? getArticleTitle(bookmark.event) : bookmark.title + const summary = bookmark.event ? getArticleSummary(bookmark.event) : bookmark.summary + const image = bookmark.event ? getArticleImage(bookmark.event) : bookmark.image + const published = bookmark.event ? getArticlePublished(bookmark.event) : undefined + const item: ReadItem = { id: coordinate, source: 'bookmark', @@ -26,9 +35,10 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { readingProgress: 0, readingTimestamp: bookmark.added_at || bookmark.created_at, event: bookmark.event, - title: bookmark.title, - summary: bookmark.summary, - image: bookmark.image, + title: title || 'Untitled', + summary, + image, + published, author: bookmark.pubkey, url: bookmark.url } From f78f1a346018c6477c1047d8a9960d1c1f5cebe3 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:06:00 +0200 Subject: [PATCH 04/19] fix(reads): use bookmark.content for article titles - IndividualBookmark doesn't have separate title/event fields - After hydration, article titles are stored in content field - Simplified extraction logic to just use bookmark.content --- src/utils/readsFromBookmarks.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/utils/readsFromBookmarks.ts b/src/utils/readsFromBookmarks.ts index 2fc493c4..ecf43b11 100644 --- a/src/utils/readsFromBookmarks.ts +++ b/src/utils/readsFromBookmarks.ts @@ -2,13 +2,11 @@ import { Bookmark, IndividualBookmark } from '../types/bookmarks' import { ReadItem } from '../services/readsService' import { classifyBookmarkType } from './bookmarkTypeClassifier' import { KINDS } from '../config/kinds' -import { Helpers } from 'applesauce-core' - -const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers /** * Derives ReadItems from bookmarks for Nostr articles (kind:30023). - * Returns items with type='article', using hydrated event data when available. + * Returns items with type='article', using hydrated data when available. + * Note: After hydration, article titles are stored in bookmark.content field. */ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { const readsMap = new Map() @@ -22,11 +20,8 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) { const coordinate = bookmark.id // Already in coordinate format - // Extract metadata from event if available - const title = bookmark.event ? getArticleTitle(bookmark.event) : bookmark.title - const summary = bookmark.event ? getArticleSummary(bookmark.event) : bookmark.summary - const image = bookmark.event ? getArticleImage(bookmark.event) : bookmark.image - const published = bookmark.event ? getArticlePublished(bookmark.event) : undefined + // After hydration, article title is in bookmark.content + const title = bookmark.content || 'Untitled' const item: ReadItem = { id: coordinate, @@ -34,13 +29,8 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { type: 'article', readingProgress: 0, readingTimestamp: bookmark.added_at || bookmark.created_at, - event: bookmark.event, - title: title || 'Untitled', - summary, - image, - published, - author: bookmark.pubkey, - url: bookmark.url + title, + author: bookmark.pubkey } readsMap.set(coordinate, item) From fe55e87496a70105142bf69b67fd63bdde1fe294 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:06:06 +0200 Subject: [PATCH 05/19] fix: remove unused import from readsFromBookmarks --- src/utils/readsFromBookmarks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/readsFromBookmarks.ts b/src/utils/readsFromBookmarks.ts index ecf43b11..01116557 100644 --- a/src/utils/readsFromBookmarks.ts +++ b/src/utils/readsFromBookmarks.ts @@ -1,4 +1,4 @@ -import { Bookmark, IndividualBookmark } from '../types/bookmarks' +import { Bookmark } from '../types/bookmarks' import { ReadItem } from '../services/readsService' import { classifyBookmarkType } from './bookmarkTypeClassifier' import { KINDS } from '../config/kinds' From 3305be1da5df8f3ca42ee2256190843e877a67a4 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:08:57 +0200 Subject: [PATCH 06/19] feat(reads): extract image, summary, and published date from bookmark tags - Extract metadata from tags same way BookmarkItem does (DRY) - Add image tag extraction for article images - Add summary tag extraction for article summaries - Add published_at tag extraction for publish dates - Images and summaries now display in /me/reads tab --- src/utils/readsFromBookmarks.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/utils/readsFromBookmarks.ts b/src/utils/readsFromBookmarks.ts index 01116557..38f0d07a 100644 --- a/src/utils/readsFromBookmarks.ts +++ b/src/utils/readsFromBookmarks.ts @@ -6,7 +6,7 @@ import { KINDS } from '../config/kinds' /** * Derives ReadItems from bookmarks for Nostr articles (kind:30023). * Returns items with type='article', using hydrated data when available. - * Note: After hydration, article titles are stored in bookmark.content field. + * Note: After hydration, article titles are in bookmark.content, metadata in tags. */ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { const readsMap = new Map() @@ -20,8 +20,11 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) { const coordinate = bookmark.id // Already in coordinate format - // After hydration, article title is in bookmark.content + // Extract metadata from tags (same as BookmarkItem does) const title = bookmark.content || 'Untitled' + const image = bookmark.tags.find(t => t[0] === 'image')?.[1] + const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1] + const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1] const item: ReadItem = { id: coordinate, @@ -30,6 +33,9 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { readingProgress: 0, readingTimestamp: bookmark.added_at || bookmark.created_at, title, + summary, + image, + published: published ? parseInt(published) : undefined, author: bookmark.pubkey } From 3db485553260678bf9b6f3f55f702e914f7590d4 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:11:21 +0200 Subject: [PATCH 07/19] fix(reads): use naddr format for IDs to match reading positions - Convert bookmark coordinates to naddr format in deriveReadsFromBookmarks - Reading positions store progress with naddr as ID - Using naddr format enables proper merging of reading progress data - Simplify getReadItemUrl to use item.id directly (already naddr) - Fixes reading progress not showing in /me/reads tab --- src/components/Me.tsx | 11 +++-------- src/utils/readsFromBookmarks.ts | 24 +++++++++++++++++++++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 52d5acc9..24cacdf6 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -292,14 +292,9 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } const getReadItemUrl = (item: ReadItem) => { - if (item.type === 'article' && item.event) { - const dTag = item.event.tags.find(t => t[0] === 'd')?.[1] || '' - const naddr = nip19.naddrEncode({ - kind: 30023, - pubkey: item.event.pubkey, - identifier: dTag - }) - return `/a/${naddr}` + if (item.type === 'article') { + // ID is already in naddr format + return `/a/${item.id}` } else if (item.url) { return `/r/${encodeURIComponent(item.url)}` } diff --git a/src/utils/readsFromBookmarks.ts b/src/utils/readsFromBookmarks.ts index 38f0d07a..99e7310b 100644 --- a/src/utils/readsFromBookmarks.ts +++ b/src/utils/readsFromBookmarks.ts @@ -2,6 +2,7 @@ import { Bookmark } from '../types/bookmarks' import { ReadItem } from '../services/readsService' import { classifyBookmarkType } from './bookmarkTypeClassifier' import { KINDS } from '../config/kinds' +import { nip19 } from 'nostr-tools' /** * Derives ReadItems from bookmarks for Nostr articles (kind:30023). @@ -18,7 +19,24 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { // Only include articles (kind:30023) if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) { - const coordinate = bookmark.id // Already in coordinate format + const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier + + // Extract identifier from coordinate + const parts = coordinate.split(':') + const identifier = parts[2] || '' + + // Convert to naddr format (reading positions use naddr as ID) + let naddr: string + try { + naddr = nip19.naddrEncode({ + kind: KINDS.BlogPost, + pubkey: bookmark.pubkey, + identifier + }) + } catch (e) { + console.warn('Failed to encode naddr for bookmark:', coordinate) + continue + } // Extract metadata from tags (same as BookmarkItem does) const title = bookmark.content || 'Untitled' @@ -27,7 +45,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1] const item: ReadItem = { - id: coordinate, + id: naddr, // Use naddr format to match reading positions source: 'bookmark', type: 'article', readingProgress: 0, @@ -39,7 +57,7 @@ export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] { author: bookmark.pubkey } - readsMap.set(coordinate, item) + readsMap.set(naddr, item) } } From 5f33ad3ba06aa3ab83aa895142ac4f779d5f5733 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:13:19 +0200 Subject: [PATCH 08/19] fix(reads): use setState callback pattern for background enrichment - Replace closure over tempMap with setState callback pattern - Ensures we always work with latest state when merging progress - Prevents stale closure issues that block state updates - Apply same fix to both reads and links tabs - Fixes reading progress not updating in UI --- src/components/Me.tsx | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 24cacdf6..84ecc2b8 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -153,8 +153,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Derive reads from bookmarks immediately const initialReads = deriveReadsFromBookmarks(fetchedBookmarks) - const tempMap = new Map(initialReads.map(item => [item.id, item])) - setReadsMap(tempMap) + const initialMap = new Map(initialReads.map(item => [item.id, item])) + setReadsMap(initialMap) setReads(initialReads) setLoadedTabs(prev => new Set(prev).add('reads')) if (!hasBeenLoaded) setLoading(false) @@ -162,10 +162,18 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Background enrichment: merge reading progress and mark-as-read // Only update items that are already in our map fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { - if (tempMap.has(item.id) && mergeReadItem(tempMap, item)) { - setReadsMap(new Map(tempMap)) - setReads(Array.from(tempMap.values())) - } + setReadsMap(prevMap => { + // Only update if item exists in our current map + if (!prevMap.has(item.id)) return prevMap + + const newMap = new Map(prevMap) + if (mergeReadItem(newMap, item)) { + // Update reads array after map is updated + setReads(Array.from(newMap.values())) + return newMap + } + return prevMap + }) }).catch(err => console.warn('Failed to enrich reads:', err)) } catch (err) { @@ -198,8 +206,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Derive links from bookmarks immediately const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks) - const tempMap = new Map(initialLinks.map(item => [item.id, item])) - setLinksMap(tempMap) + const initialMap = new Map(initialLinks.map(item => [item.id, item])) + setLinksMap(initialMap) setLinks(initialLinks) setLoadedTabs(prev => new Set(prev).add('links')) if (!hasBeenLoaded) setLoading(false) @@ -207,10 +215,18 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Background enrichment: merge reading progress and mark-as-read // Only update items that are already in our map fetchLinks(relayPool, viewingPubkey, (item) => { - if (tempMap.has(item.id) && mergeReadItem(tempMap, item)) { - setLinksMap(new Map(tempMap)) - setLinks(Array.from(tempMap.values())) - } + setLinksMap(prevMap => { + // Only update if item exists in our current map + if (!prevMap.has(item.id)) return prevMap + + const newMap = new Map(prevMap) + if (mergeReadItem(newMap, item)) { + // Update links array after map is updated + setLinks(Array.from(newMap.values())) + return newMap + } + return prevMap + }) }).catch(err => console.warn('Failed to enrich links:', err)) } catch (err) { From 9eb2f35dbfe1cea788369badc9308e2b611c479a Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:13:34 +0200 Subject: [PATCH 09/19] debug: add console logging to trace reading progress enrichment --- src/components/Me.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 84ecc2b8..b84e3597 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -162,12 +162,23 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr // Background enrichment: merge reading progress and mark-as-read // Only update items that are already in our map fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => { + console.log('📈 [Reads] Enrichment item received:', { + id: item.id.slice(0, 20) + '...', + progress: item.readingProgress, + hasProgress: item.readingProgress !== undefined && item.readingProgress > 0 + }) + setReadsMap(prevMap => { // Only update if item exists in our current map - if (!prevMap.has(item.id)) return prevMap + if (!prevMap.has(item.id)) { + console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...') + return prevMap + } const newMap = new Map(prevMap) - if (mergeReadItem(newMap, item)) { + const merged = mergeReadItem(newMap, item) + if (merged) { + console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress) // Update reads array after map is updated setReads(Array.from(newMap.values())) return newMap From 9d6b1f6f841758a2e3b184794f9c9c90960beabd Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:18:32 +0200 Subject: [PATCH 10/19] fix: call onItem callback directly for items already in reads map --- src/services/readsService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/readsService.ts b/src/services/readsService.ts index 988b783f..1000d70c 100644 --- a/src/services/readsService.ts +++ b/src/services/readsService.ts @@ -76,7 +76,7 @@ export async function fetchAllReads( processReadingPositions(readingPositionEvents, readsMap) if (onItem) { readsMap.forEach(item => { - if (item.type === 'article') emitItem(item) + if (item.type === 'article') onItem(item) }) } @@ -84,7 +84,7 @@ export async function fetchAllReads( processMarkedAsRead(markedAsReadArticles, readsMap) if (onItem) { readsMap.forEach(item => { - if (item.type === 'article') emitItem(item) + if (item.type === 'article') onItem(item) }) } From d763aa5f153abb5c6e31a388a66201cf871ebe3f Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:20:24 +0200 Subject: [PATCH 11/19] fix: merge reading progress even when timestamp is older than bookmark --- src/utils/readItemMerge.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/utils/readItemMerge.ts b/src/utils/readItemMerge.ts index 4e849e62..c9afa587 100644 --- a/src/utils/readItemMerge.ts +++ b/src/utils/readItemMerge.ts @@ -15,11 +15,17 @@ export function mergeReadItem( return true } + // Always merge if incoming has reading progress data + const hasNewProgress = incoming.readingProgress !== undefined && + (existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress) + + const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined + // Merge by taking the most recent reading activity const existingTime = existing.readingTimestamp || existing.markedAt || 0 const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0 - if (incomingTime > existingTime) { + if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) { // Keep existing data, but update with newer reading metadata stateMap.set(incoming.id, { ...existing, @@ -30,7 +36,10 @@ export function mergeReadItem( summary: incoming.summary || existing.summary, image: incoming.image || existing.image, published: incoming.published || existing.published, - author: incoming.author || existing.author + author: incoming.author || existing.author, + // Always take reading progress if available + readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress, + readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp }) return true } From e90f902f0b1ded2126cd544b09f4f74245ed47de Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:28:06 +0200 Subject: [PATCH 12/19] feat: add amber color for 'started' reading progress state (0-10%) --- src/components/BlogPostCard.tsx | 10 +++++-- src/components/BookmarkViews/LargeView.tsx | 13 +++++---- src/components/ReadingProgressIndicator.tsx | 29 +++++++++++++++------ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index 7c338a1b..d4ce0a7d 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -24,9 +24,15 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP addSuffix: true }) - // Calculate progress percentage and determine color + // Calculate progress percentage and determine color (matching readingProgressUtils.ts logic) const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0 - const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise + let progressColor = '#6366f1' // Default blue (reading) + + if (readingProgress && readingProgress >= 0.95) { + progressColor = '#10b981' // Green (completed) + } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { + progressColor = '#f59e0b' // Amber (started) + } return ( = ({ const cachedImage = useImageCache(previewImage || undefined) const isArticle = bookmark.kind === 30023 - // Calculate progress display + // Calculate progress display (matching readingProgressUtils.ts logic) const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0 - const progressColor = - progressPercent >= 95 ? '#10b981' : // green for completed - progressPercent > 5 ? '#f97316' : // orange for in-progress - 'var(--color-border)' // default for not started + let progressColor = '#6366f1' // Default blue (reading) + + if (readingProgress && readingProgress >= 0.95) { + progressColor = '#10b981' // Green (completed) + } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { + progressColor = '#f59e0b' // Amber (started) + } const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent) const handleKeyDown: React.KeyboardEventHandler = (e) => { diff --git a/src/components/ReadingProgressIndicator.tsx b/src/components/ReadingProgressIndicator.tsx index cd47932a..ff3fe56c 100644 --- a/src/components/ReadingProgressIndicator.tsx +++ b/src/components/ReadingProgressIndicator.tsx @@ -19,6 +19,23 @@ export const ReadingProgressIndicator: React.FC = }) => { const clampedProgress = Math.min(100, Math.max(0, progress)) + // Determine reading state based on progress (matching readingProgressUtils.ts logic) + const progressDecimal = clampedProgress / 100 + const isStarted = progressDecimal > 0 && progressDecimal <= 0.10 + const isReading = progressDecimal > 0.10 && progressDecimal <= 0.94 + + // Determine bar color based on state + let barColorClass = '' + let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue + + if (isComplete) { + barColorClass = 'bg-green-500' + barColorStyle = undefined + } else if (isStarted) { + barColorClass = 'bg-amber-500' + barColorStyle = undefined + } + // Calculate left and right offsets based on sidebar states (desktop only) const leftOffset = isSidebarCollapsed ? 'var(--sidebar-collapsed-width)' @@ -42,14 +59,10 @@ export const ReadingProgressIndicator: React.FC = style={{ backgroundColor: 'var(--color-border)' }} >
@@ -58,9 +71,9 @@ export const ReadingProgressIndicator: React.FC = {showPercentage && (
{isComplete ? '✓' : `${clampedProgress}%`}
From aff5bff03b7df18d68192ddf1b486e7ef863eef7 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:29:41 +0200 Subject: [PATCH 13/19] refactor: use neutral text color for 'started' reading progress state --- src/components/BlogPostCard.tsx | 2 +- src/components/BookmarkViews/LargeView.tsx | 2 +- src/components/ReadingProgressIndicator.tsx | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/BlogPostCard.tsx b/src/components/BlogPostCard.tsx index d4ce0a7d..d40d496a 100644 --- a/src/components/BlogPostCard.tsx +++ b/src/components/BlogPostCard.tsx @@ -31,7 +31,7 @@ const BlogPostCard: React.FC = ({ post, href, level, readingP if (readingProgress && readingProgress >= 0.95) { progressColor = '#10b981' // Green (completed) } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { - progressColor = '#f59e0b' // Amber (started) + progressColor = 'var(--color-text)' // Neutral text color (started) } return ( diff --git a/src/components/BookmarkViews/LargeView.tsx b/src/components/BookmarkViews/LargeView.tsx index aa9e3059..51158833 100644 --- a/src/components/BookmarkViews/LargeView.tsx +++ b/src/components/BookmarkViews/LargeView.tsx @@ -52,7 +52,7 @@ export const LargeView: React.FC = ({ if (readingProgress && readingProgress >= 0.95) { progressColor = '#10b981' // Green (completed) } else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) { - progressColor = '#f59e0b' // Amber (started) + progressColor = 'var(--color-text)' // Neutral text color (started) } const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent) diff --git a/src/components/ReadingProgressIndicator.tsx b/src/components/ReadingProgressIndicator.tsx index ff3fe56c..e9a11534 100644 --- a/src/components/ReadingProgressIndicator.tsx +++ b/src/components/ReadingProgressIndicator.tsx @@ -32,8 +32,7 @@ export const ReadingProgressIndicator: React.FC = barColorClass = 'bg-green-500' barColorStyle = undefined } else if (isStarted) { - barColorClass = 'bg-amber-500' - barColorStyle = undefined + barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles) } // Calculate left and right offsets based on sidebar states (desktop only) @@ -71,9 +70,11 @@ export const ReadingProgressIndicator: React.FC = {showPercentage && (
{isComplete ? '✓' : `${clampedProgress}%`}
From 2c913cf7e80ea80d239e6df4d6fea5e7fa582557 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:30:16 +0200 Subject: [PATCH 14/19] feat: color reading progress filter icons when active --- src/components/ReadingProgressFilters.tsx | 38 +++++++++++++---------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/components/ReadingProgressFilters.tsx b/src/components/ReadingProgressFilters.tsx index 6931ef21..d6f10048 100644 --- a/src/components/ReadingProgressFilters.tsx +++ b/src/components/ReadingProgressFilters.tsx @@ -12,26 +12,32 @@ interface ReadingProgressFiltersProps { const ReadingProgressFilters: React.FC = ({ selectedFilter, onFilterChange }) => { const filters = [ - { type: 'all' as const, icon: faAsterisk, label: 'All' }, - { type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' }, - { type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' }, - { type: 'reading' as const, icon: faBookOpen, label: 'Reading' }, - { type: 'completed' as const, icon: faCheckCircle, label: 'Completed' } + { type: 'all' as const, icon: faAsterisk, label: 'All', color: undefined }, + { type: 'unopened' as const, icon: faEnvelope, label: 'Unopened', color: undefined }, + { type: 'started' as const, icon: faEnvelopeOpen, label: 'Started', color: 'var(--color-text)' }, + { type: 'reading' as const, icon: faBookOpen, label: 'Reading', color: '#6366f1' }, + { type: 'completed' as const, icon: faCheckCircle, label: 'Completed', color: '#10b981' } ] return (
- {filters.map(filter => ( - - ))} + {filters.map(filter => { + const isActive = selectedFilter === filter.type + const activeStyle = isActive && filter.color ? { color: filter.color } : undefined + + return ( + + ) + })}
) } From bedf3daed1abcdc0d1371dcb9b05be7f267d2588 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:32:30 +0200 Subject: [PATCH 15/19] feat: add URL routing for reading progress filters --- src/App.tsx | 9 +++++++++ src/components/Bookmarks.tsx | 2 +- src/components/Me.tsx | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 52c1bfbe..c7b29d1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -120,6 +120,15 @@ function AppRoutes({ /> } /> + + } + /> = ({ relayPool, onLogout }) => { const meTab = location.pathname === '/me' ? 'highlights' : location.pathname === '/me/highlights' ? 'highlights' : location.pathname === '/me/reading-list' ? 'reading-list' : - location.pathname === '/me/reads' ? 'reads' : + location.pathname.startsWith('/me/reads') ? 'reads' : location.pathname === '/me/links' ? 'links' : location.pathname === '/me/writings' ? 'writings' : 'highlights' diff --git a/src/components/Me.tsx b/src/components/Me.tsx index b84e3597..6d49ebea 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -5,7 +5,7 @@ import { Hooks } from 'applesauce-react' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' import { nip19 } from 'nostr-tools' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' @@ -44,6 +44,7 @@ type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings' const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const activeAccount = Hooks.useActiveAccount() const navigate = useNavigate() + const { filter: urlFilter } = useParams<{ filter?: string }>() const [activeTab, setActiveTab] = useState(propActiveTab || 'highlights') // Use provided pubkey or fall back to active account @@ -61,7 +62,13 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') - const [readingProgressFilter, setReadingProgressFilter] = useState('all') + + // Initialize reading progress filter from URL param + const validFilters: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed'] + const initialFilter = urlFilter && validFilters.includes(urlFilter as ReadingProgressFilterType) + ? (urlFilter as ReadingProgressFilterType) + : 'all' + const [readingProgressFilter, setReadingProgressFilter] = useState(initialFilter) // Update local state when prop changes useEffect(() => { @@ -70,6 +77,26 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } }, [propActiveTab]) + // Sync filter state with URL changes + useEffect(() => { + const filterFromUrl = urlFilter && validFilters.includes(urlFilter as ReadingProgressFilterType) + ? (urlFilter as ReadingProgressFilterType) + : 'all' + setReadingProgressFilter(filterFromUrl) + }, [urlFilter]) + + // Handler to change reading progress filter and update URL + const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => { + setReadingProgressFilter(filter) + if (activeTab === 'reads') { + if (filter === 'all') { + navigate('/me/reads', { replace: true }) + } else { + navigate(`/me/reads/${filter}`, { replace: true }) + } + } + } + // Tab-specific loading functions const loadHighlightsTab = async () => { if (!viewingPubkey) return @@ -536,7 +563,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr <> {filteredReads.length === 0 ? (
@@ -583,7 +610,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr <> {filteredLinks.length === 0 ? (
From ed17a689865cda36cb6d01c7c7c12ff89fe548c2 Mon Sep 17 00:00:00 2001 From: Gigi Date: Thu, 16 Oct 2025 09:33:04 +0200 Subject: [PATCH 16/19] refactor: simplify filter icon colors to blue (except green for completed) --- src/components/ReadingProgressFilters.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/ReadingProgressFilters.tsx b/src/components/ReadingProgressFilters.tsx index d6f10048..6d69ccf2 100644 --- a/src/components/ReadingProgressFilters.tsx +++ b/src/components/ReadingProgressFilters.tsx @@ -12,18 +12,19 @@ interface ReadingProgressFiltersProps { const ReadingProgressFilters: React.FC = ({ selectedFilter, onFilterChange }) => { const filters = [ - { type: 'all' as const, icon: faAsterisk, label: 'All', color: undefined }, - { type: 'unopened' as const, icon: faEnvelope, label: 'Unopened', color: undefined }, - { type: 'started' as const, icon: faEnvelopeOpen, label: 'Started', color: 'var(--color-text)' }, - { type: 'reading' as const, icon: faBookOpen, label: 'Reading', color: '#6366f1' }, - { type: 'completed' as const, icon: faCheckCircle, label: 'Completed', color: '#10b981' } + { type: 'all' as const, icon: faAsterisk, label: 'All' }, + { type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' }, + { type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' }, + { type: 'reading' as const, icon: faBookOpen, label: 'Reading' }, + { type: 'completed' as const, icon: faCheckCircle, label: 'Completed' } ] return (
{filters.map(filter => { const isActive = selectedFilter === filter.type - const activeStyle = isActive && filter.color ? { color: filter.color } : undefined + // Only "completed" gets green color, everything else uses default blue + const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined return (