refactor: make code more DRY by extracting shared utilities

- Create readingProgressUtils.ts with filterByReadingProgress function
- Create readingDataProcessor.ts with shared processing functions:
  - processReadingPositions
  - processMarkedAsRead
  - filterValidItems
  - sortByReadingActivity
- Refactor readsService.ts to use shared utilities
- Refactor linksService.ts to use shared utilities
- Eliminate 100+ lines of duplicated code
- Simplify Me.tsx filter logic to 2 lines

Benefits:
- Single source of truth for reading progress filtering
- Easier to maintain and modify
- Less code duplication across services
- More testable with isolated utility functions
This commit is contained in:
Gigi
2025-10-16 01:36:28 +02:00
parent 11c7564f8c
commit f44e36e4bf
5 changed files with 195 additions and 229 deletions

View File

@@ -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<MeProps> = ({ 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 },

View File

@@ -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<string, ReadItem>()
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

View File

@@ -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<string, ReadItem>
): 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<string, ReadItem>
): 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
})
}

View File

@@ -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<string, ReadItem>()
// 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

View File

@@ -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
}
})
}