mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 15:14:20 +01:00
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:
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
103
src/services/reactionService.ts
Normal file
103
src/services/reactionService.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user