diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index acb7a728..1a50c6a1 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -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 = ({ 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 = ({ // 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 = ({ {activeAccount && (
)} diff --git a/src/index.css b/src/index.css index a4463aa3..704986af 100644 --- a/src/index.css +++ b/src/index.css @@ -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) { diff --git a/src/services/reactionService.ts b/src/services/reactionService.ts index c7262930..910e7950 100644 --- a/src/services/reactionService.ts +++ b/src/services/reactionService.ts @@ -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 { + 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 { + 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 + } +} +