mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 04:24:25 +01:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 | ||
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 |
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal 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.)
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
56
src/components/ConfirmDialog.tsx
Normal file
56
src/components/ConfirmDialog.tsx
Normal 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
119
src/components/Me.tsx
Normal 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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -110,7 +110,7 @@ export const useBookmarksData = ({
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
70
src/hooks/useScrollDirection.ts
Normal file
70
src/hooks/useScrollDirection.ts
Normal 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
|
||||
}
|
||||
|
||||
360
src/index.css
360
src/index.css
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
87
src/services/articleTitleResolver.ts
Normal file
87
src/services/articleTitleResolver.ts
Normal 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
|
||||
}
|
||||
|
||||
48
src/services/deletionService.ts
Normal file
48
src/services/deletionService.ts
Normal 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
|
||||
}
|
||||
|
||||
103
src/services/reactionService.ts
Normal file
103
src/services/reactionService.ts
Normal 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
|
||||
}
|
||||
|
||||
188
src/utils/nostrUriResolver.tsx
Normal file
188
src/utils/nostrUriResolver.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user