mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 04:24:25 +01:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9638ab0b84 | ||
|
|
8d7b853e75 | ||
|
|
cdbb920a5f | ||
|
|
cc311c7dc4 | ||
|
|
d4d54b1a7c | ||
|
|
235d6e33a9 | ||
|
|
0fe1085457 | ||
|
|
65e7709c63 | ||
|
|
17b5ffd96e | ||
|
|
7f95eae405 | ||
|
|
8f1e5e1082 | ||
|
|
c536de0144 | ||
|
|
8e0970b717 | ||
|
|
560a4a6785 | ||
|
|
320e7f000a | ||
|
|
832740fb59 | ||
|
|
4aea7b899b | ||
|
|
43492a4488 | ||
|
|
1552dd85d9 | ||
|
|
0bc89889e0 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.10",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
46
src/App.tsx
46
src/App.tsx
@@ -3,12 +3,12 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
|
||||
// Load default article from environment variable with fallback
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
|
||||
@@ -21,6 +21,44 @@ function App() {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||
await accounts.fromJSON(json)
|
||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
||||
|
||||
// Load active account from storage
|
||||
const activeId = localStorage.getItem('active')
|
||||
if (activeId && accounts.getAccount(activeId)) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('Restored active account:', activeId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load accounts from storage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
loadAccounts()
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
const accountsSub = accounts.accounts$.subscribe(() => {
|
||||
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
|
||||
})
|
||||
|
||||
// Subscribe to active account changes and persist to localStorage
|
||||
const activeSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
localStorage.setItem('active', account.id)
|
||||
} else {
|
||||
localStorage.removeItem('active')
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
@@ -57,6 +95,12 @@ function App() {
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
|
||||
// Cleanup subscriptions on unmount
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!eventStore || !accountManager || !relayPool) {
|
||||
|
||||
@@ -19,8 +19,8 @@ interface BookmarkListProps {
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
bookmarks,
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
bookmarks,
|
||||
onSelectUrl,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
@@ -8,6 +8,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
@@ -16,6 +17,7 @@ import Toast from './Toast'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -39,6 +41,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: true,
|
||||
friends: true,
|
||||
mine: true
|
||||
})
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const eventStore = useEventStore()
|
||||
@@ -70,8 +78,15 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
handleFetchBookmarks()
|
||||
handleFetchHighlights()
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey])
|
||||
|
||||
const handleFetchContacts = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
setFollowedPubkeys(contacts)
|
||||
}
|
||||
|
||||
// Apply UI settings
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
@@ -113,6 +128,19 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return highlights.map(h => {
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === activeAccount?.pubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys])
|
||||
|
||||
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
|
||||
if (!relayPool) return
|
||||
|
||||
@@ -167,7 +195,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
markdown={readerContent?.markdown}
|
||||
image={readerContent?.image}
|
||||
selectedUrl={selectedUrl}
|
||||
highlights={highlights}
|
||||
highlights={classifiedHighlights}
|
||||
showUnderlines={showUnderlines}
|
||||
highlightStyle={settings.highlightStyle || 'marker'}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
@@ -191,6 +219,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
onRefresh={handleFetchHighlights}
|
||||
onHighlightClick={setSelectedHighlightId}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onHighlightVisibilityChange={setHighlightVisibility}
|
||||
followedPubkeys={followedPubkeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react'
|
||||
import React, { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
@@ -39,68 +39,46 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
selectedHighlightId
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const originalHtmlRef = useRef<string>('')
|
||||
|
||||
// Scroll to selected highlight in article when clicked from sidebar
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
// Add pulsing animation after scroll completes
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
}
|
||||
}, [selectedHighlightId])
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
|
||||
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
|
||||
|
||||
// Store original HTML when content changes
|
||||
// Convert markdown to HTML when markdown content changes
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
// Store the fresh HTML content
|
||||
originalHtmlRef.current = contentRef.current.innerHTML
|
||||
}, [html, markdown, selectedUrl])
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
// Apply highlights after DOM is rendered
|
||||
useEffect(() => {
|
||||
// Skip if no content or underlines are hidden
|
||||
if ((!html && !markdown) || !showUnderlines) {
|
||||
// If underlines are hidden, restore original HTML
|
||||
if (!showUnderlines && contentRef.current && originalHtmlRef.current) {
|
||||
contentRef.current.innerHTML = originalHtmlRef.current
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if no relevant highlights
|
||||
if (relevantHighlights.length === 0) {
|
||||
// Restore original HTML if no highlights
|
||||
if (contentRef.current && originalHtmlRef.current) {
|
||||
contentRef.current.innerHTML = originalHtmlRef.current
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is fully rendered
|
||||
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!contentRef.current || !originalHtmlRef.current) return
|
||||
|
||||
// Always apply highlights to the ORIGINAL HTML, not already-highlighted content
|
||||
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights, highlightStyle)
|
||||
contentRef.current.innerHTML = highlightedHTML
|
||||
if (markdownPreviewRef.current) {
|
||||
setRenderedHtml(markdownPreviewRef.current.innerHTML)
|
||||
}
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [relevantHighlights, html, markdown, showUnderlines, highlightStyle])
|
||||
|
||||
// Attach click handlers separately (only when handler changes)
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [markdown])
|
||||
|
||||
// Prepare the final HTML with highlights applied
|
||||
const finalHtml = useMemo(() => {
|
||||
const sourceHtml = markdown ? renderedHtml : html
|
||||
if (!sourceHtml) return ''
|
||||
|
||||
// Apply highlights if we have them and underlines are shown
|
||||
if (showUnderlines && relevantHighlights.length > 0) {
|
||||
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
}
|
||||
|
||||
return sourceHtml
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showUnderlines, highlightStyle])
|
||||
|
||||
// Check if we need to wait for HTML conversion
|
||||
const needsHtmlConversion = markdown && !renderedHtml
|
||||
const shouldShowContent = !needsHtmlConversion || relevantHighlights.length === 0
|
||||
|
||||
// Attach click handlers to highlight marks
|
||||
useEffect(() => {
|
||||
if (!onHighlightClick || !contentRef.current) return
|
||||
|
||||
@@ -122,9 +100,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
mark.removeEventListener('click', handler)
|
||||
})
|
||||
}
|
||||
}, [onHighlightClick, relevantHighlights])
|
||||
}, [onHighlightClick, finalHtml])
|
||||
|
||||
const highlightedMarkdown = useMemo(() => markdown, [markdown])
|
||||
// Scroll to selected highlight in article when clicked from sidebar
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
// Add pulsing animation after scroll completes
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
}
|
||||
}, [selectedHighlightId, finalHtml])
|
||||
|
||||
// Calculate reading time from content (must be before early returns)
|
||||
const readingStats = useMemo(() => {
|
||||
@@ -160,6 +154,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
@@ -184,14 +187,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{markdown ? (
|
||||
<div ref={contentRef} className="reader-markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{highlightedMarkdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : html ? (
|
||||
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{markdown || html ? (
|
||||
finalHtml || (markdown && shouldShowContent) ? (
|
||||
finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={markdown ? "reader-markdown" : "reader-html"}
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : null
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
friends: boolean
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
interface HighlightsPanelProps {
|
||||
highlights: Highlight[]
|
||||
loading: boolean
|
||||
@@ -15,6 +21,10 @@ interface HighlightsPanelProps {
|
||||
selectedHighlightId?: string
|
||||
onRefresh?: () => void
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
currentUserPubkey?: string
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
followedPubkeys?: Set<string>
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -27,7 +37,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onToggleUnderlines,
|
||||
selectedHighlightId,
|
||||
onRefresh,
|
||||
onHighlightClick
|
||||
onHighlightClick,
|
||||
currentUserPubkey,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set()
|
||||
}) => {
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
|
||||
@@ -37,36 +51,55 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onToggleUnderlines?.(newValue)
|
||||
}
|
||||
|
||||
// Filter highlights to show only those relevant to the current URL or article
|
||||
// Filter highlights based on visibility levels and URL
|
||||
const filteredHighlights = useMemo(() => {
|
||||
if (!selectedUrl) return highlights
|
||||
|
||||
// For Nostr articles (URL starts with "nostr:"), we don't need to filter
|
||||
let urlFiltered = highlights
|
||||
|
||||
// For Nostr articles (URL starts with "nostr:"), we don't need to filter by URL
|
||||
// because we already fetched highlights specifically for this article
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
return highlights
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
|
||||
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
|
||||
if (!selectedUrl.startsWith('nostr:')) {
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
|
||||
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
if (!h.urlReference) return false
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
return normalizedSelected === normalizedRef ||
|
||||
normalizedSelected.includes(normalizedRef) ||
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
return highlights.filter(h => {
|
||||
if (!h.urlReference) return false
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
return normalizedSelected === normalizedRef ||
|
||||
normalizedSelected.includes(normalizedRef) ||
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
})
|
||||
}, [highlights, selectedUrl])
|
||||
// Classify and filter by visibility levels
|
||||
return urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === currentUserPubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
.filter(h => {
|
||||
// Filter by visibility settings
|
||||
if (h.level === 'mine') return highlightVisibility.mine
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
if (isCollapsed) {
|
||||
const hasHighlights = filteredHighlights.length > 0
|
||||
@@ -89,12 +122,49 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
return (
|
||||
<div className="highlights-container">
|
||||
<div className="highlights-header">
|
||||
<div className="highlights-title">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<h3>Highlights</h3>
|
||||
{!loading && <span className="count">({filteredHighlights.length})</span>}
|
||||
</div>
|
||||
<div className="highlights-actions">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
|
||||
@@ -122,13 +122,37 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Color</label>
|
||||
<label>Highlight Color (Legacy)</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColor || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>My Highlights Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorMine || '#eab308'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Friends Highlights Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Nostrverse Highlights Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-preview">
|
||||
<div className="preview-label">Preview</div>
|
||||
<div
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
|
||||
@@ -16,9 +17,25 @@ interface SidebarHeaderProps {
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsConnecting(true)
|
||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getProfileImage = () => {
|
||||
return profile?.picture || null
|
||||
}
|
||||
@@ -58,13 +75,23 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={faRightToBracket}
|
||||
onClick={isConnecting ? () => {} : handleLogin}
|
||||
title={isConnecting ? "Connecting..." : "Login"}
|
||||
ariaLabel="Login"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
|
||||
@@ -52,6 +52,11 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
if (fontKey !== 'system') loadFont(fontKey)
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#eab308')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
}, [settings])
|
||||
|
||||
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
|
||||
|
||||
200
src/index.css
200
src/index.css
@@ -99,6 +99,7 @@ body {
|
||||
/* Bookmarks Styles */
|
||||
.bookmarks-container {
|
||||
text-align: left;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
@@ -180,7 +181,7 @@ body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 0.75rem 0 0 0;
|
||||
padding: 0.75rem 0.5rem 0 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -423,7 +424,7 @@ body {
|
||||
.two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 1rem;
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
@@ -436,7 +437,7 @@ body {
|
||||
.three-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr 360px;
|
||||
gap: 1rem;
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
@@ -464,6 +465,8 @@ body {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
overflow-x: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.pane.highlights {
|
||||
@@ -474,9 +477,11 @@ body {
|
||||
.reader {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.reader.empty {
|
||||
@@ -1171,13 +1176,14 @@ body {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 0 0 0;
|
||||
padding: 0.75rem 0 0 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -1260,6 +1266,77 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle .mode-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle .mode-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle .mode-btn.active {
|
||||
background: #646cff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Three-level highlight toggles */
|
||||
.highlight-level-toggles {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:not(.active) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-underlines-btn,
|
||||
.toggle-highlights-btn {
|
||||
@@ -1453,6 +1530,7 @@ body {
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.content-highlight:hover,
|
||||
@@ -1472,6 +1550,7 @@ body {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.content-highlight-underline:hover {
|
||||
@@ -1520,6 +1599,68 @@ body {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Three-level highlight colors */
|
||||
.content-highlight-marker.level-mine,
|
||||
.content-highlight.level-mine {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #eab308) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-mine:hover,
|
||||
.content-highlight.level-mine:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #eab308) 30%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends,
|
||||
.content-highlight.level-friends {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends:hover,
|
||||
.content-highlight.level-friends:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse,
|
||||
.content-highlight.level-nostrverse {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse:hover,
|
||||
.content-highlight.level-nostrverse:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Underline styles for three levels */
|
||||
.content-highlight-underline.level-mine {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #eab308) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-mine:hover {
|
||||
text-decoration-color: var(--highlight-color-mine, #eab308);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends:hover {
|
||||
text-decoration-color: var(--highlight-color-friends, #f97316);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-nostrverse {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-nostrverse:hover {
|
||||
text-decoration-color: var(--highlight-color-nostrverse, #9333ea);
|
||||
}
|
||||
|
||||
/* Ensure highlights work in both light and dark mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.content-highlight,
|
||||
@@ -1542,6 +1683,55 @@ body {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
|
||||
}
|
||||
|
||||
/* Three-level overrides for light mode */
|
||||
.content-highlight-marker.level-mine,
|
||||
.content-highlight.level-mine {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #eab308) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-mine:hover,
|
||||
.content-highlight.level-mine:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #eab308) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #eab308) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends,
|
||||
.content-highlight.level-friends {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends:hover,
|
||||
.content-highlight.level-friends:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse,
|
||||
.content-highlight.level-nostrverse {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse:hover,
|
||||
.content-highlight.level-nostrverse:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-mine {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #eab308) 90%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-nostrverse {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent);
|
||||
}
|
||||
|
||||
.highlight-indicator {
|
||||
background: rgba(100, 108, 255, 0.15);
|
||||
border-color: rgba(100, 108, 255, 0.4);
|
||||
|
||||
50
src/services/contactService.ts
Normal file
50
src/services/contactService.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @returns Set of pubkeys that the user follows
|
||||
*/
|
||||
export const fetchContacts = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
|
||||
console.log('📊 Contact events fetched:', events.length)
|
||||
|
||||
if (events.length === 0) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
// Get the most recent contact list
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const contactList = sortedEvents[0]
|
||||
|
||||
// Extract pubkeys from 'p' tags
|
||||
const followedPubkeys = new Set<string>()
|
||||
for (const tag of contactList.tags) {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
followedPubkeys.add(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
console.log('👥 Followed contacts:', followedPubkeys.size)
|
||||
|
||||
return followedPubkeys
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contacts:', error)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export interface UserSettings {
|
||||
fontSize?: number
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
// Three-level highlight colors
|
||||
highlightColorNostrverse?: string
|
||||
highlightColorFriends?: string
|
||||
highlightColorMine?: string
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// NIP-84 Highlight types
|
||||
export type HighlightLevel = 'nostrverse' | 'friends' | 'mine'
|
||||
|
||||
export interface Highlight {
|
||||
id: string
|
||||
pubkey: string
|
||||
@@ -11,5 +13,7 @@ export interface Highlight {
|
||||
author?: string // 'p' tag with 'author' role
|
||||
context?: string // surrounding text context
|
||||
comment?: string // optional comment about the highlight
|
||||
// Level classification (computed based on user's context)
|
||||
level?: HighlightLevel
|
||||
}
|
||||
|
||||
|
||||
@@ -73,11 +73,13 @@ export function applyHighlightsToText(
|
||||
|
||||
// Add the highlighted text
|
||||
const highlightedText = text.substring(match.startIndex, match.endIndex)
|
||||
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
|
||||
result.push(
|
||||
<mark
|
||||
key={`highlight-${match.highlight.id}-${match.startIndex}`}
|
||||
className="content-highlight"
|
||||
className={`content-highlight${levelClass}`}
|
||||
data-highlight-id={match.highlight.id}
|
||||
data-highlight-level={match.highlight.level || 'nostrverse'}
|
||||
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
|
||||
>
|
||||
{highlightedText}
|
||||
@@ -101,8 +103,10 @@ const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
|
||||
// Helper to create a mark element for a highlight
|
||||
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = `content-highlight-${highlightStyle}`
|
||||
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
|
||||
mark.className = `content-highlight-${highlightStyle}${levelClass}`
|
||||
mark.setAttribute('data-highlight-id', highlight.id)
|
||||
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
|
||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||
mark.textContent = matchText
|
||||
return mark
|
||||
|
||||
@@ -12,6 +12,13 @@ export function normalizeUrl(url: string): string {
|
||||
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
||||
if (!selectedUrl || highlights.length === 0) return []
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
// So we don't need to filter them - they're all relevant
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
return highlights
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
return highlights.filter(h => {
|
||||
|
||||
Reference in New Issue
Block a user