Compare commits

..

18 Commits

Author SHA1 Message Date
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
Gigi
4d5fe1f425 chore: bump version to 0.4.0 2025-10-10 18:07:06 +01:00
Gigi
c7a4de9786 Merge pull request #1 from dergigi/mobile
Add mobile responsive design
2025-10-10 18:04:54 +01:00
18 changed files with 631 additions and 63 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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [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 ### Added
- Mobile-responsive design with overlay sidebar drawer - Mobile-responsive design with overlay sidebar drawer
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`) - Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
@@ -19,12 +34,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Mobile-optimized modals (full-screen sheet style) - Mobile-optimized modals (full-screen sheet style)
- Mobile-optimized toast notifications (bottom position) - Mobile-optimized toast notifications (bottom position)
- Dynamic viewport height support (100dvh) - Dynamic viewport height support (100dvh)
- Mobile highlights panel as overlay with toggle button
### Changed ### Changed
- Sidebar now displays as overlay drawer on mobile (≤768px) - Sidebar now displays as overlay drawer on mobile (≤768px)
- Highlights panel hidden on mobile for better content focus - Highlights panel hidden on mobile for better content focus
- Sidebar auto-closes when selecting content on mobile - Sidebar auto-closes when selecting content on mobile
- Hover effects disabled on touch devices - 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 ## [0.3.8] - 2025-10-10
@@ -564,6 +590,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - 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.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.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 [0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4

View File

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

View File

@@ -139,7 +139,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
clearTimeout(fetchTimeoutRef.current) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()

View File

@@ -119,6 +119,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
<div className="empty-state"> <div className="empty-state">
<p>No bookmarks found.</p> <p>No bookmarks found.</p>
<p>Add bookmarks using your nostr client to see them here.</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>
) : ( ) : (
<div className="bookmarks-list"> <div className="bookmarks-list">

View File

@@ -90,6 +90,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setHighlightVisibility setHighlightVisibility
} = useBookmarksUI({ settings }) } = 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 { const {
bookmarks, bookmarks,
bookmarksLoading, bookmarksLoading,

View File

@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner } from '@fortawesome/free-solid-svg-icons' import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { RelayPool } from 'applesauce-relay'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { readingTime } from 'reading-time-estimator' import { readingTime } from 'reading-time-estimator'
import { hexToRgb } from '../utils/colorHelpers' import { hexToRgb } from '../utils/colorHelpers'
@@ -32,6 +33,7 @@ interface ContentPanelProps {
currentUserPubkey?: string currentUserPubkey?: string
followedPubkeys?: Set<string> followedPubkeys?: Set<string>
settings?: UserSettings settings?: UserSettings
relayPool?: RelayPool | null
// For highlight creation // For highlight creation
onTextSelection?: (text: string) => void onTextSelection?: (text: string) => void
onClearSelection?: () => void onClearSelection?: () => void
@@ -51,6 +53,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
highlightStyle = 'marker', highlightStyle = 'marker',
highlightColor = '#ffff00', highlightColor = '#ffff00',
settings, settings,
relayPool,
onHighlightClick, onHighlightClick,
selectedHighlightId, selectedHighlightId,
highlightVisibility = { nostrverse: true, friends: true, mine: true }, highlightVisibility = { nostrverse: true, friends: true, mine: true },
@@ -59,7 +62,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onTextSelection, onTextSelection,
onClearSelection onClearSelection
}) => { }) => {
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown) const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
const { finalHtml, relevantHighlights } = useHighlightedContent({ const { finalHtml, relevantHighlights } = useHighlightedContent({
html, html,
@@ -74,7 +77,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
followedPubkeys followedPubkeys
}) })
const { contentRef, handleMouseUp } = useHighlightInteractions({ const { contentRef, handleSelectionEnd } = useHighlightInteractions({
onHighlightClick, onHighlightClick,
selectedHighlightId, selectedHighlightId,
onTextSelection, onTextSelection,
@@ -116,7 +119,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
{markdown && ( {markdown && (
<div ref={markdownPreviewRef} style={{ display: 'none' }}> <div ref={markdownPreviewRef} style={{ display: 'none' }}>
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown} {processedMarkdown || markdown}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -138,7 +141,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
ref={contentRef} ref={contentRef}
className="reader-markdown" className="reader-markdown"
dangerouslySetInnerHTML={{ __html: finalHtml }} dangerouslySetInnerHTML={{ __html: finalHtml }}
onMouseUp={handleMouseUp} onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/> />
) : ( ) : (
<div className="reader-markdown"> <div className="reader-markdown">
@@ -152,7 +156,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
ref={contentRef} ref={contentRef}
className="reader-html" className="reader-html"
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }} dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
onMouseUp={handleMouseUp} onMouseUp={handleSelectionEnd}
onTouchEnd={handleSelectionEnd}
/> />
) )
) : ( ) : (

View File

@@ -28,36 +28,45 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
}) => { }) => {
const cachedImage = useImageCache(image, settings) const cachedImage = useImageCache(image, settings)
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
const isLongSummary = summary && summary.length > 150
if (cachedImage) { if (cachedImage) {
return ( return (
<div className="reader-hero-image"> <>
<img src={cachedImage} alt={title || 'Article image'} /> <div className="reader-hero-image">
{formattedDate && ( <img src={cachedImage} alt={title || 'Article image'} />
<div className="publish-date-topright"> {formattedDate && (
{formattedDate} <div className="publish-date-topright">
</div> {formattedDate}
)}
{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> </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>
)} )}
</div> </>
) )
} }

View File

@@ -52,7 +52,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
hasRemoteRelay, hasRemoteRelay,
isConnecting 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) // Don't show indicator when fully connected (but show when connecting)
if (!localOnlyMode && !offlineMode && !isConnecting) return null if (!localOnlyMode && !offlineMode && !isConnecting) return null

View File

@@ -36,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
accountManager.setActive(account) accountManager.setActive(account)
} catch (error) { } catch (error) {
console.error('Login failed:', 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 { } finally {
setIsConnecting(false) setIsConnecting(false)
} }

View File

@@ -19,6 +19,7 @@ import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton' import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader' import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery' import { useIsMobile } from '../hooks/useMediaQuery'
import { useScrollDirection } from '../hooks/useScrollDirection'
interface ThreePaneLayoutProps { interface ThreePaneLayoutProps {
// Layout state // Layout state
@@ -86,6 +87,16 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const sidebarRef = useRef<HTMLDivElement>(null) const sidebarRef = useRef<HTMLDivElement>(null)
const highlightsRef = 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 // Lock body scroll when mobile sidebar or highlights is open
useEffect(() => { useEffect(() => {
@@ -102,22 +113,24 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
// Handle ESC key to close sidebar or highlights // Handle ESC key to close sidebar or highlights
useEffect(() => { useEffect(() => {
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
if (!isMobile) return if (!isMobile) return
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return if (!isSidebarOpen && isHighlightsCollapsed) return
const handleEscape = (e: KeyboardEvent) => { const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (props.isSidebarOpen) { if (isSidebarOpen) {
props.onToggleSidebar() onToggleSidebar()
} else if (!props.isHighlightsCollapsed) { } else if (!isHighlightsCollapsed) {
props.onToggleHighlightsPanel() onToggleHighlightsPanel()
} }
} }
} }
document.addEventListener('keydown', handleEscape) document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('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 // Trap focus in sidebar when open on mobile
useEffect(() => { useEffect(() => {
@@ -204,7 +217,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
{/* Mobile bookmark button - only show when viewing article */} {/* Mobile bookmark button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && ( {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button <button
className="mobile-hamburger-btn" className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
onClick={props.onToggleSidebar} onClick={props.onToggleSidebar}
aria-label="Open bookmarks" aria-label="Open bookmarks"
aria-expanded={props.isSidebarOpen} aria-expanded={props.isSidebarOpen}
@@ -216,7 +229,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
{/* Mobile highlights button - only show when viewing article */} {/* Mobile highlights button - only show when viewing article */}
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && ( {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
<button <button
className="mobile-highlights-btn" className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
onClick={props.onToggleHighlightsPanel} onClick={props.onToggleHighlightsPanel}
aria-label="Open highlights" aria-label="Open highlights"
aria-expanded={!props.isHighlightsCollapsed} aria-expanded={!props.isHighlightsCollapsed}
@@ -259,7 +272,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
isMobile={isMobile} isMobile={isMobile}
/> />
</div> </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 ? ( {props.showSettings ? (
<Settings <Settings
settings={props.settings} settings={props.settings}
@@ -294,6 +310,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
currentUserPubkey={props.currentUserPubkey} currentUserPubkey={props.currentUserPubkey}
followedPubkeys={props.followedPubkeys} followedPubkeys={props.followedPubkeys}
settings={props.settings} settings={props.settings}
relayPool={props.relayPool}
/> />
)} )}
</div> </div>

View File

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

View File

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

View File

@@ -1,34 +1,86 @@
import React, { useState, useEffect, useRef } from 'react' 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 * 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 previewRef = useRef<HTMLDivElement>(null)
const [renderedHtml, setRenderedHtml] = useState<string>('') const [renderedHtml, setRenderedHtml] = useState<string>('')
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
useEffect(() => { useEffect(() => {
if (!markdown) { if (!markdown) {
setRenderedHtml('') setRenderedHtml('')
setProcessedMarkdown('')
return return
} }
console.log('📝 Converting markdown to HTML...') let isCancelled = false
const rafId = requestAnimationFrame(() => { const processMarkdown = async () => {
if (previewRef.current) { // Extract all naddr references
const html = previewRef.current.innerHTML const naddrs = extractNaddrUris(markdown)
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
setRenderedHtml(html) 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 { } 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) console.log('📝 Converting markdown to HTML...')
}, [markdown])
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 // 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; justify-content: center;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 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 { .mobile-hamburger-btn:active {
@@ -468,6 +479,23 @@ body.mobile-sidebar-open {
text-decoration: underline; 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 { .logout-button {
background: #dc3545; background: #dc3545;
color: white; color: white;
@@ -717,7 +745,18 @@ body.mobile-sidebar-open {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 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 { .mobile-highlights-btn:active {
@@ -1437,6 +1476,51 @@ body.mobile-sidebar-open {
border: 1px solid rgba(100, 108, 255, 0.4); 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 Styles */
.private-bookmark { .private-bookmark {
background: #2a2a2a; background: #2a2a2a;
@@ -3062,4 +3146,13 @@ body.mobile-sidebar-open {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1.5rem; gap: 1.5rem;
} }
.blog-post-card-summary {
-webkit-line-clamp: 2;
font-size: 0.8rem;
}
.blog-post-card-content {
padding: 1rem;
}
} }

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