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
+ }
+}
+