diff --git a/.cursor/rules/app-settings-and-nostr.mdc b/.cursor/rules/app-settings-and-nostr.mdc new file mode 100644 index 00000000..3dca9096 --- /dev/null +++ b/.cursor/rules/app-settings-and-nostr.mdc @@ -0,0 +1,3 @@ +--- +alwaysApply: true +--- diff --git a/dist/index.html b/dist/index.html index 3168cbf1..74576896 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,8 +5,8 @@ Boris - Nostr Bookmarks - - + +
diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index d1644635..c8d3b638 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -42,6 +42,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { const [showSettings, setShowSettings] = useState(false) const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState(undefined) const [currentArticleEventId, setCurrentArticleEventId] = useState(undefined) + const [currentArticle, setCurrentArticle] = useState(undefined) // Store the current article event const [highlightVisibility, setHighlightVisibility] = useState({ nostrverse: true, friends: true, @@ -71,7 +72,8 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, - setCurrentArticleEventId + setCurrentArticleEventId, + setCurrentArticle }) // Load initial data on login @@ -177,12 +179,18 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { setSelectedUrl(url) setReaderLoading(true) setReaderContent(undefined) + setCurrentArticle(undefined) // Clear previous article setShowSettings(false) if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true) try { const content = await loadContent(url, relayPool, bookmark) setReaderContent(content) + + // If this is a Nostr article (kind:30023), we need to get the event for highlight creation + if (bookmark && bookmark.kind === 30023) { + setCurrentArticle(bookmark) + } } catch (err) { console.warn('Failed to fetch content:', err) } finally { @@ -190,6 +198,22 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { } } + const handleHighlightCreated = async () => { + // Refresh highlights after creating a new one + if (!relayPool || !currentArticleCoordinate) return + + try { + const newHighlights = await fetchHighlightsForArticle( + relayPool, + currentArticleCoordinate, + currentArticleEventId + ) + setHighlights(newHighlights) + } catch (err) { + console.error('Failed to refresh highlights:', err) + } + } + return ( <>
@@ -237,6 +261,19 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { if (isHighlightsCollapsed) setIsHighlightsCollapsed(false) }} selectedHighlightId={selectedHighlightId} + relayPool={relayPool || undefined} + activeAccount={activeAccount || undefined} + currentArticle={currentArticle} + currentArticleCoordinate={currentArticleCoordinate} + onHighlightCreated={handleHighlightCreated} + onShowToast={(message, type) => { + // Use existing toast mechanism + if (type === 'success') { + console.log('✅', message) + } else { + console.error('❌', message) + } + }} highlightVisibility={highlightVisibility} currentUserPubkey={activeAccount?.pubkey} followedPubkeys={followedPubkeys} diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index e95b5f84..1338530e 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useEffect, useRef, useState } from 'react' +import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -10,6 +10,11 @@ import { filterHighlightsByUrl } from '../utils/urlHelpers' import { hexToRgb } from '../utils/colorHelpers' import ReaderHeader from './ReaderHeader' import { HighlightVisibility } from './HighlightsPanel' +import { HighlightButton, HighlightButtonRef } from './HighlightButton' +import { createHighlight } from '../services/highlightCreationService' +import { RelayPool } from 'applesauce-relay' +import { IAccount } from 'applesauce-accounts' +import { NostrEvent } from 'nostr-tools' interface ContentPanelProps { loading: boolean @@ -27,6 +32,13 @@ interface ContentPanelProps { highlightVisibility?: HighlightVisibility currentUserPubkey?: string followedPubkeys?: Set + // For highlight creation + relayPool?: RelayPool + activeAccount?: IAccount + currentArticle?: NostrEvent | null + currentArticleCoordinate?: string + onHighlightCreated?: () => void + onShowToast?: (message: string, type: 'success' | 'error') => void } const ContentPanel: React.FC = ({ @@ -44,11 +56,19 @@ const ContentPanel: React.FC = ({ selectedHighlightId, highlightVisibility = { nostrverse: true, friends: true, mine: true }, currentUserPubkey, - followedPubkeys = new Set() + followedPubkeys = new Set(), + // For highlight creation + relayPool, + activeAccount, + currentArticle, + currentArticleCoordinate, + onHighlightCreated, + onShowToast }) => { const contentRef = useRef(null) const markdownPreviewRef = useRef(null) const [renderedHtml, setRenderedHtml] = useState('') + const highlightButtonRef = useRef(null) // Filter highlights by URL and visibility settings const relevantHighlights = useMemo(() => { @@ -158,6 +178,59 @@ const ContentPanel: React.FC = ({ const hasHighlights = relevantHighlights.length > 0 + // Handle text selection for highlight creation + const handleMouseUp = useCallback(() => { + // Only allow highlight creation if user is logged in + if (!activeAccount || !relayPool) { + highlightButtonRef.current?.hide() + return + } + + setTimeout(() => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) { + highlightButtonRef.current?.hide() + return + } + + const range = selection.getRangeAt(0) + const text = selection.toString().trim() + + if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) { + highlightButtonRef.current?.updateSelection(text, range.cloneRange()) + } else { + highlightButtonRef.current?.hide() + } + }, 10) + }, [activeAccount, relayPool]) + + // Handle highlight creation + const handleCreateHighlight = useCallback(async (text: string) => { + if (!activeAccount || !relayPool || !currentArticle) { + onShowToast?.('Please log in to create highlights', 'error') + return + } + + try { + await createHighlight( + text, + currentArticle, + activeAccount, + relayPool + ) + + onShowToast?.('Highlight created successfully!', 'success') + highlightButtonRef.current?.hide() + window.getSelection()?.removeAllRanges() + + // Trigger refresh of highlights + onHighlightCreated?.() + } catch (error) { + console.error('Failed to create highlight:', error) + onShowToast?.('Failed to create highlight', 'error') + } + }, [activeAccount, relayPool, currentArticle, currentArticleCoordinate, onShowToast, onHighlightCreated]) + if (!selectedUrl) { return (
@@ -202,12 +275,14 @@ const ContentPanel: React.FC = ({
) : (
{markdown} @@ -218,7 +293,8 @@ const ContentPanel: React.FC = ({
) ) : ( @@ -226,6 +302,13 @@ const ContentPanel: React.FC = ({

No readable content found for this URL.

)} + + {activeAccount && relayPool && ( + + )}
) } diff --git a/src/components/HighlightButton.tsx b/src/components/HighlightButton.tsx new file mode 100644 index 00000000..9148753c --- /dev/null +++ b/src/components/HighlightButton.tsx @@ -0,0 +1,88 @@ +import React, { useCallback, useImperativeHandle, useRef } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faHighlighter } from '@fortawesome/free-solid-svg-icons' + +interface HighlightButtonProps { + onHighlight: (text: string) => void +} + +export interface HighlightButtonRef { + updateSelection: (text: string, range: Range) => void + hide: () => void +} + +export const HighlightButton = React.forwardRef( + ({ onHighlight }, ref) => { + const currentSelectionRef = useRef('') + const buttonRef = useRef(null) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (currentSelectionRef.current) { + onHighlight(currentSelectionRef.current) + } + }, + [onHighlight] + ) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + // Prevent the button from taking focus away from the text selection + e.preventDefault() + }, []) + + // Expose methods to update selection and hide button + useImperativeHandle(ref, () => ({ + updateSelection: (text: string, range: Range) => { + currentSelectionRef.current = text + if (buttonRef.current) { + const rect = range.getBoundingClientRect() + buttonRef.current.style.display = 'flex' + buttonRef.current.style.top = `${rect.bottom + window.scrollY + 8}px` + buttonRef.current.style.left = `${rect.left + rect.width / 2 - 20}px` + } + }, + hide: () => { + currentSelectionRef.current = '' + if (buttonRef.current) { + buttonRef.current.style.display = 'none' + } + } + })) + + return ( + + ) + } +) + +HighlightButton.displayName = 'HighlightButton' + diff --git a/src/hooks/useArticleLoader.ts b/src/hooks/useArticleLoader.ts index 8bcc8eae..f57ec71b 100644 --- a/src/hooks/useArticleLoader.ts +++ b/src/hooks/useArticleLoader.ts @@ -16,6 +16,7 @@ interface UseArticleLoaderProps { setHighlightsLoading: (loading: boolean) => void setCurrentArticleCoordinate: (coord: string | undefined) => void setCurrentArticleEventId: (id: string | undefined) => void + setCurrentArticle?: (article: any) => void } export function useArticleLoader({ @@ -28,7 +29,8 @@ export function useArticleLoader({ setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, - setCurrentArticleEventId + setCurrentArticleEventId, + setCurrentArticle }: UseArticleLoaderProps) { useEffect(() => { if (!relayPool || !naddr) return @@ -54,6 +56,7 @@ export function useArticleLoader({ setCurrentArticleCoordinate(articleCoordinate) setCurrentArticleEventId(article.event.id) + setCurrentArticle?.(article.event) console.log('📰 Article loaded:', article.title) console.log('📍 Coordinate:', articleCoordinate) diff --git a/src/services/highlightCreationService.ts b/src/services/highlightCreationService.ts new file mode 100644 index 00000000..452e8fba --- /dev/null +++ b/src/services/highlightCreationService.ts @@ -0,0 +1,67 @@ +import { EventFactory } from 'applesauce-factory' +import { HighlightBlueprint } from 'applesauce-factory/blueprints' +import { RelayPool } from 'applesauce-relay' +import { IAccount } from 'applesauce-accounts' +import { AddressPointer } from 'nostr-tools/nip19' +import { NostrEvent } from 'nostr-tools' + +/** + * Creates and publishes a highlight event (NIP-84) + */ +export async function createHighlight( + selectedText: string, + article: NostrEvent | null, + account: IAccount, + relayPool: RelayPool, + comment?: string +): Promise { + if (!selectedText || !article) { + throw new Error('Missing required data to create highlight') + } + + // Create EventFactory with the account as signer + const factory = new EventFactory({ signer: account }) + + // Parse article coordinate to get address pointer + const addressPointer = parseArticleCoordinate(article) + + // Create highlight event using the blueprint + const highlightEvent = await factory.create( + HighlightBlueprint, + selectedText, + addressPointer, + comment ? { comment } : undefined + ) + + // Sign the event + const signedEvent = await factory.sign(highlightEvent) + + // Publish to relays + const relayUrls = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', + 'wss://relay.snort.social', + 'wss://purplepag.es' + ] + + await relayPool.publish(relayUrls, signedEvent) + + console.log('✅ Highlight published:', signedEvent) +} + +/** + * Parse article coordinate to create address pointer + */ +function parseArticleCoordinate(article: NostrEvent): AddressPointer { + // Try to get identifier from article tags + const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || '' + + return { + kind: article.kind, + pubkey: article.pubkey, + identifier, + relays: [] // Optional relays hint + } +} +