-
-
+
+
+
+
+ Explore
+
+
+
+ {activeTab === 'writings' ? (
+ Array.from({ length: 6 }).map((_, i) => (
+
+ ))
+ ) : (
+ Array.from({ length: 8 }).map((_, i) => (
+
+ ))
+ )}
)
@@ -351,12 +406,6 @@ const Explore: React.FC
= ({ relayPool, eventStore, settings, acti
Discover highlights and blog posts from your friends and others
- {loading && hasData && (
-
-
-
- )}
-
+
+ {/* Visibility filters */}
+
+ 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
+ }}
+ />
+ 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
+ }}
+ />
+ 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
+ }}
+ />
+
{renderTabContent()}
diff --git a/src/components/HighlightItem.tsx b/src/components/HighlightItem.tsx
index f08e8fd9..8c638aae 100644
--- a/src/components/HighlightItem.tsx
+++ b/src/components/HighlightItem.tsx
@@ -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 (
+
e.stopPropagation()}
+ >
+ @{pubkey.slice(0, 8)}...
+
+ )
+ }
+ case 'nprofile': {
+ const { pubkey } = decoded.data
+ const npub = nip19.npubEncode(pubkey)
+ return (
+
e.stopPropagation()}
+ >
+ @{pubkey.slice(0, 8)}...
+
+ )
+ }
+ 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 (
+
e.stopPropagation()}
+ >
+ {identifier || 'Article'}
+
+ )
+ }
+ // For other kinds, show shortened identifier
+ return (
+
+ nostr:{identifier.slice(0, 12)}...
+
+ )
+ }
+ case 'note': {
+ const eventId = decoded.data
+ return (
+
+ note:{eventId.slice(0, 12)}...
+
+ )
+ }
+ case 'nevent': {
+ const { id } = decoded.data
+ return (
+
+ event:{id.slice(0, 12)}...
+
+ )
+ }
+ default:
+ // Fallback for unrecognized types
+ return (
+
+ {identifier.slice(0, 20)}...
+
+ )
+ }
+ } catch (error) {
+ // If decoding fails, show shortened identifier
+ const identifier = nostrUri.replace(/^nostr:/, '')
+ return (
+
+ {identifier.slice(0, 20)}...
+
+ )
+ }
+}
+
+// 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 (
![]()
= ({ text }) => {
)
}
}
+
return
{part}
})}
>
diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx
index c74d7022..b4687150 100644
--- a/src/components/HighlightsPanel.tsx
+++ b/src/components/HighlightsPanel.tsx
@@ -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
= ({
/>
{loading && filteredHighlights.length === 0 ? (
-
-
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
) : filteredHighlights.length === 0 ? (
diff --git a/src/components/Me.tsx b/src/components/Me.tsx
index 2e4aa80a..37cfea95 100644
--- a/src/components/Me.tsx
+++ b/src/components/Me.tsx
@@ -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
= ({ relayPool, activeTab: propActiveTab, pubkey: pr
if (loading && !hasData) {
return (
-
-
-
+
+ {viewingPubkey && (
+
+ )}
+
+ {activeTab === 'writings' ? (
+ Array.from({ length: 6 }).map((_, i) => (
+
+ ))
+ ) : activeTab === 'highlights' ? (
+ Array.from({ length: 8 }).map((_, i) => (
+
+ ))
+ ) : (
+ Array.from({ length: 6 }).map((_, i) => (
+
+ ))
+ )}
)
diff --git a/src/components/Skeletons/BlogPostSkeleton.tsx b/src/components/Skeletons/BlogPostSkeleton.tsx
new file mode 100644
index 00000000..3ae3135a
--- /dev/null
+++ b/src/components/Skeletons/BlogPostSkeleton.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import Skeleton from 'react-loading-skeleton'
+
+export const BlogPostSkeleton: React.FC = () => {
+ return (
+
+ )
+}
+
diff --git a/src/components/Skeletons/BookmarkSkeleton.tsx b/src/components/Skeletons/BookmarkSkeleton.tsx
new file mode 100644
index 00000000..d723d55e
--- /dev/null
+++ b/src/components/Skeletons/BookmarkSkeleton.tsx
@@ -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
= ({ viewMode }) => {
+ if (viewMode === 'compact') {
+ return (
+
+ )
+ }
+
+ if (viewMode === 'cards') {
+ return (
+
+ )
+ }
+
+ // large view
+ return (
+
+ )
+}
+
diff --git a/src/components/Skeletons/ContentSkeleton.tsx b/src/components/Skeletons/ContentSkeleton.tsx
new file mode 100644
index 00000000..32500c05
--- /dev/null
+++ b/src/components/Skeletons/ContentSkeleton.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import Skeleton from 'react-loading-skeleton'
+
+export const ContentSkeleton: React.FC = () => {
+ return (
+
+ {/* Title */}
+
+
+ {/* Byline / Meta */}
+
+
+ {/* Cover image */}
+
+
+ {/* Paragraphs */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Another image placeholder */}
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/components/Skeletons/HighlightSkeleton.tsx b/src/components/Skeletons/HighlightSkeleton.tsx
new file mode 100644
index 00000000..9af8c46b
--- /dev/null
+++ b/src/components/Skeletons/HighlightSkeleton.tsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import Skeleton from 'react-loading-skeleton'
+
+export const HighlightSkeleton: React.FC = () => {
+ return (
+
+ {/* Author line with avatar */}
+
+
+
+
+
+
+ {/* Highlight content */}
+
+
+
+
+
+ {/* Citation/context */}
+
+
+
+
+ )
+}
+
diff --git a/src/components/Skeletons/SkeletonThemeProvider.tsx b/src/components/Skeletons/SkeletonThemeProvider.tsx
new file mode 100644
index 00000000..e56e4846
--- /dev/null
+++ b/src/components/Skeletons/SkeletonThemeProvider.tsx
@@ -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 = ({ 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 (
+
+ {children}
+
+ )
+}
+
diff --git a/src/components/Skeletons/index.ts b/src/components/Skeletons/index.ts
new file mode 100644
index 00000000..01f7656f
--- /dev/null
+++ b/src/components/Skeletons/index.ts
@@ -0,0 +1,6 @@
+export { SkeletonThemeProvider } from './SkeletonThemeProvider'
+export { BookmarkSkeleton } from './BookmarkSkeleton'
+export { BlogPostSkeleton } from './BlogPostSkeleton'
+export { HighlightSkeleton } from './HighlightSkeleton'
+export { ContentSkeleton } from './ContentSkeleton'
+
diff --git a/src/index.css b/src/index.css
index 3207addd..93b9360c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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';
diff --git a/src/main.tsx b/src/main.tsx
index b90b795e..024e3dc2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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) {
diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts
new file mode 100644
index 00000000..aa5a6839
--- /dev/null
+++ b/src/services/nostrverseService.ts
@@ -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 => {
+ 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()
+
+ 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((sub) => sub.complete())
+ const remote$ = remoteRelays.length > 0
+ ? relayPool
+ .req(remoteRelays, { kinds: [30023], limit })
+ .pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
+ : new Observable((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 => {
+ 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((sub) => sub.complete())
+
+ const remote$ = remoteRelays.length > 0
+ ? relayPool
+ .req(remoteRelays, { kinds: [9802], limit })
+ .pipe(completeOnEose(), takeUntil(timer(6000)), onlyEvents())
+ : new Observable((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 []
+ }
+}
+
diff --git a/src/styles/components/skeletons.css b/src/styles/components/skeletons.css
new file mode 100644
index 00000000..67f66999
--- /dev/null
+++ b/src/styles/components/skeletons.css
@@ -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
+ );
+}
+
diff --git a/src/styles/layout/highlights.css b/src/styles/layout/highlights.css
index f341fe89..af0bbad9 100644
--- a/src/styles/layout/highlights.css
+++ b/src/styles/layout/highlights.css
@@ -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 */