feat: add mark as read button for articles

- Create reactionService for handling kind:7 and kind:17 reactions
- Add mark as read button at the end of articles (📚 emoji)
- Use kind:7 reaction for nostr-native articles (/a/ paths)
- Use kind:17 reaction for external websites (/r/ paths)
- Pass activeAccount and currentArticle props through component tree
- Add responsive styling for mark as read button
- Button shows loading state while creating reaction
- Only visible when user is logged in

Implements NIP-25 (kind:7 reactions) and NIP-25 (kind:17 website reactions).
Users can now mark articles as read, creating a permanent record on nostr.
This commit is contained in:
Gigi
2025-10-11 08:34:36 +01:00
parent 4f952816ea
commit 6a6b8c4fad
5 changed files with 252 additions and 21 deletions

View File

@@ -247,6 +247,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onClearSelection={handleClearSelection} onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey} currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys} followedPubkeys={followedPubkeys}
activeAccount={activeAccount}
currentArticle={currentArticle}
highlights={highlights} highlights={highlights}
highlightsLoading={highlightsLoading} highlightsLoading={highlightsLoading}
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)} onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}

View File

@@ -1,9 +1,11 @@
import React, { useMemo } from 'react' import React, { useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons' import { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { readingTime } from 'reading-time-estimator' import { readingTime } from 'reading-time-estimator'
import { hexToRgb } from '../utils/colorHelpers' import { hexToRgb } from '../utils/colorHelpers'
@@ -13,6 +15,7 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent' import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions' import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
interface ContentPanelProps { interface ContentPanelProps {
loading: boolean loading: boolean
@@ -34,6 +37,8 @@ interface ContentPanelProps {
followedPubkeys?: Set<string> followedPubkeys?: Set<string>
settings?: UserSettings settings?: UserSettings
relayPool?: RelayPool | null relayPool?: RelayPool | null
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// For highlight creation // For highlight creation
onTextSelection?: (text: string) => void onTextSelection?: (text: string) => void
onClearSelection?: () => void onClearSelection?: () => void
@@ -54,6 +59,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightColor = '#ffff00', highlightColor = '#ffff00',
settings, settings,
relayPool, relayPool,
activeAccount,
currentArticle,
onHighlightClick, onHighlightClick,
selectedHighlightId, selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true }, highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -62,6 +69,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection, onTextSelection,
onClearSelection onClearSelection
}) => { }) => {
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool) const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({ const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -93,6 +101,44 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0 const hasHighlights = relevantHighlights.length > 0
// 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')
return
}
setIsMarkingAsRead(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')
}
} catch (error) {
console.error('Failed to mark as read:', error)
} finally {
setIsMarkingAsRead(false)
}
}
if (!selectedUrl) { if (!selectedUrl) {
return ( return (
<div className="reader empty"> <div className="reader empty">
@@ -135,31 +181,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
settings={settings} settings={settings}
/> />
{markdown || html ? ( {markdown || html ? (
markdown ? ( <>
renderedMarkdownHtml && finalHtml ? ( {markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
</div>
)
) : (
<div <div
ref={contentRef} ref={contentRef}
className="reader-markdown" className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml }} dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd} onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd} onTouchEnd={handleSelectionEnd}
/> />
) : ( )}
<div className="reader-markdown">
<div className="loading-spinner"> {/* Mark as Read button */}
<FontAwesomeIcon icon={faSpinner} spin size="sm" /> {activeAccount && (
</div> <div className="mark-as-read-container">
<button
className="mark-as-read-btn"
onClick={handleMarkAsRead}
disabled={isMarkingAsRead}
title="Mark as Read"
>
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
</button>
</div> </div>
) )}
) : ( </>
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)
) : ( ) : (
<div className="reader empty"> <div className="reader empty">
<p>No readable content found for this URL.</p> <p>No readable content found for this URL.</p>

View File

@@ -20,6 +20,8 @@ import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader' import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery' import { useIsMobile } from '../hooks/useMediaQuery'
import { useScrollDirection } from '../hooks/useScrollDirection' import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
interface ThreePaneLayoutProps { interface ThreePaneLayoutProps {
// Layout state // Layout state
@@ -60,6 +62,8 @@ interface ThreePaneLayoutProps {
onClearSelection: () => void onClearSelection: () => void
currentUserPubkey?: string currentUserPubkey?: string
followedPubkeys: Set<string> followedPubkeys: Set<string>
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// Highlights pane // Highlights pane
highlights: Highlight[] highlights: Highlight[]
@@ -320,6 +324,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
followedPubkeys={props.followedPubkeys} followedPubkeys={props.followedPubkeys}
settings={props.settings} settings={props.settings}
relayPool={props.relayPool} relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
/> />
)} )}
</div> </div>

View File

@@ -961,6 +961,63 @@ body.mobile-sidebar-open {
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
} }
/* Mark as Read button */
.mark-as-read-container {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem 1rem;
margin-top: 2rem;
border-top: 1px solid #333;
}
.mark-as-read-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 160px;
justify-content: center;
}
.mark-as-read-btn:hover:not(:disabled) {
background: #333;
border-color: #555;
transform: translateY(-1px);
}
.mark-as-read-btn:active:not(:disabled) {
transform: translateY(0);
}
.mark-as-read-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mark-as-read-btn svg {
font-size: 1.1rem;
}
@media (max-width: 768px) {
.mark-as-read-container {
padding: 1.5rem 1rem;
}
.mark-as-read-btn {
width: 100%;
max-width: 300px;
}
}
.bookmark-item { .bookmark-item {
background: #1a1a1a; background: #1a1a1a;
padding: 1.5rem; padding: 1.5rem;

View File

@@ -0,0 +1,103 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
const 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
* @param eventAuthor The pubkey of the event author
* @param eventKind The kind of the event being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createEventReaction(
eventId: string,
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['p', eventAuthor],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
return signed
}
/**
* Creates a kind:17 reaction to a website (for external URLs)
* @param url The URL being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createWebsiteReaction(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
// Normalize URL (remove fragments, trailing slashes as per NIP-25)
let normalizedUrl = url
try {
const parsed = new URL(url)
// Remove fragment
parsed.hash = ''
normalizedUrl = parsed.toString()
// Remove trailing slash if present
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
const tags: string[][] = [
['r', normalizedUrl]
]
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
return signed
}