diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 72ca5a19..05efc287 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -28,6 +28,7 @@ import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils' import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters' import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier' import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters' +import { filterByReadingProgress } from '../utils/readingProgressUtils' interface MeProps { relayPool: RelayPool @@ -327,51 +328,8 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr const groups = groupIndividualBookmarks(filteredBookmarks) // Apply reading progress filter - const filteredReads = reads.filter((item) => { - const progress = item.readingProgress || 0 - const isMarked = item.markedAsRead || false - - switch (readingProgressFilter) { - case 'unopened': - // No reading progress - return progress === 0 && !isMarked - case 'started': - // 0-10% reading progress - return progress > 0 && progress <= 0.10 && !isMarked - case 'reading': - // 11-94% reading progress - return progress > 0.10 && progress <= 0.94 && !isMarked - case 'completed': - // 95%+ or marked as read - return progress >= 0.95 || isMarked - case 'all': - default: - return true - } - }) - - const filteredLinks = links.filter((item) => { - const progress = item.readingProgress || 0 - const isMarked = item.markedAsRead || false - - switch (readingProgressFilter) { - case 'unopened': - // No reading progress - return progress === 0 && !isMarked - case 'started': - // 0-10% reading progress - return progress > 0 && progress <= 0.10 && !isMarked - case 'reading': - // 11-94% reading progress - return progress > 0.10 && progress <= 0.94 && !isMarked - case 'completed': - // 95%+ or marked as read - return progress >= 0.95 || isMarked - case 'all': - default: - return true - } - }) + const filteredReads = filterByReadingProgress(reads, readingProgressFilter) + const filteredLinks = filterByReadingProgress(links, readingProgressFilter) const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ { key: 'private', title: 'Private Bookmarks', items: groups.privateItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems }, diff --git a/src/services/linksService.ts b/src/services/linksService.ts index 0d15a367..ec7620cc 100644 --- a/src/services/linksService.ts +++ b/src/services/linksService.ts @@ -3,9 +3,9 @@ import { fetchReadArticles } from './libraryService' import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' import { ReadItem } from './readsService' +import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' const APP_DATA_KIND = 30078 // NIP-78 Application Data -const READING_POSITION_PREFIX = 'boris:reading-position:' /** * Fetches external URL links with reading progress from: @@ -30,90 +30,25 @@ export async function fetchLinks( markedAsRead: markedAsReadArticles.length }) - // Map to deduplicate items by ID + // Process data using shared utilities const linksMap = new Map() + processReadingPositions(readingPositionEvents, linksMap) + processMarkedAsRead(markedAsReadArticles, linksMap) - // 1. Process reading position events for external URLs - for (const event of readingPositionEvents) { - const dTag = event.tags.find(t => t[0] === 'd')?.[1] - if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue - - const identifier = dTag.replace(READING_POSITION_PREFIX, '') - - try { - const positionData = JSON.parse(event.content) - const position = positionData.position - const timestamp = positionData.timestamp - - // Skip if it's a nostr article (naddr format) - if (identifier.startsWith('naddr1')) continue - - // It's a base64url-encoded URL - let itemUrl: string - try { - itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/')) - } catch (e) { - console.warn('Failed to decode URL identifier:', identifier) - continue - } - - // Add or update the item - const existing = linksMap.get(itemUrl) - if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { - linksMap.set(itemUrl, { - ...existing, - id: itemUrl, - source: 'reading-progress', - type: 'external', - url: itemUrl, - readingProgress: position, - readingTimestamp: timestamp - }) - } - } catch (error) { - console.warn('Failed to parse reading position:', error) - } - } - - // 2. Process marked-as-read external URLs - for (const article of markedAsReadArticles) { - // Only process external URLs (skip Nostr articles) - if (article.url && !article.eventId) { - const existing = linksMap.get(article.url) - - linksMap.set(article.url, { - ...existing, - id: article.url, - source: 'marked-as-read', - type: 'external', - url: article.url, - markedAsRead: true, - markedAt: article.markedAt, - readingTimestamp: existing?.readingTimestamp || article.markedAt - }) - } - } - - // 3. Filter and sort links - const sortedLinks = Array.from(linksMap.values()) + // Filter for external URLs only with reading progress + const links = Array.from(linksMap.values()) .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') return false + // Only external URLs + if (item.type !== 'external') return false // Only include if there's reading progress or marked as read const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead return hasProgress }) - .sort((a, b) => { - const timeA = a.readingTimestamp || a.markedAt || 0 - const timeB = b.readingTimestamp || b.markedAt || 0 - return timeB - timeA - }) + + // Apply common validation and sorting + const validLinks = filterValidItems(links) + const sortedLinks = sortByReadingActivity(validLinks) console.log('✅ [Links] Processed', sortedLinks.length, 'total links') return sortedLinks diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts new file mode 100644 index 00000000..81ebcc98 --- /dev/null +++ b/src/services/readingDataProcessor.ts @@ -0,0 +1,140 @@ +import { NostrEvent } from 'nostr-tools' +import { ReadItem } from './readsService' + +const READING_POSITION_PREFIX = 'boris:reading-position:' + +interface ReadArticle { + id: string + url?: string + eventId?: string + eventKind?: number + markedAt: number +} + +/** + * Processes reading position events into ReadItems + */ +export function processReadingPositions( + events: NostrEvent[], + readsMap: Map +): void { + for (const event of events) { + const dTag = event.tags.find(t => t[0] === 'd')?.[1] + if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue + + const identifier = dTag.replace(READING_POSITION_PREFIX, '') + + try { + const positionData = JSON.parse(event.content) + const position = positionData.position + const timestamp = positionData.timestamp + + let itemId: string + let itemUrl: string | undefined + let itemType: 'article' | 'external' = 'external' + + // Check if it's a nostr article (naddr format) + if (identifier.startsWith('naddr1')) { + itemId = identifier + itemType = 'article' + } else { + // It's a base64url-encoded URL + try { + itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/')) + itemId = itemUrl + itemType = 'external' + } catch (e) { + console.warn('Failed to decode URL identifier:', identifier) + continue + } + } + + // Add or update the item + const existing = readsMap.get(itemId) + if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { + readsMap.set(itemId, { + ...existing, + id: itemId, + source: 'reading-progress', + type: itemType, + url: itemUrl, + readingProgress: position, + readingTimestamp: timestamp + }) + } + } catch (error) { + console.warn('Failed to parse reading position:', error) + } + } +} + +/** + * Processes marked-as-read articles into ReadItems + */ +export function processMarkedAsRead( + articles: ReadArticle[], + readsMap: Map +): void { + for (const article of articles) { + const existing = readsMap.get(article.id) + + if (article.eventId && article.eventKind === 30023) { + // Nostr article + readsMap.set(article.id, { + ...existing, + id: article.id, + source: 'marked-as-read', + type: 'article', + markedAsRead: true, + markedAt: article.markedAt, + readingTimestamp: existing?.readingTimestamp || article.markedAt + }) + } else if (article.url) { + // External URL + readsMap.set(article.id, { + ...existing, + id: article.id, + source: 'marked-as-read', + type: 'external', + url: article.url, + markedAsRead: true, + markedAt: article.markedAt, + readingTimestamp: existing?.readingTimestamp || article.markedAt + }) + } + } +} + +/** + * Sorts ReadItems by most recent reading activity + */ +export function sortByReadingActivity(items: ReadItem[]): ReadItem[] { + return items.sort((a, b) => { + const timeA = a.readingTimestamp || a.markedAt || 0 + const timeB = b.readingTimestamp || b.markedAt || 0 + return timeB - timeA + }) +} + +/** + * Filters out items without timestamps or proper 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 + 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 + }) +} + diff --git a/src/services/readsService.ts b/src/services/readsService.ts index d4e3a12e..eb631cc7 100644 --- a/src/services/readsService.ts +++ b/src/services/readsService.ts @@ -7,11 +7,11 @@ import { queryEvents } from './dataFetch' import { RELAYS } from '../config/relays' import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier' import { nip19 } from 'nostr-tools' +import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers const APP_DATA_KIND = 30078 // NIP-78 Application Data -const READING_POSITION_PREFIX = 'boris:reading-position:' export interface ReadItem { id: string // event ID or URL or coordinate @@ -60,89 +60,10 @@ export async function fetchAllReads( bookmarks: bookmarks.length }) - // Map to deduplicate items by ID + // Process data using shared utilities const readsMap = new Map() - - // 1. Process reading position events - for (const event of readingPositionEvents) { - const dTag = event.tags.find(t => t[0] === 'd')?.[1] - if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue - - const identifier = dTag.replace(READING_POSITION_PREFIX, '') - - try { - const positionData = JSON.parse(event.content) - const position = positionData.position - const timestamp = positionData.timestamp - - // Decode identifier to get original URL or naddr - let itemId: string - let itemUrl: string | undefined - let itemType: 'article' | 'external' = 'external' - - // Check if it's a nostr article (naddr format) - if (identifier.startsWith('naddr1')) { - itemId = identifier - itemType = 'article' - } else { - // It's a base64url-encoded URL - try { - itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/')) - itemId = itemUrl - itemType = 'external' - } catch (e) { - console.warn('Failed to decode URL identifier:', identifier) - continue - } - } - - // Add or update the item - const existing = readsMap.get(itemId) - if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) { - readsMap.set(itemId, { - ...existing, - id: itemId, - source: 'reading-progress', - type: itemType, - url: itemUrl, - readingProgress: position, - readingTimestamp: timestamp - }) - } - } catch (error) { - console.warn('Failed to parse reading position:', error) - } - } - - // 2. Process marked-as-read articles - for (const article of markedAsReadArticles) { - const existing = readsMap.get(article.id) - - if (article.eventId && article.eventKind === 30023) { - // Nostr article - readsMap.set(article.id, { - ...existing, - id: article.id, - source: 'marked-as-read', - type: 'article', - markedAsRead: true, - markedAt: article.markedAt, - readingTimestamp: existing?.readingTimestamp || article.markedAt - }) - } else if (article.url) { - // External URL - readsMap.set(article.id, { - ...existing, - id: article.id, - source: 'marked-as-read', - type: 'external', - url: article.url, - markedAsRead: true, - markedAt: article.markedAt, - readingTimestamp: existing?.readingTimestamp || article.markedAt - }) - } - } + processReadingPositions(readingPositionEvents, readsMap) + processMarkedAsRead(markedAsReadArticles, readsMap) // 3. Process bookmarked articles and article/website URLs const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) @@ -251,30 +172,12 @@ export async function fetchAllReads( } } - // 5. Filter and sort reads - const sortedReads = Array.from(readsMap.values()) - .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 - if (item.type === 'article' && !item.event) return false - // For external URLs, we need a proper title - if (item.type === 'external' && !item.title) return false - } - - // Only include Nostr-native articles in Reads - return item.type === 'article' - }) - .sort((a, b) => { - const timeA = a.readingTimestamp || a.markedAt || 0 - const timeB = b.readingTimestamp || b.markedAt || 0 - return timeB - timeA - }) + // 5. Filter for Nostr articles only and apply common validation/sorting + const articles = Array.from(readsMap.values()) + .filter(item => item.type === 'article') + + const validArticles = filterValidItems(articles) + const sortedReads = sortByReadingActivity(validArticles) console.log('✅ [Reads] Processed', sortedReads.length, 'total reads') return sortedReads diff --git a/src/utils/readingProgressUtils.ts b/src/utils/readingProgressUtils.ts new file mode 100644 index 00000000..99b6cfda --- /dev/null +++ b/src/utils/readingProgressUtils.ts @@ -0,0 +1,30 @@ +import { ReadItem } from '../services/readsService' +import { ReadingProgressFilterType } from '../components/ReadingProgressFilters' + +/** + * Filters ReadItems by reading progress + */ +export function filterByReadingProgress( + items: ReadItem[], + filter: ReadingProgressFilterType +): ReadItem[] { + return items.filter((item) => { + const progress = item.readingProgress || 0 + const isMarked = item.markedAsRead || false + + switch (filter) { + case 'unopened': + return progress === 0 && !isMarked + case 'started': + return progress > 0 && progress <= 0.10 && !isMarked + case 'reading': + return progress > 0.10 && progress <= 0.94 && !isMarked + case 'completed': + return progress >= 0.95 || isMarked + case 'all': + default: + return true + } + }) +} +