mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 04:24:25 +01:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b7816312 | ||
|
|
3f1066ca71 | ||
|
|
744642e2b7 | ||
|
|
fd28a6e171 | ||
|
|
0124de8318 | ||
|
|
b37aac0a33 | ||
|
|
81ef047a31 | ||
|
|
704033e6cb | ||
|
|
d59d27419e | ||
|
|
f8d3fac149 | ||
|
|
61e948f6a4 | ||
|
|
22323591c9 | ||
|
|
1b548cee3c | ||
|
|
fbb8fbdc20 | ||
|
|
1e7be50e35 | ||
|
|
1a7a8367a0 | ||
|
|
1f9dbf576c | ||
|
|
630c7ef0a4 | ||
|
|
b01293aa20 | ||
|
|
d9db10fd70 | ||
|
|
872d38c7f3 | ||
|
|
06c3c1ff20 | ||
|
|
107d6757bd | ||
|
|
89bd9f631a | ||
|
|
beeb296d3b | ||
|
|
0e992ae814 | ||
|
|
8b023af6a0 | ||
|
|
6e2f1102f7 |
10
.cursor/rules/web-bookmarks.mdc
Normal file
10
.cursor/rules/web-bookmarks.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: anything to do with "web bookmarks" aka NIP-B0
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
The app also supports web bookmarks (`kind:39701`) which are distinct from public/private bookmarks as defined in NIP-51.
|
||||
|
||||
See NIP-B0 for details:
|
||||
|
||||
- https://github.com/nostr-protocol/nips/blob/master/B0.md
|
||||
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<script type="module" crossorigin src="/assets/index--wClm1wz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bj-Uhit8.css">
|
||||
<script type="module" crossorigin src="/assets/index-D55Gme04.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.3",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
36
src/App.tsx
36
src/App.tsx
@@ -11,6 +11,7 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { RELAYS } from './config/relays'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
@@ -42,6 +43,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/r/*"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -94,33 +104,15 @@ function App() {
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
// This follows the applesauce-relay documentation pattern
|
||||
// Note: We could use pool.group(relayUrls) for direct requests in the future
|
||||
pool.group(relayUrls)
|
||||
console.log('Created relay group with', relayUrls.length, 'relays')
|
||||
console.log('Relay URLs:', relayUrls)
|
||||
pool.group(RELAYS)
|
||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
console.log('Relay URLs:', RELAYS)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
lookupRelays: RELAYS
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
|
||||
@@ -24,17 +24,25 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
|
||||
// Extract URLs from bookmark content
|
||||
const extractedUrls = extractUrlsFromContent(bookmark.content)
|
||||
// For web bookmarks (kind:39701), URL is stored in the 'd' tag
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
|
||||
|
||||
// Extract URLs from bookmark content (for regular bookmarks)
|
||||
// For web bookmarks, ensure URL has protocol
|
||||
const extractedUrls = webBookmarkUrl
|
||||
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
|
||||
: extractUrlsFromContent(bookmark.content)
|
||||
const hasUrls = extractedUrls.length > 0
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||
|
||||
// For kind:30023 articles, extract image tag (per NIP-23)
|
||||
// For kind:30023 articles, extract image and summary tags (per NIP-23)
|
||||
// Note: We extract directly from tags here since we don't have the full event.
|
||||
// When we have full events, we use getArticleImage() helper (see articleService.ts)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
|
||||
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[1] : undefined
|
||||
|
||||
// Fetch OG image for large view (hook must be at top level)
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
|
||||
@@ -106,7 +114,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage
|
||||
articleImage,
|
||||
articleSummary
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
@@ -37,6 +36,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isRefreshing,
|
||||
loading = false
|
||||
}) => {
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
// Re-sort after flattening to ensure newest first across all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(ib => ib.content || ib.kind === 30023 || ib.kind === 39701)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
const isBookmarked = selectedUrl && bookmarks.some(bookmark => {
|
||||
@@ -73,81 +78,24 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
) : bookmarks.length === 0 ? (
|
||||
) : allIndividualBookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{bookmarks.map((bookmark, index) => (
|
||||
<div key={`${bookmark.id}-${index}`} className="bookmark-item">
|
||||
{bookmark.bookmarkCount && (
|
||||
<p className="bookmark-count">
|
||||
{bookmark.bookmarkCount} bookmarks in{' '}
|
||||
<a
|
||||
href={`https://search.dergigi.com/e/${bookmark.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="event-link"
|
||||
>
|
||||
this list
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
)}
|
||||
{bookmark.urlReferences && bookmark.urlReferences.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
<h4>URLs:</h4>
|
||||
{bookmark.urlReferences.map((url, index) => (
|
||||
<a key={index} href={url} target="_blank" rel="noopener noreferrer" className="bookmark-url">
|
||||
{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bookmark.individualBookmarks && bookmark.individualBookmarks.length > 0 && (
|
||||
<div className="individual-bookmarks">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{bookmark.individualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={index}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.eventReferences && bookmark.eventReferences.length > 0 && bookmark.individualBookmarks?.length === 0 && (
|
||||
<div className="bookmark-events">
|
||||
<h4>Event References ({bookmark.eventReferences.length}):</h4>
|
||||
<div className="event-ids">
|
||||
{bookmark.eventReferences.slice(0, 3).map((eventId, index) => (
|
||||
<span key={index} className="event-id">
|
||||
{eventId.slice(0, 8)}...{eventId.slice(-8)}
|
||||
</span>
|
||||
))}
|
||||
{bookmark.eventReferences.length > 3 && (
|
||||
<span className="more-events">... and {bookmark.eventReferences.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<p className="bookmark-content">{bookmark.content}</p>
|
||||
)}
|
||||
<div className="bookmark-meta">
|
||||
<span>Created: {formatDate(bookmark.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
@@ -21,6 +21,7 @@ interface CardViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -35,13 +36,15 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage
|
||||
articleImage,
|
||||
articleSummary
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
@@ -54,7 +57,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
{bookmark.isPrivate ? (
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
@@ -116,7 +124,11 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmark.parsedContent ? (
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="bookmark-content article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{shouldTruncate && bookmark.content
|
||||
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}…`} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
@@ -15,6 +15,7 @@ interface CompactViewProps {
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -24,10 +25,12 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification
|
||||
firstUrlClassification,
|
||||
articleSummary
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isClickable = hasUrls || isArticle
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
@@ -39,6 +42,11 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// For articles, prefer summary; for others, use content
|
||||
const displayText = isArticle && articleSummary
|
||||
? articleSummary
|
||||
: bookmark.content
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
@@ -48,7 +56,12 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
<span className="bookmark-type-compact">
|
||||
{bookmark.isPrivate ? (
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
@@ -57,9 +70,9 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
</span>
|
||||
{bookmark.content && (
|
||||
{displayText && (
|
||||
<div className="compact-text">
|
||||
<ContentWithResolvedProfiles content={bookmark.content.slice(0, 60) + (bookmark.content.length > 60 ? '…' : '')} />
|
||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface LargeViewProps {
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -32,7 +33,8 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
@@ -59,7 +61,11 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
)}
|
||||
|
||||
<div className="large-content">
|
||||
{bookmark.content && (
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="large-text article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<div className="large-text">
|
||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -16,12 +16,13 @@ import Settings from './Settings'
|
||||
import Toast from './Toast'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
|
||||
import { createHighlight } from '../services/highlightCreationService'
|
||||
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { NostrEvent, nip19 } from 'nostr-tools'
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -31,6 +32,14 @@ interface BookmarksProps {
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Extract external URL from /r/* route
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? location.pathname.slice(3) // Remove '/r/' prefix
|
||||
: undefined
|
||||
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
@@ -66,7 +75,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
accountManager
|
||||
})
|
||||
|
||||
// Load article if naddr is in URL
|
||||
// Load nostr-native article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
@@ -80,6 +89,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Load external URL if /r/* route is used
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load initial data on login
|
||||
useEffect(() => {
|
||||
@@ -103,6 +126,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
|
||||
// Apply default highlight visibility settings
|
||||
setHighlightVisibility({
|
||||
nostrverse: settings.defaultHighlightVisibilityNostrverse !== false,
|
||||
friends: settings.defaultHighlightVisibilityFriends !== false,
|
||||
mine: settings.defaultHighlightVisibilityMine !== false
|
||||
})
|
||||
// Always start with both panels collapsed on initial load
|
||||
// Don't apply saved collapse settings on initial load - let user control them
|
||||
}, [settings])
|
||||
@@ -181,6 +210,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
|
||||
if (!relayPool) return
|
||||
|
||||
// Update the URL path based on content type
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For nostr articles, navigate to /a/:naddr
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (dTag && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For external URLs, navigate to /r/:url
|
||||
navigate(`/r/${url}`)
|
||||
}
|
||||
|
||||
setSelectedUrl(url)
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
@@ -201,22 +248,6 @@ const Bookmarks: React.FC<BookmarksProps> = ({ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextSelection = useCallback((text: string) => {
|
||||
highlightButtonRef.current?.updateSelection(text)
|
||||
}, [])
|
||||
@@ -226,28 +257,45 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}, [])
|
||||
|
||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||
if (!activeAccount || !relayPool || !currentArticle) {
|
||||
if (!activeAccount || !relayPool) {
|
||||
console.error('Missing requirements for highlight creation')
|
||||
return
|
||||
}
|
||||
|
||||
// Need either a nostr article or an external URL
|
||||
if (!currentArticle && !selectedUrl) {
|
||||
console.error('No source available for highlight creation')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createHighlight(
|
||||
// Determine the source: prefer currentArticle (for nostr content), fallback to selectedUrl (for external URLs)
|
||||
const source = currentArticle || selectedUrl!
|
||||
|
||||
// For context extraction, use article content or reader content
|
||||
const contentForContext = currentArticle
|
||||
? currentArticle.content
|
||||
: readerContent?.markdown || readerContent?.html
|
||||
|
||||
// Create and publish the highlight
|
||||
const signedEvent = await createHighlight(
|
||||
text,
|
||||
currentArticle,
|
||||
source,
|
||||
activeAccount,
|
||||
relayPool
|
||||
relayPool,
|
||||
contentForContext
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!')
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
|
||||
// Trigger refresh of highlights
|
||||
handleHighlightCreated()
|
||||
// Immediately add the highlight to the UI (optimistic update)
|
||||
const newHighlight = eventToHighlight(signedEvent)
|
||||
setHighlights(prev => [newHighlight, ...prev])
|
||||
} catch (error) {
|
||||
console.error('Failed to create highlight:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, currentArticle, handleHighlightCreated])
|
||||
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -58,10 +58,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
console.log('🔍 ContentPanel: Processing highlights', {
|
||||
totalHighlights: highlights.length,
|
||||
selectedUrl,
|
||||
showHighlights
|
||||
})
|
||||
|
||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
||||
|
||||
// Apply visibility filtering
|
||||
return urlFiltered
|
||||
const filtered = urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
@@ -78,7 +85,10 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
||||
return filtered
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
||||
|
||||
// Convert markdown to HTML when markdown content changes
|
||||
useEffect(() => {
|
||||
@@ -87,10 +97,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (markdownPreviewRef.current) {
|
||||
setRenderedHtml(markdownPreviewRef.current.innerHTML)
|
||||
const html = markdownPreviewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -100,13 +116,30 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Prepare the final HTML with highlights applied
|
||||
const finalHtml = useMemo(() => {
|
||||
const sourceHtml = markdown ? renderedHtml : html
|
||||
if (!sourceHtml) return ''
|
||||
|
||||
console.log('🎨 Preparing final HTML:', {
|
||||
hasMarkdown: !!markdown,
|
||||
hasHtml: !!html,
|
||||
renderedHtmlLength: renderedHtml.length,
|
||||
sourceHtmlLength: sourceHtml?.length || 0,
|
||||
showHighlights,
|
||||
relevantHighlightsCount: relevantHighlights.length
|
||||
})
|
||||
|
||||
if (!sourceHtml) {
|
||||
console.warn('⚠️ No source HTML available')
|
||||
return ''
|
||||
}
|
||||
|
||||
// Apply highlights if we have them and highlights are enabled
|
||||
if (showHighlights && relevantHighlights.length > 0) {
|
||||
return applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
||||
return highlightedHtml
|
||||
}
|
||||
|
||||
console.log('📄 Returning source HTML without highlights')
|
||||
return sourceHtml
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||
|
||||
@@ -224,7 +257,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
finalHtml ? (
|
||||
// For markdown, always use finalHtml once it's ready to ensure highlights are applied
|
||||
renderedHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
@@ -232,17 +266,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
// Show loading state while markdown is being converted to HTML
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// For HTML, use finalHtml directly
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
|
||||
@@ -136,7 +136,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobe} />
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import ColorPicker from './ColorPicker'
|
||||
@@ -20,6 +20,9 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
highlightColorNostrverse: '#9333ea',
|
||||
highlightColorFriends: '#f97316',
|
||||
highlightColorMine: '#ffff00',
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -39,13 +42,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
useEffect(() => {
|
||||
// Preload all fonts for the dropdown
|
||||
const fonts = ['inter', 'lora', 'merriweather', 'open-sans', 'roboto', 'source-serif-4', 'crimson-text', 'libre-baskerville', 'pt-serif']
|
||||
fonts.forEach(font => loadFont(font))
|
||||
fonts.forEach(font => {
|
||||
loadFont(font).catch(err => console.warn('Failed to preload font:', font, err))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Load font for preview when it changes
|
||||
const fontToLoad = localSettings.readingFont || 'source-serif-4'
|
||||
loadFont(fontToLoad)
|
||||
loadFont(fontToLoad).catch(err => console.warn('Failed to load preview font:', fontToLoad, err))
|
||||
}, [localSettings.readingFont])
|
||||
|
||||
// Auto-save settings whenever they change (except on initial mount)
|
||||
@@ -254,6 +259,33 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityNostrverse: !(localSettings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
title="Nostrverse highlights"
|
||||
ariaLabel="Toggle nostrverse highlights by default"
|
||||
variant={(localSettings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityFriends: !(localSettings.defaultHighlightVisibilityFriends !== false) })}
|
||||
title="Friends highlights"
|
||||
ariaLabel="Toggle friends highlights by default"
|
||||
variant={(localSettings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityMine: !(localSettings.defaultHighlightVisibilityMine !== false) })}
|
||||
title="My highlights"
|
||||
ariaLabel="Toggle my highlights by default"
|
||||
variant={(localSettings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
21
src/config/relays.ts
Normal file
21
src/config/relays.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Centralized relay configuration
|
||||
* Single set of relays used throughout the application
|
||||
*/
|
||||
|
||||
// All relays including local relay
|
||||
export const RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
||||
]
|
||||
|
||||
85
src/hooks/useExternalUrlLoader.ts
Normal file
85
src/hooks/useExternalUrlLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
interface UseExternalUrlLoaderProps {
|
||||
url: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
export function useExternalUrlLoader({
|
||||
url,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !url) return
|
||||
|
||||
const loadExternalUrl = async () => {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(url)
|
||||
setIsCollapsed(true)
|
||||
// Clear article-specific state
|
||||
setCurrentArticleCoordinate(undefined)
|
||||
setCurrentArticleEventId(undefined)
|
||||
|
||||
try {
|
||||
const content = await fetchReadableContent(url)
|
||||
setReaderContent(content)
|
||||
|
||||
console.log('🌐 External URL loaded:', content.title)
|
||||
|
||||
// Set reader loading to false immediately after content is ready
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights for this URL asynchronously
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
|
||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||
if (typeof fetchHighlightsForUrl === 'function') {
|
||||
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||
} else {
|
||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
setReaderContent({
|
||||
title: 'Error Loading Content',
|
||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url
|
||||
})
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExternalUrl()
|
||||
}, [url, relayPool])
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
|
||||
const RELAY_URLS = [
|
||||
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com', 'wss://wot.dergigi.com'
|
||||
]
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface UseSettingsParams {
|
||||
relayPool: RelayPool | null
|
||||
@@ -29,7 +25,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAY_URLS)
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
@@ -47,16 +43,32 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
// Apply settings to document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
if (fontKey !== 'system') loadFont(fontKey)
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||
const applyStyles = async () => {
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
|
||||
|
||||
// Load font first and wait for it to be ready
|
||||
if (fontKey !== 'system') {
|
||||
console.log('⏳ Waiting for font to load...')
|
||||
await loadFont(fontKey)
|
||||
console.log('✅ Font loaded, applying styles')
|
||||
}
|
||||
|
||||
// Apply font settings after font is loaded
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
|
||||
console.log('✅ All styles applied')
|
||||
}
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
applyStyles()
|
||||
}, [settings])
|
||||
|
||||
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
|
||||
@@ -65,7 +77,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const fullAccount = accountManager.getActive()
|
||||
if (!fullAccount) throw new Error('No active account')
|
||||
const factory = new EventFactory({ signer: fullAccount })
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAY_URLS)
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
|
||||
setSettings(newSettings)
|
||||
setToastType('success')
|
||||
setToastMessage('Settings saved')
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getArticlePublished,
|
||||
getArticleSummary
|
||||
} from 'applesauce-core/helpers'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
export interface ArticleContent {
|
||||
title: string
|
||||
@@ -95,15 +96,10 @@ export async function fetchArticleByNaddr(
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query
|
||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||
const relays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.primal.net'
|
||||
]
|
||||
: RELAYS
|
||||
|
||||
// Fetch the article event
|
||||
const filter = {
|
||||
|
||||
@@ -15,6 +15,9 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
}
|
||||
const unique = Array.from(byId.values())
|
||||
|
||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
@@ -33,6 +36,8 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
const out: NostrEvent[] = []
|
||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||
out.push(...setsAndNamedLists)
|
||||
// Add web bookmarks as individual events
|
||||
out.push(...webBookmarks)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,23 @@ export async function collectBookmarksFromEvents(
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
if (evt.kind === 39701) {
|
||||
publicItemsAll.push({
|
||||
id: evt.id,
|
||||
content: evt.content || '',
|
||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: evt.pubkey,
|
||||
kind: evt.kind,
|
||||
tags: evt.tags || [],
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ export const fetchBookmarks = async (
|
||||
}
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
// Fetch bookmark events - NIP-51 standards and legacy formats
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [10003, 30003, 30001], authors: [activeAccount.pubkey] })
|
||||
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
@@ -4,50 +4,81 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import {
|
||||
getHighlightText,
|
||||
getHighlightContext,
|
||||
getHighlightComment,
|
||||
getHighlightSourceEventPointer,
|
||||
getHighlightSourceAddressPointer,
|
||||
getHighlightSourceUrl,
|
||||
getHighlightAttributions
|
||||
} from 'applesauce-core/helpers'
|
||||
|
||||
/**
|
||||
* Creates and publishes a highlight event (NIP-84)
|
||||
* Supports both nostr-native articles and external URLs
|
||||
* Returns the signed event for immediate UI updates
|
||||
*/
|
||||
export async function createHighlight(
|
||||
selectedText: string,
|
||||
article: NostrEvent | null,
|
||||
source: NostrEvent | string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
contentForContext?: string,
|
||||
comment?: string
|
||||
): Promise<void> {
|
||||
if (!selectedText || !article) {
|
||||
): Promise<NostrEvent> {
|
||||
if (!selectedText || !source) {
|
||||
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)
|
||||
let blueprintSource: NostrEvent | AddressPointer | string
|
||||
let context: string | undefined
|
||||
|
||||
// Handle NostrEvent (article) source
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
blueprintSource = parseArticleCoordinate(source)
|
||||
context = extractContext(selectedText, source.content)
|
||||
}
|
||||
// Handle URL string source
|
||||
else {
|
||||
blueprintSource = source
|
||||
// Try to extract context from provided content if available
|
||||
if (contentForContext) {
|
||||
context = extractContext(selectedText, contentForContext)
|
||||
}
|
||||
}
|
||||
|
||||
// Create highlight event using the blueprint
|
||||
const highlightEvent = await factory.create(
|
||||
HighlightBlueprint,
|
||||
selectedText,
|
||||
addressPointer,
|
||||
comment ? { comment } : undefined
|
||||
blueprintSource,
|
||||
context ? { comment, context } : comment ? { comment } : undefined
|
||||
)
|
||||
|
||||
// Update the alt tag to identify Boris as the creator
|
||||
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
|
||||
if (altTagIndex !== -1) {
|
||||
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
|
||||
} else {
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Publish to relays (including local relay)
|
||||
await relayPool.publish(RELAYS, signedEvent)
|
||||
|
||||
console.log('✅ Highlight published:', signedEvent)
|
||||
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
|
||||
|
||||
// Return the signed event for immediate UI updates
|
||||
return signedEvent
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,3 +96,110 @@ function parseArticleCoordinate(article: NostrEvent): AddressPointer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts context for a highlight by finding the previous and next sentences
|
||||
* in the same paragraph as the selected text
|
||||
*/
|
||||
function extractContext(selectedText: string, articleContent: string): string | undefined {
|
||||
if (!selectedText || !articleContent) return undefined
|
||||
|
||||
// Find the position of the selected text in the article
|
||||
const selectedIndex = articleContent.indexOf(selectedText)
|
||||
if (selectedIndex === -1) return undefined
|
||||
|
||||
// Split content into paragraphs (by double newlines or single newlines)
|
||||
const paragraphs = articleContent.split(/\n\n+/)
|
||||
|
||||
// Find which paragraph contains the selected text
|
||||
let currentPos = 0
|
||||
let containingParagraph: string | undefined
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
const paragraphEnd = currentPos + paragraph.length
|
||||
if (selectedIndex >= currentPos && selectedIndex < paragraphEnd) {
|
||||
containingParagraph = paragraph
|
||||
break
|
||||
}
|
||||
currentPos = paragraphEnd + 2 // Account for the double newline
|
||||
}
|
||||
|
||||
if (!containingParagraph) return undefined
|
||||
|
||||
// Split paragraph into sentences (basic sentence splitting)
|
||||
// This regex splits on periods, exclamation marks, or question marks followed by space or end of string
|
||||
const sentences = containingParagraph.split(/([.!?]+\s+)/).filter(s => s.trim().length > 0)
|
||||
|
||||
// Reconstruct sentences properly by joining sentence text with punctuation
|
||||
const reconstructedSentences: string[] = []
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
if (sentences[i].match(/^[.!?]+\s*$/)) {
|
||||
// This is punctuation, attach it to previous sentence
|
||||
if (reconstructedSentences.length > 0) {
|
||||
reconstructedSentences[reconstructedSentences.length - 1] += sentences[i]
|
||||
}
|
||||
} else {
|
||||
reconstructedSentences.push(sentences[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Find which sentence contains the selected text
|
||||
let selectedSentenceIndex = -1
|
||||
for (let i = 0; i < reconstructedSentences.length; i++) {
|
||||
if (reconstructedSentences[i].includes(selectedText)) {
|
||||
selectedSentenceIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSentenceIndex === -1) return undefined
|
||||
|
||||
// Build context from previous and next sentences
|
||||
const contextParts: string[] = []
|
||||
|
||||
// Add previous sentence if it exists
|
||||
if (selectedSentenceIndex > 0) {
|
||||
contextParts.push(reconstructedSentences[selectedSentenceIndex - 1].trim())
|
||||
}
|
||||
|
||||
// Add the selected sentence itself
|
||||
contextParts.push(reconstructedSentences[selectedSentenceIndex].trim())
|
||||
|
||||
// Add next sentence if it exists
|
||||
if (selectedSentenceIndex < reconstructedSentences.length - 1) {
|
||||
contextParts.push(reconstructedSentences[selectedSentenceIndex + 1].trim())
|
||||
}
|
||||
|
||||
// Only return context if we have more than just the selected sentence
|
||||
return contextParts.length > 1 ? contextParts.join(' ') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a NostrEvent to a Highlight object for immediate UI display
|
||||
*/
|
||||
export function eventToHighlight(event: NostrEvent): Highlight {
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getHighlightAttributions
|
||||
} from 'applesauce-core/helpers'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
/**
|
||||
* Deduplicate highlight events by ID
|
||||
@@ -42,18 +43,9 @@ export const fetchHighlightsForArticle = async (
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
// Use well-known relays for highlights even if user isn't logged in
|
||||
const highlightRelays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.snort.social',
|
||||
'wss://purplepag.es'
|
||||
]
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
||||
console.log('🔍 Event ID:', eventId || 'none')
|
||||
console.log('🔍 From relays:', highlightRelays)
|
||||
console.log('🔍 From relays (including local):', RELAYS)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
@@ -89,7 +81,7 @@ export const fetchHighlightsForArticle = async (
|
||||
// Query for highlights that reference this article via the 'a' tag
|
||||
const aTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
@@ -111,7 +103,7 @@ export const fetchHighlightsForArticle = async (
|
||||
if (eventId) {
|
||||
eTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
@@ -183,6 +175,70 @@ export const fetchHighlightsForArticle = async (
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights for a specific URL
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param url - The external URL to find highlights for
|
||||
*/
|
||||
export const fetchHighlightsForUrl = async (
|
||||
relayPool: RelayPool,
|
||||
url: string
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(RELAYS, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Highlights for URL:', rawEvents.length)
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
})
|
||||
|
||||
return highlights.sort((a, b) => b.created_at - a.created_at)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights for URL:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights created by a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface UserSettings {
|
||||
highlightColorNostrverse?: string
|
||||
highlightColorFriends?: string
|
||||
highlightColorMine?: string
|
||||
// Default highlight visibility toggles
|
||||
defaultHighlightVisibilityNostrverse?: boolean
|
||||
defaultHighlightVisibilityFriends?: boolean
|
||||
defaultHighlightVisibilityMine?: boolean
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
@@ -30,10 +34,39 @@ export async function loadSettings(
|
||||
pubkey: string,
|
||||
relays: string[]
|
||||
): Promise<UserSettings | null> {
|
||||
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
|
||||
|
||||
// First, check if we already have settings in the local event store
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getAppDataContent<UserSettings>(localEvent)
|
||||
console.log('✅ Settings loaded from local store (cached):', content)
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached settings found, fetching from relays...')
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
console.warn('⚠️ Settings load timeout - no settings event found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
@@ -57,16 +90,20 @@ export async function loadSettings(
|
||||
)
|
||||
if (event) {
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
console.log('✅ Settings loaded from relays:', content)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
console.log('📭 No settings event found - using defaults')
|
||||
resolve(null)
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading settings:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
console.error('❌ Settings subscription error:', err)
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
@@ -88,11 +125,17 @@ export async function saveSettings(
|
||||
settings: UserSettings,
|
||||
relays: string[]
|
||||
): Promise<void> {
|
||||
console.log('💾 Saving settings to nostr:', settings)
|
||||
|
||||
const draft = await factory.create(AppDataBlueprint, SETTINGS_IDENTIFIER, settings, false)
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
|
||||
|
||||
eventStore.add(signed)
|
||||
await relayPool.publish(relays, signed)
|
||||
|
||||
console.log('✅ Settings published successfully')
|
||||
}
|
||||
|
||||
export function watchSettings(
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface IndividualBookmark {
|
||||
tags: string[][]
|
||||
parsedContent?: ParsedContent
|
||||
author?: string
|
||||
type: 'event' | 'article'
|
||||
type: 'event' | 'article' | 'web'
|
||||
isPrivate?: boolean
|
||||
encryptedContent?: string
|
||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
||||
|
||||
@@ -12,25 +12,83 @@ const FONT_FAMILIES: Record<string, string> = {
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<string>()
|
||||
const loadingFonts = new Map<string, Promise<void>>()
|
||||
|
||||
export function loadFont(fontKey: string) {
|
||||
if (fontKey === 'system' || loadedFonts.has(fontKey)) {
|
||||
return
|
||||
export async function loadFont(fontKey: string): Promise<void> {
|
||||
if (fontKey === 'system') {
|
||||
console.log('📝 Using system font')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (loadedFonts.has(fontKey)) {
|
||||
console.log('✅ Font already loaded:', fontKey)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// If font is currently loading, return the existing promise
|
||||
if (loadingFonts.has(fontKey)) {
|
||||
console.log('⏳ Font already loading:', fontKey)
|
||||
return loadingFonts.get(fontKey)!
|
||||
}
|
||||
|
||||
const fontFamily = FONT_FAMILIES[fontKey]
|
||||
if (!fontFamily) {
|
||||
console.warn(`Unknown font: ${fontKey}`)
|
||||
return
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Create a link element to load the font from Bunny Fonts
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
|
||||
document.head.appendChild(link)
|
||||
console.log('🔤 Loading font:', fontFamily)
|
||||
|
||||
loadedFonts.add(fontKey)
|
||||
// Create a promise for this font loading
|
||||
const loadPromise = new Promise<void>((resolve) => {
|
||||
// Create a link element to load the font from Bunny Fonts
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
|
||||
|
||||
// Wait for the stylesheet to load
|
||||
link.onload = () => {
|
||||
console.log('📄 Stylesheet loaded for:', fontFamily)
|
||||
|
||||
// Use Font Loading API to wait for the actual font to be ready
|
||||
if ('fonts' in document) {
|
||||
Promise.all([
|
||||
document.fonts.load(`400 16px "${fontFamily}"`),
|
||||
document.fonts.load(`700 16px "${fontFamily}"`)
|
||||
]).then(() => {
|
||||
console.log('✅ Font ready:', fontFamily)
|
||||
loadedFonts.add(fontKey)
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
}).catch((err) => {
|
||||
console.warn('⚠️ Font loading failed:', fontFamily, err)
|
||||
loadedFonts.add(fontKey) // Mark as loaded anyway to prevent retries
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
// Fallback: just wait a bit for older browsers
|
||||
setTimeout(() => {
|
||||
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
|
||||
loadedFonts.add(fontKey)
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
link.onerror = () => {
|
||||
console.error('❌ Failed to load font stylesheet:', fontFamily)
|
||||
loadedFonts.add(fontKey) // Mark as loaded to prevent retries
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve() // Resolve anyway so we don't block
|
||||
}
|
||||
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
|
||||
loadingFonts.set(fontKey, loadPromise)
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export function getFontFamily(fontKey: string | undefined): string {
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface UrlClassification {
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
export const classifyUrl = (url: string): UrlClassification => {
|
||||
export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||
if (!url) {
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
}
|
||||
const urlLower = url.toLowerCase()
|
||||
|
||||
// Check for YouTube
|
||||
|
||||
@@ -144,8 +144,6 @@ function tryMarkInTextNodes(
|
||||
|
||||
if (index === -1) continue
|
||||
|
||||
console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50))
|
||||
|
||||
let actualIndex = index
|
||||
if (useNormalized) {
|
||||
// Map normalized index back to original text
|
||||
@@ -172,14 +170,26 @@ function tryMarkInTextNodes(
|
||||
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
||||
*/
|
||||
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
|
||||
if (!html || highlights.length === 0) return html
|
||||
if (!html || highlights.length === 0) {
|
||||
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', { htmlLength: html?.length, highlightsCount: highlights.length })
|
||||
return html
|
||||
}
|
||||
|
||||
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
|
||||
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = html
|
||||
|
||||
let appliedCount = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
const searchText = highlight.content.trim()
|
||||
if (!searchText) continue
|
||||
if (!searchText) {
|
||||
console.warn('⚠️ Empty highlight content:', highlight.id)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
|
||||
|
||||
// Collect all text nodes
|
||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||
@@ -187,10 +197,21 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[], hig
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||
|
||||
console.log('📄 Found', textNodes.length, 'text nodes to search')
|
||||
|
||||
// Try exact match first, then normalized match
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
|
||||
if (found) {
|
||||
appliedCount++
|
||||
console.log('✅ Highlight applied successfully')
|
||||
} else {
|
||||
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
|
||||
|
||||
return tempDiv.innerHTML
|
||||
}
|
||||
|
||||
@@ -10,22 +10,43 @@ export function normalizeUrl(url: string): string {
|
||||
}
|
||||
|
||||
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
||||
if (!selectedUrl || highlights.length === 0) return []
|
||||
if (!selectedUrl || highlights.length === 0) {
|
||||
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
|
||||
|
||||
// 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:')) {
|
||||
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
|
||||
return highlights
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
console.log('🔗 Normalized selected URL:', normalizedSelected)
|
||||
|
||||
return highlights.filter(h => {
|
||||
if (!h.urlReference) return false
|
||||
const filtered = highlights.filter(h => {
|
||||
if (!h.urlReference) {
|
||||
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
|
||||
return false
|
||||
}
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
return normalizedSelected === normalizedRef ||
|
||||
const matches = normalizedSelected === normalizedRef ||
|
||||
normalizedSelected.includes(normalizedRef) ||
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
|
||||
if (matches) {
|
||||
console.log('✅ URL match:', normalizedRef)
|
||||
} else {
|
||||
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
|
||||
}
|
||||
|
||||
return matches
|
||||
})
|
||||
|
||||
console.log('📊 Filtered to', filtered.length, 'highlights')
|
||||
return filtered
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user