diff --git a/.cursor/rules/fontawesome.mdc b/.cursor/rules/fontawesome.mdc index 71a7c554..026c30bf 100644 --- a/.cursor/rules/fontawesome.mdc +++ b/.cursor/rules/fontawesome.mdc @@ -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. \ No newline at end of file +Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate). diff --git a/package-lock.json b/package-lock.json index 749d34dc..8aa64c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c5130082..0fd40198 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index ed2c139d..0a0c6e7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - - - -
- -
-
- {toastMessage && ( - - )} -
-
+ + + + +
+ +
+
+ {toastMessage && ( + + )} +
+
+
) } diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index 20808002..f2d11720 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -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 = ({ {allIndividualBookmarks.length === 0 ? ( loading ? ( -
- +
+
+ {Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => ( + + ))} +
) : (
diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index f311339c..b5e064f0 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -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 = ({ if (loading) { return ( -
-
- -
+
+
) } diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index ab01537c..79dd801e 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -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 = ({ relayPool, eventStore, settings, acti const [error, setError] = useState(null) const exploreContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) + + // Visibility filters (defaults from settings) + const [visibility, setVisibility] = useState({ + 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 = ({ 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() + 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() + 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 = ({ 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 = ({ relayPool, eventStore, settings, acti if (loading && !hasData) { return ( -
-
- +
+
+

+ + 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 ( + + ) +} + 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 ( + + ) +} + 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 */