Compare commits

...

38 Commits

Author SHA1 Message Date
Gigi
0fe38e94d3 chore: bump version to 0.5.0 2025-10-12 23:38:51 +02:00
Gigi
722e8adbdf Merge pull request #3 from dergigi/local-first
perf: parallel local-first fetching, instant render, and non-wiping refreshes
2025-10-12 22:37:38 +01:00
Gigi
886d5ac08c chore(lint): satisfy react-hooks dependency in Explore; lints clean 2025-10-12 23:35:23 +02:00
Gigi
89d5ba4c37 ui(explore): shrink refresh spinner footprint; inline-sized loading row 2025-10-12 23:33:28 +02:00
Gigi
b8b9f82d91 ux(explore): preserve posts across navigations; seed from cache; merge streamed + final 2025-10-12 23:30:56 +02:00
Gigi
b3fc9bb5c3 ux(bookmarks): avoid clearing list when no new events; decouple refetch from route changes 2025-10-12 23:26:18 +02:00
Gigi
d2ebcd8fbe ux(explore): keep posts visible during refresh; inline spinner; no list wipe 2025-10-12 23:25:05 +02:00
Gigi
68c9623c35 ux(bookmarks): keep list visible during refresh; show spinner only; no wipe 2025-10-12 23:22:53 +02:00
Gigi
496d1df404 perf(parallel): run local+remote fetches concurrently across services; stream+dedupe 2025-10-12 23:13:26 +02:00
Gigi
ea1046fe13 perf(local-first): always follow up with remote for articles and titles 2025-10-12 23:00:23 +02:00
Gigi
6d58d6e7f3 fix(highlights): ensure nostrverse fetch merges remote results after local for article/url 2025-10-12 22:58:25 +02:00
Gigi
e1420140d1 chore(lint): fix eslint and typescript issues; no rule changes 2025-10-12 22:55:46 +02:00
Gigi
484c2e0c2f refactor(highlights): split service into smaller modules; keep files <210 lines 2025-10-12 22:54:11 +02:00
Gigi
31f7d53829 perf(explore): stream contacts + early posts from local; merge remote later 2025-10-12 22:43:35 +02:00
Gigi
e3debfa5df perf(local-first): apply local-first then remote pattern across services (titles, bookmarks, highlights) 2025-10-12 22:42:24 +02:00
Gigi
a1305fba81 fix(explore): always query remote relays after local; stream merge into UI 2025-10-12 22:38:29 +02:00
Gigi
ca95d6c7f4 perf(ui): stream results to UI; show cached/local immediately (articles, highlights, explore) 2025-10-12 22:33:46 +02:00
Gigi
5513fc9850 perf(relays): local-first queries with short timeouts; fallback to remote if needed 2025-10-12 22:06:49 +02:00
Gigi
86de98e644 Merge pull request #2 from dergigi/pwa
Upgrade to full Progressive Web App
2025-10-12 07:54:37 +01:00
Gigi
fd374cd705 docs: remove temporary PWA launch checklist and implementation summary 2025-10-12 07:18:20 +01:00
Gigi
20b4658bef docs(pwa): update implementation summary to reflect final icons and next steps 2025-10-12 07:18:02 +01:00
Gigi
0850ba250c chore(lint): fix service worker typings and satisfy ESLint for worker globals 2025-10-12 07:15:52 +01:00
Gigi
b71d188fd8 chore: remove favicon zip file after extraction 2025-10-11 21:01:27 +01:00
Gigi
579f6b9a96 docs: update PWA docs to reflect branded icons are now in place 2025-10-11 20:58:56 +01:00
Gigi
d9403a73c6 feat(icons): replace placeholder icons with branded favicons
- Replace placeholder PWA icons with proper Boris-branded icons
- Add favicon.ico, favicon-16x16.png, favicon-32x32.png
- Add apple-touch-icon.png for iOS devices
- Update index.html with proper favicon links
- All icons extracted from boris-favicon.zip
- PWA icons now use android-chrome-192x192 and android-chrome-512x512
2025-10-11 20:58:26 +01:00
Gigi
747811fa94 docs: add PWA launch checklist 2025-10-11 20:43:13 +01:00
Gigi
489e480394 docs: add PWA implementation summary and guide 2025-10-11 20:42:29 +01:00
Gigi
418bcb0295 feat(pwa): upgrade to full PWA with vite-plugin-pwa
- Add web app manifest with proper metadata and icon support
- Configure vite-plugin-pwa with injectManifest strategy
- Migrate service worker to Workbox with precaching and runtime caching
- Add runtime caching for cross-origin images (preserves existing behavior)
- Add runtime caching for cross-origin article HTML for offline reading
- Create PWA install hook and UI component in settings
- Add online/offline status monitoring and toast notifications
- Add service worker update notifications
- Add placeholder PWA icons (192x192, 512x512, maskable variants)
- Update HTML with manifest link and theme-color meta tag
- Preserve existing relay/airplane mode functionality (WebSockets not intercepted)

The app now passes PWA installability criteria while maintaining all existing
offline functionality. Icons should be replaced with proper branded designs.
2025-10-11 20:41:49 +01:00
Gigi
88f01554e7 fix: improve mobile touch targets for highlight icons
- Increase touch target size to 44x44px on mobile (relay & delete icons)
- Add proper padding to both icons for larger tap area
- Increase spacing between relay and delete icons
- Expand quote icon container width on mobile to accommodate both targets
- Increase icon font size on mobile (0.85rem) for better visibility
- Move icons slightly outward (-8px) to prevent overlap

Icons are now much easier to tap on mobile devices without
accidentally hitting the wrong button.
2025-10-11 09:06:17 +01:00
Gigi
c85092a644 fix: color /me highlights with 'my highlights' color setting
- Set level: 'mine' on all highlights in /me page
- Highlights now use the customizable --highlight-color-mine CSS variable
- Quote icons and borders automatically match user's color preference
- Consistent styling with highlights shown elsewhere in the app
2025-10-11 09:04:48 +01:00
Gigi
096478bcec feat: add author info card for nostr-native articles
- Create AuthorCard component showing profile picture and bio
- Display author card after mark as read button
- Only shown for nostr-native articles (not external URLs)
- Fetch author profile data using applesauce ProfileModel
- Card displays author name, avatar, and bio (truncated to 3 lines)
- Responsive design with smaller avatar on mobile
- Elegant card styling matching app design system

Author information helps readers learn more about article authors
directly within the reading experience.
2025-10-11 08:55:37 +01:00
Gigi
b8de4a85e0 docs: update CHANGELOG for v0.4.3 release 2025-10-11 08:40:05 +01:00
Gigi
a5b7cedfaa chore: bump version to 0.4.3 2025-10-11 08:39:16 +01:00
Gigi
0adb8d6766 feat: add highlight deletion with confirmation dialog
- Create deletionService for NIP-09 kind:5 event deletion requests
- Add ConfirmDialog component for user confirmation before deletion
- Add subtle delete button to highlight items (trash icon)
- Only show delete button for user's own highlights
- Position delete button symmetrically opposite to relay indicator
- Add confirmation dialog to prevent accidental deletions
- Remove highlights from UI immediately after deletion request
- Style delete button with red hover color
- Add comprehensive confirmation dialog styling (danger/warning/info variants)

Implements NIP-09 Event Deletion Request.
Users can now delete their own highlights after confirming the action.
2025-10-11 08:38:22 +01:00
Gigi
6a6b8c4fad feat: add mark as read button for articles
- Create reactionService for handling kind:7 and kind:17 reactions
- Add mark as read button at the end of articles (📚 emoji)
- Use kind:7 reaction for nostr-native articles (/a/ paths)
- Use kind:17 reaction for external websites (/r/ paths)
- Pass activeAccount and currentArticle props through component tree
- Add responsive styling for mark as read button
- Button shows loading state while creating reaction
- Only visible when user is logged in

Implements NIP-25 (kind:7 reactions) and NIP-25 (kind:17 website reactions).
Users can now mark articles as read, creating a permanent record on nostr.
2025-10-11 08:34:36 +01:00
Gigi
4f952816ea feat: compact relay status indicator on mobile
- Show only icon on mobile (44x44px touch target)
- Tap to expand for full details
- Smooth transition between compact and expanded states
- Maintains full display on desktop
- Reduces screen clutter on mobile while keeping info accessible

Flight mode notice now just shows airplane icon on mobile.
Tap it to see full connection details.
2025-10-11 08:09:29 +01:00
Gigi
76835e2509 feat: add /me page to show user's recent highlights
- Create Me component to display logged-in user's highlights
- Add /me route to App routing
- Update SidebarHeader to navigate to /me when clicking profile avatar
- Integrate Me page in ThreePaneLayout (same as Settings/Explore)
- Show user profile info and highlight count
- List all highlights created by the user

Clicking the profile picture now takes you to your personal highlights page.
2025-10-11 08:07:20 +01:00
Gigi
63af770c83 docs: update CHANGELOG for v0.4.2 release 2025-10-11 03:25:11 +01:00
50 changed files with 6269 additions and 447 deletions

View File

@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.3] - 2025-10-11
### Added
- Mark as read functionality for articles (NIP-25)
- Button at the end of each article to mark as read with 📚 emoji
- Creates kind:7 reactions for nostr-native articles (`/a/` paths)
- Creates kind:17 reactions for external websites (`/r/` paths)
- Button shows loading state while publishing reaction
- Only visible when user is logged in
- Highlight deletion with confirmation dialog (NIP-09)
- Small delete button (trash icon) on highlight items
- Only visible for user's own highlights
- Confirmation dialog prevents accidental deletions
- Styled to match relay indicator (subtle, same size)
- Removes highlights from UI immediately after deletion request
- `/me` page showing user's recent highlights
- Accessible by clicking profile picture in bookmark sidebar
- Displays all highlights created by the logged-in user
- Uses same rendering as Settings and Explore pages
- Includes highlight count in header
- Confirmation dialog component
- Reusable modal with danger/warning/info variants
- Backdrop blur effect
- Mobile-responsive design
- Prevents accidental destructive actions
### Changed
- Relay status indicator on mobile now displays in compact mode
- Shows only airplane icon by default (44x44px touch target)
- Tap to expand for full connection details
- Reduces screen clutter on mobile while keeping info accessible
- Smooth transition between compact and expanded states
- Desktop view remains unchanged (always shows full details)
## [0.4.2] - 2025-10-11
### Added
- NIP-19 identifier resolution in article content (NIP-19, NIP-27)
- Support for `nostr:npub1...`, `nostr:note1...`, `nostr:nprofile1...`, `nostr:nevent1...`, `nostr:naddr1...`
- Converts nostr: URIs to clickable links with human-readable labels
- Automatically fetches and displays article titles for `naddr` references
- Falls back to identifier when title fetch fails
- Auto-hide mobile UI buttons on scroll down
- Floating bookmark/highlights buttons hide when scrolling down
- Buttons reappear when scrolling up for distraction-free reading
- Smooth opacity transitions for better UX
- Scroll direction detection hook (`useScrollDirection`)
- Supports both window and element-based scroll detection
- Configurable threshold and enable/disable options
### Changed
- Article references (`naddr`) now link internally to `/a/{naddr}` instead of external njump.me
- Sidebar auto-closes on mobile when navigating to content via routes
- Handles clicking on blog posts in Explore view
- Complements existing sidebar auto-close for bookmarks
- Markdown processing now async to support article title resolution
- Article title resolution fetches titles in parallel for better performance
### Fixed
- Mobile button scroll detection now correctly monitors main pane element
- Previously monitored window scroll which didn't work on mobile
- Content scrolls within `.pane.main` div on mobile devices
- All ESLint warnings and TypeScript type errors resolved
- Added react-hooks plugin to ESLint configuration
- Fixed exhaustive-deps warnings in components
- Added block scoping to switch case statements
- Corrected type references for nostr-tools decode result
## [0.4.1] - 2025-10-10
### Fixed

View File

@@ -2,8 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Boris - Nostr Bookmarks</title>
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
<link rel="canonical" href="https://read.withboris.com/" />

4181
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.4.2",
"version": "0.5.0",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -40,7 +40,9 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vite-plugin-pwa": "^1.0.3",
"workbox-window": "^7.3.0"
},
"eslintConfig": {
"root": true,

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,37 @@
{
"name": "Boris - Nostr Bookmarks",
"short_name": "Boris",
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#0f172a",
"background_color": "#0b1220",
"orientation": "any",
"categories": ["productivity", "social", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -1,56 +0,0 @@
// Service Worker for Boris - handles offline image caching
const CACHE_NAME = 'boris-image-cache-v1'
// Install event - activate immediately
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...')
self.skipWaiting()
})
// Activate event - take control immediately
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...')
event.waitUntil(self.clients.claim())
})
// Fetch event - intercept image requests
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Only intercept image requests
const isImage = event.request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
if (!isImage) {
return // Let other requests pass through
}
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('[SW] Serving cached image:', url.pathname)
return cachedResponse
}
// Not in cache, try to fetch
return fetch(event.request)
.then(response => {
// Only cache successful responses
if (response && response.status === 200) {
// Clone the response before caching
cache.put(event.request, response.clone())
console.log('[SW] Cached new image:', url.pathname)
}
return response
})
.catch(error => {
console.error('[SW] Fetch failed for:', url.pathname, error)
// Return a fallback or let it fail
throw error
})
})
})
)
})

View File

@@ -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 { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
@@ -69,6 +70,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/me"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)
@@ -79,6 +89,7 @@ function App() {
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
const { toastMessage, toastType, showToast, clearToast } = useToast()
const isOnline = useOnlineStatus()
useEffect(() => {
const initializeApp = async () => {
@@ -174,6 +185,25 @@ function App() {
}
}, [])
// Monitor online/offline status
useEffect(() => {
if (!isOnline) {
showToast('You are offline. Some features may be limited.')
}
}, [isOnline, showToast])
// Listen for service worker updates
useEffect(() => {
const handleSWUpdate = () => {
showToast('New version available! Refresh to update.')
}
window.addEventListener('sw-update-available', handleSWUpdate)
return () => {
window.removeEventListener('sw-update-available', handleSWUpdate)
}
}, [showToast])
if (!eventStore || !accountManager || !relayPool) {
return (
<div className="loading">

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
interface AuthorCardProps {
authorPubkey: string
}
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
const getAuthorName = () => {
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
}
const authorImage = profile?.picture || profile?.image
const authorBio = profile?.about
return (
<div className="author-card">
<div className="author-card-avatar">
{authorImage ? (
<img src={authorImage} alt={getAuthorName()} />
) : (
<FontAwesomeIcon icon={faUserCircle} />
)}
</div>
<div className="author-card-content">
<div className="author-card-name">{getAuthorName()}</div>
{authorBio && (
<p className="author-card-bio">{authorBio}</p>
)}
</div>
</div>
)
}
export default AuthorCard

View File

@@ -111,16 +111,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isMobile={isMobile}
/>
{loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
) : allIndividualBookmarks.length === 0 ? (
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
{allIndividualBookmarks.length === 0 ? (
loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
) : (
<div className="empty-state">
<p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</p>
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
</div>
)
) : (
<div className="bookmarks-list">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>

View File

@@ -14,6 +14,7 @@ import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -35,13 +36,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
const showMe = location.pathname === '/me'
// Track previous location for going back from settings
// Track previous location for going back from settings/me/explore
useEffect(() => {
if (!showSettings) {
if (!showSettings && !showMe && !showExplore) {
previousLocationRef.current = location.pathname
}
}, [location.pathname, showSettings])
}, [location.pathname, showSettings, showMe, showExplore])
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
@@ -202,6 +204,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -244,6 +247,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
onClearSelection={handleClearSelection}
currentUserPubkey={activeAccount?.pubkey}
followedPubkeys={followedPubkeys}
activeAccount={activeAccount}
currentArticle={currentArticle}
highlights={highlights}
highlightsLoading={highlightsLoading}
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
@@ -257,6 +262,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
me={showMe ? (
relayPool ? <Me relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel: () => void
variant?: 'danger' | 'warning' | 'info'
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
variant = 'warning'
}) => {
if (!isOpen) return null
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className={`confirm-dialog-icon ${variant}`}>
<FontAwesomeIcon icon={faExclamationTriangle} />
</div>
<h3 className="confirm-dialog-title">{title}</h3>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-btn cancel"
onClick={onCancel}
>
{cancelText}
</button>
<button
className={`confirm-dialog-btn confirm ${variant}`}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
)
}
export default ConfirmDialog

View File

@@ -1,9 +1,11 @@
import React, { useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { readingTime } from 'reading-time-estimator'
import { hexToRgb } from '../utils/colorHelpers'
@@ -13,6 +15,8 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
import { useHighlightedContent } from '../hooks/useHighlightedContent'
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
import { UserSettings } from '../services/settingsService'
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
import AuthorCard from './AuthorCard'
interface ContentPanelProps {
loading: boolean
@@ -34,6 +38,8 @@ interface ContentPanelProps {
followedPubkeys?: Set<string>
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -54,6 +60,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightColor = '#ffff00',
settings,
relayPool,
activeAccount,
currentArticle,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -62,6 +70,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
@@ -93,6 +102,44 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
const hasHighlights = relevantHighlights.length > 0
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
const handleMarkAsRead = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot mark as read: no account or relay pool')
return
}
setIsMarkingAsRead(true)
try {
if (isNostrArticle && currentArticle) {
// Kind 7 reaction for nostr-native articles
await createEventReaction(
currentArticle.id,
currentArticle.pubkey,
currentArticle.kind,
activeAccount,
relayPool
)
console.log('✅ Marked nostr article as read')
} else if (selectedUrl) {
// Kind 17 reaction for external websites
await createWebsiteReaction(
selectedUrl,
activeAccount,
relayPool
)
console.log('✅ Marked website as read')
}
} catch (error) {
console.error('Failed to mark as read:', error)
} finally {
setIsMarkingAsRead(false)
}
}
if (!selectedUrl) {
return (
<div className="reader empty">
@@ -135,31 +182,55 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
settings={settings}
/>
{markdown || html ? (
markdown ? (
renderedMarkdownHtml && finalHtml ? (
<>
{markdown ? (
renderedMarkdownHtml && finalHtml ? (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
</div>
)
) : (
<div
ref={contentRef}
className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
) : (
<div className="reader-markdown">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
</div>
)}
{/* Mark as Read button */}
{activeAccount && (
<div className="mark-as-read-container">
<button
className="mark-as-read-btn"
onClick={handleMarkAsRead}
disabled={isMarkingAsRead}
title="Mark as Read"
>
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
</button>
</div>
)
) : (
<div
ref={contentRef}
className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/>
)
)}
{/* Author info card for nostr-native articles */}
{isNostrArticle && currentArticle && (
<div className="author-card-container">
<AuthorCard authorPubkey={currentArticle.pubkey} />
</div>
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

View File

@@ -7,6 +7,7 @@ import { nip19 } from 'nostr-tools'
import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import BlogPostCard from './BlogPostCard'
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
interface ExploreProps {
relayPool: RelayPool
@@ -27,11 +28,59 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
try {
// show spinner but keep existing posts
setLoading(true)
setError(null)
// Seed from in-memory cache if available to avoid empty flash
const cached = getCachedPosts(activeAccount.pubkey)
if (cached && cached.length > 0 && blogPosts.length === 0) {
setBlogPosts(cached)
}
// Fetch the user's contacts (friends)
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
const contacts = await fetchContacts(
relayPool,
activeAccount.pubkey,
(partial) => {
// When local contacts are available, kick off early posts fetch
if (partial.size > 0) {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
fetchBlogPostsFromAuthors(
relayPool,
Array.from(partial),
relayUrls,
(post) => {
// merge into UI and cache as we stream
setBlogPosts((prev) => {
const exists = prev.some(p => p.event.id === post.event.id)
if (exists) return prev
const next = [...prev, post]
return next.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
})
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
}
).then((all) => {
// Ensure union of streamed + final is displayed
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of all) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
})
}
}
)
if (contacts.size === 0) {
setError('You are not following anyone yet. Follow some people to see their blog posts!')
@@ -39,21 +88,25 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return
}
// Get relay URLs from pool
// After full contacts, do a final pass for completeness
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
// Fetch blog posts from friends
const posts = await fetchBlogPostsFromAuthors(
relayPool,
Array.from(contacts),
relayUrls
)
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
if (posts.length === 0) {
setError('No blog posts found from your friends yet')
}
setBlogPosts(posts)
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of posts) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
} catch (err) {
console.error('Failed to load blog posts:', err)
setError('Failed to load blog posts. Please try again.')
@@ -63,7 +116,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
}
loadBlogPosts()
}, [relayPool, activeAccount])
}, [relayPool, activeAccount, blogPosts.length])
const getPostUrl = (post: BlogPostPreview) => {
// Get the d-tag identifier
@@ -79,17 +132,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
return `/a/${naddr}`
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading blog posts from your friends...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
@@ -112,6 +154,12 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
Discover blog posts from your friends on Nostr
</p>
</div>
{loading && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
<span>Refreshing posts</span>
</div>
)}
<div className="explore-grid">
{blogPosts.map((post) => (
<BlogPostCard
@@ -120,6 +168,11 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
href={getPostUrl(post)}
/>
))}
{!loading && blogPosts.length === 0 && (
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
<p>No blog posts found yet.</p>
</div>
)}
</div>
</div>
)

View File

@@ -1,15 +1,18 @@
import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights'
import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay'
import { Hooks } from 'applesauce-react'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
import { createDeletionRequest } from '../services/deletionService'
import ConfirmDialog from './ConfirmDialog'
interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse'
@@ -23,6 +26,7 @@ interface HighlightItemProps {
relayPool?: RelayPool | null
eventStore?: IEventStore | null
onHighlightUpdate?: (highlight: Highlight) => void
onHighlightDelete?: (highlightId: string) => void
}
export const HighlightItem: React.FC<HighlightItemProps> = ({
@@ -32,12 +36,17 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
onHighlightClick,
relayPool,
eventStore,
onHighlightUpdate
onHighlightUpdate,
onHighlightDelete
}) => {
const itemRef = useRef<HTMLDivElement>(null)
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const activeAccount = Hooks.useActiveAccount()
// Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -243,7 +252,51 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const relayIndicator = getRelayIndicatorInfo()
// Check if current user can delete this highlight
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteConfirm(true)
}
const handleConfirmDelete = async () => {
if (!activeAccount || !relayPool) {
console.warn('Cannot delete: no account or relay pool')
return
}
setIsDeleting(true)
setShowDeleteConfirm(false)
try {
await createDeletionRequest(
highlight.id,
9802, // kind for highlights
'Deleted by user',
activeAccount,
relayPool
)
console.log('✅ Highlight deletion request published')
// Notify parent to remove this highlight from the list
if (onHighlightDelete) {
onHighlightDelete(highlight.id)
}
} catch (error) {
console.error('Failed to delete highlight:', error)
} finally {
setIsDeleting(false)
}
}
const handleCancelDelete = () => {
setShowDeleteConfirm(false)
}
return (
<>
<div
ref={itemRef}
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
@@ -263,6 +316,15 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
</div>
)}
{canDelete && (
<div
className="highlight-delete-btn"
title="Delete highlight"
onClick={handleDeleteClick}
>
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
</div>
)}
</div>
<div className="highlight-content">
@@ -301,6 +363,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</div>
</div>
</div>
<ConfirmDialog
isOpen={showDeleteConfirm}
title="Delete Highlight?"
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
confirmText="Delete"
cancelText="Cancel"
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
)
}

View File

@@ -72,6 +72,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
)
}
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
}
const filteredHighlights = useFilteredHighlights({
highlights: localHighlights,
selectedUrl,
@@ -129,6 +134,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
relayPool={relayPool}
eventStore={eventStore}
onHighlightUpdate={handleHighlightUpdate}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>

119
src/components/Me.tsx Normal file
View File

@@ -0,0 +1,119 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faUser, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { HighlightItem } from './HighlightItem'
import { fetchHighlights } from '../services/highlightService'
interface MeProps {
relayPool: RelayPool
}
const Me: React.FC<MeProps> = ({ relayPool }) => {
const activeAccount = Hooks.useActiveAccount()
const [highlights, setHighlights] = useState<Highlight[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
const getUserDisplayName = () => {
if (!activeAccount) return 'Unknown User'
if (profile?.name) return profile.name
if (profile?.display_name) return profile.display_name
if (profile?.nip05) return profile.nip05
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
}
useEffect(() => {
const loadHighlights = async () => {
if (!activeAccount) {
setError('Please log in to view your highlights')
setLoading(false)
return
}
try {
setLoading(true)
setError(null)
// Fetch highlights created by the user
const userHighlights = await fetchHighlights(
relayPool,
activeAccount.pubkey
)
if (userHighlights.length === 0) {
setError('No highlights yet. Start highlighting content to see them here!')
}
setHighlights(userHighlights)
} catch (err) {
console.error('Failed to load highlights:', err)
setError('Failed to load highlights. Please try again.')
} finally {
setLoading(false)
}
}
loadHighlights()
}, [relayPool, activeAccount])
const handleHighlightDelete = (highlightId: string) => {
// Remove highlight from local state
setHighlights(prev => prev.filter(h => h.id !== highlightId))
}
if (loading) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<p>Loading your highlights...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="explore-container">
<div className="explore-error">
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
<p>{error}</p>
</div>
</div>
)
}
return (
<div className="explore-container">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faUser} />
{getUserDisplayName()}
</h1>
<p className="explore-subtitle">
<FontAwesomeIcon icon={faHighlighter} /> {highlights.length} highlight{highlights.length !== 1 ? 's' : ''}
</p>
</div>
<div className="highlights-list me-highlights-list">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={{ ...highlight, level: 'mine' }}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
</div>
)
}
export default Me

View File

@@ -4,6 +4,7 @@ import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-s
import { RelayPool } from 'applesauce-relay'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { isLocalRelay } from '../utils/helpers'
import { useIsMobile } from '../hooks/useMediaQuery'
interface RelayStatusIndicatorProps {
relayPool: RelayPool | null
@@ -13,6 +14,8 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
// Poll frequently for responsive offline indicator (5s instead of default 20s)
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
const [isConnecting, setIsConnecting] = useState(true)
const [isExpanded, setIsExpanded] = useState(false)
const isMobile = useIsMobile()
if (!relayPool) return null
@@ -57,36 +60,55 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
// Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null
const handleClick = () => {
if (isMobile) {
setIsExpanded(!isExpanded)
}
}
const showDetails = !isMobile || isExpanded
return (
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
}>
<div
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''}`}
title={
!isMobile ? (
isConnecting
? 'Connecting to relays...'
: offlineMode
? 'Offline - No relays connected'
: 'Local Relays Only - Highlights will be marked as local'
) : undefined
}
onClick={handleClick}
style={{ cursor: isMobile ? 'pointer' : 'default' }}
>
<div className="relay-status-icon">
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
</div>
<div className="relay-status-text">
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>
{showDetails && (
<>
<div className="relay-status-text">
{isConnecting ? (
<span className="relay-status-title">Connecting</span>
) : offlineMode ? (
<>
<span className="relay-status-title">Offline</span>
<span className="relay-status-subtitle">No relays connected</span>
</>
) : (
<>
<span className="relay-status-title">Flight Mode</span>
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
{!offlineMode && !isConnecting && (
<div className="relay-status-pulse">
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
</div>
)}
</>
)}
</div>
)

View File

@@ -10,6 +10,7 @@ import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus'
const DEFAULT_SETTINGS: UserSettings = {
@@ -164,6 +165,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div>
</div>
)

View File

@@ -0,0 +1,84 @@
import React from 'react'
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { usePWAInstall } from '../../hooks/usePWAInstall'
const PWASettings: React.FC = () => {
const { isInstallable, isInstalled, installApp } = usePWAInstall()
const handleInstall = async () => {
const success = await installApp()
if (success) {
console.log('App installed successfully')
}
}
if (isInstalled) {
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
<span>Boris is installed as an app</span>
</div>
<p className="setting-description">
You can launch Boris from your home screen or app drawer.
</p>
</div>
</div>
)
}
if (!isInstallable) {
return null
}
return (
<div className="settings-section">
<h3>Progressive Web App</h3>
<div className="setting-item">
<div className="setting-info">
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
<span>Install Boris as an app</span>
</div>
<p className="setting-description">
Install Boris on your device for a native app experience with offline support.
</p>
<button
onClick={handleInstall}
className="install-button"
style={{
marginTop: '12px',
padding: '8px 16px',
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
transition: 'transform 0.2s, box-shadow 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
>
<FontAwesomeIcon icon={faDownload} />
Install App
</button>
</div>
</div>
)
}
export default PWASettings

View File

@@ -90,8 +90,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
<div
className="profile-avatar"
title={activeAccount ? getUserDisplayName() : "Login"}
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
onClick={
activeAccount
? () => navigate('/me')
: (isConnecting ? () => {} : handleLogin)
}
style={{ cursor: 'pointer' }}
>
{profileImage ? (
<img src={profileImage} alt={getUserDisplayName()} />

View File

@@ -20,6 +20,8 @@ import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
import { useScrollDirection } from '../hooks/useScrollDirection'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
interface ThreePaneLayoutProps {
// Layout state
@@ -28,6 +30,7 @@ interface ThreePaneLayoutProps {
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
showMe?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -59,6 +62,8 @@ interface ThreePaneLayoutProps {
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// Highlights pane
highlights: Highlight[]
@@ -81,6 +86,9 @@ interface ThreePaneLayoutProps {
// Optional Explore content
explore?: React.ReactNode
// Optional Me content
me?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
@@ -288,6 +296,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.explore}
</>
) : props.showMe && props.me ? (
// Render Me inside the main pane to keep side panels
<>
{props.me}
</>
) : (
<ContentPanel
loading={props.readerLoading}
@@ -311,6 +324,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
/>
)}
</div>

View File

@@ -44,10 +44,14 @@ export const useBookmarksData = ({
const handleFetchBookmarks = useCallback(async () => {
if (!relayPool || !activeAccount) return
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
setBookmarksLoading(true)
try {
const fullAccount = accountManager.getActive()
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
// merge-friendly: updater form that preserves visible list until replacement
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
setBookmarks(() => next)
}, settings)
} finally {
setBookmarksLoading(false)
}
@@ -102,15 +106,21 @@ export const useBookmarksData = ({
}
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
// Load initial data
// Load initial data (avoid clearing on route-only changes)
useEffect(() => {
if (!relayPool || !activeAccount) return
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
handleFetchBookmarks()
}, [relayPool, activeAccount, handleFetchBookmarks])
// Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => {
if (!relayPool || !activeAccount) return
if (!naddr) {
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,

View File

@@ -11,7 +11,7 @@ interface UseExternalUrlLoaderProps {
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
setHighlights: (highlights: Highlight[]) => void
setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void
setHighlightsLoading: (loading: boolean) => void
setCurrentArticleCoordinate: (coord: string | undefined) => void
setCurrentArticleEventId: (id: string | undefined) => void
@@ -57,7 +57,21 @@ export function useExternalUrlLoader({
// Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') {
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
const seen = new Set<string>()
const highlightsList = await fetchHighlightsForUrl(
relayPool,
url,
(highlight) => {
if (seen.has(highlight.id)) return
seen.add(highlight.id)
setHighlights((prev) => {
if (prev.some(h => h.id === highlight.id)) return prev
const next = [...prev, highlight]
return next.sort((a, b) => b.created_at - a.created_at)
})
}
)
// Ensure final list is sorted and contains all items
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
} else {

View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react'
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Back online')
setIsOnline(true)
}
const handleOffline = () => {
console.log('📴 Gone offline')
setIsOnline(false)
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstallable, setIsInstallable] = useState(false)
const [isInstalled, setIsInstalled] = useState(false)
useEffect(() => {
// Check if app is already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true)
return
}
// Listen for the beforeinstallprompt event
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
const installPromptEvent = e as BeforeInstallPromptEvent
setDeferredPrompt(installPromptEvent)
setIsInstallable(true)
}
// Listen for successful installation
const handleAppInstalled = () => {
setIsInstalled(true)
setIsInstallable(false)
setDeferredPrompt(null)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
}
}, [])
const installApp = async () => {
if (!deferredPrompt) {
return false
}
try {
await deferredPrompt.prompt()
const choiceResult = await deferredPrompt.userChoice
if (choiceResult.outcome === 'accepted') {
console.log('✅ PWA installed')
setIsInstallable(false)
setDeferredPrompt(null)
return true
} else {
console.log('❌ PWA installation dismissed')
return false
}
} catch (error) {
console.error('Error installing PWA:', error)
return false
}
}
return {
isInstallable,
isInstalled,
installApp,
}
}

View File

@@ -961,6 +961,156 @@ body.mobile-sidebar-open {
padding: 0.1rem 0.3rem;
}
/* Mark as Read button */
.mark-as-read-container {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem 1rem;
margin-top: 2rem;
border-top: 1px solid #333;
}
.mark-as-read-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
min-width: 160px;
justify-content: center;
}
.mark-as-read-btn:hover:not(:disabled) {
background: #333;
border-color: #555;
transform: translateY(-1px);
}
.mark-as-read-btn:active:not(:disabled) {
transform: translateY(0);
}
.mark-as-read-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mark-as-read-btn svg {
font-size: 1.1rem;
}
@media (max-width: 768px) {
.mark-as-read-container {
padding: 1.5rem 1rem;
}
.mark-as-read-btn {
width: 100%;
max-width: 300px;
}
}
/* Author Card */
.author-card-container {
display: flex;
justify-content: center;
padding: 2rem 1rem;
}
.author-card {
display: flex;
gap: 1rem;
padding: 1.5rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
max-width: 600px;
width: 100%;
}
.author-card-avatar {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
background: #2a2a2a;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.author-card-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-card-avatar svg {
font-size: 2.5rem;
}
.author-card-content {
flex: 1;
min-width: 0;
}
.author-card-name {
font-size: 1rem;
font-weight: 600;
color: #ddd;
margin-bottom: 0.5rem;
}
.author-card-bio {
font-size: 0.9rem;
color: #999;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.author-card-container {
padding: 1.5rem 1rem;
}
.author-card {
padding: 1rem;
}
.author-card-avatar {
width: 48px;
height: 48px;
}
.author-card-avatar svg {
font-size: 2rem;
}
.author-card-name {
font-size: 0.95rem;
}
.author-card-bio {
font-size: 0.85rem;
-webkit-line-clamp: 2;
}
}
.bookmark-item {
background: #1a1a1a;
padding: 1.5rem;
@@ -1934,13 +2084,19 @@ body.mobile-sidebar-open {
.highlight-relay-indicator {
position: absolute;
bottom: -2px;
left: 0;
bottom: -4px;
left: -6px;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
padding: 4px;
min-width: 20px;
min-height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.highlight-relay-indicator:hover {
@@ -1953,6 +2109,58 @@ body.mobile-sidebar-open {
transform: scale(0.95);
}
.highlight-delete-btn {
position: absolute;
bottom: -4px;
right: -6px;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
padding: 4px;
min-width: 20px;
min-height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.highlight-delete-btn:hover {
opacity: 1;
color: #ff4444;
transform: scale(1.1);
}
.highlight-delete-btn:active {
transform: scale(0.95);
}
/* Mobile: Larger touch targets and better spacing */
@media (max-width: 768px) {
.highlight-quote-icon {
min-width: 100px; /* Ensure enough space for both touch targets */
}
.highlight-relay-indicator {
bottom: -8px;
left: -8px;
padding: 8px;
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
font-size: 0.85rem;
}
.highlight-delete-btn {
bottom: -8px;
right: -8px;
padding: 8px;
min-width: var(--min-touch-target);
min-height: var(--min-touch-target);
font-size: 0.85rem;
}
}
/* Level-colored quote icon */
.highlight-item.level-mine .highlight-quote-icon {
color: var(--highlight-color-mine, #ffff00);
@@ -2909,6 +3117,34 @@ body.mobile-sidebar-open {
cursor: default;
}
/* Mobile compact mode - just show icon */
@media (max-width: 768px) {
.relay-status-indicator.mobile {
padding: 0.5rem;
width: var(--min-touch-target);
height: var(--min-touch-target);
display: flex;
align-items: center;
justify-content: center;
bottom: 1rem;
left: 1rem;
}
.relay-status-indicator.mobile.expanded {
width: auto;
padding: 0.75rem 1rem;
gap: 0.75rem;
}
.relay-status-indicator.mobile .relay-status-icon {
font-size: 1.1rem;
}
.relay-status-indicator.mobile:active {
transform: scale(0.95);
}
}
.relay-status-indicator.connecting {
background: rgba(100, 108, 255, 0.15);
border: 1px solid rgba(100, 108, 255, 0.25);
@@ -3024,10 +3260,15 @@ body.mobile-sidebar-open {
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 50vh;
color: rgba(255, 255, 255, 0.7);
}
/* Compact, inline loading row for Explore refresh */
.explore-loading {
min-height: 0;
padding: 0.25rem 0;
}
.explore-error {
color: #ff6b6b;
}
@@ -3156,3 +3397,160 @@ body.mobile-sidebar-open {
padding: 1rem;
}
}
/* Confirmation Dialog */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
}
.confirm-dialog {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
padding: 2rem;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.confirm-dialog-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
}
.confirm-dialog-icon.warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border: 2px solid rgba(245, 158, 11, 0.3);
}
.confirm-dialog-icon.danger {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 2px solid rgba(239, 68, 68, 0.3);
}
.confirm-dialog-icon.info {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border: 2px solid rgba(59, 130, 246, 0.3);
}
.confirm-dialog-title {
font-size: 1.25rem;
font-weight: 600;
color: #ddd;
margin: 0;
text-align: center;
}
.confirm-dialog-message {
font-size: 0.95rem;
color: #999;
margin: 0;
text-align: center;
line-height: 1.5;
}
.confirm-dialog-actions {
display: flex;
gap: 0.75rem;
width: 100%;
margin-top: 0.5rem;
}
.confirm-dialog-btn {
flex: 1;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.confirm-dialog-btn.cancel {
background: #2a2a2a;
color: #ddd;
border: 1px solid #444;
}
.confirm-dialog-btn.cancel:hover {
background: #333;
border-color: #555;
}
.confirm-dialog-btn.confirm {
color: #1a1a1a;
}
.confirm-dialog-btn.confirm.warning {
background: #f59e0b;
}
.confirm-dialog-btn.confirm.warning:hover {
background: #d97706;
}
.confirm-dialog-btn.confirm.danger {
background: #ef4444;
color: white;
}
.confirm-dialog-btn.confirm.danger:hover {
background: #dc2626;
}
.confirm-dialog-btn.confirm.info {
background: #3b82f6;
color: white;
}
.confirm-dialog-btn.confirm.info:hover {
background: #2563eb;
}
.confirm-dialog-btn:active {
transform: scale(0.98);
}
@media (max-width: 768px) {
.confirm-dialog {
padding: 1.5rem;
max-width: 90%;
}
.confirm-dialog-icon {
width: 50px;
height: 50px;
font-size: 1.5rem;
}
.confirm-dialog-title {
font-size: 1.1rem;
}
.confirm-dialog-message {
font-size: 0.9rem;
}
}

View File

@@ -3,21 +3,31 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
// Register Service Worker for offline image caching
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.register('/sw.js', { type: 'module' })
.then(registration => {
console.log('✅ Service Worker registered:', registration.scope)
// Update service worker when a new version is available
// Check for updates periodically
setInterval(() => {
registration.update()
}, 60 * 60 * 1000) // Check every hour
// Handle service worker updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('🔄 Service Worker updated, page may need reload')
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New service worker available
console.log('🔄 New version available! Reload to update.')
// Optionally show a toast notification
const updateAvailable = new CustomEvent('sw-update-available')
window.dispatchEvent(updateAvailable)
}
})
}

View File

@@ -1,10 +1,12 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, take } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
@@ -98,9 +100,11 @@ export async function fetchArticleByNaddr(
const pointer = decoded.data as AddressPointer
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
const relays = pointer.relays && pointer.relays.length > 0
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event
const filter = {
@@ -109,12 +113,10 @@ export async function fetchArticleByNaddr(
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
// Parallel local+remote, stream immediate, collect up to first from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
const events = collected as NostrEvent[]
if (events.length === 0) {
throw new Error('Article not found')

View File

@@ -1,9 +1,11 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { RelayPool } from 'applesauce-relay'
import { lastValueFrom, take } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
import { merge, toArray as rxToArray } from 'rxjs'
const { getArticleTitle } = Helpers
@@ -25,9 +27,11 @@ export async function fetchArticleTitle(
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
const baseRelays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
const orderedRelays = prioritizeLocalRelays(baseRelays)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
// Fetch the article event
const filter = {
@@ -36,11 +40,11 @@ export async function fetchArticleTitle(
'#d': [pointer.identifier]
}
// Parallel local+remote: collect up to one event from each
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 5000)
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
)
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
) as unknown as { created_at: number }[]
if (events.length === 0) {
return null
@@ -48,7 +52,7 @@ export async function fetchArticleTitle(
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
return getArticleTitle(article) || null
} catch (err) {

View File

@@ -1,5 +1,5 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import {
AccountWithExtension,
NostrEvent,
@@ -16,6 +16,7 @@ import { Bookmark } from '../types/bookmarks'
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
@@ -31,14 +32,22 @@ export const fetchBookmarks = async (
throw new Error('Invalid account object provided')
}
// Get relay URLs from the pool
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls)
// 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, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
)
// Try local-first quickly, then full set fallback
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
// Rebroadcast bookmark events to local/all relays based on settings
@@ -64,7 +73,7 @@ export const fetchBookmarks = async (
const bookmarkListEvents = dedupeNip51Events(rawEvents)
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
if (bookmarkListEvents.length === 0) {
setBookmarks([])
// Keep existing bookmarks visible; do not clear list if nothing new found
return
}
// Aggregate across events
@@ -102,9 +111,14 @@ export const fetchBookmarks = async (
let idToEvent: Map<string, NostrEvent> = new Map()
if (noteIds.length > 0) {
try {
const events = await lastValueFrom(
relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls)
const localHydrate$ = localHydrate.length > 0
? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remoteHydrate$ = remoteHydrate.length > 0
? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500)))
: new Observable<NostrEvent>((sub) => sub.complete())
const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray()))
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
} catch (error) {
console.warn('Failed to fetch events for hydration:', error)

View File

@@ -1,5 +1,6 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { prioritizeLocalRelays } from '../utils/helpers'
/**
* Fetches the contact list (follows) for a specific user
@@ -9,40 +10,49 @@ import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
*/
export const fetchContacts = async (
relayPool: RelayPool,
pubkey: string
pubkey: string,
onPartial?: (contacts: Set<string>) => void
): Promise<Set<string>> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
// Local-first quick attempt
const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1'))
const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1'))
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
const events = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [3], authors: [pubkey] })
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
merge(local$, remote$).pipe(toArray())
)
const followed = new Set<string>()
if (events.length > 0) {
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followed.add(tag[1])
}
}
if (onPartial) onPartial(new Set(followed))
}
// merged already via streams
console.log('📊 Contact events fetched:', events.length)
if (events.length === 0) {
return new Set()
}
// Get the most recent contact list
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
const contactList = sortedEvents[0]
// Extract pubkeys from 'p' tags
const followedPubkeys = new Set<string>()
for (const tag of contactList.tags) {
if (tag[0] === 'p' && tag[1]) {
followedPubkeys.add(tag[1])
}
}
console.log('👥 Followed contacts:', followedPubkeys.size)
return followedPubkeys
console.log('👥 Followed contacts:', followed.size)
return followed
} catch (error) {
console.error('Failed to fetch contacts:', error)
return new Set()

View File

@@ -0,0 +1,48 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
/**
* Creates a kind:5 event deletion request (NIP-09)
* @param eventId The ID of the event to delete
* @param eventKind The kind of the event being deleted
* @param reason Optional reason for deletion
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed deletion request event
*/
export async function createDeletionRequest(
eventId: string,
eventKind: number,
reason: string | undefined,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 5, // Event Deletion Request
content: reason || '',
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
return signed
}

View File

@@ -0,0 +1,42 @@
import { NostrEvent } from 'nostr-tools'
export interface CachedBlogPostPreview {
event: NostrEvent
title: string
summary?: string
image?: string
published?: number
author: string
}
type CacheValue = {
posts: CachedBlogPostPreview[]
timestamp: number
}
const exploreCache = new Map<string, CacheValue>() // key: pubkey
export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
const entry = exploreCache.get(pubkey)
if (!entry) return null
return entry.posts
}
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
}
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
const current = exploreCache.get(pubkey)?.posts || []
const byId = new Map(current.map(p => [p.event.id, p]))
byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const ta = a.published || a.event.created_at
const tb = b.published || b.event.created_at
return tb - ta
})
setCachedPosts(pubkey, merged)
return merged
}

View File

@@ -1,5 +1,6 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { NostrEvent } from 'nostr-tools'
import { Helpers } from 'applesauce-core'
@@ -24,7 +25,8 @@ export interface BlogPostPreview {
export const fetchBlogPostsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
relayUrls: string[]
relayUrls: string[],
onPost?: (post: BlogPostPreview) => void
): Promise<BlogPostPreview[]> => {
try {
if (pubkeys.length === 0) {
@@ -34,42 +36,65 @@ export const fetchBlogPostsFromAuthors = async (
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
const events = await lastValueFrom(
relayPool
.req(relayUrls, {
kinds: [30023],
authors: pubkeys,
limit: 100 // Fetch up to 100 recent posts
})
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
)
console.log('📊 Blog post events fetched:', events.length)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Deduplicate replaceable events by keeping the most recent version
// Group by author + d-tag identifier
const uniqueEvents = new Map<string, NostrEvent>()
for (const event of events) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
// Emit as we incorporate
if (onPost) {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
onPost(post)
}
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
.pipe(completeOnEose(), takeUntil(timer(1200)))
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
.pipe(completeOnEose(), takeUntil(timer(6000)))
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.map(event => {
const post: BlogPostPreview = {
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}
if (onPost) onPost(post)
return post
})
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at

View File

@@ -1,204 +1,5 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../types/highlights'
import { RELAYS } from '../config/relays'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
import { UserSettings } from './settingsService'
import { rebroadcastEvents } from './rebroadcastService'
export * from './highlights/fetchForArticle'
export * from './highlights/fetchForUrl'
export * from './highlights/fetchByAuthor'
/**
* 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
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
console.log('🔍 Event ID:', eventId || 'none')
console.log('🔍 From relays (including local):', RELAYS)
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
return eventToHighlight(event)
}
// Query for highlights that reference this article via the 'a' tag
const aTagEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
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) {
eTagEvents = await lastValueFrom(
relayPool
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) {
onHighlight(highlight)
}
}),
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)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
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(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights for article:', error)
return []
}
}
/**
* Fetches highlights for a specific URL
* @param relayPool - The relay pool to query
* @param url - The external URL to find highlights for
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
settings?: UserSettings
): 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)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} 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
* @param pubkey - The user's public key
* @param onHighlight - Optional callback to receive highlights as they arrive
* @param settings - User settings for rebroadcast options
*/
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
const seenIds = new Set<string>()
const rawEvents = await lastValueFrom(
relayPool
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
const highlight = eventToHighlight(event)
if (onHighlight) {
onHighlight(highlight)
}
}
}),
completeOnEose(),
takeUntil(timer(10000)),
toArray()
)
)
console.log('📊 Raw highlight events fetched:', rawEvents.length)
// Rebroadcast highlight events to local/all relays based on settings
await rebroadcastEvents(rawEvents, relayPool, settings)
// Deduplicate and process events
const uniqueEvents = dedupeHighlights(rawEvents)
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch highlights by author:', error)
return []
}
}

View File

@@ -0,0 +1,63 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlights = async (
relayPool: RelayPool,
pubkey: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const ordered = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
const seenIds = new Set<string>()
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,98 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlightsForArticle = async (
relayPool: RelayPool,
articleCoordinate: string,
eventId?: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const seenIds = new Set<string>()
const processEvent = (event: NostrEvent): Highlight | null => {
if (seenIds.has(event.id)) return null
seenIds.add(event.id)
return eventToHighlight(event)
}
const orderedRelays = prioritizeLocalRelays(RELAYS)
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
const aLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
let eTagEvents: NostrEvent[] = []
if (eventId) {
const eLocal$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const eRemote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
const highlight = processEvent(event)
if (highlight && onHighlight) onHighlight(highlight)
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
}
const rawEvents = [...aTagEvents, ...eTagEvents]
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,57 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { Highlight } from '../../types/highlights'
import { RELAYS } from '../../config/relays'
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { UserSettings } from '../settingsService'
import { rebroadcastEvents } from '../rebroadcastService'
export const fetchHighlightsForUrl = async (
relayPool: RelayPool,
url: string,
onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings
): Promise<Highlight[]> => {
try {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
const local$ = localRelaysUrl.length > 0
? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(1200))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelaysUrl.length > 0
? relayPool
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
.pipe(
onlyEvents(),
tap((event: NostrEvent) => {
seenIds.add(event.id)
if (onHighlight) onHighlight(eventToHighlight(event))
}),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
await rebroadcastEvents(rawEvents, relayPool, settings)
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights)
} catch {
return []
}
}

View File

@@ -0,0 +1,103 @@
import { EventFactory } from 'applesauce-factory'
import { RelayPool } from 'applesauce-relay'
import { IAccount } from 'applesauce-accounts'
import { NostrEvent } from 'nostr-tools'
import { RELAYS } from '../config/relays'
const MARK_AS_READ_EMOJI = '📚'
/**
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
* @param eventId The ID of the event being reacted to
* @param eventAuthor The pubkey of the event author
* @param eventKind The kind of the event being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createEventReaction(
eventId: string,
eventAuthor: string,
eventKind: number,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
const tags: string[][] = [
['e', eventId],
['p', eventAuthor],
['k', eventKind.toString()]
]
const draft = await factory.create(async () => ({
kind: 7, // Reaction
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
return signed
}
/**
* Creates a kind:17 reaction to a website (for external URLs)
* @param url The URL being reacted to
* @param account The user's account for signing
* @param relayPool The relay pool for publishing
* @returns The signed reaction event
*/
export async function createWebsiteReaction(
url: string,
account: IAccount,
relayPool: RelayPool
): Promise<NostrEvent> {
const factory = new EventFactory({ signer: account })
// Normalize URL (remove fragments, trailing slashes as per NIP-25)
let normalizedUrl = url
try {
const parsed = new URL(url)
// Remove fragment
parsed.hash = ''
normalizedUrl = parsed.toString()
// Remove trailing slash if present
if (normalizedUrl.endsWith('/')) {
normalizedUrl = normalizedUrl.slice(0, -1)
}
} catch (error) {
console.warn('Failed to normalize URL:', error)
}
const tags: string[][] = [
['r', normalizedUrl]
]
const draft = await factory.create(async () => ({
kind: 17, // Reaction to a website
content: MARK_AS_READ_EMOJI,
tags,
created_at: Math.floor(Date.now() / 1000)
}))
const signed = await factory.sign(draft)
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
// Publish to relays
await relayPool.publish(RELAYS, signed)
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
return signed
}

111
src/sw.ts Normal file
View File

@@ -0,0 +1,111 @@
/// <reference lib="webworker" />
/* eslint-env worker */
/* global ServiceWorkerGlobalScope, ExtendableMessageEvent, FetchEvent */
import { clientsClaim } from 'workbox-core'
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
// Narrow the global service worker scope for proper typings
const sw = self as unknown as ServiceWorkerGlobalScope
// Precache all build assets (app shell)
// @ts-ignore - __WB_MANIFEST is injected by vite-plugin-pwa
precacheAndRoute(self.__WB_MANIFEST)
// Clean up old caches
cleanupOutdatedCaches()
// Take control immediately
sw.skipWaiting()
clientsClaim()
console.log('[SW] Boris service worker loaded')
// Runtime cache: Cross-origin images
// This preserves the existing image caching behavior
registerRoute(
({ request, url }) => {
const isImage = request.destination === 'image' ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
return isImage && url.origin !== sw.location.origin
},
new StaleWhileRevalidate({
cacheName: 'boris-images',
plugins: [
new ExpirationPlugin({
maxEntries: 300,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
)
// Runtime cache: Cross-origin article HTML
// Cache fetched articles for offline reading
registerRoute(
({ request, url }) => {
const accept = request.headers.get('accept') || ''
const isHTML = accept.includes('text/html')
const isCrossOrigin = url.origin !== sw.location.origin
// Exclude relay connections and local URLs
const isNotRelay = !url.protocol.includes('ws')
return isHTML && isCrossOrigin && isNotRelay
},
new StaleWhileRevalidate({
cacheName: 'boris-articles',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 14, // 14 days
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
)
// SPA navigation fallback - serve app shell for navigation requests
// This ensures the app loads offline
const navigationRoute = new NavigationRoute(
async ({ request }) => {
try {
// Try to fetch from network first
const response = await fetch(request)
return response
} catch (error) {
// If offline, serve the cached app shell
const cache = await caches.match('/index.html')
if (cache) {
return cache
}
throw error
}
}
)
registerRoute(navigationRoute)
// Listen for messages from the app
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
sw.skipWaiting()
}
})
// Log fetch errors for debugging (doesn't affect functionality)
sw.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url)
// Don't interfere with WebSocket connections (relay traffic)
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
return
}
})

View File

@@ -63,3 +63,58 @@ export const hasRemoteRelay = (relayUrls: string[]): boolean => {
return relayUrls.some(url => !isLocalRelay(url))
}
/**
* Splits relay URLs into local and remote groups
*/
export const partitionRelays = (
relayUrls: string[]
): { local: string[]; remote: string[] } => {
const local: string[] = []
const remote: string[] = []
for (const url of relayUrls) {
if (isLocalRelay(url)) local.push(url)
else remote.push(url)
}
return { local, remote }
}
/**
* Returns relays ordered with local first while keeping uniqueness
*/
export const prioritizeLocalRelays = (relayUrls: string[]): string[] => {
const { local, remote } = partitionRelays(relayUrls)
const seen = new Set<string>()
const out: string[] = []
for (const url of [...local, ...remote]) {
if (!seen.has(url)) {
seen.add(url)
out.push(url)
}
}
return out
}
// Parallel request helper
import { completeOnEose, onlyEvents, RelayPool } from 'applesauce-relay'
import { Observable, takeUntil, timer } from 'rxjs'
export function createParallelReqStreams(
relayPool: RelayPool,
localRelays: string[],
remoteRelays: string[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
filter: any,
localTimeoutMs = 1200,
remoteTimeoutMs = 6000
): { local$: Observable<unknown>; remote$: Observable<unknown> } {
const local$ = (localRelays.length > 0)
? relayPool.req(localRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(localTimeoutMs)))
: new Observable<unknown>((sub) => { sub.complete() })
const remote$ = (remoteRelays.length > 0)
? relayPool.req(remoteRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(remoteTimeoutMs)))
: new Observable<unknown>((sub) => { sub.complete() })
return { local$, remote$ }
}

View File

@@ -1,8 +1,43 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectRegister: null,
manifest: {
name: 'Boris - Nostr Bookmarks',
short_name: 'Boris',
description: 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.',
start_url: '/',
scope: '/',
display: 'standalone',
theme_color: '#0f172a',
background_color: '#0b1220',
orientation: 'any',
categories: ['productivity', 'social', 'utilities'],
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
globIgnores: ['**/_headers', '**/_redirects', '**/robots.txt']
},
devOptions: {
enabled: true,
type: 'module'
}
})
],
server: {
port: 9802
},