Compare commits

...

23 Commits

Author SHA1 Message Date
Gigi
d96ee50f5a chore: bump version to 0.6.9 2025-10-14 16:48:32 +02:00
Gigi
d4a172ba7e docs: update CHANGELOG.md for v0.6.8 2025-10-14 16:40:08 +02:00
Gigi
52ddb8dd7d chore: bump version to 0.6.8 2025-10-14 16:39:08 +02:00
Gigi
8c16614752 chore: update favicon and app icons to purple theme 2025-10-14 16:38:21 +02:00
Gigi
700d7cc5fa chore: update favicon and app icons 2025-10-14 16:34:21 +02:00
Gigi
017703dab2 fix: use consistent yellow color (#fde047) for default highlight settings 2025-10-14 16:31:55 +02:00
Gigi
c59fdb14f1 docs: update CHANGELOG.md for v0.6.7 2025-10-14 15:46:21 +02:00
Gigi
0c104f95d9 chore: bump version to 0.6.7 2025-10-14 15:44:25 +02:00
Gigi
acbefae501 Merge pull request #7 from dergigi/loading-placeholders
Remove loading spinners in favor of skeleton placeholders
2025-10-14 15:43:55 +02:00
Gigi
2ce83ef88a fix: use React.ReactElement instead of JSX.Element type
Change return type from JSX.Element to React.ReactElement to fix ESLint no-undef error
2025-10-14 15:42:54 +02:00
Gigi
dab3412ecd refactor: remove loading spinner from explore page
Remove incremental loading spinner as pull-to-refresh indicator already provides visual feedback for refresh state. Initial loading continues to use skeleton placeholders.
2025-10-14 15:41:34 +02:00
Gigi
988b3164d2 docs: add loading placeholder guideline to fontawesome rule 2025-10-14 15:40:32 +02:00
Gigi
4161053821 fix: Me - handle undefined viewingPubkey in skeleton loading state 2025-10-14 15:37:03 +02:00
Gigi
60054c4865 feat: ContentPanel - replace spinner with skeleton loaders 2025-10-14 15:36:57 +02:00
Gigi
f4e8aa576c feat: HighlightsPanel - replace spinner with skeleton loaders 2025-10-14 15:35:28 +02:00
Gigi
30a495bcd1 feat: Me - replace spinner with skeleton loaders 2025-10-14 15:35:23 +02:00
Gigi
6dde0eb220 feat: Explore - replace spinner with skeleton loaders 2025-10-14 15:35:17 +02:00
Gigi
90d8ef3423 feat: BookmarkList - replace spinner with skeleton loaders 2025-10-14 15:35:10 +02:00
Gigi
f626a8ec9b feat: add skeleton components and theme provider 2025-10-14 15:35:03 +02:00
Gigi
a7c7535236 feat: add react-loading-skeleton package 2025-10-14 14:53:40 +02:00
Gigi
5b0f2821d6 feat: parse and render nostr identifiers in highlight comments
- Detect and decode nostr: URIs (npub, nprofile, naddr, note, nevent) in comments
- Render profiles as clickable links with shortened pubkeys (@abc12345...)
- Render blog posts (kind:30023) as clickable article links
- Shorten other event identifiers to prevent layout breaks
- Add monospace styling for shortened nostr IDs
- Maintains DRY principles by extending existing CommentContent component
2025-10-14 12:58:01 +02:00
Gigi
be045557b8 feat: add nostrverse content and visibility filters to explore page
- Add visibility filter state and UI (mine/friends/nostrverse toggles)
- Create nostrverseService to fetch public content from the entire network
- Fetch both friends content and nostrverse content in parallel
- Apply visibility filters to both highlights and blog posts
- Filter buttons match highlight sidebar styling
- Users can now discover content beyond their friend network
- Maintains performance with sensible limits (50 posts, 100 highlights)
2025-10-14 12:09:12 +02:00
Gigi
a0c92182f9 docs: update CHANGELOG.md for v0.6.6 release 2025-10-14 12:03:53 +02:00
31 changed files with 828 additions and 78 deletions

View File

@@ -5,4 +5,4 @@ alwaysApply: false
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
Never write "Loading" - always show a spinner, and just a spinner.
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).

View File

@@ -7,6 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.8] - 2025-10-14
### Changed
- Updated favicon and app icons to purple theme
- Replaced all 8 icon files (apple-touch-icon, favicon variants, and Android Chrome icons)
- New purple color scheme for better brand recognition
## [0.6.7] - 2025-10-14
### Added
- Skeleton loading placeholders using `react-loading-skeleton` package
- Replaced loading spinners with skeleton loaders across all major components
- BookmarkList, Explore, Me, ContentPanel, and HighlightsPanel now use skeleton placeholders
- Theme-aware skeleton animations matching app color scheme
- Nostr identifier parsing and rendering in highlight comments
- Support for `nostr:npub`, `nostr:nprofile`, `nostr:naddr`, `nostr:note`, `nostr:nevent`
- Clickable links to profiles and articles from highlight comments
- Shortened display format for better readability
- Visibility filters for explore page content
- Toggle filters for nostrverse, friends, and own content
- Icon buttons with color coding matching highlight levels
- Filter state persists across tab switches
### Changed
- Loading states now use skeleton placeholders instead of spinners for more polished UX
- Removed incremental loading spinner from explore page refresh
- Pull-to-refresh indicator provides refresh state feedback
### Fixed
- Type error in `HighlightItem.tsx` using `React.ReactElement` instead of `JSX.Element`
- Me page skeleton loading now handles undefined `viewingPubkey` gracefully
### Documentation
- Updated FontAwesome rule to prefer skeleton placeholders over loading text or spinners
## [0.6.6] - 2025-10-14
### Added
- Profile fetching and caching for explore page
- Automatically fetches kind:0 metadata for all blog post authors
- Stores profiles in event store for instant access across app
- Rebroadcasts profiles to local/all relays per user settings
- Fixes "Unknown" author names by proactively caching profiles
- Rich content rendering in highlight comments
- URLs automatically detected and rendered as clickable links
- Image URLs (jpg, png, gif, webp, etc.) render as inline images
- Images lazy-load with responsive sizing and rounded borders
- Links open in new tab with security attributes
### Changed
- Hide citation in highlights sidebar when viewing article
- Citation removed from sidebar since all highlights are from same source
- Citation still shown in Explore and Me pages where context is needed
- Reduces visual clutter and redundant information
### Fixed
- Blog posts with far-future publication dates no longer appear in explore
- Filter excludes posts with dates more than 1 day in future
- Allows 1 day tolerance for clock skew between systems
- Prevents spam posts with unrealistic dates (e.g., "53585 years from now")
- Layout breaks from long URLs in highlight comments
- Added word-wrap, overflow-wrap, and word-break CSS properties
- Set min-width: 0 to allow flex child to shrink properly
- Long URLs now wrap correctly instead of causing horizontal overflow
- Profile fetching implementation
- Use eventStore.add() directly for immediate profile storage
- Use tap() operator to process events as they arrive
- Correct TypeScript types and dependency array
## [0.6.5] - 2025-10-14
### Added
@@ -1216,7 +1293,10 @@ 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
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.5...HEAD
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.8...HEAD
[0.6.8]: https://github.com/dergigi/boris/compare/v0.6.7...v0.6.8
[0.6.7]: https://github.com/dergigi/boris/compare/v0.6.6...v0.6.7
[0.6.6]: https://github.com/dergigi/boris/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/dergigi/boris/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/dergigi/boris/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/dergigi/boris/compare/v0.6.2...v0.6.3

10
package-lock.json generated
View File

@@ -26,6 +26,7 @@
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",
@@ -9820,6 +9821,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-loading-skeleton": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.5.0.tgz",
"integrity": "sha512-gxxSyLbrEAdXTKgfbpBEFZCO/P153DnqSCQau2+o6lNy1jgMRr2MmRmOzMmyrwSaSYLRB8g7b0waYPmUjz7IhQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.6.6",
"version": "0.6.9",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",
@@ -29,6 +29,7 @@
"prismjs": "^1.30.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-router-dom": "^7.9.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -13,6 +13,7 @@ import Toast from './components/Toast'
import { useToast } from './hooks/useToast'
import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
@@ -271,22 +272,24 @@ function App() {
}
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
<SkeletonThemeProvider>
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
</div>
</BrowserRouter>
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={clearToast}
/>
)}
</AccountsProvider>
</EventStoreProvider>
</SkeletonThemeProvider>
)
}

View File

@@ -1,6 +1,6 @@
import React, { useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
import { formatDistanceToNow } from 'date-fns'
import { RelayPool } from 'applesauce-relay'
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
@@ -12,6 +12,7 @@ import { extractUrlsFromContent } from '../services/bookmarkHelpers'
import { UserSettings } from '../services/settingsService'
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { BookmarkSkeleton } from './Skeletons'
interface BookmarkListProps {
bookmarks: Bookmark[]
@@ -128,8 +129,12 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
{allIndividualBookmarks.length === 0 ? (
loading ? (
<div className="loading">
<FontAwesomeIcon icon={faSpinner} spin />
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))}
</div>
</div>
) : (
<div className="empty-state">

View File

@@ -7,6 +7,7 @@ import rehypePrism from 'rehype-prism-plus'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import 'prismjs/themes/prism-tomorrow.css'
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
import { ContentSkeleton } from './Skeletons'
import { nip19 } from 'nostr-tools'
import { getNostrUrl } from '../config/nostrGateways'
import { RELAYS } from '../config/relays'
@@ -406,10 +407,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
if (loading) {
return (
<div className="reader loading">
<div className="loading-spinner">
<FontAwesomeIcon icon={faSpinner} spin />
</div>
<div className="reader" aria-busy="true">
<ContentSkeleton />
</div>
)
}

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter } from '@fortawesome/free-solid-svg-icons'
import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
import IconButton from './IconButton'
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
@@ -10,6 +12,7 @@ import { fetchContacts } from '../services/contactService'
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
import { fetchHighlightsFromAuthors } from '../services/highlightService'
import { fetchProfiles } from '../services/profileService'
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
import { Highlight } from '../types/highlights'
import { UserSettings } from '../services/settingsService'
import BlogPostCard from './BlogPostCard'
@@ -18,6 +21,7 @@ import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights,
import { usePullToRefresh } from '../hooks/usePullToRefresh'
import PullToRefreshIndicator from './PullToRefreshIndicator'
import { classifyHighlights } from '../utils/highlightClassification'
import { HighlightVisibility } from './HighlightsPanel'
interface ExploreProps {
relayPool: RelayPool
@@ -39,6 +43,13 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
const [error, setError] = useState<string | null>(null)
const exploreContainerRef = useRef<HTMLDivElement>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
// Visibility filters (defaults from settings)
const [visibility, setVisibility] = useState<HighlightVisibility>({
nostrverse: settings?.defaultHighlightVisibilityNostrverse !== false,
friends: settings?.defaultHighlightVisibilityFriends !== false,
mine: settings?.defaultHighlightVisibilityMine !== false
})
// Update local state when prop changes
useEffect(() => {
@@ -149,45 +160,57 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
// Store final followed pubkeys
setFollowedPubkeys(contacts)
// After full contacts, do a final pass for completeness
// Fetch both friends content and nostrverse content in parallel
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const contactsArray = Array.from(contacts)
const [posts, userHighlights] = await Promise.all([
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
fetchHighlightsFromAuthors(relayPool, contactsArray)
fetchHighlightsFromAuthors(relayPool, contactsArray),
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
fetchNostrverseHighlights(relayPool, 100)
])
// Merge and deduplicate all posts
const allPosts = [...friendsPosts, ...nostrversePosts]
const postsByKey = new Map<string, BlogPostPreview>()
for (const post of allPosts) {
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
const existing = postsByKey.get(key)
if (!existing || post.event.created_at > existing.event.created_at) {
postsByKey.set(key, post)
}
}
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
// Merge and deduplicate all highlights
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
const highlightsByKey = new Map<string, Highlight>()
for (const highlight of allHighlights) {
highlightsByKey.set(highlight.id, highlight)
}
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
// Fetch profiles for all blog post authors to cache them
if (posts.length > 0) {
const authorPubkeys = Array.from(new Set(posts.map(p => p.author)))
if (uniquePosts.length > 0) {
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
console.error('Failed to fetch author profiles:', err)
})
}
if (posts.length === 0 && userHighlights.length === 0) {
setError('No content found from your friends yet')
if (uniquePosts.length === 0 && uniqueHighlights.length === 0) {
setError('No content found yet')
}
setBlogPosts((prev) => {
const byId = new Map(prev.map(p => [p.event.id, p]))
for (const post of posts) byId.set(post.event.id, post)
const merged = Array.from(byId.values()).sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA
})
setCachedPosts(activeAccount.pubkey, merged)
return merged
})
setBlogPosts(uniquePosts)
setCachedPosts(activeAccount.pubkey, uniquePosts)
setHighlights((prev) => {
const byId = new Map(prev.map(h => [h.id, h]))
for (const highlight of userHighlights) byId.set(highlight.id, highlight)
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
setCachedHighlights(activeAccount.pubkey, merged)
return merged
})
setHighlights(uniqueHighlights)
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
} catch (err) {
console.error('Failed to load data:', err)
setError('Failed to load content. Please try again.')
@@ -251,19 +274,37 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
}
}
// Classify highlights with levels based on user context
// Classify highlights with levels based on user context and apply visibility filters
const classifiedHighlights = useMemo(() => {
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
}, [highlights, activeAccount?.pubkey, followedPubkeys])
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
return classified.filter(h => {
if (h.level === 'mine' && !visibility.mine) return false
if (h.level === 'friends' && !visibility.friends) return false
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
return true
})
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
// Filter out blog posts with unreasonable future dates (allow 1 day for clock skew)
// Filter blog posts by future dates and visibility
const filteredBlogPosts = useMemo(() => {
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
return blogPosts.filter(post => {
// Filter out future dates
const publishedTime = post.published || post.event.created_at
return publishedTime <= maxFutureTime
if (publishedTime > maxFutureTime) return false
// Apply visibility filters
const isMine = activeAccount && post.author === activeAccount.pubkey
const isFriend = followedPubkeys.has(post.author)
const isNostrverse = !isMine && !isFriend
if (isMine && !visibility.mine) return false
if (isFriend && !visibility.friends) return false
if (isNostrverse && !visibility.nostrverse) return false
return true
})
}, [blogPosts])
}, [blogPosts, activeAccount, followedPubkeys, visibility])
const renderTabContent = () => {
switch (activeTab) {
@@ -312,9 +353,23 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
if (loading && !hasData) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-container" aria-busy="true">
<div className="explore-header">
<h1>
<FontAwesomeIcon icon={faNewspaper} />
Explore
</h1>
</div>
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
)}
</div>
</div>
)
@@ -351,12 +406,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
Discover highlights and blog posts from your friends and others
</p>
{loading && hasData && (
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
<FontAwesomeIcon icon={faSpinner} spin />
</div>
)}
<div className="me-tabs">
<button
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
@@ -375,6 +424,45 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
<span className="tab-label">Writings</span>
</button>
</div>
{/* Visibility filters */}
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
<IconButton
icon={faNetworkWired}
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
title="Toggle nostrverse content"
ariaLabel="Toggle nostrverse content"
variant="ghost"
style={{
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
opacity: visibility.nostrverse ? 1 : 0.4
}}
/>
<IconButton
icon={faUserGroup}
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
ariaLabel="Toggle friends content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
opacity: visibility.friends ? 1 : 0.4
}}
/>
<IconButton
icon={faUser}
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
title={activeAccount ? "Toggle my content" : "Login to see your content"}
ariaLabel="Toggle my content"
variant="ghost"
disabled={!activeAccount}
style={{
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
opacity: visibility.mine ? 1 : 0.4
}}
/>
</div>
</div>
{renderTabContent()}

View File

@@ -29,16 +29,115 @@ const isImageUrl = (url: string): boolean => {
}
}
// Component to render comment with links and inline images
// Helper to render a nostr identifier
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
try {
// Remove nostr: prefix
const identifier = nostrUri.replace(/^nostr:/, '')
const decoded = nip19.decode(identifier)
switch (decoded.type) {
case 'npub': {
const pubkey = decoded.data
return (
<a
key={index}
href={`/p/${nip19.npubEncode(pubkey)}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'nprofile': {
const { pubkey } = decoded.data
const npub = nip19.npubEncode(pubkey)
return (
<a
key={index}
href={`/p/${npub}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
@{pubkey.slice(0, 8)}...
</a>
)
}
case 'naddr': {
const { kind, pubkey, identifier } = decoded.data
// Check if it's a blog post (kind:30023)
if (kind === 30023) {
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
return (
<a
key={index}
href={`/a/${naddr}`}
className="highlight-comment-link"
onClick={(e) => e.stopPropagation()}
>
{identifier || 'Article'}
</a>
)
}
// For other kinds, show shortened identifier
return (
<span key={index} className="highlight-comment-nostr-id">
nostr:{identifier.slice(0, 12)}...
</span>
)
}
case 'note': {
const eventId = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
note:{eventId.slice(0, 12)}...
</span>
)
}
case 'nevent': {
const { id } = decoded.data
return (
<span key={index} className="highlight-comment-nostr-id">
event:{id.slice(0, 12)}...
</span>
)
}
default:
// Fallback for unrecognized types
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
} catch (error) {
// If decoding fails, show shortened identifier
const identifier = nostrUri.replace(/^nostr:/, '')
return (
<span key={index} className="highlight-comment-nostr-id">
{identifier.slice(0, 20)}...
</span>
)
}
}
// Component to render comment with links, inline images, and nostr identifiers
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
// URL regex pattern
const urlPattern = /(https?:\/\/[^\s]+)/g
// Pattern to match both http(s) URLs and nostr: URIs
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
const parts = text.split(urlPattern)
return (
<>
{parts.map((part, index) => {
if (part.match(urlPattern)) {
// Handle nostr: URIs
if (part.startsWith('nostr:')) {
return renderNostrId(part, index)
}
// Handle http(s) URLs
if (part.match(/^https?:\/\//)) {
if (isImageUrl(part)) {
return (
<img
@@ -64,6 +163,7 @@ const CommentContent: React.FC<{ text: string }> = ({ text }) => {
)
}
}
return <span key={index}>{part}</span>
})}
</>

View File

@@ -11,6 +11,7 @@ import PullToRefreshIndicator from './PullToRefreshIndicator'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { UserSettings } from '../services/settingsService'
import { HighlightSkeleton } from './Skeletons'
export interface HighlightVisibility {
nostrverse: boolean
@@ -127,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
/>
{loading && filteredHighlights.length === 0 ? (
<div className="highlights-loading">
<FontAwesomeIcon icon={faHighlighter} spin />
<div className="highlights-list" aria-busy="true">
{Array.from({ length: 4 }).map((_, i) => (
<HighlightSkeleton key={i} />
))}
</div>
) : filteredHighlights.length === 0 ? (
<div className="highlights-empty">

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools'
import { useNavigate } from 'react-router-dom'
@@ -198,9 +199,26 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
if (loading && !hasData) {
return (
<div className="explore-container">
<div className="explore-loading">
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
<div className="explore-container" aria-busy="true">
{viewingPubkey && (
<div className="explore-header">
<AuthorCard authorPubkey={viewingPubkey} />
</div>
)}
<div className="explore-grid">
{activeTab === 'writings' ? (
Array.from({ length: 6 }).map((_, i) => (
<BlogPostSkeleton key={i} />
))
) : activeTab === 'highlights' ? (
Array.from({ length: 8 }).map((_, i) => (
<HighlightSkeleton key={i} />
))
) : (
Array.from({ length: 6 }).map((_, i) => (
<BookmarkSkeleton key={i} viewMode={viewMode} />
))
)}
</div>
</div>
)

View File

@@ -23,10 +23,10 @@ const DEFAULT_SETTINGS: UserSettings = {
readingFont: 'source-serif-4',
fontSize: 21,
highlightStyle: 'marker',
highlightColor: '#ffff00',
highlightColor: '#fde047',
highlightColorNostrverse: '#9333ea',
highlightColorFriends: '#f97316',
highlightColorMine: '#ffff00',
highlightColorMine: '#fde047',
defaultHighlightVisibilityNostrverse: true,
defaultHighlightVisibilityFriends: true,
defaultHighlightVisibilityMine: true,

View File

@@ -0,0 +1,42 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const BlogPostSkeleton: React.FC = () => {
return (
<div
className="blog-post-card"
style={{
textDecoration: 'none',
color: 'inherit',
display: 'block'
}}
aria-hidden="true"
>
<div className="blog-post-card-image">
<Skeleton height={200} style={{ display: 'block' }} />
</div>
<div className="blog-post-card-content">
<Skeleton
height={24}
width="85%"
style={{ marginBottom: '0.75rem' }}
className="blog-post-card-title"
/>
<Skeleton
count={2}
style={{ marginBottom: '0.5rem' }}
className="blog-post-card-summary"
/>
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Skeleton width={100} height={14} />
</span>
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Skeleton width={80} height={14} />
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
import { ViewMode } from '../Bookmarks'
interface BookmarkSkeletonProps {
viewMode: ViewMode
}
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
if (viewMode === 'compact') {
return (
<div
className="bookmark-item-compact"
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
aria-hidden="true"
>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
<Skeleton width={40} height={40} />
<div style={{ flex: 1, minWidth: 0 }}>
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
<Skeleton width="60%" height={14} />
</div>
</div>
</div>
)
}
if (viewMode === 'cards') {
return (
<div
className="bookmark-card"
style={{
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'var(--color-bg-elevated)',
marginBottom: '1rem'
}}
aria-hidden="true"
>
<Skeleton height={160} style={{ display: 'block' }} />
<div style={{ padding: '1rem' }}>
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<Skeleton width={80} height={14} />
<Skeleton width={60} height={14} />
</div>
</div>
</div>
)
}
// large view
return (
<div
className="bookmark-large"
style={{
marginBottom: '1.5rem',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'var(--color-bg-elevated)'
}}
aria-hidden="true"
>
<Skeleton height={240} style={{ display: 'block' }} />
<div style={{ padding: '1.5rem' }}>
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<Skeleton circle width={32} height={32} />
<div style={{ flex: 1 }}>
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
<Skeleton width={100} height={12} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const ContentSkeleton: React.FC = () => {
return (
<div
className="reader-content"
style={{
maxWidth: '900px',
margin: '0 auto',
padding: '2rem 1rem'
}}
aria-hidden="true"
>
{/* Title */}
<Skeleton
height={48}
width="90%"
style={{ marginBottom: '1rem' }}
/>
{/* Byline / Meta */}
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
<Skeleton circle width={40} height={40} />
<div style={{ flex: 1 }}>
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
<Skeleton width={200} height={14} />
</div>
</div>
{/* Cover image */}
<Skeleton
height={400}
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
/>
{/* Paragraphs */}
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="80%" />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="65%" />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="90%" />
</div>
{/* Another image placeholder */}
<Skeleton
height={300}
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
/>
<div style={{ marginBottom: '1.5rem' }}>
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
<Skeleton width="75%" />
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
import Skeleton from 'react-loading-skeleton'
export const HighlightSkeleton: React.FC = () => {
return (
<div
className="highlight-item"
style={{
padding: '1rem',
marginBottom: '0.75rem',
borderRadius: '8px',
backgroundColor: 'var(--color-bg-elevated)'
}}
aria-hidden="true"
>
{/* Author line with avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<Skeleton circle width={24} height={24} />
<Skeleton width={120} height={14} />
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
</div>
{/* Highlight content */}
<div style={{ marginBottom: '0.5rem' }}>
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
<Skeleton width="70%" />
</div>
{/* Citation/context */}
<div style={{ marginTop: '0.75rem' }}>
<Skeleton width="90%" height={12} />
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react'
import { SkeletonTheme } from 'react-loading-skeleton'
interface SkeletonThemeProviderProps {
children: React.ReactNode
}
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
const [colors, setColors] = useState({
baseColor: '#27272a',
highlightColor: '#52525b'
})
useEffect(() => {
const updateColors = () => {
const rootStyles = getComputedStyle(document.documentElement)
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
setColors({ baseColor, highlightColor })
}
// Initial update
updateColors()
// Watch for theme changes via MutationObserver
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
updateColors()
}
})
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
return () => observer.disconnect()
}, [])
return (
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
{children}
</SkeletonTheme>
)
}

View File

@@ -0,0 +1,6 @@
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
export { BookmarkSkeleton } from './BookmarkSkeleton'
export { BlogPostSkeleton } from './BlogPostSkeleton'
export { HighlightSkeleton } from './HighlightSkeleton'
export { ContentSkeleton } from './ContentSkeleton'

View File

@@ -13,6 +13,7 @@
@import './styles/components/settings.css';
@import './styles/components/me.css';
@import './styles/components/pull-to-refresh.css';
@import './styles/components/skeletons.css';
@import './styles/utils/animations.css';
@import './styles/utils/utilities.css';
@import './styles/utils/legacy.css';

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './styles/tailwind.css'
import './index.css'
import 'react-loading-skeleton/dist/skeleton.css'
// Register Service Worker for PWA functionality
if ('serviceWorker' in navigator) {

View File

@@ -0,0 +1,126 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { Helpers } from 'applesauce-core'
import { BlogPostPreview } from './exploreService'
import { Highlight } from '../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
/**
* Fetches public blog posts (kind:30023) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param relayUrls - Array of relay URLs to query
* @param limit - Maximum number of posts to fetch (default: 50)
* @returns Array of blog post previews
*/
export const fetchNostrverseBlogPosts = async (
relayPool: RelayPool,
relayUrls: string[],
limit = 50
): Promise<BlogPostPreview[]> => {
try {
console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
// Deduplicate replaceable events by keeping the most recent version
const uniqueEvents = new Map<string, NostrEvent>()
const processEvents = (incoming: NostrEvent[]) => {
for (const event of incoming) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${dTag}`
const existing = uniqueEvents.get(key)
if (!existing || event.created_at > existing.created_at) {
uniqueEvents.set(key, event)
}
}
}
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [30023], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
processEvents(events)
console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size)
// Convert to blog post previews and sort by published date (most recent first)
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
.map(event => ({
event,
title: getArticleTitle(event) || 'Untitled',
summary: getArticleSummary(event),
image: getArticleImage(event),
published: getArticlePublished(event),
author: event.pubkey
}))
.sort((a, b) => {
const timeA = a.published || a.event.created_at
const timeB = b.published || b.event.created_at
return timeB - timeA // Most recent first
})
console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts')
return blogPosts
} catch (error) {
console.error('Failed to fetch nostrverse blog posts:', error)
return []
}
}
/**
* Fetches public highlights (kind:9802) from the nostrverse (not filtered by author)
* @param relayPool - The relay pool to query
* @param limit - Maximum number of highlights to fetch (default: 100)
* @returns Array of highlights
*/
export const fetchNostrverseHighlights = async (
relayPool: RelayPool,
limit = 100
): Promise<Highlight[]> => {
try {
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
const prioritized = prioritizeLocalRelays(relayUrls)
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
const local$ = localRelays.length > 0
? relayPool
.req(localRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(1200)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, { kinds: [9802], limit })
.pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
: new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)
console.log('💡 Processed', highlights.length, 'unique nostrverse highlights')
return sortHighlights(highlights)
} catch (error) {
console.error('Failed to fetch nostrverse highlights:', error)
return []
}
}

View File

@@ -0,0 +1,35 @@
/* Skeleton loading animations - respects prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.react-loading-skeleton {
animation: none !important;
}
}
/* Ensure skeletons have proper border radius to match design */
.react-loading-skeleton {
border-radius: 4px;
line-height: 1.2;
}
/* Image skeleton aspect ratio boxes to prevent CLS */
.blog-post-card-image .react-loading-skeleton,
.bookmark-card .react-loading-skeleton:first-child {
aspect-ratio: 16 / 9;
}
/* Skeleton spacing adjustments */
.highlights-list .react-loading-skeleton,
.bookmarks-list .react-loading-skeleton {
margin-bottom: 0.5rem;
}
/* Ensure skeletons inherit theme colors properly */
.react-loading-skeleton::after {
background: linear-gradient(
90deg,
transparent,
var(--color-border-subtle, rgba(255, 255, 255, 0.05)),
transparent
);
}

View File

@@ -134,6 +134,7 @@
.highlight-comment-text { flex: 1; min-width: 0; }
.highlight-comment-link { color: var(--color-primary); text-decoration: underline; word-wrap: break-word; overflow-wrap: break-word; }
.highlight-comment-link:hover { opacity: 0.8; }
.highlight-comment-nostr-id { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; font-size: 0.8em; color: var(--color-text-secondary); background: rgba(255, 255, 255, 0.05); padding: 0.125rem 0.375rem; border-radius: 3px; word-wrap: break-word; overflow-wrap: break-word; }
.highlight-comment-image { display: block; max-width: 100%; height: auto; margin-top: 0.5rem; border-radius: 6px; border: 1px solid var(--color-border); }
/* Level-colored comment icons */