feat: instant mark-as-read with checkmark animation and read status checking

- Add functions to check if article/URL was already marked as read via NIP-25 reactions
- Make mark-as-read action instant with fire-and-forget publishing
- Add checkmark icon animation when marking as read
- Display read status on load by querying kind:7 (nostr events) and kind:17 (websites) reactions
- Add green styling for already-read state
- Button shows checkmark and is disabled when article is already marked as read
This commit is contained in:
Gigi
2025-10-13 10:17:16 +02:00
parent d83712127b
commit 6c0a2439ad
3 changed files with 222 additions and 38 deletions

View File

@@ -1,8 +1,8 @@
import React, { useMemo, useState } from 'react'
import React, { useMemo, useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faBook, faCheck } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
@@ -15,7 +15,12 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
import {
createEventReaction,
createWebsiteReaction,
hasMarkedEventAsRead,
hasMarkedWebsiteAsRead
} from '../services/reactionService'
import AuthorCard from './AuthorCard'
interface ContentPanelProps {
@@ -70,7 +75,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -105,39 +112,82 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const handleMarkAsRead = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot mark as read: no account or relay pool')
// Check if article is already marked as read when URL/article changes
useEffect(() => {
const checkReadStatus = async () => {
if (!activeAccount || !relayPool || !selectedUrl) {
setIsMarkedAsRead(false)
return
}
setIsCheckingReadStatus(true)
try {
let hasRead = false
if (isNostrArticle && currentArticle) {
hasRead = await hasMarkedEventAsRead(
currentArticle.id,
activeAccount.pubkey,
relayPool
)
} else {
hasRead = await hasMarkedWebsiteAsRead(
selectedUrl,
activeAccount.pubkey,
relayPool
)
}
setIsMarkedAsRead(hasRead)
} catch (error) {
console.error('Failed to check read status:', error)
} finally {
setIsCheckingReadStatus(false)
}
}
checkReadStatus()
}, [selectedUrl, currentArticle?.id, activeAccount, relayPool, isNostrArticle])
const handleMarkAsRead = () => {
if (!activeAccount || !relayPool || isMarkedAsRead) {
return
}
setIsMarkingAsRead(true)
// Instantly update UI with checkmark animation
setIsMarkedAsRead(true)
setShowCheckAnimation(true)
try {
if (isNostrArticle && currentArticle) {
// Kind 7 reaction for nostr-native articles
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
// Kind 17 reaction for external websites
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
// Reset animation after it completes
setTimeout(() => {
setShowCheckAnimation(false)
}, 600)
// Fire-and-forget: publish in background without blocking UI
;(async () => {
try {
if (isNostrArticle && currentArticle) {
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
// Revert UI state on error
setIsMarkedAsRead(false)
}
} catch (error) {
console.error('Failed to mark as read:', error)
} finally {
setIsMarkingAsRead(false)
}
})()
}
if (!selectedUrl) {
@@ -215,13 +265,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{activeAccount && (
<div className="mark-as-read-container">
<button
className="mark-as-read-btn"
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
onClick={handleMarkAsRead}
disabled={isMarkingAsRead}
title="Mark as Read"
disabled={isMarkedAsRead || isCheckingReadStatus}
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
>
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
<FontAwesomeIcon
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheck : faBook}
spin={isCheckingReadStatus}
/>
<span>
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
</span>
</button>
</div>
)}

View File

@@ -676,6 +676,39 @@
.mark-as-read-btn svg {
font-size: 1.1rem;
transition: transform 0.3s ease;
}
/* Marked as read state */
.mark-as-read-btn.marked {
background: #1a4d1a;
border-color: #2d662d;
color: #90ee90;
}
.mark-as-read-btn.marked:disabled {
opacity: 1;
cursor: default;
}
/* Checkmark animation */
.mark-as-read-btn.animating svg {
animation: checkmarkPop 0.6s ease;
}
@keyframes checkmarkPop {
0% {
transform: scale(0.8);
opacity: 0.8;
}
50% {
transform: scale(1.3);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 768px) {

View File

@@ -1,11 +1,14 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
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'
const MARK_AS_READ_EMOJI = '📚'
export { MARK_AS_READ_EMOJI }
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
* @param eventId The ID of the event being reacted to
@@ -101,3 +104,96 @@ export async function createWebsiteReaction(
return signed
}
/**
* Checks if the user has already marked a nostr event as read
* @param eventId The ID of the event to check
* @param userPubkey The user's pubkey
* @param relayPool The relay pool for querying
* @returns True if the user has already reacted with the mark-as-read emoji
*/
export async function hasMarkedEventAsRead(
eventId: string,
userPubkey: string,
relayPool: RelayPool
): Promise<boolean> {
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$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
return hasReadReaction
} catch (error) {
console.error('Error checking read status:', error)
return false
}
}
/**
* Checks if the user has already marked a website as read
* @param url The URL to check
* @param userPubkey The user's pubkey
* @param relayPool The relay pool for querying
* @returns True if the user has already reacted with the mark-as-read emoji
*/
export async function hasMarkedWebsiteAsRead(
url: string,
userPubkey: string,
relayPool: RelayPool
): Promise<boolean> {
try {
// Normalize URL the same way as when creating reactions
let normalizedUrl = url
try {
const parsed = new URL(url)
parsed.hash = ''
normalizedUrl = parsed.toString()
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
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$)
// Check if any reaction has our mark-as-read emoji
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
return hasReadReaction
} catch (error) {
console.error('Error checking read status:', error)
return false
}
}