Compare commits

..

30 Commits

Author SHA1 Message Date
Gigi
5e6c8b7516 chore: bump version to 0.8.4 2025-10-20 13:35:13 +02:00
Gigi
e50af42c96 fix: import React types correctly in useArticleLoader
- Import Dispatch and SetStateAction directly from 'react'
- Fixes linting errors about React not being defined
- Resolves eslint no-undef errors
2025-10-20 13:34:48 +02:00
Gigi
73470987be feat: add progressive article hydration for reads tab
- Create readsController service with background article fetching
- Implement progressive hydration pattern similar to bookmarkController
- Use AddressLoader for efficient batched article event retrieval
- Update Me.tsx to use readsController instead of direct readingProgressController
- Articles now show titles, summaries, images as data arrives from relays
- Fixes issue where reads showed 'Untitled' for all articles
- Keep event store integration for caching article events
- Maintain DRY principle by centralizing reads data fetching
2025-10-20 13:33:17 +02:00
Gigi
31e203825d fix(types): correct setHighlights type to accept setState updater functions 2025-10-20 13:19:39 +02:00
Gigi
6f9c0a35e2 fix(reader): trigger archive animation even if already archived on auto-complete 2025-10-20 13:17:35 +02:00
Gigi
96f59a54f3 fix(reading): ensure 2s linger at 100% uses live position ref for auto-archive 2025-10-20 13:14:10 +02:00
Gigi
87c0a0454b refactor(me): DRY archive-only builders into shared helper for reads/links 2025-10-20 13:12:34 +02:00
Gigi
77c2ef1794 feat(links): mirror archive-only vs progress-only behavior in Links tab 2025-10-20 13:02:56 +02:00
Gigi
8d08911bd3 feat(reads): separate archive vs reading-progress filters; archive shows emoji-only, progress filters ignore emoji 2025-10-20 13:00:34 +02:00
Gigi
31b005a989 fix(reads): build archive list exactly like debug loader (streamed union, no overwrite) 2025-10-20 12:56:19 +02:00
Gigi
337bfe5432 fix(reads): union archive marks from readingProgress and archiveController to prevent empty archive view 2025-10-20 12:49:29 +02:00
Gigi
2f275375f7 ui(animation): restore archive success burst on manual archive (animating state) 2025-10-20 12:45:12 +02:00
Gigi
27cbcb56ec ui(reader): keep Archived label and subtle style while remaining clickable 2025-10-20 12:43:28 +02:00
Gigi
7f150003b5 feat(reader): wire unarchive actions to delete matching reactions and clear controller 2025-10-20 12:39:28 +02:00
Gigi
1f50d8e1b6 feat(reader): make Archived button clickable and perform unarchive via NIP-09 2025-10-20 12:39:09 +02:00
Gigi
f53decef16 feat(archive): add unarchive service to delete ARCHIVE_EMOJI reactions (kind 7/17) 2025-10-20 12:38:27 +02:00
Gigi
f272943b64 chore: commit pending working changes before implementing unarchive behavior 2025-10-20 12:36:27 +02:00
Gigi
49745e1b8a refactor(archive): remove direct markedIds mutation; use controller.mark/unmark for DRY updates; fix duplicate import in reactionService 2025-10-20 11:23:45 +02:00
Gigi
470f4fb34e feat(archive): support un-archive toggle; add ArchiveController mark/unmark; prep NIP-09 deletion hook 2025-10-20 11:21:59 +02:00
Gigi
8cde36c08c fix(archive): add 'a' coord tag to mark-as-read reactions for articles; archiveController maps a-tag instantly; add debug 2025-10-20 11:17:30 +02:00
Gigi
c21f96f5bb chore(debug): deepen [archive] mapping with eventStore timeline and logs; add sampleMarked logs in Me 2025-10-20 11:05:59 +02:00
Gigi
c9fef5804b chore(debug): add [archive] debug logs in archiveController, Me, and ContentPanel to trace archive filter behavior 2025-10-20 10:48:44 +02:00
Gigi
8337622a22 feat(archive): introduce archiveController to manage marked-as-read (kind:7/17); wire into App, Me, and ContentPanel for DRY archive state 2025-10-20 10:33:42 +02:00
Gigi
572f0fed6f fix(reads/links): keep DRY filtering but enforce type separation (articles vs external) for /me/reads and /me/links filters 2025-10-20 10:14:20 +02:00
Gigi
27a55ec329 fix(links): keep Links tab active when using /me/links/:filter by recognizing links path prefix in tab detection 2025-10-20 09:50:13 +02:00
Gigi
7ba362a3bb feat(links): add /me/links/:filter routes and mirror Reads filters/state for Links tab 2025-10-20 09:47:31 +02:00
Gigi
dc1844907e feat(settings): enable 'Hide bookmarks missing a creation date' by default 2025-10-20 09:43:51 +02:00
Gigi
28123b5e13 feat(archive): rename 'Mark as Read' UI to 'Move to Archive' and show 'Archived' state; update settings and filters wording 2025-10-20 09:42:34 +02:00
Gigi
d9eb87aa5c feat(reads): rename 'emoji' filter to 'archive' and use fa-books icon; map legacy /me/reads/emoji to /me/reads/archive 2025-10-20 09:39:45 +02:00
Gigi
a0ff0daf9d docs: update CHANGELOG.md for v0.8.3 release 2025-10-20 09:30:30 +02:00
24 changed files with 1019 additions and 253 deletions

View File

@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.3] - 2025-01-19
### Fixed
- Highlight creation now shows immediate UI feedback without page refresh
- Fixed streaming highlight merge logic to preserve newly created highlights
- Decoupled cached highlight sync from content loading to prevent unintended reloads
- Newly created highlights appear instantly in both reader and highlights panel
- Highlights remain visible while remote results stream in and merge properly
### Changed
- Improved highlight creation user experience
- Selection clearing and synchronous rendering for immediate highlight display
- Better error handling for bunker permission issues with user-friendly messages
## [0.8.2] - 2025-10-19
### Added
@@ -2069,7 +2085,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.2...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.8.3...HEAD
[0.8.3]: https://github.com/dergigi/boris/compare/v0.8.2...v0.8.3
[0.8.2]: https://github.com/dergigi/boris/compare/v0.8.0...v0.8.2
[0.8.0]: https://github.com/dergigi/boris/compare/v0.7.4...v0.8.0
[0.7.4]: https://github.com/dergigi/boris/compare/v0.7.3...v0.7.4

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.6.13",
"version": "0.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.6.13",
"version": "0.8.4",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.8.3",
"version": "0.8.4",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -28,6 +28,7 @@ import { readingProgressController } from './services/readingProgressController'
// import { fetchNostrverseHighlights } from './services/nostrverseService'
import { nostrverseHighlightsController } from './services/nostrverseHighlightsController'
import { nostrverseWritingsController } from './services/nostrverseWritingsController'
import { archiveController } from './services/archiveController'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -114,6 +115,11 @@ function AppRoutes({
readingProgressController.start({ relayPool, eventStore, pubkey })
}
// Load archive (marked-as-read) controller
if (pubkey && eventStore && !archiveController.isLoadedFor(pubkey)) {
archiveController.start({ relayPool, eventStore, pubkey })
}
// Start centralized nostrverse highlights controller (non-blocking)
if (eventStore) {
nostrverseHighlightsController.start({ relayPool, eventStore })
@@ -145,6 +151,7 @@ function AppRoutes({
contactsController.reset() // Clear contacts via controller
highlightsController.reset() // Clear highlights via controller
readingProgressController.reset() // Clear reading progress via controller
archiveController.reset() // Clear archive state
showToast('Logged out successfully')
}
@@ -286,6 +293,18 @@ function AppRoutes({
/>
}
/>
<Route
path="/me/links/:filter"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/me/writings"
element={

View File

@@ -16,7 +16,7 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
{ type: 'marked' as const, icon: faBooks, label: 'Archived' }
]
return (

View File

@@ -64,7 +64,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
location.pathname === '/me/highlights' ? 'highlights' :
location.pathname === '/me/reading-list' ? 'reading-list' :
location.pathname.startsWith('/me/reads') ? 'reads' :
location.pathname === '/me/links' ? 'links' :
location.pathname.startsWith('/me/links') ? 'links' :
location.pathname === '/me/writings' ? 'writings' : 'highlights'
// Extract tab from profile routes

View File

@@ -29,6 +29,8 @@ import {
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import { unarchiveEvent, unarchiveWebsite } from '../services/unarchiveService'
import { archiveController } from '../services/archiveController'
import AuthorCard from './AuthorCard'
import { faBooks } from '../icons/customIcons'
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
@@ -188,8 +190,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onSave: handleSavePosition,
onReadingComplete: () => {
// Auto-mark as read when reading is complete (if enabled in settings)
if (activeAccount && !isMarkedAsRead && settings?.autoMarkAsReadOnCompletion) {
if (!settings?.autoMarkAsReadOnCompletion || !activeAccount) return
if (!isMarkedAsRead) {
handleMarkAsRead()
} else {
// Already archived: still show the success animation for feedback
setShowCheckAnimation(true)
setTimeout(() => setShowCheckAnimation(false), 600)
}
}
})
@@ -566,12 +573,27 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
try {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
hasRead = hasRead || archiveController.isMarked(naddr)
console.log('[archive][content] check article', { naddr: naddr.slice(0, 24) + '...', hasRead })
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
}
}
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
// Also check archiveController
const ctrl = archiveController.isMarked(selectedUrl)
hasRead = hasRead || ctrl
console.log('[archive][content] check url', { url: selectedUrl, hasRead, ctrl })
}
setIsMarkedAsRead(hasRead)
} catch (error) {
@@ -585,7 +607,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
if (!activeAccount || !relayPool) return
// Toggle archive state: if already archived, request deletion; else archive
if (isMarkedAsRead) {
// Optimistically unarchive in UI; background deletion request (NIP-09)
setIsMarkedAsRead(false)
;(async () => {
try {
if (isNostrArticle && currentArticle) {
// Send deletion for all matching reactions
await unarchiveEvent(currentArticle.id, activeAccount, relayPool)
// Also clear controller mark so lists update
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.unmark(naddr)
}
} catch (e) {
console.warn('[archive][content] encode naddr failed', e)
}
} else if (selectedUrl) {
await unarchiveWebsite(selectedUrl, activeAccount, relayPool)
archiveController.unmark(selectedUrl)
}
} catch (err) {
console.warn('[archive][content] unarchive failed', err)
}
})()
return
}
@@ -607,14 +657,36 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
relayPool,
{
aCoord: (() => {
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) return undefined
return `${30023}:${currentArticle.pubkey}:${dTag}`
} catch { return undefined }
})()
}
)
// Update archiveController immediately
try {
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: 30023, pubkey: currentArticle.pubkey, identifier: dTag })
archiveController.mark(naddr)
console.log('[archive][content] optimistic mark article', naddr.slice(0, 24) + '...')
}
} catch (err) {
console.warn('[archive][content] optimistic article mark failed', err)
}
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
archiveController.mark(selectedUrl)
console.log('[archive][content] optimistic mark url', selectedUrl)
}
} catch (error) {
console.error('Failed to mark as read:', error)
@@ -755,8 +827,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
@@ -918,21 +991,22 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
</div>
)}
{/* Mark as Read button */}
{/* Archive button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
disabled={isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Archived' : 'Move to Archive'}
style={isMarkedAsRead ? { opacity: 0.85 } : undefined}
>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Archived' : 'Move to Archive'}
</span>
</button>
</div>

View File

@@ -833,13 +833,13 @@ const Debug: React.FC<DebugProps> = ({
setLiveTiming(prev => ({ ...prev, loadMarkAsRead: { startTime: start } }))
const { queryEvents } = await import('../services/dataFetch')
const { MARK_AS_READ_EMOJI } = await import('../services/reactionService')
const { ARCHIVE_EMOJI } = await import('../services/reactionService')
// Load both kind:7 (reactions to events) and kind:17 (reactions to URLs)
const [kind7Events, kind17Events] = await Promise.all([
queryEvents(relayPool, { kinds: [7], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === MARK_AS_READ_EMOJI) {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
@@ -850,7 +850,7 @@ const Debug: React.FC<DebugProps> = ({
}),
queryEvents(relayPool, { kinds: [17], authors: [activeAccount.pubkey] }, {
onEvent: (evt) => {
if (evt.content === MARK_AS_READ_EMOJI) {
if (evt.content === ARCHIVE_EMOJI) {
if (firstEventTime === null) {
firstEventTime = performance.now() - start
setTFirstMarkAsRead(Math.round(firstEventTime))
@@ -1638,7 +1638,7 @@ const Debug: React.FC<DebugProps> = ({
{/* Mark-as-read Reactions Loading Section */}
<div className="settings-section">
<h3 className="section-title">Mark-as-read Reactions Loading</h3>
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the MARK_AS_READ_EMOJI for the logged-in user</div>
<div className="text-sm opacity-70 mb-3">Test loading mark-as-read reactions (kind: 7 and 17) with the ARCHIVE_EMOJI for the logged-in user</div>
<div className="flex gap-2 mb-3 items-center">
<button
className="btn btn-primary"

View File

@@ -12,7 +12,7 @@ import { HighlightItem } from './HighlightItem'
import { highlightsController } from '../services/highlightsController'
import { writingsController } from '../services/writingsController'
import { fetchLinks } from '../services/linksService'
import { ReadItem } from '../services/readsService'
import { ReadItem, readsController } from '../services/readsController'
import { BlogPostPreview } from '../services/exploreService'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
import AuthorCard from './AuthorCard'
@@ -30,6 +30,7 @@ import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProg
import { filterByReadingProgress } from '../utils/readingProgressUtils'
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
import { readingProgressController } from '../services/readingProgressController'
import { archiveController } from '../services/archiveController'
interface MeProps {
relayPool: RelayPool
@@ -42,7 +43,7 @@ interface MeProps {
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
// Valid reading progress filters
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'emoji']
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed', 'highlighted', 'archive']
const Me: React.FC<MeProps> = ({
relayPool,
@@ -59,7 +60,6 @@ const Me: React.FC<MeProps> = ({
const viewingPubkey = activeAccount?.pubkey
const [highlights, setHighlights] = useState<Highlight[]>([])
const [reads, setReads] = useState<ReadItem[]>([])
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
const [links, setLinks] = useState<ReadItem[]>([])
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
const [writings, setWritings] = useState<BlogPostPreview[]>([])
@@ -88,8 +88,10 @@ const Me: React.FC<MeProps> = ({
}
// Initialize reading progress filter from URL param
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
// Backward compat: map legacy 'emoji' route to 'archive'
const normalizedUrlFilter = urlFilter === 'emoji' ? 'archive' : urlFilter
const initialFilter = normalizedUrlFilter && VALID_FILTERS.includes(normalizedUrlFilter as ReadingProgressFilterType)
? (normalizedUrlFilter as ReadingProgressFilterType)
: 'all'
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
@@ -133,8 +135,9 @@ const Me: React.FC<MeProps> = ({
// Sync filter state with URL changes
useEffect(() => {
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
? (urlFilter as ReadingProgressFilterType)
const normalized = urlFilter === 'emoji' ? 'archive' : urlFilter
const filterFromUrl = normalized && VALID_FILTERS.includes(normalized as ReadingProgressFilterType)
? (normalized as ReadingProgressFilterType)
: 'all'
setReadingProgressFilter(filterFromUrl)
}, [urlFilter])
@@ -148,90 +151,41 @@ const Me: React.FC<MeProps> = ({
} else {
navigate(`/me/reads/${filter}`, { replace: true })
}
} else if (activeTab === 'links') {
if (filter === 'all') {
navigate('/me/links', { replace: true })
} else {
navigate(`/me/links/${filter}`, { replace: true })
}
}
}
// Subscribe to reading progress controller
// Subscribe to reads controller
useEffect(() => {
// Get initial state immediately
setReads(readsController.getReads())
// Subscribe to updates
const unsubReads = readsController.onReads(setReads)
return () => {
unsubReads()
}
}, [])
// Subscribe to reading progress map for writings and links enrichment
useEffect(() => {
// Get initial state immediately
setReadingProgressMap(readingProgressController.getProgressMap())
// Subscribe to updates
const unsubProgress = readingProgressController.onProgress((progressMap) => {
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
// Include items that are only marked-as-read (no progress event yet)
const markedIds = readingProgressController.getMarkedAsReadIds()
for (const id of markedIds) {
if (!readItems.find(i => i.id === id)) {
const isArticle = id.startsWith('naddr1')
readItems.push({
id,
source: 'marked-as-read',
type: isArticle ? 'article' : 'external',
url: isArticle ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
})
const unsubProgress = readingProgressController.onProgress(setReadingProgressMap)
return () => {
unsubProgress()
}
}, [])
// Subscribe to marked-as-read changes and rebuild reads list
useEffect(() => {
const unsubMarkedAsRead = readingProgressController.onMarkedAsReadChanged(() => {
// Rebuild reads list including marked-as-read-only items
const progressMap = readingProgressController.getProgressMap()
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
// Include items that are only marked-as-read (no progress event yet)
const markedIds = readingProgressController.getMarkedAsReadIds()
for (const id of markedIds) {
if (!readItems.find(i => i.id === id)) {
const isArticle = id.startsWith('naddr1')
readItems.push({
id,
source: 'marked-as-read',
type: isArticle ? 'article' : 'external',
url: isArticle ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
})
return () => {
unsubMarkedAsRead()
}
}, [])
// Load reading progress data for writings tab
useEffect(() => {
@@ -299,39 +253,12 @@ const Me: React.FC<MeProps> = ({
try {
if (!hasBeenLoaded) setLoading(true)
// Reads come from centralized readingProgressController (already loaded in App.tsx)
// It provides deduped reading progress per article
const progressMap = readingProgressController.getProgressMap()
// Convert progress map to ReadItems
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
// Include items that are only marked-as-read (no progress event yet)
const markedIds = readingProgressController.getMarkedAsReadIds()
for (const id of markedIds) {
if (!readItems.find(i => i.id === id)) {
const isArticle = id.startsWith('naddr1')
readItems.push({
id,
source: 'marked-as-read',
type: isArticle ? 'article' : 'external',
url: isArticle ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
// Use readsController to get reads with progressive hydration
await readsController.start({
relayPool,
eventStore,
pubkey: viewingPubkey
})
setLoadedTabs(prev => new Set(prev).add('reads'))
if (!hasBeenLoaded) setLoading(false)
@@ -341,28 +268,6 @@ const Me: React.FC<MeProps> = ({
if (!hasBeenLoaded) setLoading(false)
}
}
// Subscribe to reading progress updates
useEffect(() => {
const unsubProgress = readingProgressController.onProgress((progressMap) => {
const readItems: ReadItem[] = Array.from(progressMap.entries()).map(([id, progress]) => ({
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
markedAsRead: readingProgressController.isMarkedAsRead(id),
readingTimestamp: Math.floor(Date.now() / 1000)
}))
const readsMap = new Map(readItems.map(item => [item.id, item]))
setReadsMap(readsMap)
setReads(readItems)
})
return () => {
unsubProgress()
}
}, [])
const loadLinksTab = async () => {
if (!viewingPubkey || !activeAccount) return
@@ -590,17 +495,7 @@ const Me: React.FC<MeProps> = ({
const groups = groupIndividualBookmarks(filteredBookmarks)
// Enrich reads and links with reading progress from controller
const readsWithProgress = reads.map(item => {
if (item.type === 'article' && item.author) {
const progress = readingProgressMap.get(item.id)
if (progress !== undefined) {
return { ...item, readingProgress: progress }
}
}
return item
})
// Enrich links with reading progress (reads already have progress from controller)
const linksWithProgress = links.map(item => {
if (item.url) {
const progress = readingProgressMap.get(item.url)
@@ -611,9 +506,81 @@ const Me: React.FC<MeProps> = ({
return item
})
// Apply reading progress filter
const filteredReads = filterByReadingProgress(readsWithProgress, readingProgressFilter, highlights)
const filteredLinks = filterByReadingProgress(linksWithProgress, readingProgressFilter, highlights)
// Apply reading progress filter with simple type separation to keep Views distinct and DRY
const filteredReads = filterByReadingProgress(
reads.filter(item => item.type === 'article'),
readingProgressFilter,
highlights
)
const filteredLinks = filterByReadingProgress(
linksWithProgress.filter(item => item.type === 'external'),
readingProgressFilter,
highlights
)
// Helper: build archive-only list from marked IDs and a base list
const buildArchiveOnly = (
baseItems: ReadItem[],
options: { kind: 'article' | 'external' }
): ReadItem[] => {
const allMarked = archiveController.getMarkedIds()
const relevantMarked = options.kind === 'article'
? allMarked.filter(id => id.startsWith('naddr1'))
: allMarked.filter(id => !id.startsWith('naddr1'))
const markedSet = new Set(relevantMarked)
const items: ReadItem[] = []
for (const item of baseItems) {
const key = options.kind === 'article' ? item.id : (item.url || item.id)
if (key && markedSet.has(key)) {
items.push({ ...item, markedAsRead: true })
}
}
for (const id of markedSet) {
const exists = items.find(i => (options.kind === 'article' ? i.id : (i.url || i.id)) === id)
if (!exists) {
items.push({
id,
source: 'marked-as-read',
type: options.kind,
url: options.kind === 'article' ? undefined : id,
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
})
}
}
return items
}
// Archive-only lists: independent of reading progress
const archiveOnlyReads: ReadItem[] = readingProgressFilter === 'archive'
? buildArchiveOnly(reads, { kind: 'article' })
: []
const archiveOnlyLinks: ReadItem[] = readingProgressFilter === 'archive'
? buildArchiveOnly(linksWithProgress, { kind: 'external' })
: []
// Debug logs for archive filter issues
if (readingProgressFilter === 'archive') {
const ids = Array.from(new Set([
...archiveController.getMarkedIds(),
...readingProgressController.getMarkedAsReadIds()
]))
const readIds = new Set(reads.map(i => i.id))
const matches = ids.filter(id => readIds.has(id))
const nonMatches = ids.filter(id => !readIds.has(id)).slice(0, 5)
console.log('[archive][me] counts', {
reads: reads.length,
filteredReads: filteredReads.length,
links: links.length,
linksWithProgress: linksWithProgress.length,
filteredLinks: filteredLinks.length,
markedIds: ids.length,
sampleMarked: ids.slice(0, 3),
matches: matches.length,
nonMatches
})
}
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> =
groupingMode === 'flat'
? [{ key: 'all', title: `All Bookmarks (${filteredBookmarks.length})`, items: filteredBookmarks }]
@@ -751,21 +718,42 @@ const Me: React.FC<MeProps> = ({
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
{readingProgressFilter === 'archive' ? (
archiveOnlyReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles in archive.
</div>
) : (
<div className="explore-grid">
{archiveOnlyReads
.filter(item => item.type === 'article')
.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
filteredReads.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No articles match this filter.
</div>
) : (
<div className="explore-grid">
{filteredReads.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
)}
</>
)
@@ -798,21 +786,40 @@ const Me: React.FC<MeProps> = ({
selectedFilter={readingProgressFilter}
onFilterChange={handleReadingProgressFilterChange}
/>
{filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
{readingProgressFilter === 'archive' ? (
archiveOnlyLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links in archive.
</div>
) : (
<div className="explore-grid">
{archiveOnlyLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
filteredLinks.length === 0 ? (
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
No links match this filter.
</div>
) : (
<div className="explore-grid">
{filteredLinks.map((item) => (
<BlogPostCard
key={item.id}
post={convertReadItemToBlogPostPreview(item)}
href={getReadItemUrl(item)}
readingProgress={item.readingProgress}
/>
))}
</div>
)
)}
</>
)

View File

@@ -1,10 +1,10 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookOpen, faCheckCircle, faAsterisk, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faBook } from '@fortawesome/free-solid-svg-icons'
import { faBooks } from '../icons/customIcons'
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'emoji'
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed' | 'highlighted' | 'archive'
interface ReadingProgressFiltersProps {
selectedFilter: ReadingProgressFilterType
@@ -19,8 +19,8 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
// Emoji-marked items (marked via reaction emoji)
{ type: 'emoji' as const, icon: faBook, label: 'Emoji' }
// Archive-marked items (previously emoji-marked)
{ type: 'archive' as const, icon: faBooks, label: 'Archive' }
]
return (
@@ -34,7 +34,7 @@ const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selecte
activeStyle = { color: '#10b981' } // green
} else if (filter.type === 'highlighted') {
activeStyle = { color: '#fde047' } // yellow
} else if (filter.type === 'emoji') {
} else if (filter.type === 'archive') {
activeStyle = { color: '#60a5fa' } // blue accent
}
}

View File

@@ -41,7 +41,7 @@ const DEFAULT_SETTINGS: UserSettings = {
paragraphAlignment: 'justify',
syncReadingPosition: true,
autoMarkAsReadOnCompletion: false,
hideBookmarksWithoutCreationDate: false,
hideBookmarksWithoutCreationDate: true,
}
interface SettingsProps {

View File

@@ -127,7 +127,7 @@ const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ setting
onChange={(e) => onUpdate({ autoMarkAsReadOnCompletion: e.target.checked })}
className="setting-checkbox"
/>
<span>Automatically mark as read at 100%</span>
<span>Automatically move to archive at 100%</span>
</label>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, Dispatch, SetStateAction } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
@@ -14,7 +14,7 @@ interface UseArticleLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: Dispatch<SetStateAction<Highlight[]>>
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -81,8 +81,8 @@ export function useArticleLoader({
article.event.id,
(highlight) => {
// Merge streaming results with existing UI state to preserve locally created highlights
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
setHighlights((prev: Highlight[]) => {
if (prev.some((h: Highlight) => h.id === highlight.id)) return prev
const next = [highlight, ...prev]
return next.sort((a, b) => b.created_at - a.created_at)
})

View File

@@ -141,6 +141,7 @@ export function useExternalUrlLoader({
}
loadExternalUrl()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId])
// Keep UI highlights synced with cached store updates without reloading content

View File

@@ -22,6 +22,7 @@ export const useReadingPosition = ({
completionHoldMs = 2000
}: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0)
const positionRef = useRef(0)
const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(false)
const lastSavedPosition = useRef(0)
@@ -104,6 +105,7 @@ export const useReadingPosition = ({
}
setPosition(clampedProgress)
positionRef.current = clampedProgress
onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
@@ -115,7 +117,7 @@ export const useReadingPosition = ({
if (clampedProgress === 1) {
if (!completionTimerRef.current) {
completionTimerRef.current = setTimeout(() => {
if (!hasTriggeredComplete.current && position === 1) {
if (!hasTriggeredComplete.current && positionRef.current === 1) {
setIsReadingComplete(true)
hasTriggeredComplete.current = true
onReadingComplete?.()

View File

@@ -0,0 +1,212 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
type MarkedChangeCallback = (markedIds: Set<string>) => void
class ArchiveController {
private markedIds: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
private listeners: MarkedChangeCallback[] = []
private generation = 0
private timelineSubscription: { unsubscribe: () => void } | null = null
private pendingEventIds: Set<string> = new Set()
onMarked(cb: MarkedChangeCallback): () => void {
this.listeners.push(cb)
// Emit current state immediately to new subscribers
cb(new Set(this.markedIds))
return () => {
this.listeners = this.listeners.filter(l => l !== cb)
}
}
private emit(): void {
const snapshot = new Set(this.markedIds)
this.listeners.forEach(cb => cb(snapshot))
}
mark(id: string): void {
if (!this.markedIds.has(id)) {
this.markedIds.add(id)
this.emit()
console.log('[archive] mark() added', id.slice(0, 48))
}
}
unmark(id: string): void {
if (this.markedIds.delete(id)) {
this.emit()
console.log('[archive] unmark() removed', id.slice(0, 48))
}
}
isMarked(id: string): boolean {
return this.markedIds.has(id)
}
getMarkedIds(): string[] {
return Array.from(this.markedIds)
}
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey
}
reset(): void {
this.generation++
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
this.timelineSubscription = null
}
this.markedIds = new Set()
this.pendingEventIds = new Set()
this.lastLoadedPubkey = null
this.emit()
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
const startGen = this.generation
if (!force && this.isLoadedFor(pubkey)) {
console.log('[archive] start() skipped - already loaded for pubkey')
return
}
// Mark as loaded immediately (fetch runs non-blocking)
this.lastLoadedPubkey = pubkey
console.log('[archive] start() begin for pubkey:', pubkey.slice(0, 12), '...')
// Handlers for streaming queries
const handleUrlReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedIds.add(rTag)
this.emit()
console.log('[archive] mark url:', rTag)
}
const handleEventReaction = (evt: NostrEvent) => {
if (evt.content !== ARCHIVE_EMOJI) return
// Direct coordinate tag ('a') - can be mapped immediately
const aTag = evt.tags.find(t => t[0] === 'a')?.[1]
if (aTag) {
try {
const [kindStr, pubkey, identifier] = aTag.split(':')
const kind = Number(kindStr)
if (kind === KINDS.BlogPost && pubkey && identifier) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
this.markedIds.add(naddr)
this.emit()
console.log('[archive] mark naddr via a-tag:', naddr.slice(0, 24), '...')
return
}
} catch { /* ignore malformed a-tag */ }
}
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
this.pendingEventIds.add(eTag)
console.log('[archive] pending event id:', eTag)
}
try {
// Stream kind:17 and kind:7 in parallel
const [kind17, kind7] = await Promise.all([
queryEvents(relayPool, { kinds: [17], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleUrlReaction }),
queryEvents(relayPool, { kinds: [7], authors: [pubkey] }, { relayUrls: RELAYS, onEvent: handleEventReaction })
])
if (startGen !== this.generation) return
// Include EOSE events
kind17.forEach(handleUrlReaction)
kind7.forEach(handleEventReaction)
console.log('[archive] EOSE sizes kind17:', kind17.length, 'kind7:', kind7.length, 'pendingEventIds:', this.pendingEventIds.size)
if (this.pendingEventIds.size > 0) {
// Fetch referenced articles (kind:30023) and map event IDs to naddr
const ids = Array.from(this.pendingEventIds)
const articleEvents = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids }, { relayUrls: RELAYS })
console.log('[archive] fetched articles for mapping:', articleEvents.length)
for (const article of articleEvents) {
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
this.markedIds.add(naddr)
console.log('[archive] mark naddr:', naddr.slice(0, 24), '...')
} catch {
// skip invalid
}
}
this.emit()
}
console.log('[archive] total marked ids:', this.markedIds.size)
// Try immediate mapping via eventStore for any still-pending e-ids
if (this.pendingEventIds.size > 0) {
const stillPending = new Set<string>()
for (const eId of this.pendingEventIds) {
try {
const store = eventStore as unknown as { getEvent?: (id: string) => NostrEvent | undefined }
const evt: NostrEvent | undefined = typeof store.getEvent === 'function' ? store.getEvent(eId) : undefined
if (evt && evt.kind === KINDS.BlogPost) {
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (dTag) {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
console.log('[archive] map via eventStore naddr:', naddr.slice(0, 24), '...')
}
} else {
stillPending.add(eId)
}
} catch (e) { stillPending.add(eId) }
}
this.pendingEventIds = stillPending
if (stillPending.size > 0) {
// Subscribe to future 30023 arrivals to finalize mapping
if (this.timelineSubscription) {
try { this.timelineSubscription.unsubscribe() } catch (e) { console.warn('[archive] timeline unsub error', e) }
this.timelineSubscription = null
}
const sub$ = eventStore.timeline({ kinds: [KINDS.BlogPost] })
const genAtSub = this.generation
this.timelineSubscription = sub$.subscribe((events: NostrEvent[]) => {
if (genAtSub !== this.generation) return
for (const evt of events) {
if (!this.pendingEventIds.has(evt.id)) continue
const dTag = evt.tags.find(t => t[0] === 'd')?.[1]
if (!dTag) continue
try {
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: evt.pubkey, identifier: dTag })
this.markedIds.add(naddr)
this.pendingEventIds.delete(evt.id)
console.log('[archive] map via timeline naddr:', naddr.slice(0, 24), '...')
this.emit()
} catch (e) { console.warn('[archive] map via timeline encode error', e) }
}
})
}
}
} catch (err) {
// Non-blocking fetch; ignore errors here
console.warn('[archive] start() error:', err)
}
}
}
export const archiveController = new ArchiveController()

View File

@@ -3,7 +3,7 @@ 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 { ARCHIVE_EMOJI } from './reactionService'
import { BlogPostPreview } from './exploreService'
import { queryEvents } from './dataFetch'
@@ -38,7 +38,7 @@ export async function fetchReadArticles(
// Process kind:7 reactions (nostr-native articles)
for (const event of kind7Events) {
if (event.content === MARK_AS_READ_EMOJI) {
if (event.content === ARCHIVE_EMOJI) {
const eTag = event.tags.find((t) => t[0] === 'e')
const pTag = event.tags.find((t) => t[0] === 'p')
const kTag = event.tags.find((t) => t[0] === 'k')
@@ -58,7 +58,7 @@ export async function fetchReadArticles(
// Process kind:17 reactions (external URLs)
for (const event of kind17Events) {
if (event.content === MARK_AS_READ_EMOJI) {
if (event.content === ARCHIVE_EMOJI) {
const rTag = event.tags.find((t) => t[0] === 'r')
if (rTag && rTag[1]) {

View File

@@ -1,13 +1,13 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { EventFactory } from 'applesauce-factory'
const MARK_AS_READ_EMOJI = '📚'
const ARCHIVE_EMOJI = '📚'
export { MARK_AS_READ_EMOJI }
export { ARCHIVE_EMOJI }
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
@@ -23,7 +23,8 @@ export async function createEventReaction(
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
relayPool: RelayPool,
options?: { aCoord?: string }
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
@@ -32,10 +33,14 @@ export async function createEventReaction(
['p', eventAuthor],
['k', eventKind.toString()]
]
if (options?.aCoord) {
tags.push(['a', options.aCoord])
console.log('[archive] createEventReaction add a-tag:', options.aCoord)
}
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
content: ARCHIVE_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
@@ -85,7 +90,7 @@ export async function createWebsiteReaction(
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
content: ARCHIVE_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
@@ -100,6 +105,27 @@ export async function createWebsiteReaction(
return signed
}
/**
* Sends a deletion request (NIP-09) for a reaction event to effectively un-archive.
* The caller must know the reaction event id to delete.
*/
export async function deleteReaction(
reactionEventId: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const draft = await factory.create(async () => ({
kind: 5, // Deletion per NIP-09
content: 'unarchive',
tags: [['e', reactionEventId]],
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
await relayPool.publish(RELAYS, signed)
return signed
}
/**
* Checks if the user has already marked a nostr event as read
* @param eventId The ID of the event to check
@@ -130,8 +156,8 @@ export async function hasMarkedEventAsRead(
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
// Check if any reaction has our archive emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
return hasReadReaction
} catch (error) {
@@ -183,8 +209,8 @@ export async function hasMarkedWebsiteAsRead(
const events: NostrEvent[] = await lastValueFrom(events$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
// Check if any reaction has our archive emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === ARCHIVE_EMOJI)
return hasReadReaction
} catch (error) {

View File

@@ -6,7 +6,7 @@ import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { processReadingProgress } from './readingDataProcessor'
import { ReadItem } from './readsService'
import { MARK_AS_READ_EMOJI } from './reactionService'
import { ARCHIVE_EMOJI } from './reactionService'
import { nip19 } from 'nostr-tools'
console.log('[readingProgress] Module loaded')
@@ -324,7 +324,7 @@ class ReadingProgressController {
const handleUrlReaction = (evt: NostrEvent) => {
if (seenReactionIds.has(evt.id)) return
seenReactionIds.add(evt.id)
if (evt.content !== MARK_AS_READ_EMOJI) return
if (evt.content !== ARCHIVE_EMOJI) return
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
if (!rTag) return
this.markedAsReadIds.add(rTag)
@@ -335,7 +335,7 @@ class ReadingProgressController {
const handleEventReaction = (evt: NostrEvent) => {
if (seenReactionIds.has(evt.id)) return
seenReactionIds.add(evt.id)
if (evt.content !== MARK_AS_READ_EMOJI) return
if (evt.content !== ARCHIVE_EMOJI) return
const eTag = evt.tags.find(t => t[0] === 'e')?.[1]
if (!eTag) return
pendingEventIds.add(eTag)

View File

@@ -0,0 +1,276 @@
import { RelayPool } from 'applesauce-relay'
import { Helpers, IEventStore } from 'applesauce-core'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import { merge } from 'rxjs'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
import { readingProgressController } from './readingProgressController'
import { archiveController } from './archiveController'
const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers
export interface ReadItem {
id: string // naddr coordinate
source: 'reading-progress' | 'marked-as-read' | 'bookmark'
type: 'article' | 'external'
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
type ReadsCallback = (reads: ReadItem[]) => void
type LoadingCallback = (loading: boolean) => void
/**
* Reads controller - manages read articles with progressive hydration
* Follows the same pattern as bookmarkController
*/
class ReadsController {
private readsListeners: ReadsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentReads: Map<string, ReadItem> = new Map()
private isLoading = false
private hydrationGeneration = 0
// Address loader for efficient batching
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private eventStore: IEventStore | null = null
onReads(cb: ReadsCallback): () => void {
this.readsListeners.push(cb)
return () => {
this.readsListeners = this.readsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
reset(): void {
this.hydrationGeneration++
this.currentReads.clear()
this.setLoading(false)
}
private setLoading(loading: boolean): void {
if (this.isLoading !== loading) {
this.isLoading = loading
this.loadingListeners.forEach(cb => cb(loading))
}
}
getReads(): ReadItem[] {
return Array.from(this.currentReads.values())
}
/**
* Hydrate article events by coordinates using AddressLoader (auto-batching, streaming)
*/
private hydrateArticles(
coordinates: string[],
onProgress: () => void,
generation: number
): void {
if (!this.addressLoader) {
return
}
if (coordinates.length === 0) return
// Parse coordinates into pointers
const pointers: Array<{ kind: number; pubkey: string; identifier: string }> = []
for (const coord of coordinates) {
try {
// Decode naddr to get article coordinates
if (coord.startsWith('naddr1')) {
const decoded = nip19.decode(coord)
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
pointers.push({
kind: decoded.data.kind,
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
}
}
} catch (e) {
console.warn('Failed to decode article coordinate:', coord)
}
}
if (pointers.length === 0) return
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
// Build naddr from event
try {
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag
})
const item = this.currentReads.get(naddr)
if (item) {
// Enrich the item with article data
item.event = event
item.title = getArticleTitle(event) || 'Untitled'
item.summary = getArticleSummary(event)
item.image = getArticleImage(event)
item.published = getArticlePublished(event)
item.author = event.pubkey
// Store in event store if available
if (this.eventStore) {
this.eventStore.add(event)
}
onProgress()
}
} catch (e) {
console.warn('Failed to encode naddr for event:', event.id)
}
},
error: () => {
// Silent error - AddressLoader handles retries
}
})
}
/**
* Build ReadItems from reading progress and emit them
*/
private buildAndEmitReads(): void {
const progressMap = readingProgressController.getProgressMap()
const markedIds = Array.from(new Set([
...readingProgressController.getMarkedAsReadIds(),
...archiveController.getMarkedIds()
]))
// Build read items from progress map
const readItems: ReadItem[] = []
for (const [id, progress] of progressMap.entries()) {
const existing = this.currentReads.get(id)
const item: ReadItem = existing || {
id,
source: 'reading-progress',
type: 'article',
readingProgress: progress,
readingTimestamp: Math.floor(Date.now() / 1000)
}
// Update progress
item.readingProgress = progress
item.markedAsRead = markedIds.includes(id)
readItems.push(item)
this.currentReads.set(id, item)
}
// Include items that are only marked-as-read (no progress event yet)
for (const id of markedIds) {
if (!this.currentReads.has(id) && id.startsWith('naddr1')) {
const item: ReadItem = {
id,
source: 'marked-as-read',
type: 'article',
markedAsRead: true,
readingTimestamp: Math.floor(Date.now() / 1000)
}
readItems.push(item)
this.currentReads.set(id, item)
}
}
// Emit current state (items without article data yet)
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
// Fetch missing articles in background (progressive hydration)
const generation = this.hydrationGeneration
const onProgress = () => {
this.readsListeners.forEach(cb => cb(Array.from(this.currentReads.values())))
}
const coordinatesToFetch = readItems
.filter(item => !item.event && item.type === 'article')
.map(item => item.id)
this.hydrateArticles(coordinatesToFetch, onProgress, generation)
}
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
}): Promise<void> {
const { relayPool, eventStore } = options
// Increment generation to cancel any in-flight hydration
this.hydrationGeneration++
this.eventStore = eventStore
// Initialize loader for this session
this.addressLoader = createAddressLoader(relayPool, {
eventStore,
extraRelays: RELAYS
})
this.setLoading(true)
try {
// Subscribe to reading progress changes
const unsubProgress = readingProgressController.onProgress(() => {
this.buildAndEmitReads()
})
const unsubMarked = archiveController.onMarked(() => {
this.buildAndEmitReads()
})
// Build initial reads
this.buildAndEmitReads()
// Cleanup subscriptions on next start
setTimeout(() => {
unsubProgress()
unsubMarked()
}, 0)
} catch (error) {
console.error('Failed to load reads:', error)
this.readsListeners.forEach(cb => cb([]))
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const readsController = new ReadsController()

View File

@@ -1,5 +1,4 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { Bookmark } from '../types/bookmarks'
import { fetchReadArticles } from './libraryService'
@@ -8,31 +7,15 @@ import { RELAYS } from '../config/relays'
import { KINDS } from '../config/kinds'
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { processReadingProgress, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
import { mergeReadItem } from '../utils/readItemMerge'
import type { ReadItem } from './readsController'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
export interface ReadItem {
id: string // event ID or URL or coordinate
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
type: 'article' | 'external' // article=kind:30023, external=URL
// Article data
event?: NostrEvent
url?: string
title?: string
summary?: string
image?: string
published?: number
author?: string
// Reading metadata
readingProgress?: number // 0-1
readingTimestamp?: number // Unix timestamp of last reading activity
markedAsRead?: boolean
markedAt?: number
}
// Re-export ReadItem from readsController for consistency
export type { ReadItem } from './readsController'
/**
* Fetches all reads from multiple sources:
@@ -117,11 +100,14 @@ 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 === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: decoded.data.pubkey,
identifier: decoded.data.identifier || ''
})
if (decoded.type === 'naddr') {
const data = decoded.data as AddressPointer
if (data.kind === KINDS.BlogPost) {
articlesToFetch.push({
pubkey: data.pubkey,
identifier: data.identifier || ''
})
}
}
} else {
// Try coordinate format (kind:pubkey:identifier)

View File

@@ -0,0 +1,121 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RELAYS } from '../config/relays'
import { ARCHIVE_EMOJI, deleteReaction } from './reactionService'
/**
* Returns the user's archive reactions (kind:7) for a given event id.
*/
export async function findArchiveReactionsForEvent(
eventId: string,
userPubkey: string,
relayPool: RelayPool
): Promise<NostrEvent[]> {
try {
const filter = {
kinds: [7],
authors: [userPubkey],
'#e': [eventId]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
} catch (error) {
console.error('[unarchive] findArchiveReactionsForEvent error:', error)
return []
}
}
/**
* Returns the user's archive reactions (kind:17) for a given website URL.
*/
export async function findArchiveReactionsForWebsite(
url: string,
userPubkey: string,
relayPool: RelayPool
): Promise<NostrEvent[]> {
try {
// Normalize URL same as creation
let normalizedUrl = url
try {
const parsed = new URL(url)
parsed.hash = ''
normalizedUrl = parsed.toString()
if (normalizedUrl.endsWith('/')) normalizedUrl = normalizedUrl.slice(0, -1)
} catch (e) {
console.warn('[unarchive] URL normalize failed:', e)
}
const filter = {
kinds: [17],
authors: [userPubkey],
'#r': [normalizedUrl]
}
const events$ = relayPool
.req(RELAYS, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(2000)),
toArray()
)
const events: NostrEvent[] = await lastValueFrom(events$)
return events.filter(evt => evt.content === ARCHIVE_EMOJI)
} catch (error) {
console.error('[unarchive] findArchiveReactionsForWebsite error:', error)
return []
}
}
/**
* Sends deletion requests for all of the user's archive reactions to an event.
* Returns the number of deletion requests published.
*/
export async function unarchiveEvent(
eventId: string,
account: IAccount,
relayPool: RelayPool
): Promise<number> {
try {
const reactions = await findArchiveReactionsForEvent(eventId, account.pubkey, relayPool)
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
return reactions.length
} catch (error) {
console.error('[unarchive] unarchiveEvent error:', error)
return 0
}
}
/**
* Sends deletion requests for all of the user's archive reactions to a website URL.
* Returns the number of deletion requests published.
*/
export async function unarchiveWebsite(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<number> {
try {
const reactions = await findArchiveReactionsForWebsite(url, account.pubkey, relayPool)
await Promise.all(reactions.map(r => deleteReaction(r.id, account, relayPool)))
return reactions.length
} catch (error) {
console.error('[unarchive] unarchiveWebsite error:', error)
return 0
}
}

View File

@@ -14,6 +14,31 @@
50% { opacity: 1; transform: scale(1.1); }
}
/* Subtle success burst used for archive action */
@keyframes success-burst {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45), 0 0 0 0 rgba(16, 185, 129, 0.25);
transform: scale(1);
}
60% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
transform: scale(1.05);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0), 0 0 0 0 rgba(16, 185, 129, 0);
transform: scale(1);
}
}
/* Apply archive animation when button enters animating state */
.mark-as-read-btn.animating {
animation: success-burst 600ms ease-out 1;
}
.mark-as-read-btn.animating svg {
animation: pulse 600ms ease-in-out 1;
}
@keyframes highlight-pulse-animation {
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }

View File

@@ -50,23 +50,23 @@ export function filterByReadingProgress(
return items.filter((item) => {
const progress = item.readingProgress || 0
const isMarked = item.markedAsRead || false
// Reading progress filters MUST ignore emoji/archive reactions
const hasHighlights = (articleHighlightCount.get(item.id) || 0) > 0 ||
(item.url && (articleHighlightCount.get(item.url) || 0) > 0)
switch (filter) {
case 'unopened':
return progress === 0 && !isMarked
return progress === 0
case 'started':
return progress > 0 && progress <= 0.10 && !isMarked
return progress > 0 && progress <= 0.10
case 'reading':
return progress > 0.10 && progress <= 0.94 && !isMarked
return progress > 0.10 && progress <= 0.94
case 'completed':
// Completed is 95%+ progress only (no emoji fallback)
return progress >= 0.95
case 'emoji':
// Emoji-marked items regardless of progress
return isMarked
case 'archive':
// Archive filter handled upstream; keep fallback as false to avoid mixing
return false
case 'highlighted':
return hasHighlights
case 'all':