Compare commits

...

9 Commits

Author SHA1 Message Date
Gigi
59b7816312 chore: bump version to 0.2.3 2025-10-07 05:35:32 +01:00
Gigi
3f1066ca71 build: update production build artifacts
- Update dist/index.html with latest changes
- Includes all features from recent commits
2025-10-07 05:34:20 +01:00
Gigi
744642e2b7 fix: ensure bookmarks are sorted newest first after merging lists
- Re-sort all individual bookmarks after flattening from multiple lists
- Sort by added_at first, then fall back to created_at
- Prevents interleaving of sorted lists from breaking overall order
- Ensures newest bookmarks always appear at the top
2025-10-07 05:25:50 +01:00
Gigi
fd28a6e171 feat: parse and display summary tag for nostr articles
- Extract 'summary' tag from kind:30023 article bookmarks
- Display summary in place of truncated content for articles
- Show summary in all view modes (compact, cards, large)
- Add article-summary CSS class for potential styling
- Follows NIP-23 long-form content specification
2025-10-07 05:12:11 +01:00
Gigi
0124de8318 feat: merge and flatten bookmarks from multiple lists
- Extract all individual bookmarks from all bookmark lists
- Display them in a single flat list (already sorted by date in service)
- Remove wrapper metadata like 'N bookmarks in this list'
- Show all bookmarks together, newest first
- Implements NIP-51 and NIP-B0 bookmark list merging
2025-10-07 05:06:00 +01:00
Gigi
b37aac0a33 fix: hide empty bookmarks without content
- Filter out individual bookmarks that have no content
- Keep articles (kind:30023) and web bookmarks (kind:39701) even if empty
- Prevents display of placeholder items showing only icons and timestamps
2025-10-07 05:02:36 +01:00
Gigi
81ef047a31 chore: remove created date from bookmark list display
- Remove bookmark-meta div showing creation timestamp
- Cleaner UI without redundant date information
2025-10-07 04:59:55 +01:00
Gigi
704033e6cb fix: remove encrypted cyphertext display from bookmark list
- Remove display of top-level bookmark.content and bookmark.parsedContent
- These fields contain encrypted data that shouldn't be shown to users
- Individual bookmarks are already displayed properly within each list
2025-10-07 04:56:42 +01:00
Gigi
d59d27419e feat: update URL path when opening bookmarks from sidebar
- Add URL navigation when selecting bookmarks
- Navigate to /a/:naddr for nostr articles (kind:30023)
- Navigate to /r/:url for external URLs
- Encode article bookmarks to naddr format on selection
2025-10-07 04:55:30 +01:00
8 changed files with 71 additions and 83 deletions

2
dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<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-rEUBRPdE.js"></script>
<script type="module" crossorigin src="/assets/index-D55Gme04.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.css">
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.2.2",
"version": "0.2.3",
"description": "A minimal nostr client for bookmark management",
"type": "module",
"scripts": {

View File

@@ -37,11 +37,12 @@ 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)
// 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
@@ -113,7 +114,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage
articleImage,
articleSummary
}
if (viewMode === 'compact') {

View File

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

View File

@@ -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,7 +36,8 @@ export const CardView: React.FC<CardViewProps> = ({
eventNevent,
getAuthorDisplayName,
handleReadNow,
articleImage
articleImage,
articleSummary
}) => {
const [expanded, setExpanded] = useState(false)
const [urlsExpanded, setUrlsExpanded] = useState(false)
@@ -122,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()}`} />

View File

@@ -15,6 +15,7 @@ interface CompactViewProps {
getIconForUrlType: IconGetter
firstUrlClassification: { buttonText: string } | null
articleImage?: string
articleSummary?: string
}
export const CompactView: React.FC<CompactViewProps> = ({
@@ -24,7 +25,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
extractedUrls,
onSelectUrl,
getIconForUrlType,
firstUrlClassification
firstUrlClassification,
articleSummary
}) => {
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
@@ -40,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
@@ -63,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>

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react'
import { useParams, useLocation } 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'
@@ -22,7 +22,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
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 {
@@ -33,6 +33,7 @@ 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/')
@@ -209,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)