diff --git a/.cursor/rules/mobile-first-ui-ux.mdc b/.cursor/rules/mobile-first-ui-ux.mdc index cc64f96a..6834bebb 100644 --- a/.cursor/rules/mobile-first-ui-ux.mdc +++ b/.cursor/rules/mobile-first-ui-ux.mdc @@ -4,3 +4,5 @@ alwaysApply: false --- This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.) + +Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic. diff --git a/package-lock.json b/package-lock.json index 8aa64c08..ba5f7236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boris", - "version": "0.6.6", + "version": "0.6.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boris", - "version": "0.6.6", + "version": "0.6.9", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -33,7 +33,8 @@ "reading-time-estimator": "^1.14.0", "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", @@ -11695,6 +11696,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-pull-to-refresh": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/use-pull-to-refresh/-/use-pull-to-refresh-2.4.1.tgz", + "integrity": "sha512-mI3utetwSPT3ovZHUJ4LBW29EtmkrzpK/O38msP5WnI8ocFmM5boy3QZALosgeQwqwdmtQgC+8xnJIYHXeABew==", + "license": "MIT", + "peerDependencies": { + "react": "18.x || 19.x" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 84dc1a0f..3d5b8fa9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "reading-time-estimator": "^1.14.0", "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.14", diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index f2d11720..e4036142 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -10,8 +10,8 @@ import IconButton from './IconButton' import { ViewMode } from './Bookmarks' import { extractUrlsFromContent } from '../services/bookmarkHelpers' import { UserSettings } from '../services/settingsService' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { BookmarkSkeleton } from './Skeletons' interface BookmarkListProps { @@ -54,14 +54,15 @@ export const BookmarkList: React.FC = ({ const bookmarksListRef = useRef(null) // Pull-to-refresh for bookmarks - const pullToRefreshState = usePullToRefresh(bookmarksListRef, { + const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({ onRefresh: () => { if (onRefresh) { onRefresh() } }, - isRefreshing: isRefreshing || false, - disabled: !onRefresh + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !onRefresh }) // Helper to check if a bookmark has either content or a URL @@ -146,13 +147,11 @@ export const BookmarkList: React.FC = ({ ) : (
-
{allIndividualBookmarks.map((individualBookmark, index) => diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 79dd801e..b02e81dd 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faExclamationCircle, faNewspaper, faPenToSquare, faHighlighter, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons' +import { 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' @@ -18,8 +18,8 @@ import { UserSettings } from '../services/settingsService' import BlogPostCard from './BlogPostCard' import { HighlightItem } from './HighlightItem' import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache' -import { usePullToRefresh } from '../hooks/usePullToRefresh' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import { usePullToRefresh } from 'use-pull-to-refresh' +import RefreshIndicator from './RefreshIndicator' import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' @@ -40,8 +40,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [highlights, setHighlights] = useState([]) const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const exploreContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) // Visibility filters (defaults from settings) @@ -61,7 +59,6 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti useEffect(() => { const loadData = async () => { if (!activeAccount) { - setError('Please log in to explore content from your friends') setLoading(false) return } @@ -69,16 +66,16 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti try { // show spinner but keep existing data setLoading(true) - setError(null) // Seed from in-memory cache if available to avoid empty flash + // Use functional update to check current state without creating dependency const cachedPosts = getCachedPosts(activeAccount.pubkey) - if (cachedPosts && cachedPosts.length > 0 && blogPosts.length === 0) { - setBlogPosts(cachedPosts) + if (cachedPosts && cachedPosts.length > 0) { + setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev) } const cachedHighlights = getCachedHighlights(activeAccount.pubkey) - if (cachedHighlights && cachedHighlights.length > 0 && highlights.length === 0) { - setHighlights(cachedHighlights) + if (cachedHighlights && cachedHighlights.length > 0) { + setHighlights(prev => prev.length === 0 ? cachedHighlights : prev) } // Fetch the user's contacts (friends) @@ -151,11 +148,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } ) - if (contacts.size === 0) { - setError('You are not following anyone yet. Follow some people to see their content!') - setLoading(false) - return - } + // Always proceed to load nostrverse content even if no contacts + // (removed blocking error for empty contacts) // Store final followed pubkeys setFollowedPubkeys(contacts) @@ -202,10 +196,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) } - if (uniquePosts.length === 0 && uniqueHighlights.length === 0) { - setError('No content found yet') - } - + // No blocking errors - let empty states handle messaging setBlogPosts(uniquePosts) setCachedPosts(activeAccount.pubkey, uniquePosts) @@ -213,21 +204,23 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti setCachedHighlights(activeAccount.pubkey, uniqueHighlights) } catch (err) { console.error('Failed to load data:', err) - setError('Failed to load content. Please try again.') + // No blocking error - user can pull-to-refresh } finally { setLoading(false) } } loadData() - }, [relayPool, activeAccount, blogPosts.length, highlights.length, refreshTrigger, eventStore, settings]) + }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh - const pullToRefreshState = usePullToRefresh(exploreContainerRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { setRefreshTrigger(prev => prev + 1) }, - isRefreshing: loading + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !activeAccount }) const getPostUrl = (post: BlogPostPreview) => { @@ -309,9 +302,18 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const renderTabContent = () => { switch (activeTab) { case 'writings': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } return filteredBlogPosts.length === 0 ? ( -
-

No blog posts found yet.

+
+

No blog posts yet. Pull to refresh!

) : (
@@ -326,9 +328,18 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti ) case 'highlights': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) + } return classifiedHighlights.length === 0 ? ( -
-

No highlights yet. Your friends should start highlighting content!

+
+

No highlights yet. Pull to refresh!

) : (
@@ -348,54 +359,15 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } } - // Only show full loading screen if we don't have any data yet + // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || blogPosts.length > 0 - - if (loading && !hasData) { - return ( -
-
-

- - Explore -

-
-
- {activeTab === 'writings' ? ( - Array.from({ length: 6 }).map((_, i) => ( - - )) - ) : ( - Array.from({ length: 8 }).map((_, i) => ( - - )) - )} -
-
- ) - } - - if (error) { - return ( -
-
- -

{error}

-
-
- ) - } + const showSkeletons = loading && !hasData return ( -
- +

diff --git a/src/components/HighlightsPanel.tsx b/src/components/HighlightsPanel.tsx index b4687150..f2596c89 100644 --- a/src/components/HighlightsPanel.tsx +++ b/src/components/HighlightsPanel.tsx @@ -1,13 +1,13 @@ -import React, { useState, useRef } from 'react' +import React, { useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter } from '@fortawesome/free-solid-svg-icons' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { useFilteredHighlights } from '../hooks/useFilteredHighlights' -import { usePullToRefresh } from '../hooks/usePullToRefresh' +import { usePullToRefresh } from 'use-pull-to-refresh' import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed' import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader' -import PullToRefreshIndicator from './PullToRefreshIndicator' +import RefreshIndicator from './RefreshIndicator' import { RelayPool } from 'applesauce-relay' import { IEventStore } from 'applesauce-core' import { UserSettings } from '../services/settingsService' @@ -60,7 +60,6 @@ export const HighlightsPanel: React.FC = ({ }) => { const [showHighlights, setShowHighlights] = useState(true) const [localHighlights, setLocalHighlights] = useState(highlights) - const highlightsListRef = useRef(null) const handleToggleHighlights = () => { const newValue = !showHighlights @@ -69,14 +68,15 @@ export const HighlightsPanel: React.FC = ({ } // Pull-to-refresh for highlights - const pullToRefreshState = usePullToRefresh(highlightsListRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { if (onRefresh) { onRefresh() } }, - isRefreshing: loading, - disabled: !onRefresh + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !onRefresh }) // Keep track of highlight updates @@ -144,15 +144,10 @@ export const HighlightsPanel: React.FC = ({

) : ( -
- + {filteredHighlights.map((highlight) => ( = ({ relayPool, activeTab: propActiveTab, pubkey: pr const [readArticles, setReadArticles] = useState([]) const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) const [viewMode, setViewMode] = useState('cards') - const meContainerRef = useRef(null) const [refreshTrigger, setRefreshTrigger] = useState(0) // Update local state when prop changes @@ -62,14 +59,12 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr useEffect(() => { const loadData = async () => { if (!viewingPubkey) { - setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile') setLoading(false) return } try { setLoading(true) - setError(null) // Seed from cache if available to avoid empty flash (own profile only) if (isOwnProfile) { @@ -115,7 +110,7 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } } catch (err) { console.error('Failed to load data:', err) - setError('Failed to load data. Please try again.') + // No blocking error - user can pull-to-refresh } finally { setLoading(false) } @@ -125,11 +120,13 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr }, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) // Pull-to-refresh - const pullToRefreshState = usePullToRefresh(meContainerRef, { + const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { setRefreshTrigger(prev => prev + 1) }, - isRefreshing: loading + maximumPullLength: 240, + refreshThreshold: 80, + isDisabled: !viewingPubkey }) const handleHighlightDelete = (highlightId: string) => { @@ -194,56 +191,28 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr .filter(hasContentOrUrl) .sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0))) - // Only show full loading screen if we don't have any data yet + // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0 - - 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) => ( - - )) - )} -
-
- ) - } - - if (error) { - return ( -
-
- -

{error}

-
-
- ) - } + const showSkeletons = loading && !hasData const renderTabContent = () => { switch (activeTab) { case 'highlights': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) + } return highlights.length === 0 ? ( -
+

{isOwnProfile - ? 'No highlights yet. Start highlighting content to see them here!' - : 'No highlights yet. You should shame them on nostr!'} + ? 'No highlights yet. Pull to refresh!' + : 'No highlights yet. Pull to refresh!'}

) : ( @@ -260,9 +229,20 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'reading-list': + if (showSkeletons) { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) + } return allIndividualBookmarks.length === 0 ? ( -
-

No bookmarks yet. Bookmark articles to see them here!

+
+

No bookmarks yet. Pull to refresh!

) : (
@@ -311,9 +291,18 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'archive': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } return readArticles.length === 0 ? ( -
-

No read articles yet. Mark articles as read to see them here!

+
+

No read articles yet. Pull to refresh!

) : (
@@ -328,25 +317,21 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr ) case 'writings': + if (showSkeletons) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } return writings.length === 0 ? ( -
+

{isOwnProfile - ? 'No articles written yet. Publish your first article to see it here!' - : ( - <> - No articles written. You can find other stuff from this user using{' '} - - ants - - . - - )} + ? 'No articles written yet. Pull to refresh!' + : 'No articles written yet. Pull to refresh!'}

) : ( @@ -367,15 +352,10 @@ const Me: React.FC = ({ relayPool, activeTab: propActiveTab, pubkey: pr } return ( -
- +
{viewingPubkey && } diff --git a/src/components/PullToRefreshIndicator.tsx b/src/components/PullToRefreshIndicator.tsx deleted file mode 100644 index 1af1b149..00000000 --- a/src/components/PullToRefreshIndicator.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faArrowDown } from '@fortawesome/free-solid-svg-icons' - -interface PullToRefreshIndicatorProps { - isPulling: boolean - pullDistance: number - canRefresh: boolean - isRefreshing: boolean - threshold?: number -} - -const PullToRefreshIndicator: React.FC = ({ - isPulling, - pullDistance, - canRefresh, - threshold = 80 -}) => { - // Only show when actively pulling, not when refreshing - if (!isPulling) return null - - const opacity = Math.min(pullDistance / threshold, 1) - const rotation = (pullDistance / threshold) * 180 - - return ( -
-
- -
-
- {canRefresh ? 'Release to refresh' : 'Pull to refresh'} -
-
- ) -} - -export default PullToRefreshIndicator - diff --git a/src/components/RefreshIndicator.tsx b/src/components/RefreshIndicator.tsx new file mode 100644 index 00000000..0cd0184d --- /dev/null +++ b/src/components/RefreshIndicator.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons' + +interface RefreshIndicatorProps { + isRefreshing: boolean + pullPosition: number +} + +const THRESHOLD = 80 + +/** + * Simple pull-to-refresh visual indicator + */ +const RefreshIndicator: React.FC = ({ + isRefreshing, + pullPosition +}) => { + const isVisible = isRefreshing || pullPosition > 0 + if (!isVisible) return null + + const opacity = Math.min(pullPosition / THRESHOLD, 1) + const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3 + + return ( +
+
+ +
+
+ ) +} + +export default RefreshIndicator + diff --git a/src/config/network.ts b/src/config/network.ts new file mode 100644 index 00000000..6d3ffd33 --- /dev/null +++ b/src/config/network.ts @@ -0,0 +1,12 @@ +// Centralized network configuration for relay queries +// Keep timeouts modest for local-first, longer for remote; tweak per use-case + +export const LOCAL_TIMEOUT_MS = 1200 +export const REMOTE_TIMEOUT_MS = 6000 + +// Contacts often need a bit more time on mobile networks +export const CONTACTS_REMOTE_TIMEOUT_MS = 9000 + +// Future knobs could live here (e.g., max limits per kind) + + diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts deleted file mode 100644 index abd490d5..00000000 --- a/src/hooks/usePullToRefresh.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useEffect, useRef, useState, RefObject } from 'react' -import { useIsCoarsePointer } from './useMediaQuery' - -interface UsePullToRefreshOptions { - onRefresh: () => void | Promise - isRefreshing?: boolean - disabled?: boolean - threshold?: number // Distance in pixels to trigger refresh - resistance?: number // Resistance factor (higher = harder to pull) -} - -interface PullToRefreshState { - isPulling: boolean - pullDistance: number - canRefresh: boolean -} - -/** - * Hook to enable pull-to-refresh gesture on touch devices - * @param containerRef - Ref to the scrollable container element - * @param options - Configuration options - * @returns State of the pull gesture - */ -export function usePullToRefresh( - containerRef: RefObject, - options: UsePullToRefreshOptions -): PullToRefreshState { - const { - onRefresh, - isRefreshing = false, - disabled = false, - threshold = 80, - resistance = 2.5 - } = options - - const isTouch = useIsCoarsePointer() - const [pullState, setPullState] = useState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - - const touchStartY = useRef(0) - const startScrollTop = useRef(0) - const isDragging = useRef(false) - - useEffect(() => { - const container = containerRef.current - if (!container || !isTouch || disabled || isRefreshing) return - - const handleTouchStart = (e: TouchEvent) => { - // Only start if scrolled to top - const scrollTop = container.scrollTop - if (scrollTop <= 0) { - touchStartY.current = e.touches[0].clientY - startScrollTop.current = scrollTop - isDragging.current = true - } - } - - const handleTouchMove = (e: TouchEvent) => { - if (!isDragging.current) return - - const currentY = e.touches[0].clientY - const deltaY = currentY - touchStartY.current - const scrollTop = container.scrollTop - - // Only pull down when at top and pulling down - if (scrollTop <= 0 && deltaY > 0) { - // Prevent default scroll behavior - e.preventDefault() - - // Apply resistance to make pulling feel natural - const distance = Math.min(deltaY / resistance, threshold * 1.5) - const canRefresh = distance >= threshold - - setPullState({ - isPulling: true, - pullDistance: distance, - canRefresh - }) - } else { - // Reset if scrolled or pulling up - isDragging.current = false - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - } - - const handleTouchEnd = async () => { - if (!isDragging.current) return - - isDragging.current = false - - if (pullState.canRefresh && !isRefreshing) { - // Keep the indicator visible while refreshing - setPullState(prev => ({ - ...prev, - isPulling: false - })) - - // Trigger refresh - await onRefresh() - } - - // Reset state - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - - const handleTouchCancel = () => { - isDragging.current = false - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - - // Add event listeners with passive: false to allow preventDefault - container.addEventListener('touchstart', handleTouchStart, { passive: true }) - container.addEventListener('touchmove', handleTouchMove, { passive: false }) - container.addEventListener('touchend', handleTouchEnd, { passive: true }) - container.addEventListener('touchcancel', handleTouchCancel, { passive: true }) - - return () => { - container.removeEventListener('touchstart', handleTouchStart) - container.removeEventListener('touchmove', handleTouchMove) - container.removeEventListener('touchend', handleTouchEnd) - container.removeEventListener('touchcancel', handleTouchCancel) - } - }, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh]) - - // Reset pull state when refresh completes - useEffect(() => { - if (!isRefreshing && pullState.isPulling) { - setPullState({ - isPulling: false, - pullDistance: 0, - canRefresh: false - }) - } - }, [isRefreshing, pullState.isPulling]) - - return pullState -} - diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index a6b6ff6d..fd210500 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -85,7 +85,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U const fullAccount = accountManager.getActive() if (!fullAccount) throw new Error('No active account') const factory = new EventFactory({ signer: fullAccount }) - await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS) + await saveSettings(relayPool, eventStore, factory, newSettings) setSettings(newSettings) setToastType('success') setToastMessage('Settings saved') diff --git a/src/services/bookmarkService.ts b/src/services/bookmarkService.ts index 3f461055..c9e42963 100644 --- a/src/services/bookmarkService.ts +++ b/src/services/bookmarkService.ts @@ -1,5 +1,4 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { AccountWithExtension, NostrEvent, @@ -16,7 +15,7 @@ import { Bookmark } from '../types/bookmarks' import { collectBookmarksFromEvents } from './bookmarkProcessing.ts' import { UserSettings } from './settingsService' import { rebroadcastEvents } from './rebroadcastService' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { queryEvents } from './dataFetch' @@ -31,23 +30,14 @@ export const fetchBookmarks = async ( if (!isAccountWithExtension(activeAccount)) { throw new Error('Invalid account object provided') } - // Get relay URLs from the pool - const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) - const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls) // Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0) - console.log('🔍 Fetching bookmark events from relays:', relayUrls) - // Try local-first quickly, then full set fallback - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(1200))) - : new Observable((sub) => sub.complete()) - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(6000))) - : new Observable((sub) => sub.complete()) - const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + console.log('🔍 Fetching bookmark events') + + const rawEvents = await queryEvents( + relayPool, + { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] }, + {} + ) console.log('📊 Raw events fetched:', rawEvents.length, 'events') // Rebroadcast bookmark events to local/all relays based on settings @@ -111,14 +101,11 @@ export const fetchBookmarks = async ( let idToEvent: Map = new Map() if (noteIds.length > 0) { try { - const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls) - const localHydrate$ = localHydrate.length > 0 - ? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800))) - : new Observable((sub) => sub.complete()) - const remoteHydrate$ = remoteHydrate.length > 0 - ? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500))) - : new Observable((sub) => sub.complete()) - const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray())) + const events = await queryEvents( + relayPool, + { ids: noteIds }, + { localTimeoutMs: 800, remoteTimeoutMs: 2500 } + ) idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e])) } catch (error) { console.warn('Failed to fetch events for hydration:', error) diff --git a/src/services/contactService.ts b/src/services/contactService.ts index 00dabd78..d7c3e780 100644 --- a/src/services/contactService.ts +++ b/src/services/contactService.ts @@ -1,6 +1,7 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { prioritizeLocalRelays } from '../utils/helpers' +import { queryEvents } from './dataFetch' +import { CONTACTS_REMOTE_TIMEOUT_MS } from '../config/network' /** * Fetches the contact list (follows) for a specific user @@ -15,24 +16,27 @@ export const fetchContacts = async ( ): Promise> => { try { const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url)) - console.log('🔍 Fetching contacts (kind 3) for user:', pubkey) - - // Local-first quick attempt - const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1')) - const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1')) - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [3], authors: [pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(1200))) - : new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete()) - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [3], authors: [pubkey] }) - .pipe(completeOnEose(), takeUntil(timer(6000))) - : new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete()) - const events = await lastValueFrom( - merge(local$, remote$).pipe(toArray()) + + const partialFollowed = new Set() + const events = await queryEvents( + relayPool, + { kinds: [3], authors: [pubkey] }, + { + relayUrls, + remoteTimeoutMs: CONTACTS_REMOTE_TIMEOUT_MS, + onEvent: (event: { created_at: number; tags: string[][] }) => { + // Stream partials as we see any contact list + for (const tag of event.tags) { + if (tag[0] === 'p' && tag[1]) { + partialFollowed.add(tag[1]) + } + } + if (onPartial && partialFollowed.size > 0) { + onPartial(new Set(partialFollowed)) + } + } + } ) const followed = new Set() if (events.length > 0) { diff --git a/src/services/dataFetch.ts b/src/services/dataFetch.ts new file mode 100644 index 00000000..f4f31bb4 --- /dev/null +++ b/src/services/dataFetch.ts @@ -0,0 +1,70 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { Observable, merge, takeUntil, timer, toArray, tap, lastValueFrom } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { LOCAL_TIMEOUT_MS, REMOTE_TIMEOUT_MS } from '../config/network' + +export interface QueryOptions { + relayUrls?: string[] + localTimeoutMs?: number + remoteTimeoutMs?: number + onEvent?: (event: NostrEvent) => void +} + +/** + * Unified local-first query helper with optional streaming callback. + * Returns all collected events (deduped by id) after both streams complete or time out. + */ +export async function queryEvents( + relayPool: RelayPool, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filter: any, + options: QueryOptions = {} +): Promise { + const { + relayUrls, + localTimeoutMs = LOCAL_TIMEOUT_MS, + remoteTimeoutMs = REMOTE_TIMEOUT_MS, + onEvent + } = options + + const urls = relayUrls && relayUrls.length > 0 + ? relayUrls + : Array.from(relayPool.relays.values()).map(r => r.url) + + const ordered = prioritizeLocalRelays(urls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) + + const local$: Observable = localRelays.length > 0 + ? relayPool + .req(localRelays, filter) + .pipe( + onlyEvents(), + onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}), + completeOnEose(), + takeUntil(timer(localTimeoutMs)) + ) as unknown as Observable + : new Observable((sub) => sub.complete()) + + const remote$: Observable = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, filter) + .pipe( + onlyEvents(), + onEvent ? tap((e: NostrEvent) => onEvent(e)) : tap(() => {}), + completeOnEose(), + takeUntil(timer(remoteTimeoutMs)) + ) as unknown as Observable + : new Observable((sub) => sub.complete()) + + const events = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + + // Deduplicate by id (callers can perform higher-level replaceable grouping if needed) + const byId = new Map() + for (const ev of events) { + if (!byId.has(ev.id)) byId.set(ev.id, ev) + } + return Array.from(byId.values()) +} + + diff --git a/src/services/exploreService.ts b/src/services/exploreService.ts index 04cff231..b4b4c122 100644 --- a/src/services/exploreService.ts +++ b/src/services/exploreService.ts @@ -1,8 +1,7 @@ -import { RelayPool, completeOnEose } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' +import { queryEvents } from './dataFetch' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -35,49 +34,38 @@ export const fetchBlogPostsFromAuthors = async ( } console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors') - - const prioritized = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) // Deduplicate replaceable events by keeping the most recent version // Group by author + d-tag identifier 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) - // Emit as we incorporate - if (onPost) { - const post: BlogPostPreview = { - event, - title: getArticleTitle(event) || 'Untitled', - summary: getArticleSummary(event), - image: getArticleImage(event), - published: getArticlePublished(event), - author: event.pubkey + await queryEvents( + relayPool, + { kinds: [30023], authors: pubkeys, limit: 100 }, + { + relayUrls, + onEvent: (event: NostrEvent) => { + 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) + // Emit as we incorporate + if (onPost) { + const post: BlogPostPreview = { + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } + onPost(post) } - onPost(post) } } } - } - - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 }) - .pipe(completeOnEose(), takeUntil(timer(1200))) - : new Observable((sub) => sub.complete()) - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 }) - .pipe(completeOnEose(), takeUntil(timer(6000))) - : new Observable((sub) => sub.complete()) - const events = await lastValueFrom(merge(local$, remote$).pipe(toArray())) - processEvents(events) + ) console.log('📊 Blog post events fetched (unique):', uniqueEvents.size) diff --git a/src/services/highlightCreationService.ts b/src/services/highlightCreationService.ts index edbdd47d..36d30503 100644 --- a/src/services/highlightCreationService.ts +++ b/src/services/highlightCreationService.ts @@ -7,8 +7,8 @@ import { Helpers, IEventStore } from 'applesauce-core' import { RELAYS } from '../config/relays' import { Highlight } from '../types/highlights' import { UserSettings } from './settingsService' -import { areAllRelaysLocal } from '../utils/helpers' -import { markEventAsOfflineCreated } from './offlineSyncService' +import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers' +import { publishEvent } from './writeService' // Boris pubkey for zap splits // npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x @@ -118,59 +118,26 @@ export async function createHighlight( // Sign the event const signedEvent = await factory.sign(highlightEvent) - // Publish to all configured relays - let the relay pool handle connection state - const targetRelays = RELAYS - - // Store the event in the local EventStore FIRST for immediate UI display - eventStore.add(signedEvent) - console.log('💾 Stored highlight in EventStore:', signedEvent.id.slice(0, 8)) - - // Check current connection status - are we online or in flight mode? + // Use unified write service to store and publish + await publishEvent(relayPool, eventStore, signedEvent) + + // Check current connection status for UI feedback const connectedRelays = Array.from(relayPool.relays.values()) .filter(relay => relay.connected) .map(relay => relay.url) - - const hasRemoteConnection = connectedRelays.some(url => - !url.includes('localhost') && !url.includes('127.0.0.1') - ) - - // Determine which relays we expect to succeed - const expectedSuccessRelays = hasRemoteConnection - ? RELAYS - : RELAYS.filter(r => r.includes('localhost') || r.includes('127.0.0.1')) - + + const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url)) + const expectedSuccessRelays = hasRemoteConnection + ? RELAYS + : RELAYS.filter(isLocalRelay) const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) - - console.log('📍 Highlight relay status:', { - targetRelays: targetRelays.length, - expectedSuccessRelays, - isLocalOnly, - hasRemoteConnection, - eventId: signedEvent.id - }) - - // If we're in local-only mode, mark this event for later sync - if (isLocalOnly) { - markEventAsOfflineCreated(signedEvent.id) - } - + // Convert to Highlight with relay tracking info and return IMMEDIATELY const highlight = eventToHighlight(signedEvent) - highlight.publishedRelays = expectedSuccessRelays // Show only relays we expect to succeed + highlight.publishedRelays = expectedSuccessRelays highlight.isLocalOnly = isLocalOnly - highlight.isOfflineCreated = isLocalOnly // Mark as created offline if local-only - - // Publish to relays in the background (non-blocking) - // This allows instant UI updates while publishing happens asynchronously - relayPool.publish(targetRelays, signedEvent) - .then(() => { - console.log('✅ Highlight published to', targetRelays.length, 'relay(s):', targetRelays) - }) - .catch((error) => { - console.warn('⚠️ Failed to publish highlight to relays (event still saved locally):', error) - }) - - // Return the highlight immediately for instant UI updates + highlight.isOfflineCreated = isLocalOnly + return highlight } diff --git a/src/services/highlights/fetchFromAuthors.ts b/src/services/highlights/fetchFromAuthors.ts index 2eb4ffc6..b157be5b 100644 --- a/src/services/highlights/fetchFromAuthors.ts +++ b/src/services/highlights/fetchFromAuthors.ts @@ -1,9 +1,8 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Highlight } from '../../types/highlights' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' +import { queryEvents } from '../dataFetch' /** * Fetches highlights (kind:9802) from a list of pubkeys (friends) @@ -24,46 +23,20 @@ export const fetchHighlightsFromAuthors = async ( } console.log('💡 Fetching highlights (kind 9802) from', pubkeys.length, 'authors') - - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const prioritized = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) const seenIds = new Set() - - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [9802], authors: pubkeys, limit: 200 }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - } - }), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [9802], authors: pubkeys, limit: 200 }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - } - }), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + const rawEvents = await queryEvents( + relayPool, + { kinds: [9802], authors: pubkeys, limit: 200 }, + { + onEvent: (event: NostrEvent) => { + if (!seenIds.has(event.id)) { + seenIds.add(event.id) + if (onHighlight) onHighlight(eventToHighlight(event)) + } + } + } + ) const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) diff --git a/src/services/libraryService.ts b/src/services/libraryService.ts index 0c8b3729..8818b818 100644 --- a/src/services/libraryService.ts +++ b/src/services/libraryService.ts @@ -1,11 +1,10 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { Helpers } from 'applesauce-core' import { RELAYS } from '../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' import { MARK_AS_READ_EMOJI } from './reactionService' import { BlogPostPreview } from './exploreService' +import { queryEvents } from './dataFetch' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -28,58 +27,11 @@ export async function fetchReadArticles( userPubkey: string ): Promise { try { - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - - // Fetch kind:7 reactions (nostr-native articles) - const kind7Local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [7], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const kind7Remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [7], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const kind7Events: NostrEvent[] = await lastValueFrom( - merge(kind7Local$, kind7Remote$).pipe(toArray()) - ) - - // Fetch kind:17 reactions (external URLs) - const kind17Local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [17], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const kind17Remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [17], authors: [userPubkey] }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const kind17Events: NostrEvent[] = await lastValueFrom( - merge(kind17Local$, kind17Remote$).pipe(toArray()) - ) + // Fetch kind:7 and kind:17 reactions in parallel + const [kind7Events, kind17Events] = await Promise.all([ + queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }), + queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS }) + ]) const readArticles: ReadArticle[] = [] @@ -157,34 +109,13 @@ export async function fetchReadArticlesWithData( return [] } - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - // Fetch the actual article events const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean) - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [30023], ids: eventIds }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [30023], ids: eventIds }) - .pipe( - onlyEvents(), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - - const articleEvents: NostrEvent[] = await lastValueFrom( - merge(local$, remote$).pipe(toArray()) + const articleEvents = await queryEvents( + relayPool, + { kinds: [30023], ids: eventIds }, + { relayUrls: RELAYS } ) // Deduplicate article events by ID diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index aa5a6839..586136b5 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -1,11 +1,10 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' 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' +import { queryEvents } from './dataFetch' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -23,36 +22,25 @@ export const fetchNostrverseBlogPosts = async ( ): 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) + await queryEvents( + relayPool, + { kinds: [30023], limit }, + { + relayUrls, + onEvent: (event: NostrEvent) => { + 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) @@ -93,24 +81,12 @@ export const fetchNostrverseHighlights = async ( ): 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 rawEvents = await queryEvents( + relayPool, + { kinds: [9802], limit }, + {} + ) const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 021b6655..16458601 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -3,6 +3,7 @@ import { EventFactory } from 'applesauce-factory' import { RelayPool, onlyEvents } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' import { firstValueFrom } from 'rxjs' +import { publishEvent } from './writeService' const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings' const APP_DATA_KIND = 30078 // NIP-78 Application Data @@ -147,11 +148,10 @@ export async function saveSettings( relayPool: RelayPool, eventStore: IEventStore, factory: EventFactory, - settings: UserSettings, - relays: string[] + settings: UserSettings ): Promise { console.log('💾 Saving settings to nostr:', settings) - + // Create NIP-78 application data event manually // Note: AppDataBlueprint is not available in the npm package const draft = await factory.create(async () => ({ @@ -160,14 +160,12 @@ export async function saveSettings( tags: [['d', SETTINGS_IDENTIFIER]], created_at: Math.floor(Date.now() / 1000) })) - + const signed = await factory.sign(draft) - - console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays') - - eventStore.add(signed) - await relayPool.publish(relays, signed) - + + // Use unified write service + await publishEvent(relayPool, eventStore, signed) + console.log('✅ Settings published successfully') } diff --git a/src/services/writeService.ts b/src/services/writeService.ts new file mode 100644 index 00000000..7ef6aa67 --- /dev/null +++ b/src/services/writeService.ts @@ -0,0 +1,57 @@ +import { RelayPool } from 'applesauce-relay' +import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' +import { RELAYS } from '../config/relays' +import { isLocalRelay, areAllRelaysLocal } from '../utils/helpers' +import { markEventAsOfflineCreated } from './offlineSyncService' + +/** + * Unified write helper: add event to EventStore, detect connectivity, + * mark for offline sync if needed, and publish in background. + */ +export async function publishEvent( + relayPool: RelayPool, + eventStore: IEventStore, + event: NostrEvent +): Promise { + // Store the event in the local EventStore FIRST for immediate UI display + eventStore.add(event) + console.log('💾 Stored event in EventStore:', event.id.slice(0, 8), `(kind ${event.kind})`) + + // Check current connection status - are we online or in flight mode? + const connectedRelays = Array.from(relayPool.relays.values()) + .filter(relay => relay.connected) + .map(relay => relay.url) + + const hasRemoteConnection = connectedRelays.some(url => !isLocalRelay(url)) + + // Determine which relays we expect to succeed + const expectedSuccessRelays = hasRemoteConnection + ? RELAYS + : RELAYS.filter(isLocalRelay) + + const isLocalOnly = areAllRelaysLocal(expectedSuccessRelays) + + console.log('📍 Event relay status:', { + targetRelays: RELAYS.length, + expectedSuccessRelays: expectedSuccessRelays.length, + isLocalOnly, + hasRemoteConnection, + eventId: event.id.slice(0, 8) + }) + + // If we're in local-only mode, mark this event for later sync + if (isLocalOnly) { + markEventAsOfflineCreated(event.id) + } + + // Publish to all configured relays in the background (non-blocking) + relayPool.publish(RELAYS, event) + .then(() => { + console.log('✅ Event published to', RELAYS.length, 'relay(s):', event.id.slice(0, 8)) + }) + .catch((error) => { + console.warn('⚠️ Failed to publish event to relays (event still saved locally):', error) + }) +} +