diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index fecafdfd..dd7e78ab 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -3,7 +3,10 @@ import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' -import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' +import { + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' @@ -65,7 +68,7 @@ export default function ExploreRelayReviews() { getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [], maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 7978cc39..8a5beb9b 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' import { Skeleton } from '@/components/ui/skeleton' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants' import { cn } from '@/lib/utils' @@ -60,7 +61,7 @@ export default function GifPicker({ const fileInputRef = useRef(null) const gifbuddyPopupRef = useRef(null) - const userReadRelays = relayList?.read ?? [] + const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userWriteRelays = relayList?.write ?? [] /** Paste / upload: GIF discovery relays + user writes (unchanged). */ diff --git a/src/components/MemePicker/index.tsx b/src/components/MemePicker/index.tsx index 05e5468e..1e9f7632 100644 --- a/src/components/MemePicker/index.tsx +++ b/src/components/MemePicker/index.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label' import { ScrollArea } from '@/components/ui/scroll-area' import { Skeleton } from '@/components/ui/skeleton' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { useNostr } from '@/providers/NostrProvider' import { ExtendedKind, GIF_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' @@ -22,7 +23,7 @@ import { } from '@/services/meme.service' import mediaUpload from '@/services/media-upload.service' import { ExternalLink, X } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -79,7 +80,7 @@ export default function MemePicker({ const fileInputRef = useRef(null) const memeamigoPopupRef = useRef(null) - const userReadRelays = relayList?.read ?? [] + const userReadRelays = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const userWriteRelays = relayList?.write ?? [] const loadMemes = useCallback( diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 5acaf7af..ed34720e 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -13,6 +13,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useNostr } from '@/providers/NostrProvider' import { getRelayListFromEvent } from '@/lib/event-metadata' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import indexedDb from '@/services/indexed-db.service' import { Check, ChevronDown, Server } from 'lucide-react' import { NostrEvent } from 'nostr-tools' @@ -60,6 +61,7 @@ export default function PostRelaySelector({ useCurrentRelays() // Keep this hook call for any side effects const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() const { pubkey, relayList } = useNostr() + const userReadRelaysForSelection = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const [selectedRelayUrls, setSelectedRelayUrls] = useState([]) const [selectableRelays, setSelectableRelays] = useState([]) const [relayTypes, setRelayTypes] = useState>({}) @@ -220,7 +222,7 @@ export default function PostRelaySelector({ const result = await relaySelectionService.selectRelays({ userWriteRelays, userHttpWriteRelays: relayList?.httpWrite ?? [], - userReadRelays: relayList?.read || [], + userReadRelays: userReadRelaysForSelection, favoriteRelays: memoizedFavoriteRelays, blockedRelays: memoizedBlockedRelays, relaySets: memoizedRelaySets, @@ -328,7 +330,7 @@ export default function PostRelaySelector({ const result = await relaySelectionService.selectRelays({ userWriteRelays, userHttpWriteRelays: relayList?.httpWrite ?? [], - userReadRelays: relayList?.read || [], + userReadRelays: userReadRelaysForSelection, favoriteRelays: memoizedFavoriteRelays, blockedRelays: memoizedBlockedRelays, relaySets: memoizedRelaySets, diff --git a/src/components/RssArticleWebBookmarks/index.tsx b/src/components/RssArticleWebBookmarks/index.tsx index 8a49333d..37bdc68e 100644 --- a/src/components/RssArticleWebBookmarks/index.tsx +++ b/src/components/RssArticleWebBookmarks/index.tsx @@ -5,7 +5,10 @@ import { Separator } from '@/components/ui/separator' import { Textarea } from '@/components/ui/textarea' import { ExtendedKind } from '@/constants' import { createWebBookmarkDraftEvent } from '@/lib/draft-event' -import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' +import { + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' import { showPublishingError } from '@/lib/publishing-feedback' import { @@ -40,11 +43,11 @@ export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: str }, [canonical]) const relayUrls = useMemo(() => { - const read = relayList?.read ?? [] + const read = userReadRelaysWithHttp(relayList) const base = getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, read, {}) if (!base.length) return [] return appendCuratedReadOnlyRelays(base, blockedRelays) - }, [favoriteRelays, blockedRelays, relayList?.read]) + }, [favoriteRelays, blockedRelays, relayList]) const [mine, setMine] = useState([]) const [loading, setLoading] = useState(false) diff --git a/src/constants.ts b/src/constants.ts index 6104b759..123fb174 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -93,6 +93,15 @@ export const RELAY_NIP42_PUBLISH_ACK_TIMEOUT_MS = 90_000 /** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */ export const FIRST_RELAY_RESULT_GRACE_MS = 2000 +/** + * Timelines that include HTTP index relays: interval between periodic `query()` polls while the WebSocket + * subscription stays open (HTTP relays do not receive live `EVENT` over REQ). + */ +export const HTTP_TIMELINE_POLL_INTERVAL_MS = 45_000 + +/** Subtracted from the polling `since` cursor so borderline events are not missed between polls. */ +export const HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC = 120 + /** Legacy name: was used to cap spell NoteList skeleton time; loading now ends on EOSE / first events / safety timeouts. Kept for forks. */ export const SPELL_FEED_LOADING_MAX_MS = 1000 diff --git a/src/hooks/useFetchCalendarRsvps.tsx b/src/hooks/useFetchCalendarRsvps.tsx index 365307b4..6c331a60 100644 --- a/src/hooks/useFetchCalendarRsvps.tsx +++ b/src/hooks/useFetchCalendarRsvps.tsx @@ -8,6 +8,7 @@ import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' import { normalizeUrl } from '@/lib/url' import { FAST_READ_RELAY_URLS } from '@/constants' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { tagNameEquals } from '@/lib/tag' function getRsvpStatus(rsvp: Event): 'accepted' | 'tentative' | 'declined' | undefined { @@ -39,7 +40,7 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { setIsFetching(true) const coordinate = getReplaceableCoordinateFromEvent(calendarEvent) - const userRead = relayList?.read ?? [] + const userRead = userReadRelaysWithHttp(relayList) const baseUrls = new Set([ ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url), ...userRead.map((url) => normalizeUrl(url) || url) @@ -86,7 +87,7 @@ export function useFetchCalendarRsvps(calendarEvent: Event | undefined) { return () => { cancelled = true } - }, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey, relayList?.read]) + }, [calendarEvent?.id, calendarEvent?.kind, calendarEvent?.pubkey, relayList]) // When we publish an RSVP, NostrProvider calls client.emitNewEvent(event). Merge it into rsvps so the UI updates immediately. useEffect(() => { diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index 645f4757..69b195f3 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -25,6 +25,17 @@ const blockedSet = (blockedRelays: string[]) => * {@link DEFAULT_FAVORITE_RELAYS}. Same list drives the favorites tier in REQ/publish prioritization and the * all-favorites home feed. */ +/** + * NIP-65 `read` plus HTTP index inboxes (kind 10243) for feed REQ / query URL lists. + */ +export function userReadRelaysWithHttp( + relayList: { read?: string[]; httpRead?: string[] } | undefined | null +): string[] { + const http = relayList?.httpRead ?? [] + const read = relayList?.read ?? [] + return dedupeNormalizeRelayUrlsOrdered([...http, ...read]) +} + export function getFavoritesFeedRelayUrls( favoriteRelays: string[], blockedRelays: string[] diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index a724c3e5..5ef7ea54 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -1,6 +1,9 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' -import { augmentSubRequestsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' +import { + augmentSubRequestsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' import { buildFollowingFeedDeltaSubRequests } from '@/lib/following-feed-delta' import { getPubkeysFromPTags } from '@/lib/tag' import { normalizeUrl } from '@/lib/url' @@ -46,12 +49,12 @@ const FollowingFeed = forwardRef< ) const relayReadKey = useMemo( () => - [...(relayList?.read ?? [])] + [...userReadRelaysWithHttp(relayList)] .map((u) => normalizeUrl(u) || u) .filter(Boolean) .sort() .join('\0'), - [relayList?.read] + [relayList] ) const relayWriteKey = useMemo( () => @@ -84,7 +87,7 @@ const FollowingFeed = forwardRef< raw, favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index 1b592581..f9bc4693 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -24,6 +24,7 @@ import { useNostr } from '@/providers/NostrProvider' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { eventService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { getRelaysForSpellCatalogSync } from '@/services/spell.service' import { Info, Minus, Plus, X } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -323,7 +324,7 @@ export default function CreateSpellDialog({ const { draft, notices, pendingATags } = applyListEventToSpellDraft(base, ev) setForm(draft) setListImportNotices(notices) - const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, relayList?.read ?? [], { + const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] }) if (pendingATags.length === 0) return diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 159b37de..4a060f24 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -55,7 +55,8 @@ import { getPubkeysFromPTags } from '@/lib/tag' import { formatPubkey, normalizeHexPubkey } from '@/lib/pubkey' import { augmentSubRequestsWithFavoritesFastReadAndInbox, - getRelayUrlsWithFavoritesFastReadAndInbox + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { computeKind777SpellFeedSubscriptionKey, @@ -490,7 +491,7 @@ const SpellsPage = forwardRef(function SpellsPage( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) if (!feedUrls.length) { @@ -598,7 +599,7 @@ const SpellsPage = forwardRef(function SpellsPage( return } - const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, relayList?.read ?? [], { + const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] }) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) @@ -760,7 +761,7 @@ const SpellsPage = forwardRef(function SpellsPage( raw, favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) try { @@ -843,7 +844,7 @@ const SpellsPage = forwardRef(function SpellsPage( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [], applySocialKindBlockedFilter: false @@ -868,7 +869,7 @@ const SpellsPage = forwardRef(function SpellsPage( raw, favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') })) @@ -987,7 +988,7 @@ const SpellsPage = forwardRef(function SpellsPage( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [], applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined diff --git a/src/pages/secondary/FollowSetsSettingsPage/index.tsx b/src/pages/secondary/FollowSetsSettingsPage/index.tsx index 00848c38..4c00120b 100644 --- a/src/pages/secondary/FollowSetsSettingsPage/index.tsx +++ b/src/pages/secondary/FollowSetsSettingsPage/index.tsx @@ -34,7 +34,10 @@ import { randomString } from '@/lib/random' import { showPublishingError } from '@/lib/publishing-feedback' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' +import { + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' import { createFollowSetDraftEvent } from '@/lib/draft-event' import { filterEventsExcludingTombstones } from '@/lib/event' import logger from '@/lib/logger' @@ -84,11 +87,11 @@ const FollowSetsSettingsPage = forwardRef( const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) return appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - }, [favoriteRelays, blockedRelays, relayList?.read, relayList?.write]) + }, [favoriteRelays, blockedRelays, relayList]) const loadLists = useCallback(async () => { if (!pubkey) { diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index f5ddd4f5..52956118 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button' import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' import { augmentSubRequestsWithFavoritesFastReadAndInbox, - getRelayUrlsWithFavoritesFastReadAndInbox + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toProfileList } from '@/lib/link' @@ -100,7 +101,7 @@ const NoteListPage = forwardRef(({ index, hid urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), readUrlOpts ) } @@ -143,7 +144,7 @@ const NoteListPage = forwardRef(({ index, hid urls: getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) } @@ -175,7 +176,7 @@ const NoteListPage = forwardRef(({ index, hid raw, favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), { userWriteRelays: relayList?.write ?? [] } ) ) @@ -202,7 +203,7 @@ const NoteListPage = forwardRef(({ index, hid const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, - relayList?.read ?? [], + userReadRelaysWithHttp(relayList), readUrlOpts ) const mergedReqKinds = Array.from( diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index 6c36d362..8c8c199c 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -6,11 +6,20 @@ import { resolveParentSpacesForLiveActivities, type TLiveActivityItem } from '@/lib/live-activities' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import logger from '@/lib/logger' import client from '@/services/client.service' import storage from '@/services/local-storage.service' import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge' -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFollowListOptional } from './FollowListProvider' import { useNostr } from './NostrProvider' @@ -47,7 +56,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) - const relayRead = relayList?.read ?? [] + const relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const relayWrite = relayList?.write ?? [] const refresh = useCallback(async () => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index f11ab9e0..b0e8e950 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -4,6 +4,8 @@ import { FAST_WRITE_RELAY_URLS, DOCUMENT_RELAY_URLS, FIRST_RELAY_RESULT_GRACE_MS, + HTTP_TIMELINE_POLL_INTERVAL_MS, + HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC, isDocumentRelayKind, isSocialKindBlockedKind, relayFilterIncludesDocumentRelayKind, @@ -2286,6 +2288,23 @@ class ClientService extends EventTarget { let eosedAt: number | null = null let eventIds = new Set() + const httpTimelinePollBases = Array.from( + new Set( + relays + .filter((u) => isHttpRelayUrl(u)) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter(Boolean) + ) + ) + let httpPollIntervalId: ReturnType | null = null + let httpPollCursorUnix = 0 + const clearHttpTimelinePoll = () => { + if (httpPollIntervalId != null) { + clearInterval(httpPollIntervalId) + httpPollIntervalId = null + } + } + let firstResultGraceTimer: ReturnType | null = null const clearFirstResultGraceTimer = () => { if (firstResultGraceTimer != null) { @@ -2358,6 +2377,108 @@ class ClientService extends EventTarget { logger.warn('[ClientService] Timeline disk hydrate failed', err) } + const applySubscribedTimelineEvent = (evt: NEvent) => { + that.addEventToCache(evt) + if (!eosedAt) { + if (eventIds.has(evt.id)) return + eventIds.add(evt.id) + events.push(evt) + flushStreamingSnapshot() + armFirstResultGraceAfterFirstEvent() + return + } + + if (eventIds.has(evt.id)) return + + const wallClockAtEose = eosedAt + const isBacklogStraggler = evt.created_at + TIMELINE_STRAGGLER_MAX_AGE_SEC < wallClockAtEose + + if (isBacklogStraggler) { + eventIds.add(evt.id) + events.push(evt) + if (needSort) { + events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) + } + eventIds = new Set(events.map((e) => e.id)) + onEvents([...events], false) + + const timeline = that.timelines[key] + if (timeline && !Array.isArray(timeline)) { + timeline.refs = events + .map((e) => [e.id, e.created_at] as TTimelineRef) + .sort((a, b) => b[1] - a[1]) + that.scheduleTimelinePersist(key) + } + return + } + + eventIds.add(evt.id) + onNew(evt) + + const timeline = that.timelines[key] + if (!timeline || Array.isArray(timeline)) { + return + } + + if (timeline.refs.length === 0) { + timeline.refs = events.map((e) => [e.id, e.created_at] as TTimelineRef).sort((a, b) => b[1] - a[1]) + that.scheduleTimelinePersist(key) + return + } + + let idx = 0 + for (const ref of timeline.refs) { + if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) { + break + } + if (evt.created_at === ref[1] && evt.id === ref[0]) { + return + } + idx++ + } + if (idx >= timeline.refs.length) return + + timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) + that.scheduleTimelinePersist(key) + } + + const runHttpTimelinePollQuery = async (pollFilter: Filter) => { + if (httpTimelinePollBases.length === 0) return + try { + await this.query( + httpTimelinePollBases, + pollFilter, + (evt: NEvent) => { + applySubscribedTimelineEvent(evt) + }, + { + firstRelayResultGraceMs: false, + globalTimeout: 25_000, + eoseTimeout: 2500 + } + ) + } catch (err) { + logger.debug('[ClientService] HTTP index timeline poll failed', err) + } + } + + const armHttpTimelinePollingAfterInitial = () => { + clearHttpTimelinePoll() + if (httpTimelinePollBases.length === 0) return + const newestCreated = events.length > 0 ? Math.max(...events.map((e) => e.created_at)) : 0 + httpPollCursorUnix = Math.max(eosedAt ?? 0, newestCreated) + httpPollIntervalId = setInterval(() => { + const base = { ...(filter as Filter) } as Filter & { until?: number } + delete base.until + const since = Math.max(0, httpPollCursorUnix - HTTP_TIMELINE_POLL_SINCE_OVERLAP_SEC) + const pollLimit = Math.min(Math.max(filter.limit ?? 200, 1), 500) + const pollFilter: Filter = { ...base, since, limit: pollLimit } + void runHttpTimelinePollQuery(pollFilter).then(() => { + httpPollCursorUnix = dayjs().unix() + }) + }, HTTP_TIMELINE_POLL_INTERVAL_MS) + } + const handleTimelineEose = (eosed: boolean) => { if (!eosed) return if (eosedAt != null) return @@ -2367,6 +2488,7 @@ class ClientService extends EventTarget { eosedAt = dayjs().unix() if (!needSort) { + armHttpTimelinePollingAfterInitial() return onEvents([...events], true) } @@ -2378,7 +2500,7 @@ class ClientService extends EventTarget { that.timelines[key] = { refs: events.map((evt) => [evt.id, evt.created_at]), filter, - urls + urls: relays } } else if (tl.refs.length === 0) { tl.refs = events.map((evt) => [evt.id, evt.created_at] as TTimelineRef) @@ -2393,6 +2515,7 @@ class ClientService extends EventTarget { tl.refs = newRefs.concat(tl.refs) } } + armHttpTimelinePollingAfterInitial() onEvents([...events], true) that.scheduleTimelinePersist(key) } @@ -2400,80 +2523,24 @@ class ClientService extends EventTarget { const subCloser = this.subscribe(relays, filter, { startLogin, onevent: (evt: NEvent) => { - that.addEventToCache(evt) - // not eosed yet, push to events - if (!eosedAt) { - if (eventIds.has(evt.id)) return - eventIds.add(evt.id) - events.push(evt) - flushStreamingSnapshot() - armFirstResultGraceAfterFirstEvent() - return - } - - if (eventIds.has(evt.id)) return - - const wallClockAtEose = eosedAt - const isBacklogStraggler = - evt.created_at + TIMELINE_STRAGGLER_MAX_AGE_SEC < wallClockAtEose - - if (isBacklogStraggler) { - eventIds.add(evt.id) - events.push(evt) - if (needSort) { - events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) - } - eventIds = new Set(events.map((e) => e.id)) - onEvents([...events], false) - - const timeline = that.timelines[key] - if (timeline && !Array.isArray(timeline)) { - timeline.refs = events - .map((e) => [e.id, e.created_at] as TTimelineRef) - .sort((a, b) => b[1] - a[1]) - that.scheduleTimelinePersist(key) - } - return - } - - eventIds.add(evt.id) - onNew(evt) - - const timeline = that.timelines[key] - if (!timeline || Array.isArray(timeline)) { - return - } - - if (timeline.refs.length === 0) { - timeline.refs = events.map((e) => [e.id, e.created_at] as TTimelineRef).sort((a, b) => b[1] - a[1]) - that.scheduleTimelinePersist(key) - return - } - - let idx = 0 - for (const ref of timeline.refs) { - if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) { - break - } - if (evt.created_at === ref[1] && evt.id === ref[0]) { - return - } - idx++ - } - if (idx >= timeline.refs.length) return - - timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) - that.scheduleTimelinePersist(key) + applySubscribedTimelineEvent(evt) }, oneose: handleTimelineEose, onclose: onClose }, relayReqLog) + if (httpTimelinePollBases.length > 0) { + const backfillFilter = { ...(filter as Filter) } as Filter & { until?: number } + delete backfillFilter.until + void runHttpTimelinePollQuery(backfillFilter) + } + return { timelineKey: key, closer: () => { clearFirstResultGraceTimer() + clearHttpTimelinePoll() onEvents = () => {} onNew = () => {} subCloser.close() @@ -2602,8 +2669,19 @@ class ClientService extends EventTarget { } = {} ) { const originalDedupedRelays = Array.from(new Set(urls)) - let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) - if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS] + const httpRelayBases = Array.from( + new Set( + originalDedupedRelays + .filter((u) => isHttpRelayUrl(u)) + .map((u) => normalizeHttpRelayUrl(u) || u) + .filter(Boolean) + ) + ) + const wsOriginal = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) + let relays = [...wsOriginal] + if (relays.length === 0 && httpRelayBases.length === 0) { + relays = [...FAST_READ_RELAY_URLS] + } const filters = Array.isArray(filter) ? filter : [filter] relays = withDocumentRelayUrlsForFilters(relays, filters) const stripSocialBlockedRelays = @@ -2612,10 +2690,11 @@ class ClientService extends EventTarget { if (stripSocialBlockedRelays) { const socialKindBlockedSet = new Set(SOCIAL_KIND_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const stripped = relays.filter((url) => !socialKindBlockedSet.has(normalizeUrl(url) || url)) - relays = relaysAfterSocialKindBlockedStrip(originalDedupedRelays, stripped) + relays = relaysAfterSocialKindBlockedStrip(wsOriginal, stripped) } relays = this.relayUrlsAfterStrikesOrRecover(relays) - const events = await this.queryService.query(relays, filter, onevent, { + const queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases]) + const events = await this.queryService.query(queryRelays, filter, onevent, { eoseTimeout, globalTimeout, firstRelayResultGraceMs, diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 1924f5ea..74bdad38 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -23,6 +23,7 @@ import { getWebExternalReactionTargetUrl, rssArticleStableEventId } from '@/lib/rss-article' +import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag' import { normalizeUrl } from '@/lib/url' import client, { eventService } from '@/services/client.service' @@ -285,7 +286,7 @@ class NoteStatsService { client.fetchRelayList(event.pubkey), new Promise<{ read?: string[] }>((r) => setTimeout(() => r({}), 2000)) ]) - ;(relayList?.read ?? []).slice(0, 10).forEach(add) + userReadRelaysWithHttp(relayList).slice(0, 10).forEach(add) } catch { // ignore }