mirror of
https://github.com/dergigi/boris.git
synced 2026-01-01 22:14:20 +01:00
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:
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
140
src/services/readingDataProcessor.ts
Normal file
140
src/services/readingDataProcessor.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
30
src/utils/readingProgressUtils.ts
Normal file
30
src/utils/readingProgressUtils.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user