Compare commits

..

27 Commits

Author SHA1 Message Date
Gigi
c536de0144 chore: bump version to 0.1.9 2025-10-05 19:07:06 +01:00
Gigi
8e0970b717 fix: show markdown content immediately when finalHtml is empty
- Render markdown directly with ReactMarkdown when finalHtml is not ready yet
- Prevents empty content display while markdown is being converted to HTML
- Fixes issue where default article text doesn't show
2025-10-05 19:06:53 +01:00
Gigi
560a4a6785 chore: bump version to 0.1.8 2025-10-05 13:46:27 +01:00
Gigi
320e7f000a fix: prevent 'No readable content' flash for markdown articles
- Check for markdown/html existence before checking finalHtml
- Show empty container while markdown is being converted to HTML
- Fixes issue where nostr blog posts briefly showed error message
2025-10-05 13:34:38 +01:00
Gigi
832740fb59 fix: enable highlights display and scroll-to for markdown content
- Convert markdown to HTML before applying highlights
- Use hidden ReactMarkdown preview to render markdown
- Apply highlights to rendered HTML for both HTML and markdown content
- Fix scroll-to-highlight functionality for nostr blog posts (kind:30023)
- Ensure highlight marks are properly injected into markdown-rendered content
2025-10-05 13:28:49 +01:00
Gigi
4aea7b899b feat: persist accounts to localStorage
- Register common account types for deserialization
- Load persisted accounts and active account on app init
- Subscribe to account changes and save to localStorage
- Add cleanup for subscriptions on unmount
2025-10-05 13:26:28 +01:00
Gigi
43492a4488 refactor: simplify login by handling it directly in sidebar
Instead of navigating to /login route, login now happens directly when
clicking the login button in the sidebar header.

Changes:
- Moved login logic from Login component to SidebarHeader
- Uses Accounts.ExtensionAccount.fromExtension() directly
- Removed onLogin prop chain (App → Bookmarks → BookmarkList)
- Removed unnecessary BookmarksRoute wrapper component
- Shows 'Connecting...' state in button title during login
- Keeps code DRY by reusing same login logic without navigation

Result: Simpler, more direct user experience - one click to log in
from anywhere in the app.
2025-10-05 13:17:22 +01:00
Gigi
1552dd85d9 feat: show login button when logged out instead of logout button
- Added onLogin prop to Bookmarks, BookmarkList, and SidebarHeader
- SidebarHeader now conditionally renders login or logout button
- Login button uses faRightToBracket icon
- Logout button uses faRightFromBracket icon
- Clicking login button navigates to /login route
- Created BookmarksRoute wrapper to handle navigation
- Better UX for anonymous users browsing articles
2025-10-05 13:12:32 +01:00
Gigi
0bc89889e0 feat: show highlights in article content and add mode toggle
Fixes:
- Fixed highlight filtering for Nostr articles in urlHelpers.ts
  Now returns all highlights for nostr: URLs since they're pre-filtered
- This fixes highlights not appearing in article content

Features:
- Added highlight mode toggle: 'my highlights' vs 'other highlights'
- Icons: faUser (mine) and faUserGroup (others)
- Mode toggle only shows when user is logged in
- Filters highlights by user pubkey based on selected mode
- Default mode is 'others' to show community highlights
- Added CSS styling for mode toggle buttons

Result: Highlights now show both in the panel AND underlined in
the article text. Users can switch between viewing their own
highlights vs highlights from others.
2025-10-05 12:57:09 +01:00
Gigi
7a3dd421fb chore: bump version to 0.1.7 2025-10-05 12:55:10 +01:00
Gigi
4d95657bca refactor: keep Bookmarks.tsx under 210 lines by extracting logic
Extracted large functions into separate modules to follow DRY principles
and keep files manageable:

- Created useArticleLoader.ts hook (92 lines)
  - Handles article loading from naddr
  - Fetches article content and highlights
  - Sets up article coordinate for refresh

- Created contentLoader.ts utility (44 lines)
  - Handles both Nostr articles and web URLs
  - Unified content loading logic
  - Reusable across components

Result: Bookmarks.tsx reduced from 282 to 208 lines 

All files now under 210 line limit while maintaining functionality.
2025-10-05 12:47:32 +01:00
Gigi
6f28c3906c fix: show highlights for nostr articles by skipping URL filter
The HighlightsPanel was filtering out ALL highlights that didn't have
a urlReference. But Nostr article highlights reference the article via
the 'a' tag (article coordinate), not a URL.

Since we already fetch highlights specifically for the current article
using fetchHighlightsForArticle(), we don't need to filter them again.

Solution:
- Skip URL filtering when selectedUrl starts with 'nostr:'
- Keep URL filtering for web articles (backwards compatible)
- Highlights are already pre-filtered by the fetch query

This fixes the issue where 101 highlights existed for the default
article but weren't being displayed in the UI.
2025-10-05 12:37:28 +01:00
Gigi
fafe378585 fix: remove ImportMeta interface redeclaration
- ImportMeta is already defined as built-in global by vite/client
- Keep only ImportMetaEnv extension for custom env variables
- Fixes eslint no-redeclare error
2025-10-05 12:23:50 +01:00
Gigi
70b85b0cf0 fix: refresh button now works without login for article highlights
- Track current article coordinate and event ID in state
- Update handleFetchHighlights to refresh article highlights if viewing article
- Fall back to fetching user's highlights only if logged in and not viewing article
- Refresh button now works for anonymous article viewing
- No longer requires activeAccount to refresh highlights

Previously the refresh button only worked when logged in because it tried
to fetch highlights BY the user. Now it intelligently fetches highlights
FOR the current article, or falls back to user highlights if logged in.
2025-10-05 12:23:08 +01:00
Gigi
2297d8ae96 fix: query highlights using both a-tag and e-tag
- Highlights on replaceable events include BOTH 'a' and 'e' tags
- Query for highlights using article coordinate (#a tag)
- Also query using event ID (#e tag) for comprehensive results
- Combine and deduplicate results from both queries
- Add detailed logging to help diagnose why highlights aren't found
- Suggest checking highlighter.com if no highlights found

Per NIP-84 and applesauce implementation, highlights on kind:30023
articles include both an addressable reference ('a' tag) and an event
reference ('e' tag).
2025-10-05 09:19:43 +01:00
Gigi
343f176f06 debug: add detailed logging for highlight fetching
- Log article details (event ID, author, kind, d-tag, coordinate)
- Log filter being used for highlight queries
- Log sample highlight tags when found
- This will help debug why highlights aren't showing
2025-10-05 09:18:55 +01:00
Gigi
ee788cffb0 feat: add caching for nostr-native articles
- Add localStorage caching for kind:30023 articles (same as web articles)
- Cache TTL: 7 days
- Cache key prefix: article_cache_
- Add bypassCache parameter to fetchArticleByNaddr()
- Log cache hits and misses for debugging
- Gracefully handle storage errors

Articles are now cached locally after first fetch, making subsequent
loads instant and reducing relay queries.
2025-10-05 09:17:07 +01:00
Gigi
ca46feb80f fix: fetch highlights by article reference instead of author
- Add fetchHighlightsForArticle() to query highlights by article coordinate
- Use #a tag filter to find highlights that reference the article
- Query well-known relays for highlights even without authentication
- Extract article's d-tag and construct coordinate (kind:pubkey:identifier)
- Keep original fetchHighlights() for fetching user's own highlights
- Add detailed logging for debugging highlight fetching

This fixes the issue where no highlights were shown because we were
querying for highlights created BY the article author rather than
highlights created ABOUT the article.
2025-10-05 09:12:01 +01:00
Gigi
82ab07e606 feat: configure default article via environment variable
- Add VITE_DEFAULT_ARTICLE_NADDR env variable support
- Create .env with default article naddr
- Create .env.example for documentation
- Add vite-env.d.ts for TypeScript type support
- Fallback to hardcoded value if env var not set
- Using Vite's built-in env variable support (no dotenv needed)
2025-10-05 09:08:10 +01:00
Gigi
1f5e3f82b0 feat: load default article on startup with collapsed sidebars
- Redirect root path to default article (naddr)
- Start with both sidebars (bookmarks and highlights) collapsed
- Auto-fetch and show highlights for the article author
- No authentication required to view articles
- Highlights panel auto-expands when article loads
- Login page moved to /login route
2025-10-05 09:06:54 +01:00
Gigi
6265af74f2 docs: clarify why we extract image tag directly in BookmarkItem
Add comment explaining that we extract the image tag directly from
bookmark.tags since we don't have the full NostrEvent here. When we
do have full events (like in articleService), we use getArticleImage()
helper from applesauce-core as intended.
2025-10-05 08:24:05 +01:00
Gigi
e8f44986da feat: display article hero images in bookmark views and reader
- Add image prop to ContentPanel to display hero images
- Extract image tag from kind:30023 bookmark tags
- Display article images in Card, Large, and Compact views
- Show hero image at top of article reader view
- Add CSS styling for article-hero-image and reader-hero-image
- Article images clickable to open article in reader
- Per NIP-23: image tag contains header/preview image URL
2025-10-05 08:22:46 +01:00
Gigi
3d304dab15 fix: use bookmark pubkey for article author instead of tag lookup
- Pass pubkey along with bookmark data to handleSelectUrl
- Use bookmark.pubkey directly when constructing naddr
- More reliable article loading with correct author attribution
- Update type signatures across all components
2025-10-05 08:20:38 +01:00
Gigi
0f7a4d7877 feat: enable clicking on kind:30023 articles to open in reader
- Update handleSelectUrl to detect kind:30023 bookmarks
- Construct naddr from article event data (pubkey, d tag)
- Fetch and render articles using article service
- Update all bookmark views (Compact, Card, Large) to handle articles
- Show 'Read Article' button for kind:30023 bookmarks
- Articles load in the existing ContentPanel with full reader features
2025-10-05 08:19:50 +01:00
Gigi
d5e847e515 feat: show article titles for kind:30023 bookmarks
- Update hydrateItems to detect long-form articles (kind:30023)
- Extract and display article title using getArticleTitle helper
- Article titles now appear as bookmark content in lists
- Provides better context for bookmarked articles
2025-10-05 08:17:34 +01:00
Gigi
edd4e20e22 refactor: integrate long-form article rendering into existing reader view
- Create articleService to fetch articles by naddr
- Update Bookmarks component to detect naddr in URL params
- Articles now render in the existing ContentPanel with highlight support
- Remove standalone Article component
- Articles work seamlessly within the existing three-pane layout
- Support for article metadata (title, image, published date, summary)
2025-10-05 08:12:55 +01:00
Gigi
9b0c59b1ae feat: add native support for rendering Nostr long-form articles (NIP-23)
- Install react-router-dom for routing support
- Create Article component to decode naddr and fetch/render articles
- Add /a/:naddr route to App.tsx for article viewing
- Use applesauce relay pool patterns for event fetching
- Render articles with markdown using ReactMarkdown
- Support article metadata (title, image, published date, summary)
2025-10-05 08:08:34 +01:00
26 changed files with 1030 additions and 156 deletions

View File

@@ -0,0 +1,9 @@
---
description: nostr highlights spec and docs
alwaysApply: false
---
Here's the spec for nostr-native highlights:
- https://github.com/nostr-protocol/nips/blob/master/84.md
- https://nostrbook.dev/kinds/9802

View File

@@ -0,0 +1,11 @@
---
description: anything that has to do with kind:30023 aka nostr blog posts aka nostr-native long-form content
alwaysApply: false
---
Always stick to NIPs. Do everything with applesauce (getArticleTitle, getArticleSummary, getHashtags, getMentions).
- https://github.com/hzrd149/applesauce/blob/17c9dbb0f2c263e2ebd01729ea2fa138eca12bd1/packages/docs/tutorial/02-helpers.md
- https://github.com/nostr-protocol/nips/blob/master/19.md
- https://github.com/nostr-protocol/nips/blob/master/23.md
- https://nostrbook.dev/kinds/30023

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Default article to display on app load
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew

6
dist/index.html vendored
View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markr - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-sYF0VIKc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNyWhz1u.css">
<title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-8PiwZoBK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.css">
</head>
<body>
<div id="root"></div>

55
node_modules/.package-lock.json generated vendored
View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -2299,6 +2299,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4830,6 +4839,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/reading-time-estimator": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
@@ -5070,6 +5117,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

58
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
@@ -23,6 +23,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
},
@@ -2291,6 +2292,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4822,6 +4832,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/reading-time-estimator": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
@@ -5062,6 +5110,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.6",
"version": "0.1.9",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {
@@ -25,6 +25,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
},

View File

@@ -1,22 +1,64 @@
import { useState, useEffect } from 'react'
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'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
function App() {
const [eventStore, setEventStore] = useState<EventStore | null>(null)
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
useEffect(() => {
// 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
@@ -53,6 +95,12 @@ function App() {
setEventStore(store)
setAccountManager(accounts)
setRelayPool(pool)
// Cleanup subscriptions on unmount
return () => {
accountsSub.unsubscribe()
activeSub.unsubscribe()
}
}, [])
if (!eventStore || !accountManager || !relayPool) {
@@ -62,16 +110,23 @@ function App() {
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<div className="app">
{!isAuthenticated ? (
<Login onLogin={() => setIsAuthenticated(true)} />
) : (
<Bookmarks
relayPool={relayPool}
onLogout={() => setIsAuthenticated(false)}
/>
)}
</div>
<BrowserRouter>
<div className="app">
<Routes>
<Route
path="/a/:naddr"
element={
<Bookmarks
relayPool={relayPool}
onLogout={() => {}}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
<Route path="/login" element={<Login onLogin={() => {}} />} />
</Routes>
</div>
</BrowserRouter>
</AccountsProvider>
</EventStoreProvider>
)

View File

@@ -15,7 +15,7 @@ import { CardView } from './BookmarkViews/CardView'
interface BookmarkItemProps {
bookmark: IndividualBookmark
index: number
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
viewMode?: ViewMode
}
@@ -30,13 +30,19 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
const firstUrl = hasUrls ? extractedUrls[0] : null
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
// For kind:30023 articles, extract image tag (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
// Fetch OG image for large view (hook must be at top level)
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
React.useEffect(() => {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage) {
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage && !articleImage) {
fetchOgImage(firstUrl).then(setOgImage)
}
}, [viewMode, firstUrl, instantPreview, ogImage])
}, [viewMode, firstUrl, instantPreview, ogImage, articleImage])
// Resolve author profile using applesauce
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
@@ -68,10 +74,20 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
// For kind:30023 articles, pass the bookmark data instead of URL
if (bookmark.kind === 30023) {
if (onSelectUrl) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
}
return
}
// For regular bookmarks with URLs
if (!hasUrls) return
const firstUrl = extractedUrls[0]
if (onSelectUrl) {
event.preventDefault()
onSelectUrl(firstUrl)
} else {
window.open(firstUrl, '_blank')
@@ -89,7 +105,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
handleReadNow,
articleImage
}
if (viewMode === 'compact') {
@@ -97,9 +114,9 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
}
if (viewMode === 'large') {
const previewImage = instantPreview || ogImage
const previewImage = articleImage || instantPreview || ogImage
return <LargeView {...sharedProps} previewImage={previewImage} />
}
return <CardView {...sharedProps} />
return <CardView {...sharedProps} articleImage={articleImage} />
}

View File

@@ -9,7 +9,7 @@ import { ViewMode } from './Bookmarks'
interface BookmarkListProps {
bookmarks: Bookmark[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
isCollapsed: boolean
onToggleCollapse: () => void
onLogout: () => void
@@ -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,

View File

@@ -13,13 +13,14 @@ interface CardViewProps {
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
authorNpub: string
eventNevent?: string
getAuthorDisplayName: () => string
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
articleImage?: string
}
export const CardView: React.FC<CardViewProps> = ({
@@ -33,15 +34,24 @@ export const CardView: React.FC<CardViewProps> = ({
authorNpub,
eventNevent,
getAuthorDisplayName,
handleReadNow
handleReadNow,
articleImage
}) => {
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
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{isArticle && articleImage && (
<div
className="article-hero-image"
style={{ backgroundImage: `url(${articleImage})` }}
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
/>
)}
<div className="bookmark-header">
<span className="bookmark-type">
{bookmark.isPrivate ? (
@@ -141,11 +151,11 @@ export const CardView: React.FC<CardViewProps> = ({
{getAuthorDisplayName()}
</a>
</div>
{hasUrls && firstUrlClassification && (
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
<button className="read-now-button-minimal" onClick={handleReadNow}>
{firstUrlClassification.buttonText}
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
</button>
)}
) : null}
</div>
</div>
)

View File

@@ -11,9 +11,10 @@ interface CompactViewProps {
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
articleImage?: string
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -25,8 +26,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
getIconForUrlType,
firstUrlClassification
}) => {
const isArticle = bookmark.kind === 30023
const isClickable = hasUrls || isArticle
const handleCompactClick = () => {
if (hasUrls && onSelectUrl) {
if (!onSelectUrl) return
if (isArticle) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else if (hasUrls) {
onSelectUrl(extractedUrls[0])
}
}
@@ -34,10 +42,10 @@ export const CompactView: React.FC<CompactViewProps> = ({
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
className={`compact-row ${hasUrls ? 'clickable' : ''}`}
className={`compact-row ${isClickable ? 'clickable' : ''}`}
onClick={handleCompactClick}
role={hasUrls ? 'button' : undefined}
tabIndex={hasUrls ? 0 : undefined}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
>
<span className="bookmark-type-compact">
{bookmark.isPrivate ? (
@@ -55,13 +63,20 @@ export const CompactView: React.FC<CompactViewProps> = ({
</div>
)}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
{hasUrls && (
{isClickable && (
<button
className="compact-read-btn"
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(extractedUrls[0]) }}
title={firstUrlClassification?.buttonText}
onClick={(e) => {
e.stopPropagation()
if (isArticle) {
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else {
onSelectUrl?.(extractedUrls[0])
}
}}
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
>
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
</button>
)}
</div>

View File

@@ -10,7 +10,7 @@ interface LargeViewProps {
index: number
hasUrls: boolean
extractedUrls: string[]
onSelectUrl?: (url: string) => void
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
previewImage: string | null
@@ -34,15 +34,23 @@ export const LargeView: React.FC<LargeViewProps> = ({
getAuthorDisplayName,
handleReadNow
}) => {
const isArticle = bookmark.kind === 30023
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
{hasUrls && (
{(hasUrls || (isArticle && previewImage)) && (
<div
className="large-preview-image"
onClick={() => onSelectUrl?.(extractedUrls[0])}
onClick={() => {
if (isArticle) {
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
} else {
onSelectUrl?.(extractedUrls[0])
}
}}
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
>
{!previewImage && (
{!previewImage && hasUrls && (
<div className="preview-placeholder">
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
</div>
@@ -80,12 +88,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
</a>
)}
{hasUrls && firstUrlClassification && (
{(hasUrls && firstUrlClassification) || isArticle ? (
<button className="large-read-button" onClick={handleReadNow}>
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
{firstUrlClassification.buttonText}
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
</button>
)}
) : null}
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay'
@@ -6,13 +7,16 @@ import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
import { fetchBookmarks } from '../services/bookmarkService'
import { fetchHighlights } from '../services/highlightService'
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { ReadableContent } from '../services/readerService'
import Settings from './Settings'
import Toast from './Toast'
import { useSettings } from '../hooks/useSettings'
import { useArticleLoader } from '../hooks/useArticleLoader'
import { loadContent, BookmarkReference } from '../utils/contentLoader'
import { HighlightMode } from './HighlightsPanel'
export type ViewMode = 'compact' | 'cards' | 'large'
interface BookmarksProps {
@@ -21,18 +25,22 @@ interface BookmarksProps {
}
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [highlights, setHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true)
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
const [readerLoading, setReaderLoading] = useState(false)
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
const [isCollapsed, setIsCollapsed] = useState(false)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
const [viewMode, setViewMode] = useState<ViewMode>('compact')
const [showUnderlines, setShowUnderlines] = useState(true)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
const [highlightMode, setHighlightMode] = useState<HighlightMode>('others')
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
@@ -44,6 +52,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager
})
// Load article if naddr is in URL
useArticleLoader({
naddr,
relayPool,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setIsHighlightsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
})
// Load initial data on login
useEffect(() => {
if (!relayPool || !activeAccount) return
@@ -66,11 +89,25 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
const handleFetchHighlights = async () => {
if (!relayPool || !activeAccount) return
if (!relayPool) return
setHighlightsLoading(true)
try {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
// If we're viewing an article, fetch highlights for that article
if (currentArticleCoordinate) {
const fetchedHighlights = await fetchHighlightsForArticle(
relayPool,
currentArticleCoordinate,
currentArticleEventId
)
console.log(`🔄 Refreshed ${fetchedHighlights.length} highlights for article`)
setHighlights(fetchedHighlights)
}
// Otherwise, if logged in, fetch user's own highlights
else if (activeAccount) {
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
setHighlights(fetchedHighlights)
}
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
@@ -78,17 +115,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
}
const handleSelectUrl = async (url: string) => {
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
if (!relayPool) return
setSelectedUrl(url)
setReaderLoading(true)
setReaderContent(undefined)
setShowSettings(false)
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
try {
const content = await fetchReadableContent(url)
const content = await loadContent(url, relayPool, bookmark)
setReaderContent(content)
} catch (err) {
console.warn('Failed to fetch readable content:', err)
console.warn('Failed to fetch content:', err)
} finally {
setReaderLoading(false)
}
@@ -127,6 +167,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
title={readerContent?.title}
html={readerContent?.html}
markdown={readerContent?.markdown}
image={readerContent?.image}
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}
@@ -152,6 +193,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
selectedHighlightId={selectedHighlightId}
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
currentUserPubkey={activeAccount?.pubkey}
highlightMode={highlightMode}
onHighlightModeChange={setHighlightMode}
/>
</div>
</div>

View File

@@ -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'
@@ -15,6 +15,7 @@ interface ContentPanelProps {
html?: string
markdown?: string
selectedUrl?: string
image?: string
highlights?: Highlight[]
showUnderlines?: boolean
highlightStyle?: 'marker' | 'underline'
@@ -29,6 +30,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
html,
markdown,
selectedUrl,
image,
highlights = [],
showUnderlines = true,
highlightStyle = 'marker',
@@ -37,68 +39,42 @@ 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])
// Attach click handlers to highlight marks
useEffect(() => {
if (!onHighlightClick || !contentRef.current) return
@@ -120,9 +96,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(() => {
@@ -158,6 +150,20 @@ 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'} />
</div>
)}
{title && (
<div className="reader-header">
<h2 className="reader-title">{title}</h2>
@@ -177,14 +183,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 ? (
<div
ref={contentRef}
className={markdown ? "reader-markdown" : "reader-html"}
dangerouslySetInnerHTML={{ __html: finalHtml }}
/>
) : markdown ? (
<div
ref={contentRef}
className="reader-markdown"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
) : (
<div className="reader-html" ref={contentRef} />
)
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -1,9 +1,11 @@
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 } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
export type HighlightMode = 'mine' | 'others'
interface HighlightsPanelProps {
highlights: Highlight[]
loading: boolean
@@ -15,6 +17,9 @@ interface HighlightsPanelProps {
selectedHighlightId?: string
onRefresh?: () => void
onHighlightClick?: (highlightId: string) => void
currentUserPubkey?: string
highlightMode?: HighlightMode
onHighlightModeChange?: (mode: HighlightMode) => void
}
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
@@ -27,7 +32,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleUnderlines,
selectedHighlightId,
onRefresh,
onHighlightClick
onHighlightClick,
currentUserPubkey,
highlightMode = 'others',
onHighlightModeChange
}) => {
const [showUnderlines, setShowUnderlines] = useState(true)
@@ -37,29 +45,48 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
onToggleUnderlines?.(newValue)
}
// Filter highlights to show only those relevant to the current URL
// Filter highlights based on mode and URL
const filteredHighlights = useMemo(() => {
if (!selectedUrl) return highlights
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()
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:')) {
// 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)
// Filter by mode (mine vs others)
if (!currentUserPubkey) {
// If no user is logged in, show all highlights (others mode only makes sense)
return urlFiltered
}
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])
if (highlightMode === 'mine') {
return urlFiltered.filter(h => h.pubkey === currentUserPubkey)
} else {
return urlFiltered.filter(h => h.pubkey !== currentUserPubkey)
}
}, [highlights, selectedUrl, highlightMode, currentUserPubkey])
if (isCollapsed) {
const hasHighlights = filteredHighlights.length > 0
@@ -88,6 +115,26 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
{!loading && <span className="count">({filteredHighlights.length})</span>}
</div>
<div className="highlights-actions">
{currentUserPubkey && onHighlightModeChange && (
<div className="highlight-mode-toggle">
<button
onClick={() => onHighlightModeChange('mine')}
className={`mode-btn ${highlightMode === 'mine' ? 'active' : ''}`}
title="My highlights"
aria-label="Show my highlights"
>
<FontAwesomeIcon icon={faUser} />
</button>
<button
onClick={() => onHighlightModeChange('others')}
className={`mode-btn ${highlightMode === 'others' ? 'active' : ''}`}
title="Other highlights"
aria-label="Show highlights from others"
>
<FontAwesomeIcon icon={faUserGroup} />
</button>
</div>
)}
{onRefresh && (
<button
onClick={onRefresh}

View File

@@ -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

View File

@@ -0,0 +1,92 @@
import { useEffect } from 'react'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchHighlightsForArticle } from '../services/highlightService'
import { ReadableContent } from '../services/readerService'
import { Highlight } from '../types/highlights'
interface UseArticleLoaderProps {
naddr: string | undefined
relayPool: RelayPool | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setIsHighlightsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
}
export function useArticleLoader({
naddr,
relayPool,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed,
setIsHighlightsCollapsed,
setHighlights,
setHighlightsLoading,
setCurrentArticleCoordinate,
setCurrentArticleEventId
}: UseArticleLoaderProps) {
useEffect(() => {
if (!relayPool || !naddr) return
const loadArticle = async () => {
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`)
setIsCollapsed(true)
setIsHighlightsCollapsed(false)
try {
const article = await fetchArticleByNaddr(relayPool, naddr)
setReaderContent({
title: article.title,
markdown: article.markdown,
image: article.image,
url: `nostr:${naddr}`
})
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
setCurrentArticleCoordinate(articleCoordinate)
setCurrentArticleEventId(article.event.id)
console.log('📰 Article loaded:', article.title)
console.log('📍 Coordinate:', articleCoordinate)
try {
setHighlightsLoading(true)
const fetchedHighlights = await fetchHighlightsForArticle(
relayPool,
articleCoordinate,
article.event.id
)
console.log(`📌 Found ${fetchedHighlights.length} highlights`)
setHighlights(fetchedHighlights)
} catch (err) {
console.error('Failed to fetch highlights:', err)
} finally {
setHighlightsLoading(false)
}
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
setReaderLoading(false)
} finally {
setReaderLoading(false)
}
}
loadArticle()
}, [naddr, relayPool])
}

View File

@@ -1023,6 +1023,48 @@ body {
background: #218838;
}
/* Article hero image in card view */
.article-hero-image {
width: 100%;
height: 200px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 8px 8px 0 0;
position: relative;
}
.article-hero-image:hover {
opacity: 0.9;
}
.article-hero-image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%);
pointer-events: none;
border-radius: 8px 8px 0 0;
}
/* Hero image in reader view */
.reader-hero-image {
width: 100%;
margin: 0 0 2rem 0;
border-radius: 8px;
overflow: hidden;
}
.reader-hero-image img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
display: block;
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;
@@ -1218,6 +1260,35 @@ 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;
}
.refresh-highlights-btn,
.toggle-underlines-btn,
.toggle-highlights-btn {

View File

@@ -0,0 +1,165 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import {
getArticleTitle,
getArticleImage,
getArticlePublished,
getArticleSummary
} from 'applesauce-core/helpers'
export interface ArticleContent {
title: string
markdown: string
image?: string
published?: number
summary?: string
author: string
event: NostrEvent
}
interface CachedArticle {
content: ArticleContent
timestamp: number
}
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds
const CACHE_PREFIX = 'article_cache_'
function getCacheKey(naddr: string): string {
return `${CACHE_PREFIX}${naddr}`
}
function getFromCache(naddr: string): ArticleContent | null {
try {
const cacheKey = getCacheKey(naddr)
const cached = localStorage.getItem(cacheKey)
if (!cached) return null
const { content, timestamp }: CachedArticle = JSON.parse(cached)
const age = Date.now() - timestamp
if (age > CACHE_TTL) {
localStorage.removeItem(cacheKey)
return null
}
console.log('📦 Loaded article from cache:', naddr)
return content
} catch {
return null
}
}
function saveToCache(naddr: string, content: ArticleContent): void {
try {
const cacheKey = getCacheKey(naddr)
const cached: CachedArticle = {
content,
timestamp: Date.now()
}
localStorage.setItem(cacheKey, JSON.stringify(cached))
console.log('💾 Saved article to cache:', naddr)
} catch (err) {
console.warn('Failed to cache article:', err)
// Silently fail if storage is full or unavailable
}
}
/**
* Fetches a Nostr long-form article (NIP-23) by naddr
* @param relayPool - The relay pool to query
* @param naddr - The article's naddr
* @param bypassCache - If true, skip cache and fetch fresh from relays
*/
export async function fetchArticleByNaddr(
relayPool: RelayPool,
naddr: string,
bypassCache = false
): Promise<ArticleContent> {
try {
// Check cache first unless bypassed
if (!bypassCache) {
const cached = getFromCache(naddr)
if (cached) return cached
}
// Decode the naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
// Define relays to query
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'
]
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
if (events.length === 0) {
throw new Error('Article not found')
}
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
const title = getArticleTitle(article) || 'Untitled Article'
const image = getArticleImage(article)
const published = getArticlePublished(article)
const summary = getArticleSummary(article)
const content: ArticleContent = {
title,
markdown: article.content,
image,
published,
summary,
author: article.pubkey,
event: article
}
// Save to cache before returning
saveToCache(naddr, content)
return content
} catch (err) {
console.error('Failed to fetch article:', err)
throw err
}
}
/**
* Checks if a string is a valid naddr
*/
export function isNaddr(str: string): boolean {
try {
const decoded = nip19.decode(str)
return decoded.type === 'naddr'
} catch {
return false
}
}

View File

@@ -1,4 +1,5 @@
import { getParsedContent } from 'applesauce-content/text'
import { getArticleTitle } from 'applesauce-core/helpers'
import { ActiveAccount, IndividualBookmark, ParsedContent } from '../types/bookmarks'
import type { NostrEvent } from './bookmarkEvents'
@@ -94,14 +95,24 @@ export function hydrateItems(
return items.map(item => {
const ev = idToEvent.get(item.id)
if (!ev) return item
// For long-form articles (kind:30023), use the article title as content
let content = ev.content || item.content || ''
if (ev.kind === 30023) {
const articleTitle = getArticleTitle(ev)
if (articleTitle) {
content = articleTitle
}
}
return {
...item,
pubkey: ev.pubkey || item.pubkey,
content: ev.content || item.content || '',
content,
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(ev.content) as ParsedContent) : item.parsedContent
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
}
})
}

View File

@@ -29,6 +29,113 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
return Array.from(byId.values())
}
/**
* Fetches highlights for a specific article by its address coordinate and/or event ID
* @param relayPool - The relay pool to query
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
* @param eventId - Optional event ID to also query by 'e' tag
*/
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string
): 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)
// Query for highlights that reference this article via the 'a' tag
console.log('🔍 Filter 1 (a-tag):', JSON.stringify({ kinds: [9802], '#a': [articleCoordinate] }, null, 2))
const aTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
console.log('📊 Highlights via a-tag:', aTagEvents.length)
// If we have an event ID, also query for highlights that reference via the 'e' tag
let eTagEvents: NostrEvent[] = []
if (eventId) {
console.log('🔍 Filter 2 (e-tag):', JSON.stringify({ kinds: [9802], '#e': [eventId] }, null, 2))
eTagEvents = await lastValueFrom(
relayPool
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
console.log('📊 Highlights via e-tag:', eTagEvents.length)
}
// Combine results from both queries
const rawEvents = [...aTagEvents, ...eTagEvents]
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
if (rawEvents.length > 0) {
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
} else {
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
console.log('❌ Event ID:', eventId || 'none')
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
}
// Deduplicate events by ID
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
// Use applesauce helpers to extract highlight data
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)
// Get author from attributions
const author = attributions.find(a => a.role === 'author')?.pubkey
// Get event reference (prefer event pointer, fallback to address pointer)
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
}
})
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
} catch (error) {
console.error('Failed to fetch highlights for article:', error)
return []
}
}
/**
* Fetches highlights created by a specific user
* @param relayPool - The relay pool to query
* @param pubkey - The user's public key
*/
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string
@@ -36,7 +143,7 @@ export const fetchHighlights = async (
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching highlights (kind 9802) from relays:', relayUrls)
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
const rawEvents = await lastValueFrom(
relayPool
@@ -84,7 +191,7 @@ export const fetchHighlights = async (
// Sort by creation time (newest first)
return highlights.sort((a, b) => b.created_at - a.created_at)
} catch (error) {
console.error('Failed to fetch highlights:', error)
console.error('Failed to fetch highlights by author:', error)
return []
}
}

View File

@@ -6,6 +6,7 @@ export interface ReadableContent {
title?: string
html?: string
markdown?: string
image?: string
}
interface CachedContent {

View File

@@ -0,0 +1,44 @@
import { nip19 } from 'nostr-tools'
import { RelayPool } from 'applesauce-relay'
import { fetchArticleByNaddr } from '../services/articleService'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
export interface BookmarkReference {
id: string
kind: number
tags: string[][]
pubkey: string
}
export async function loadContent(
url: string,
relayPool: RelayPool,
bookmark?: BookmarkReference
): Promise<ReadableContent> {
// Check if this is a kind:30023 article
if (bookmark && bookmark.kind === 30023) {
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
if (dTag !== undefined && bookmark.pubkey) {
const pointer = {
identifier: dTag,
kind: 30023,
pubkey: bookmark.pubkey,
}
const naddr = nip19.naddrEncode(pointer)
const article = await fetchArticleByNaddr(relayPool, naddr)
return {
title: article.title,
markdown: article.markdown,
image: article.image,
url: `nostr:${naddr}`
}
} else {
throw new Error('Invalid article reference - missing d tag or pubkey')
}
} else {
// For regular URLs, fetch readable content
return await fetchReadableContent(url)
}
}

View File

@@ -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 => {

5
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEFAULT_ARTICLE_NADDR: string
}