Compare commits

...

22 Commits

Author SHA1 Message Date
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
Gigi
165c427e5f chore: bump version to 0.4.2 2025-10-11 03:24:15 +01:00
Gigi
a0e30aa197 fix: resolve all linting and type errors
- Remove unused React import from nostrUriResolver
- Add block scoping to switch case statements
- Add react-hooks plugin to eslint config
- Fix exhaustive-deps warnings in components
- Fix DecodeResult type to use ReturnType<typeof decode>
- Update dependency arrays to include all used values
- Add eslint-disable comment for intentional dependency omission

All linting warnings resolved. TypeScript type checking passes.
2025-10-11 03:23:38 +01:00
Gigi
3a8203d26e fix: mobile button scroll detection on main pane element
- Update useScrollDirection to accept elementRef parameter
- Detect scroll on main pane div instead of window
- Create mainPaneRef and attach to scrollable content area
- Fix issue where scroll events weren't detected on mobile

On mobile, content scrolls within .pane.main (overflow-y: auto) not on window.
Now buttons properly hide on scroll down and show on scroll up.
2025-10-11 01:48:45 +01:00
Gigi
ffe848883e feat: resolve and display article titles for naddr references
- Add articleTitleResolver service to fetch article titles from relays
- Extract naddr identifiers from markdown content
- Fetch article titles in parallel using relay pool
- Replace naddr references with actual article titles
- Fallback to identifier if title fetch fails
- Update markdown processing to be async for title resolution
- Pass relayPool through component tree to enable resolution

Example: nostr:naddr1... now shows as "My Article Title" instead of "article:identifier"

Improves readability by showing human-friendly article titles in cross-references
2025-10-11 01:47:11 +01:00
Gigi
078a13c45d fix: link naddr articles internally instead of to njump
- Articles (naddr) now link to /a/{naddr} route (internal)
- Other nostr identifiers still link to njump.me (external)
- Improved article labels to show identifier instead of generic text
- Better UX: clicking article references opens them in-app
2025-10-11 01:43:54 +01:00
Gigi
8a69d5bc6b feat: resolve NIP-19 identifiers in article content
- Add nostrUriResolver utility to detect and replace nostr: URIs
- Support npub, note, nprofile, nevent, and naddr identifiers
- Convert nostr: URIs to clickable njump.me links
- Process markdown before rendering to handle nostr mentions
- Add CSS styling for nostr-uri-link class
- Implements NIP-19 and NIP-27 (nostr: URI scheme)

Nostr-native articles can now contain references like:
- nostr:npub1... → @npub1abc...
- nostr:note1... → note:note1abc...
- nostr:naddr1... → article:identifier

All identifiers become clickable links to njump.me
2025-10-11 01:42:03 +01:00
Gigi
6783ff23f9 feat: auto-hide mobile buttons on scroll down
- Add useScrollDirection hook for scroll direction detection
- Hide bookmark and highlight buttons when scrolling down
- Show buttons again when scrolling up
- Smooth opacity transitions for better UX
- Only detect scroll when buttons are visible
- Improves mobile reading experience by maximizing content area
2025-10-11 01:39:24 +01:00
Gigi
72a264a01e feat: auto-close sidebar on mobile when navigating to content
- Add effect to close sidebar when route changes on mobile
- Handles clicking on blog posts in Explore view
- Complements existing sidebar auto-close for bookmarks and highlights
- Improves mobile UX by preventing sidebar from blocking content
2025-10-11 01:37:46 +01:00
Gigi
5a67be8096 docs: update CHANGELOG for v0.4.1 release 2025-10-10 21:46:30 +01:00
Gigi
9a929a6be4 chore: bump version to 0.4.1 2025-10-10 21:45:41 +01:00
Gigi
e0ca010026 feat: improve hero image rendering with zoom-to-fit on mobile 2025-10-10 21:44:51 +01:00
Gigi
8bd5d7aadf fix: move long article summaries below image on mobile to prevent overlay issues 2025-10-10 21:43:55 +01:00
Gigi
9115c38cde fix: improve article summary display on mobile devices 2025-10-10 21:40:15 +01:00
Gigi
0c7c1d54d9 feat: add nstart.me onboarding link for new users 2025-10-10 21:26:06 +01:00
Gigi
d529d83eb8 fix: add touch event support for highlight creation on mobile 2025-10-10 21:24:46 +01:00
Gigi
a3127c7836 docs: update CHANGELOG for v0.4.0 release 2025-10-10 18:07:57 +01:00
25 changed files with 1507 additions and 115 deletions

View File

@@ -0,0 +1,6 @@
---
description: anything related to UI/UX
alwaysApply: false
---
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)

View File

@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
- Long article summaries overlapping with hero image content on mobile devices
- Article summary now moves below hero image on mobile when longer than 150 characters
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
### Changed
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
- Improved image cropping with center positioning for better visual presentation
- Optimized reader header overlay padding and title sizing on mobile
## [0.4.0] - 2025-10-10
### Added
- Mobile-responsive design with overlay sidebar drawer
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
@@ -19,12 +68,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Mobile-optimized modals (full-screen sheet style)
- Mobile-optimized toast notifications (bottom position)
- Dynamic viewport height support (100dvh)
- Mobile highlights panel as overlay with toggle button
### Changed
- Sidebar now displays as overlay drawer on mobile (≤768px)
- Highlights panel hidden on mobile for better content focus
- Sidebar auto-closes when selecting content on mobile
- Hover effects disabled on touch devices
- Replace hamburger icon with bookmark icon on mobile
### Fixed
- Ensure bookmarks container fills mobile sidepane properly
- Restore desktop grid layout for highlights panel
- Improve empty state and loading visibility in mobile sidepanes
- Add flex properties to mobile bookmark containers for proper filling
- Force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop
- Reduce mobile backdrop opacity and ensure sidepanes appear above it
- Replace any type with proper bookmark interface for linter compliance
## [0.3.8] - 2025-10-10
@@ -564,6 +624,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
[0.3.7]: https://github.com/dergigi/boris/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.4.0",
"version": "0.4.3",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -59,7 +59,8 @@
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"react-refresh"
"react-refresh",
"react-hooks"
],
"rules": {
"react-refresh/only-export-components": [
@@ -68,6 +69,7 @@
"allowConstantExport": true
}
],
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{

View File

@@ -69,6 +69,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/me"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
</Routes>
)

View File

@@ -139,7 +139,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
clearTimeout(fetchTimeoutRef.current)
}
}
}, [url]) // Only depend on url
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()

View File

@@ -119,6 +119,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<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">

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()
@@ -90,6 +92,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightVisibility
} = useBookmarksUI({ settings })
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
useEffect(() => {
if (isMobile && isSidebarOpen) {
toggleSidebar()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname])
const {
bookmarks,
bookmarksLoading,
@@ -194,6 +204,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
showMe={showMe}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -236,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)}
@@ -249,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,8 +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'
@@ -12,6 +15,7 @@ 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'
interface ContentPanelProps {
loading: boolean
@@ -32,6 +36,9 @@ interface ContentPanelProps {
currentUserPubkey?: string
followedPubkeys?: Set<string>
settings?: UserSettings
relayPool?: RelayPool | null
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// For highlight creation
onTextSelection?: (text: string) => void
onClearSelection?: () => void
@@ -51,6 +58,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightStyle = 'marker',
highlightColor = '#ffff00',
settings,
relayPool,
activeAccount,
currentArticle,
onHighlightClick,
selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -59,7 +69,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection,
onClearSelection
}) => {
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({
html,
@@ -74,7 +85,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
followedPubkeys
})
const { contentRef, handleMouseUp } = useHighlightInteractions({
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick,
selectedHighlightId,
onTextSelection,
@@ -90,6 +101,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">
@@ -116,7 +165,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
{processedMarkdown || markdown}
</ReactMarkdown>
</div>
)}
@@ -132,29 +181,48 @@ 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 }}
onMouseUp={handleMouseUp}
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={handleMouseUp}
/>
)
)}
</>
) : (
<div className="reader empty">
<p>No readable content found for this URL.</p>

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}
relayPool={relayPool}
onHighlightDelete={handleHighlightDelete}
/>
))}
</div>
</div>
)
}
export default Me

View File

@@ -28,36 +28,45 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
}) => {
const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
if (cachedImage) {
return (
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && (
<div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2>
{summary && <p className="reader-summary">{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
<>
<div className="reader-hero-image">
<img src={cachedImage} alt={title || 'Article image'} />
{formattedDate && (
<div className="publish-date-topright">
{formattedDate}
</div>
)}
{title && (
<div className="reader-header-overlay">
<h2 className="reader-title">{title}</h2>
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
<div className="reader-meta">
{readingTimeText && (
<div className="reading-time">
<FontAwesomeIcon icon={faClock} />
<span>{readingTimeText}</span>
</div>
)}
{hasHighlights && (
<div className="highlight-indicator">
<FontAwesomeIcon icon={faHighlighter} />
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
</div>
)}
</div>
{isLongSummary && (
<div className="reader-summary-below-image">
<p className="reader-summary">{summary}</p>
</div>
)}
</div>
</>
)
}

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
@@ -52,41 +55,60 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
hasRemoteRelay,
isConnecting
})
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
// 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

@@ -36,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
accountManager.setActive(account)
} catch (error) {
console.error('Login failed:', error)
alert('Login failed. Please install a nostr browser extension and try again.')
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
} finally {
setIsConnecting(false)
}
@@ -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

@@ -19,6 +19,9 @@ import { HighlightVisibility } from './HighlightsPanel'
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
@@ -27,6 +30,7 @@ interface ThreePaneLayoutProps {
isSidebarOpen: boolean
showSettings: boolean
showExplore?: boolean
showMe?: boolean
// Bookmarks pane
bookmarks: Bookmark[]
@@ -58,6 +62,8 @@ interface ThreePaneLayoutProps {
onClearSelection: () => void
currentUserPubkey?: string
followedPubkeys: Set<string>
activeAccount?: IAccount | null
currentArticle?: NostrEvent | null
// Highlights pane
highlights: Highlight[]
@@ -80,12 +86,25 @@ interface ThreePaneLayoutProps {
// Optional Explore content
explore?: React.ReactNode
// Optional Me content
me?: React.ReactNode
}
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const isMobile = useIsMobile()
const sidebarRef = useRef<HTMLDivElement>(null)
const highlightsRef = useRef<HTMLDivElement>(null)
const mainPaneRef = useRef<HTMLDivElement>(null)
// Detect scroll direction to hide/show mobile buttons
// On mobile, scroll happens in the main pane, not on window
const scrollDirection = useScrollDirection({
threshold: 10,
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
elementRef: mainPaneRef
})
const showMobileButtons = scrollDirection !== 'down'
// Lock body scroll when mobile sidebar or highlights is open
useEffect(() => {
@@ -102,22 +121,24 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
// Handle ESC key to close sidebar or highlights
useEffect(() => {
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
if (!isMobile) return
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
if (!isSidebarOpen && isHighlightsCollapsed) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (props.isSidebarOpen) {
props.onToggleSidebar()
} else if (!props.isHighlightsCollapsed) {
props.onToggleHighlightsPanel()
if (isSidebarOpen) {
onToggleSidebar()
} else if (!isHighlightsCollapsed) {
onToggleHighlightsPanel()
}
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed, props.onToggleSidebar, props.onToggleHighlightsPanel])
}, [isMobile, props])
// Trap focus in sidebar when open on mobile
useEffect(() => {
@@ -204,7 +225,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
{/* Mobile bookmark button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button
className="mobile-hamburger-btn"
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
onClick={props.onToggleSidebar}
aria-label="Open bookmarks"
aria-expanded={props.isSidebarOpen}
@@ -216,7 +237,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
{/* Mobile highlights button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button
className="mobile-highlights-btn"
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed}
@@ -259,7 +280,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
isMobile={isMobile}
/>
</div>
<div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
<div
ref={mainPaneRef}
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
>
{props.showSettings ? (
<Settings
settings={props.settings}
@@ -272,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}
@@ -294,6 +323,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys}
settings={props.settings}
relayPool={props.relayPool}
activeAccount={props.activeAccount}
currentArticle={props.currentArticle}
/>
)}
</div>

View File

@@ -110,7 +110,7 @@ export const useBookmarksData = ({
handleFetchHighlights()
}
handleFetchContacts()
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
return {
bookmarks,

View File

@@ -56,8 +56,8 @@ export const useHighlightInteractions = ({
}
}, [selectedHighlightId])
// Handle text selection
const handleMouseUp = useCallback(() => {
// Handle text selection (works for both mouse and touch)
const handleSelectionEnd = useCallback(() => {
setTimeout(() => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
@@ -76,6 +76,6 @@ export const useHighlightInteractions = ({
}, 10)
}, [onTextSelection, onClearSelection])
return { contentRef, handleMouseUp }
return { contentRef, handleSelectionEnd }
}

View File

@@ -1,34 +1,86 @@
import React, { useState, useEffect, useRef } from 'react'
import { RelayPool } from 'applesauce-relay'
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
import { fetchArticleTitles } from '../services/articleTitleResolver'
/**
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
* Also processes nostr: URIs in the markdown and resolves article titles
*/
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
export const useMarkdownToHTML = (
markdown?: string,
relayPool?: RelayPool | null
): {
renderedHtml: string
previewRef: React.RefObject<HTMLDivElement>
processedMarkdown: string
} => {
const previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('')
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => {
if (!markdown) {
setRenderedHtml('')
setProcessedMarkdown('')
return
}
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
let isCancelled = false
const processMarkdown = async () => {
// Extract all naddr references
const naddrs = extractNaddrUris(markdown)
let processed: string
if (naddrs.length > 0 && relayPool) {
// Fetch article titles for all naddrs
try {
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
if (isCancelled) return
// Replace nostr URIs with resolved titles
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
console.log(`📚 Resolved ${articleTitles.size} article titles`)
} catch (error) {
console.warn('Failed to fetch article titles:', error)
// Fall back to basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
} else {
console.warn('⚠️ markdownPreviewRef.current is null')
// No articles to resolve, use basic replacement
processed = replaceNostrUrisInMarkdown(markdown)
}
})
if (isCancelled) return
setProcessedMarkdown(processed)
return () => cancelAnimationFrame(rafId)
}, [markdown])
console.log('📝 Converting markdown to HTML...')
const rafId = requestAnimationFrame(() => {
if (previewRef.current && !isCancelled) {
const html = previewRef.current.innerHTML
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html)
} else if (!isCancelled) {
console.warn('⚠️ markdownPreviewRef.current is null')
}
})
return { renderedHtml, previewRef }
return () => cancelAnimationFrame(rafId)
}
processMarkdown()
return () => {
isCancelled = true
}
}, [markdown, relayPool])
return { renderedHtml, previewRef, processedMarkdown }
}
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef

View File

@@ -0,0 +1,70 @@
import { useState, useEffect, RefObject } from 'react'
export type ScrollDirection = 'up' | 'down' | 'none'
interface UseScrollDirectionOptions {
threshold?: number
enabled?: boolean
elementRef?: RefObject<HTMLElement>
}
/**
* Hook to detect scroll direction on window or a specific element
* @param options Configuration options
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
* @param options.enabled Whether scroll detection is enabled (default: true)
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
* @returns Current scroll direction ('up', 'down', or 'none')
*/
export function useScrollDirection({
threshold = 10,
enabled = true,
elementRef
}: UseScrollDirectionOptions = {}): ScrollDirection {
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
useEffect(() => {
if (!enabled) return
const scrollElement = elementRef?.current || window
const getScrollY = () => {
if (elementRef?.current) {
return elementRef.current.scrollTop
}
return window.scrollY
}
let lastScrollY = getScrollY()
let ticking = false
const updateScrollDirection = () => {
const scrollY = getScrollY()
// Only update if scroll distance exceeds threshold
if (Math.abs(scrollY - lastScrollY) < threshold) {
ticking = false
return
}
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
lastScrollY = scrollY > 0 ? scrollY : 0
ticking = false
}
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDirection)
ticking = true
}
}
scrollElement.addEventListener('scroll', onScroll)
return () => {
scrollElement.removeEventListener('scroll', onScroll)
}
}, [threshold, enabled, elementRef])
return scrollDirection
}

View File

@@ -156,7 +156,18 @@ body.mobile-sidebar-open {
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-hamburger-btn.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-hamburger-btn.visible {
opacity: 1;
visibility: visible;
}
.mobile-hamburger-btn:active {
@@ -468,6 +479,23 @@ body.mobile-sidebar-open {
text-decoration: underline;
}
.nostr-uri-link {
color: #007bff;
text-decoration: none;
font-family: monospace;
background: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
word-wrap: break-word;
overflow-wrap: break-word;
}
.nostr-uri-link:hover {
background: #e9ecef;
text-decoration: underline;
}
.logout-button {
background: #dc3545;
color: white;
@@ -717,7 +745,18 @@ body.mobile-sidebar-open {
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
}
.mobile-highlights-btn.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.mobile-highlights-btn.visible {
opacity: 1;
visibility: visible;
}
.mobile-highlights-btn:active {
@@ -922,6 +961,63 @@ 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;
}
}
.bookmark-item {
background: #1a1a1a;
padding: 1.5rem;
@@ -1437,6 +1533,51 @@ body.mobile-sidebar-open {
border: 1px solid rgba(100, 108, 255, 0.4);
}
.reader-summary-below-image {
display: none;
}
@media (max-width: 768px) {
.reader-header-overlay .reader-summary.hide-on-mobile {
display: none;
}
.reader-summary-below-image {
display: block;
padding: 0 0 1.5rem 0;
margin-top: -1rem;
}
.reader-summary-below-image .reader-summary {
color: #aaa;
font-size: 1rem;
line-height: 1.6;
margin: 0;
}
.reader-hero-image {
min-height: 280px;
max-height: 400px;
height: 50vh;
}
.reader-hero-image img {
height: 100%;
width: 100%;
object-fit: cover;
object-position: center;
}
.reader-header-overlay {
padding: 1.5rem 1rem 1rem;
}
.reader-header-overlay .reader-title {
font-size: 1.5rem;
line-height: 1.3;
}
}
/* Private Bookmark Styles */
.private-bookmark {
background: #2a2a2a;
@@ -1869,6 +2010,27 @@ body.mobile-sidebar-open {
transform: scale(0.95);
}
.highlight-delete-btn {
position: absolute;
bottom: -2px;
right: 0;
font-size: 0.7rem;
color: #888;
opacity: 0.7;
transition: all 0.2s ease;
cursor: pointer;
}
.highlight-delete-btn:hover {
opacity: 1;
color: #ff4444;
transform: scale(1.1);
}
.highlight-delete-btn:active {
transform: scale(0.95);
}
/* Level-colored quote icon */
.highlight-item.level-mine .highlight-quote-icon {
color: var(--highlight-color-mine, #ffff00);
@@ -2825,6 +2987,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);
@@ -3062,4 +3252,170 @@ body.mobile-sidebar-open {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.blog-post-card-summary {
-webkit-line-clamp: 2;
font-size: 0.8rem;
}
.blog-post-card-content {
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

@@ -0,0 +1,87 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { Helpers } from 'applesauce-core'
import { RELAYS } from '../config/relays'
const { getArticleTitle } = Helpers
/**
* Fetch article title for a single naddr
* Returns the title or null if not found/error
*/
export async function fetchArticleTitle(
relayPool: RelayPool,
naddr: string
): Promise<string | null> {
try {
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
return null
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: RELAYS
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
)
if (events.length === 0) {
return null
}
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
return getArticleTitle(article) || null
} catch (err) {
console.warn('Failed to fetch article title for', naddr, err)
return null
}
}
/**
* Fetch titles for multiple naddrs in parallel
* Returns a map of naddr -> title
*/
export async function fetchArticleTitles(
relayPool: RelayPool,
naddrs: string[]
): Promise<Map<string, string>> {
const titleMap = new Map<string, string>()
// Fetch all titles in parallel
const results = await Promise.allSettled(
naddrs.map(async (naddr) => {
const title = await fetchArticleTitle(relayPool, naddr)
return { naddr, title }
})
)
// Process results
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value.title) {
titleMap.set(result.value.naddr, result.value.title)
}
})
return titleMap
}

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,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
}

View File

@@ -0,0 +1,188 @@
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
/**
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
* Also matches bare identifiers without the nostr: prefix
*/
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
/**
* Extract all nostr URIs from text
*/
export function extractNostrUris(text: string): string[] {
const matches = text.match(NOSTR_URI_REGEX)
if (!matches) return []
// Extract just the NIP-19 identifier (without nostr: prefix)
return matches.map(match => {
const cleanMatch = match.replace(/^nostr:/, '')
return cleanMatch
})
}
/**
* Extract all naddr (article) identifiers from text
*/
export function extractNaddrUris(text: string): string[] {
const allUris = extractNostrUris(text)
return allUris.filter(uri => {
try {
const decoded = decode(uri)
return decoded.type === 'naddr'
} catch {
return false
}
})
}
/**
* Decode a NIP-19 identifier and return a human-readable link
* For articles (naddr), returns an internal app link
* For other types, returns an external njump.me link
*/
export function createNostrLink(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'naddr':
// For articles, link to our internal app route
return `/a/${encoded}`
case 'npub':
case 'nprofile':
case 'note':
case 'nevent':
return `https://njump.me/${encoded}`
default:
return `https://njump.me/${encoded}`
}
} catch (error) {
console.warn('Failed to decode nostr URI:', encoded, error)
return `https://njump.me/${encoded}`
}
}
/**
* Get a display label for a nostr URI
*/
export function getNostrUriLabel(encoded: string): string {
try {
const decoded = decode(encoded)
switch (decoded.type) {
case 'npub':
return `@${encoded.slice(0, 12)}...`
case 'nprofile': {
const npub = npubEncode(decoded.data.pubkey)
return `@${npub.slice(0, 12)}...`
}
case 'note':
return `note:${encoded.slice(5, 12)}...`
case 'nevent': {
const note = noteEncode(decoded.data.id)
return `note:${note.slice(5, 12)}...`
}
case 'naddr': {
// For articles, show the identifier if available
const identifier = decoded.data.identifier
if (identifier && identifier.length > 0) {
// Truncate long identifiers but keep them readable
return identifier.length > 40 ? `${identifier.slice(0, 37)}...` : identifier
}
return 'nostr article'
}
default:
return encoded.slice(0, 16) + '...'
}
} catch (error) {
return encoded.slice(0, 16) + '...'
}
}
/**
* Replace nostr: URIs in markdown with proper markdown links
* This converts: nostr:npub1... to [label](link)
*/
export function replaceNostrUrisInMarkdown(markdown: string): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in markdown with proper markdown links, using resolved titles for articles
* This converts: nostr:naddr1... to [Article Title](link)
* @param markdown The markdown content to process
* @param articleTitles Map of naddr -> title for resolved articles
*/
export function replaceNostrUrisInMarkdownWithTitles(
markdown: string,
articleTitles: Map<string, string>
): string {
return markdown.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
// For articles, use the resolved title if available
try {
const decoded = decode(encoded)
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
const title = articleTitles.get(encoded)!
return `[${title}](${link})`
}
} catch (error) {
// Ignore decode errors, fall through to default label
}
// For other types or if title not resolved, use default label
const label = getNostrUriLabel(encoded)
return `[${label}](${link})`
})
}
/**
* Replace nostr: URIs in HTML with clickable links
* This is used when processing HTML content directly
*/
export function replaceNostrUrisInHTML(html: string): string {
return html.replace(NOSTR_URI_REGEX, (match) => {
// Extract just the NIP-19 identifier (without nostr: prefix)
const encoded = match.replace(/^nostr:/, '')
const link = createNostrLink(encoded)
const label = getNostrUriLabel(encoded)
return `<a href="${link}" class="nostr-uri-link" target="_blank" rel="noopener noreferrer">${label}</a>`
})
}
/**
* Get decoded information from a nostr URI for detailed display
*/
export function getNostrUriInfo(encoded: string): {
type: string
decoded: ReturnType<typeof decode> | null
link: string
label: string
} {
let decoded: ReturnType<typeof decode> | null = null
try {
decoded = decode(encoded)
} catch (error) {
// ignore decoding errors
}
return {
type: decoded?.type || 'unknown',
decoded,
link: createNostrLink(encoded),
label: getNostrUriLabel(encoded)
}
}