mirror of
https://github.com/dergigi/boris.git
synced 2026-02-23 07:54:59 +01:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.4.1] - 2025-10-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.4.2",
|
"version": "0.4.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useRelayStatus } from '../hooks/useRelayStatus'
|
|||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
|
import Me from './Me'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
|
||||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||||
@@ -35,13 +36,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
|
|
||||||
const showSettings = location.pathname === '/settings'
|
const showSettings = location.pathname === '/settings'
|
||||||
const showExplore = location.pathname === '/explore'
|
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(() => {
|
useEffect(() => {
|
||||||
if (!showSettings) {
|
if (!showSettings && !showMe && !showExplore) {
|
||||||
previousLocationRef.current = location.pathname
|
previousLocationRef.current = location.pathname
|
||||||
}
|
}
|
||||||
}, [location.pathname, showSettings])
|
}, [location.pathname, showSettings, showMe, showExplore])
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -202,6 +204,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
isSidebarOpen={isSidebarOpen}
|
isSidebarOpen={isSidebarOpen}
|
||||||
showSettings={showSettings}
|
showSettings={showSettings}
|
||||||
showExplore={showExplore}
|
showExplore={showExplore}
|
||||||
|
showMe={showMe}
|
||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
bookmarksLoading={bookmarksLoading}
|
bookmarksLoading={bookmarksLoading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
@@ -244,6 +247,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
currentUserPubkey={activeAccount?.pubkey}
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
followedPubkeys={followedPubkeys}
|
followedPubkeys={followedPubkeys}
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
currentArticle={currentArticle}
|
||||||
highlights={highlights}
|
highlights={highlights}
|
||||||
highlightsLoading={highlightsLoading}
|
highlightsLoading={highlightsLoading}
|
||||||
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||||
@@ -257,6 +262,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
explore={showExplore ? (
|
explore={showExplore ? (
|
||||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
me={showMe ? (
|
||||||
|
relayPool ? <Me relayPool={relayPool} /> : null
|
||||||
|
) : undefined}
|
||||||
toastMessage={toastMessage ?? undefined}
|
toastMessage={toastMessage ?? undefined}
|
||||||
toastType={toastType}
|
toastType={toastType}
|
||||||
onClearToast={clearToast}
|
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,9 +1,11 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
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, faBook } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
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'
|
||||||
@@ -13,6 +15,7 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
|||||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -34,6 +37,8 @@ interface ContentPanelProps {
|
|||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
currentArticle?: NostrEvent | null
|
||||||
// For highlight creation
|
// For highlight creation
|
||||||
onTextSelection?: (text: string) => void
|
onTextSelection?: (text: string) => void
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
@@ -54,6 +59,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
settings,
|
settings,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
activeAccount,
|
||||||
|
currentArticle,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
@@ -62,6 +69,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection
|
onClearSelection
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
|
||||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||||
|
|
||||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||||
@@ -93,6 +101,44 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
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) {
|
if (!selectedUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
@@ -135,31 +181,48 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
{markdown || html ? (
|
{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
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="reader-markdown"
|
className="reader-html"
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||||
onMouseUp={handleSelectionEnd}
|
onMouseUp={handleSelectionEnd}
|
||||||
onTouchEnd={handleSelectionEnd}
|
onTouchEnd={handleSelectionEnd}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<div className="reader-markdown">
|
|
||||||
<div className="loading-spinner">
|
{/* Mark as Read button */}
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
{activeAccount && (
|
||||||
</div>
|
<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>
|
||||||
)
|
)}
|
||||||
) : (
|
</>
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className="reader-html"
|
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
|
||||||
onMouseUp={handleSelectionEnd}
|
|
||||||
onTouchEnd={handleSelectionEnd}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
<p>No readable content found for this URL.</p>
|
<p>No readable content found for this URL.</p>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
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 { Highlight } from '../types/highlights'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, IEventStore } from 'applesauce-core'
|
import { Models, IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal } from '../utils/helpers'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
|
import ConfirmDialog from './ConfirmDialog'
|
||||||
|
|
||||||
interface HighlightWithLevel extends Highlight {
|
interface HighlightWithLevel extends Highlight {
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
@@ -23,6 +26,7 @@ interface HighlightItemProps {
|
|||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
onHighlightUpdate?: (highlight: Highlight) => void
|
onHighlightUpdate?: (highlight: Highlight) => void
|
||||||
|
onHighlightDelete?: (highlightId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||||
@@ -32,12 +36,17 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
onHighlightUpdate
|
onHighlightUpdate,
|
||||||
|
onHighlightDelete
|
||||||
}) => {
|
}) => {
|
||||||
const itemRef = useRef<HTMLDivElement>(null)
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
||||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
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
|
// Resolve the profile of the user who made the highlight
|
||||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||||
@@ -243,7 +252,51 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
const relayIndicator = getRelayIndicatorInfo()
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
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} />
|
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<div
|
||||||
|
className="highlight-delete-btn"
|
||||||
|
title="Delete highlight"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
@@ -301,6 +363,18 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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({
|
const filteredHighlights = useFilteredHighlights({
|
||||||
highlights: localHighlights,
|
highlights: localHighlights,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
@@ -129,6 +134,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
eventStore={eventStore}
|
eventStore={eventStore}
|
||||||
onHighlightUpdate={handleHighlightUpdate}
|
onHighlightUpdate={handleHighlightUpdate}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-s
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { isLocalRelay } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
|
||||||
interface RelayStatusIndicatorProps {
|
interface RelayStatusIndicatorProps {
|
||||||
relayPool: RelayPool | null
|
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)
|
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||||
const [isConnecting, setIsConnecting] = useState(true)
|
const [isConnecting, setIsConnecting] = useState(true)
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
if (!relayPool) return null
|
if (!relayPool) return null
|
||||||
|
|
||||||
@@ -57,36 +60,55 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
|||||||
// Don't show indicator when fully connected (but show when connecting)
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setIsExpanded(!isExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDetails = !isMobile || isExpanded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
|
<div
|
||||||
isConnecting
|
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''}`}
|
||||||
? 'Connecting to relays...'
|
title={
|
||||||
: offlineMode
|
!isMobile ? (
|
||||||
? 'Offline - No relays connected'
|
isConnecting
|
||||||
: 'Local Relays Only - Highlights will be marked as local'
|
? '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">
|
<div className="relay-status-icon">
|
||||||
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relay-status-text">
|
{showDetails && (
|
||||||
{isConnecting ? (
|
<>
|
||||||
<span className="relay-status-title">Connecting</span>
|
<div className="relay-status-text">
|
||||||
) : offlineMode ? (
|
{isConnecting ? (
|
||||||
<>
|
<span className="relay-status-title">Connecting</span>
|
||||||
<span className="relay-status-title">Offline</span>
|
) : offlineMode ? (
|
||||||
<span className="relay-status-subtitle">No relays connected</span>
|
<>
|
||||||
</>
|
<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>
|
<>
|
||||||
</>
|
<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">
|
</div>
|
||||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
{!offlineMode && !isConnecting && (
|
||||||
</div>
|
<div className="relay-status-pulse">
|
||||||
|
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,8 +90,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
<div
|
<div
|
||||||
className="profile-avatar"
|
className="profile-avatar"
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
onClick={
|
||||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
activeAccount
|
||||||
|
? () => navigate('/me')
|
||||||
|
: (isConnecting ? () => {} : handleLogin)
|
||||||
|
}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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'
|
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
interface ThreePaneLayoutProps {
|
interface ThreePaneLayoutProps {
|
||||||
// Layout state
|
// Layout state
|
||||||
@@ -28,6 +30,7 @@ interface ThreePaneLayoutProps {
|
|||||||
isSidebarOpen: boolean
|
isSidebarOpen: boolean
|
||||||
showSettings: boolean
|
showSettings: boolean
|
||||||
showExplore?: boolean
|
showExplore?: boolean
|
||||||
|
showMe?: boolean
|
||||||
|
|
||||||
// Bookmarks pane
|
// Bookmarks pane
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -59,6 +62,8 @@ interface ThreePaneLayoutProps {
|
|||||||
onClearSelection: () => void
|
onClearSelection: () => void
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys: Set<string>
|
followedPubkeys: Set<string>
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
currentArticle?: NostrEvent | null
|
||||||
|
|
||||||
// Highlights pane
|
// Highlights pane
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
@@ -81,6 +86,9 @@ interface ThreePaneLayoutProps {
|
|||||||
|
|
||||||
// Optional Explore content
|
// Optional Explore content
|
||||||
explore?: React.ReactNode
|
explore?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Me content
|
||||||
|
me?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||||
@@ -288,6 +296,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<>
|
<>
|
||||||
{props.explore}
|
{props.explore}
|
||||||
</>
|
</>
|
||||||
|
) : props.showMe && props.me ? (
|
||||||
|
// Render Me inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.me}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ContentPanel
|
<ContentPanel
|
||||||
loading={props.readerLoading}
|
loading={props.readerLoading}
|
||||||
@@ -311,6 +324,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
|
activeAccount={props.activeAccount}
|
||||||
|
currentArticle={props.currentArticle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
263
src/index.css
263
src/index.css
@@ -961,6 +961,63 @@ body.mobile-sidebar-open {
|
|||||||
padding: 0.1rem 0.3rem;
|
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 {
|
.bookmark-item {
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -1953,6 +2010,27 @@ body.mobile-sidebar-open {
|
|||||||
transform: scale(0.95);
|
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 */
|
/* Level-colored quote icon */
|
||||||
.highlight-item.level-mine .highlight-quote-icon {
|
.highlight-item.level-mine .highlight-quote-icon {
|
||||||
color: var(--highlight-color-mine, #ffff00);
|
color: var(--highlight-color-mine, #ffff00);
|
||||||
@@ -2909,6 +2987,34 @@ body.mobile-sidebar-open {
|
|||||||
cursor: default;
|
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 {
|
.relay-status-indicator.connecting {
|
||||||
background: rgba(100, 108, 255, 0.15);
|
background: rgba(100, 108, 255, 0.15);
|
||||||
border: 1px solid rgba(100, 108, 255, 0.25);
|
border: 1px solid rgba(100, 108, 255, 0.25);
|
||||||
@@ -3156,3 +3262,160 @@ body.mobile-sidebar-open {
|
|||||||
padding: 1rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user