From 0b8b0aea97897791362ce51b21eb05b64ae0a369 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 07:18:32 +0100 Subject: [PATCH] add session snapshot to all feeds add relay operation batch logging retry outboxes in a second wave toggle-off green toasts --- src/App.tsx | 2 + src/components/NormalFeed/index.tsx | 4 + src/components/NoteList/index.tsx | 107 ++++++-- src/components/NoteOptions/useMenuActions.tsx | 12 +- src/components/Profile/index.tsx | 6 +- src/components/ProfileOptions/index.tsx | 6 +- .../PublishSuccessSubtleIndicator/index.tsx | 55 ++++ src/constants.ts | 11 + src/i18n/locales/de.ts | 5 + src/i18n/locales/en.ts | 5 + src/lib/publishing-feedback.tsx | 74 +++++- src/lib/spell-feed-request-identity.ts | 21 +- src/pages/primary/NoteListPage/RelaysFeed.tsx | 10 +- src/pages/primary/SpellsPage/index.tsx | 14 +- .../PublishSuccessToastSetting.tsx | 33 +++ .../secondary/PostSettingsPage/index.tsx | 5 + src/services/client-query.service.ts | 39 ++- src/services/client.service.ts | 156 ++++++++++-- src/services/local-storage.service.ts | 16 ++ src/services/relay-notice-strike.ts | 30 +++ src/services/relay-operation-log.service.ts | 237 ++++++++++++++++++ src/services/session-feed-snapshot.service.ts | 38 +++ src/types/index.d.ts | 7 + 23 files changed, 820 insertions(+), 73 deletions(-) create mode 100644 src/components/PublishSuccessSubtleIndicator/index.tsx create mode 100644 src/pages/secondary/PostSettingsPage/PublishSuccessToastSetting.tsx create mode 100644 src/services/relay-notice-strike.ts create mode 100644 src/services/relay-operation-log.service.ts create mode 100644 src/services/session-feed-snapshot.service.ts diff --git a/src/App.tsx b/src/App.tsx index 0c8869c7..0a39f984 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import 'yet-another-react-lightbox/styles.css' import './index.css' +import PublishSuccessSubtleIndicator from '@/components/PublishSuccessSubtleIndicator' import { Toaster } from '@/components/ui/sonner' import { BookmarksProvider } from '@/providers/BookmarksProvider' import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider' @@ -51,6 +52,7 @@ export default function App(): JSX.Element { + diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index f2203469..a12f01f1 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -11,6 +11,8 @@ import KindFilter from '../KindFilter' const NormalFeed = forwardRef void @@ -20,6 +22,7 @@ const NormalFeed = forwardRef diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 4e10c6fb..967e171a 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -24,6 +24,10 @@ import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/contexts/user-trust-context' import { useZap } from '@/providers/ZapProvider' import client from '@/services/client.service' +import { + getSessionFeedSnapshot, + setSessionFeedSnapshot +} from '@/services/session-feed-snapshot.service' import type { TFeedSubRequest, TSubRequestFilter } from '@/types' import dayjs from 'dayjs' import { type Event, type Filter, kinds } from 'nostr-tools' @@ -88,6 +92,7 @@ const NoteList = forwardRef( hideReplies = false, hideUntrustedNotes = false, areAlgoRelays = false, + relayCapabilityReady = true, pinnedEventIds = [], useFilterAsIs = false, extraShouldHideEvent, @@ -154,6 +159,11 @@ const NoteList = forwardRef( hideReplies?: boolean hideUntrustedNotes?: boolean areAlgoRelays?: boolean + /** + * When false (e.g. home relay feed waiting on `getRelayInfos`), skip timeline subscribe so + * `areAlgoRelays` does not flip after the first REQ and tear the subscription down. + */ + relayCapabilityReady?: boolean pinnedEventIds?: string[] /** When true, use filter from subRequests as-is (kinds, limit) instead of showKinds. For spell feeds. */ useFilterAsIs?: boolean @@ -202,6 +212,12 @@ const NoteList = forwardRef( /** Batched profile + embed prefetch after timeline updates (avoids N×9s profile storms while relays stream). */ const timelinePrefetchDebounceRef = useRef | null>(null) const lastEventsForTimelinePrefetchRef = useRef([]) + /** + * {@link client.subscribeTimeline} resolves asynchronously; cleanup used to only close via + * `promise.then(closer)`, so the next effect could open a new REQ before the prior closer ran. + * That stacks subscriptions on strict relays (e.g. ≤10 subs) and triggers rejections / rate limits. + */ + const timelineEstablishedCloserRef = useRef<(() => void) | null>(null) const [feedProfileBatch, setFeedProfileBatch] = useState<{ profiles: Map @@ -282,6 +298,20 @@ const NoteList = forwardRef( return JSON.stringify([...showKinds].sort((a, b) => a - b)) }, [showKinds]) + /** Session snapshot identity: same feed + kind / reply UI toggles so restore matches filtering. */ + const sessionSnapshotIdentityKey = useMemo( + () => + JSON.stringify({ + feed: timelineSubscriptionKey, + kinds: showKindsKey, + op: showKind1OPs, + rep: showKind1Replies, + c1111: showKind1111, + hr: hideReplies + }), + [timelineSubscriptionKey, showKindsKey, showKind1OPs, showKind1Replies, showKind1111, hideReplies] + ) + const showKindsRef = useRef(showKinds) showKindsRef.current = showKinds const useFilterAsIsRef = useRef(useFilterAsIs) @@ -507,6 +537,9 @@ const NoteList = forwardRef( useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useEffect(() => { + timelineEstablishedCloserRef.current?.() + timelineEstablishedCloserRef.current = null + const currentSubRequests = subRequestsRef.current if (!currentSubRequests.length) { if (oneShotDebugLabel) { @@ -520,6 +553,11 @@ const NoteList = forwardRef( return () => {} } + if (!relayCapabilityReady && !oneShotFetch) { + setLoading(true) + return () => {} + } + const prevSubKey = prevSubRequestsKeyForTimelineRef.current const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current if (userPulledRefresh) { @@ -543,13 +581,26 @@ const NoteList = forwardRef( preserveTimelineOnSubRequestsChange && keepExistingTimelineEvents && eventsRef.current.length > 0 - if (!keepRowsVisible) { - setLoading(true) - } + + const sessionSnap = + !userPulledRefresh ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) : undefined + const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length) + if (!keepExistingTimelineEvents) { - setEvents([]) - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) + if (restoredFromSession && sessionSnap) { + setEvents(sessionSnap) + lastEventsForTimelinePrefetchRef.current = sessionSnap + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(!!oneShotFetch) + } else { + if (!keepRowsVisible) setLoading(true) + setEvents([]) + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + } + } else if (!keepRowsVisible) { + setLoading(true) } setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh @@ -598,7 +649,7 @@ const NoteList = forwardRef( } setLoading(false) setEvents([]) - return () => {} + return undefined } const narrowLiveBatch = (evs: Event[]) => { @@ -607,10 +658,6 @@ const NoteList = forwardRef( } if (oneShotFetch) { - if (!keepExistingTimelineEvents) { - setEvents([]) - setNewEvents([]) - } setHasMore(false) try { const firstRelayGraceResolved = @@ -642,6 +689,9 @@ const NoteList = forwardRef( if (useFilterAsIs && clientSideKindFilter) { merged = merged.filter((e) => showKinds.includes(e.kind)) } + if (sessionSnap?.length && !userPulledRefresh) { + merged = mergeEventBatchesById(sessionSnap, merged, oneShotMergedCap ?? ONE_SHOT_MERGED_CAP) + } if (oneShotDebugLabel) { const f0 = mappedSubRequests[0]?.filter const batchEventCounts = batches.map((b) => b.length) @@ -720,8 +770,11 @@ const NoteList = forwardRef( return next }) } else { - setEvents(narrowed) - lastEventsForTimelinePrefetchRef.current = narrowed + setEvents((prev) => { + const next = mergeEventBatchesById(prev, narrowed, eventCap) + lastEventsForTimelinePrefetchRef.current = next + return next + }) } // Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+ setLoading(false) @@ -756,15 +809,9 @@ const NoteList = forwardRef( } }, 450) } else if (eosed) { - if (!preserveTimelineOnSubRequestsChange) { - setEvents([]) - } setLoading(false) } } else if (eosed) { - if (!preserveTimelineOnSubRequestsChange) { - setEvents([]) - } setLoading(false) } @@ -821,12 +868,13 @@ const NoteList = forwardRef( const result = await Promise.race([timelineSubscribePromise, timeoutPromise]) if (!effectActive) { result.closer() - return () => {} + return undefined } closer = result.closer + timelineEstablishedCloserRef.current = closer timelineKey = result.timelineKey - setTimelineKey(timelineKey) - return closer + setTimelineKey(timelineKey) + return closer } catch (_error) { setLoading(false) // Race timeout or subscribe failure: if the timeline promise later resolves, close or subs leak (relay slots + stale setEvents). @@ -837,21 +885,31 @@ const NoteList = forwardRef( }) .catch(() => {}) } - return () => {} + return undefined } } const promise = init() + const snapshotKeyForCleanup = sessionSnapshotIdentityKey return () => { effectActive = false + setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) if (timelinePrefetchDebounceRef.current) { clearTimeout(timelinePrefetchDebounceRef.current) timelinePrefetchDebounceRef.current = null } - promise.then((closer) => closer?.()) + const syncClose = timelineEstablishedCloserRef.current + timelineEstablishedCloserRef.current = null + syncClose?.() + void promise.then((fallbackClose) => { + if (fallbackClose && fallbackClose !== syncClose) { + fallbackClose() + } + }) } }, [ timelineSubscriptionKey, + sessionSnapshotIdentityKey, subRequestsKey, preserveTimelineOnSubRequestsChange, mergeTimelineWhenSubRequestFiltersMatch, @@ -862,6 +920,7 @@ const NoteList = forwardRef( showKind1111, useFilterAsIs, areAlgoRelays, + relayCapabilityReady, oneShotFetch, oneShotMergedCap, revealBatchSize, diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 2bd602fe..c827aed8 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -44,7 +44,7 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import RelayIcon from '../RelayIcon' import { PrimaryPageContext } from '@/contexts/primary-page-context' -import { showPublishingFeedback } from '@/lib/publishing-feedback' +import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback' import type { TEditOrCloneMode } from './EditOrCloneEventDialog' export interface SubMenuAction { @@ -283,7 +283,7 @@ export function useMenuActions({ } return result }) - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to all available relays'), error: (err) => t('Failed to republish to all available relays: {{error}}', { error: err.message }) @@ -326,7 +326,7 @@ export function useMenuActions({ } return result })() - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to all active relays'), error: (err) => t('Failed to republish to all active relays: {{error}}', { error: err.message }) @@ -352,7 +352,7 @@ export function useMenuActions({ } return result })() - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to your write relays'), error: (err) => t('Failed to republish to your write relays: {{error}}', { error: err.message }) @@ -375,7 +375,7 @@ export function useMenuActions({ } return result }) - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to relay set: {{name}}', { name: set.name }), error: (err) => t('Failed to republish to relay set: {{name}}. Error: {{error}}', { @@ -406,7 +406,7 @@ export function useMenuActions({ } return result }) - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to relay: {{url}}', { url: simplifyUrl(relay) }), error: (err) => t('Failed to republish to relay: {{url}}. Error: {{error}}', { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 19b3596a..b6201c98 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -15,7 +15,7 @@ import { useFetchProfile } from '@/hooks' import { kinds, type NostrEvent } from 'nostr-tools' import { createReactionDraftEvent } from '@/lib/draft-event' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' -import { showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { toProfileEditor } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' import { usePrimaryPage } from '@/contexts/primary-page-context' @@ -308,7 +308,7 @@ export default function Profile({ } return result }) - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to all available relays'), error: (err) => t('Failed to republish to all available relays: {{error}}', { error: err.message }) @@ -337,7 +337,7 @@ export default function Profile({ } return result })() - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to all active relays'), error: (err) => t('Failed to republish to all active relays: {{error}}', { error: err.message }) diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 07948a81..c000eb4b 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -22,7 +22,7 @@ import { Bell, BellOff, Copy, Ellipsis, ThumbsUp, MessageCircle, Send, Video, Sa import { useMemo, useState, useEffect } from 'react' import { createReactionDraftEvent } from '@/lib/draft-event' import PostEditor from '@/components/PostEditor' -import { showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Event } from 'nostr-tools' @@ -111,7 +111,7 @@ export default function ProfileOptions({ } return result }) - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to all available relays'), error: (err) => t('Failed to republish to all available relays: {{error}}', { error: err.message }) @@ -144,7 +144,7 @@ export default function ProfileOptions({ } return result })() - toast.promise(promise, { + toastPublishPromise(promise, { loading: t('Republishing...'), success: () => t('Successfully republish to all active relays'), error: (err) => t('Failed to republish to all active relays: {{error}}', { error: err.message }) diff --git a/src/components/PublishSuccessSubtleIndicator/index.tsx b/src/components/PublishSuccessSubtleIndicator/index.tsx new file mode 100644 index 00000000..235388b0 --- /dev/null +++ b/src/components/PublishSuccessSubtleIndicator/index.tsx @@ -0,0 +1,55 @@ +import { PUBLISH_SUCCESS_SUBTLE_EVENT, type PublishSuccessSubtleDetail } from '@/lib/publishing-feedback' +import { cn } from '@/lib/utils' +import { CheckCircle2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * When publish success toasts are off, {@link emitPublishSuccessSubtle} shows this instead: + * small green check + label, bottom-right, auto-dismiss. + */ +export default function PublishSuccessSubtleIndicator() { + const { t } = useTranslation() + const [payload, setPayload] = useState<{ message: string } | null>(null) + const hideTimerRef = useRef | null>(null) + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail + const raw = detail?.message?.trim() + const message = raw && raw.length > 0 ? raw : t('Publish successful') + if (hideTimerRef.current != null) { + clearTimeout(hideTimerRef.current) + hideTimerRef.current = null + } + setPayload({ message }) + hideTimerRef.current = setTimeout(() => { + setPayload(null) + hideTimerRef.current = null + }, 3200) + } + window.addEventListener(PUBLISH_SUCCESS_SUBTLE_EVENT, handler) + return () => { + window.removeEventListener(PUBLISH_SUCCESS_SUBTLE_EVENT, handler) + if (hideTimerRef.current != null) clearTimeout(hideTimerRef.current) + } + }, [t]) + + if (!payload) return null + + return ( +
+ + {payload.message} +
+ ) +} diff --git a/src/constants.ts b/src/constants.ts index bedf97d1..52d28817 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,9 +35,18 @@ export const DEFAULT_FAVORITE_RELAYS = [ */ export const MAX_CONCURRENT_RELAY_CONNECTIONS = 10 +/** + * Max concurrent live REQ subscriptions on a single relay. Some relays enforce ≤10 SUBs; stay under + * the advertised cap to avoid "too many subscriptions" NOTICEs when other clients or shards overlap. + */ +export const MAX_CONCURRENT_SUBS_PER_RELAY = 9 + /** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */ export const MAX_PUBLISH_RELAYS = MAX_CONCURRENT_RELAY_CONNECTIONS +/** After a publish wave, failed NIP-65 write (outbox) relays are retried once after this delay. */ +export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 + /** Max merged URLs per REQ / timeline relay list (see `relay-url-priority`). */ export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS @@ -130,6 +139,8 @@ export const StorageKey = { SHOW_RSS_FEED: 'showRssFeed', PANE_MODE: 'paneMode', ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', + /** When not `'false'`, show green Sonner toasts after successful publishes (default on). */ + SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts', /** Temporary draft cache: new notes and replies. Persisted after 30s idle; restored on refresh; cleared on logout/switch. */ POST_EDITOR_DRAFT: 'postEditorDraft', MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 7772494f..fcaad1bc 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -474,6 +474,11 @@ export default { 'no more relays': 'keine weiteren Relays', 'Favorited by': 'Favorisiert von', 'Post settings': 'Beitragseinstellungen', + 'Publishing feedback': 'Rückmeldungen beim Veröffentlichen', + 'Publish success toasts': 'Erfolgs-Benachrichtigungen beim Veröffentlichen', + 'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.': + 'Grüne Hinweise anzeigen, wenn Beiträge, Antworten, Reaktionen und andere Veröffentlichungen gelingen. Wenn aus, erscheint kurz ein kleines Häkchen unten rechts. Fehler weiterhin als Hinweis.', + 'Publish successful': 'Veröffentlichung erfolgreich', 'Media upload service': 'Medien-Upload-Service', 'Choose a relay': 'Wähle ein Relay', 'no relays found': 'Keine Relays gefunden', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a8886b11..da199cef 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -469,6 +469,11 @@ export default { 'no more relays': 'no more relays', 'Favorited by': 'Favorited by', 'Post settings': 'Post settings', + 'Publishing feedback': 'Publishing feedback', + 'Publish success toasts': 'Publish success toasts', + 'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.': + 'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.', + 'Publish successful': 'Publish successful', 'Media upload service': 'Media upload service', 'Choose a relay': 'Choose a relay', 'no relays found': 'no relays found', diff --git a/src/lib/publishing-feedback.tsx b/src/lib/publishing-feedback.tsx index 092a4966..93e63fc6 100644 --- a/src/lib/publishing-feedback.tsx +++ b/src/lib/publishing-feedback.tsx @@ -1,7 +1,37 @@ import RelayStatusDisplay from '@/components/RelayStatusDisplay' import { CheckCircle2 } from 'lucide-react' +import type { ReactNode } from 'react' +import storage from '@/services/local-storage.service' import { toast } from 'sonner' +export type PublishSuccessSubtleDetail = { message?: string } + +export const PUBLISH_SUCCESS_SUBTLE_EVENT = 'jumble:publishSuccessSubtle' + +export function emitPublishSuccessSubtle(message?: string): void { + if (typeof window === 'undefined') return + window.dispatchEvent( + new CustomEvent(PUBLISH_SUCCESS_SUBTLE_EVENT, { + detail: { message } + }) + ) +} + +function publishSuccessToastsEnabled(): boolean { + return storage.getShowPublishSuccessToasts() +} + +function resolvePromiseSuccessLabel(success: string | (() => ReactNode)): string | undefined { + if (typeof success === 'string') return success + try { + const v = success() + if (typeof v === 'string') return v + } catch { + /* ignore */ + } + return undefined +} + export type RelayStatus = { url: string success: boolean @@ -32,15 +62,23 @@ export function showPublishingFeedback( const { message = 'Published successfully', duration = 6000 } = options const { relayStatuses, successCount, totalCount } = result - + if (relayStatuses.length === 0) { // Fallback for events without relay status tracking - toast.success(message, { duration: 2000 }) + if (publishSuccessToastsEnabled()) { + toast.success(message, { duration: 2000 }) + } else { + emitPublishSuccessSubtle(message) + } return } - // Show toast with custom relay status display const isSuccess = successCount > 0 + if (isSuccess && !publishSuccessToastsEnabled()) { + emitPublishSuccessSubtle(message) + return + } + const toastFunction = isSuccess ? toast.success : toast.error toastFunction( @@ -69,6 +107,7 @@ export function showPublishingFeedback( * Simple success toast without relay details */ export function showSimplePublishSuccess(message = 'Published successfully') { + if (!publishSuccessToastsEnabled()) return toast.success(message, { duration: 2000 }) } @@ -80,3 +119,32 @@ export function showPublishingError(error: Error | string) { toast.error(message, { duration: 4000 }) } +type PublishPromiseToastOptions = { + loading: string + success: string | (() => ReactNode) + error: (err: Error) => string +} + +/** + * Like `toast.promise` for publish/republish flows: respects {@link storage.getShowPublishSuccessToasts} + * (no green success toast when disabled). Loading and error toasts still appear. + */ +export function toastPublishPromise(promise: Promise, opts: PublishPromiseToastOptions): void { + if (!publishSuccessToastsEnabled()) { + const id = toast.loading(opts.loading) + promise + .then(() => { + toast.dismiss(id) + const label = resolvePromiseSuccessLabel(opts.success) + emitPublishSuccessSubtle(label) + }) + .catch((err: unknown) => { + toast.dismiss(id) + const e = err instanceof Error ? err : new Error(String(err)) + toast.error(opts.error(e)) + }) + return + } + toast.promise(promise, opts) +} + diff --git a/src/lib/spell-feed-request-identity.ts b/src/lib/spell-feed-request-identity.ts index 1f28ba69..dbf1a1c9 100644 --- a/src/lib/spell-feed-request-identity.ts +++ b/src/lib/spell-feed-request-identity.ts @@ -1,6 +1,7 @@ import type { TFeedSubRequest } from '@/types' import { normalizeUrl } from '@/lib/url' -import type { Filter } from 'nostr-tools' +import type { Event, Filter } from 'nostr-tools' +import { tagNameEquals } from '@/lib/tag' /** Canonical JSON for a REQ filter so subscription identity ignores object identity / key order. */ export function stableSpellFeedFilterKey(filter: Filter): string { @@ -25,6 +26,24 @@ export function computeSpellSubRequestsIdentityKey(subRequests: TFeedSubRequest[ ) } +/** + * Kind-777 spell feed key: use raw `since` / `until` tag strings plus filters **without** resolved unix + * timestamps. Resolved times change on every memo recompute (`resolveRelativeTime` uses `Date.now()`), + * which was restarting NoteList subscriptions every tick → endless loading. + */ +export function computeKind777SpellFeedSubscriptionKey(spell: Event, subRequests: TFeedSubRequest[]): string { + const sinceRaw = spell.tags.find(tagNameEquals('since'))?.[1] ?? '' + const untilRaw = spell.tags.find(tagNameEquals('until'))?.[1] ?? '' + const sansTime = subRequests.map((req) => { + const { since: _s, until: _u, ...rest } = req.filter as Filter & { since?: number; until?: number } + return { + urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(), + filter: stableSpellFeedFilterKey(rest as Filter) + } + }) + return `${spell.id}|sinceRaw:${sinceRaw}|untilRaw:${untilRaw}|${JSON.stringify(sansTime)}` +} + /** * True when `nextKey` is the same REQ filters as `prevKey` but with a strict superset of relay URLs * in at least one request slot (e.g. Explore relay reviews: bootstrap relays → full list). diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 0a6f03c4..a3f8cc7f 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -20,6 +20,7 @@ const RelaysFeed = forwardRef< const { feedInfo, relayUrls } = useFeed() const { showKinds } = useKindFilter() const [areAlgoRelays, setAreAlgoRelays] = useState(false) + const [relayAlgoReady, setRelayAlgoReady] = useState(false) const relayUrlsKey = useMemo( () => @@ -32,8 +33,12 @@ const RelaysFeed = forwardRef< ) useEffect(() => { - if (relayUrls.length === 0) return + if (relayUrls.length === 0) { + setRelayAlgoReady(false) + return + } let cancelled = false + setRelayAlgoReady(false) const init = async () => { const timeoutPromise = new Promise((_, reject) => { @@ -52,6 +57,8 @@ const RelaysFeed = forwardRef< setAreAlgoRelays(areAlgo) } catch (_error) { if (!cancelled) setAreAlgoRelays(false) + } finally { + if (!cancelled) setRelayAlgoReady(true) } } @@ -96,6 +103,7 @@ const RelaysFeed = forwardRef< ref={ref} subRequests={subRequests} areAlgoRelays={areAlgoRelays} + relayCapabilityReady={relayAlgoReady} isMainFeed setSubHeader={setSubHeader} onSubHeaderRefresh={onSubHeaderRefresh} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index bc6877cc..94b609cd 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -45,7 +45,10 @@ import { augmentSubRequestsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' -import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' +import { + computeKind777SpellFeedSubscriptionKey, + computeSpellSubRequestsIdentityKey +} from '@/lib/spell-feed-request-identity' import { normalizeUrl } from '@/lib/url' import { buildSpellCatalogAuthors, @@ -746,10 +749,11 @@ const SpellsPage = forwardRef(function SpellsPage( return spellSubRequests }, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) - const spellFeedSubscriptionKey = useMemo( - () => computeSpellSubRequestsIdentityKey(subRequests), - [subRequests] - ) + const spellFeedSubscriptionKey = useMemo(() => { + if (selectedFauxSpell) return computeSpellSubRequestsIdentityKey(subRequests) + if (selectedSpell) return computeKind777SpellFeedSubscriptionKey(selectedSpell, subRequests) + return '' + }, [selectedFauxSpell, selectedSpell, subRequests]) const spellBrowseRelayUrls = useMemo(() => { const set = new Set() diff --git a/src/pages/secondary/PostSettingsPage/PublishSuccessToastSetting.tsx b/src/pages/secondary/PostSettingsPage/PublishSuccessToastSetting.tsx new file mode 100644 index 00000000..ba0703d5 --- /dev/null +++ b/src/pages/secondary/PostSettingsPage/PublishSuccessToastSetting.tsx @@ -0,0 +1,33 @@ +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import storage from '@/services/local-storage.service' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function PublishSuccessToastSetting() { + const { t } = useTranslation() + const [enabled, setEnabled] = useState(true) + + useEffect(() => { + setEnabled(storage.getShowPublishSuccessToasts()) + }, []) + + const onChange = (checked: boolean) => { + setEnabled(checked) + storage.setShowPublishSuccessToasts(checked) + } + + return ( +
+
+ + +
+
+ {t( + 'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.' + )} +
+
+ ) +} diff --git a/src/pages/secondary/PostSettingsPage/index.tsx b/src/pages/secondary/PostSettingsPage/index.tsx index 70b110d7..8b058a34 100644 --- a/src/pages/secondary/PostSettingsPage/index.tsx +++ b/src/pages/secondary/PostSettingsPage/index.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' import MediaUploadServiceSetting from './MediaUploadServiceSetting' import ExpirationSettings from './ExpirationSettings' import QuietSettings from './QuietSettings' +import PublishSuccessToastSetting from './PublishSuccessToastSetting' const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() @@ -31,6 +32,10 @@ const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: >
+
+

{t('Publishing feedback')}

+ +

{t('Expiration Tags')}

diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 441b82bd..9e641f72 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -3,11 +3,14 @@ import { FIRST_RELAY_RESULT_GRACE_MS, KIND_1_BLOCKED_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, + MAX_CONCURRENT_SUBS_PER_RELAY, SEARCHABLE_RELAY_URLS } from '@/constants' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import logger from '@/lib/logger' import { normalizeUrl } from '@/lib/url' +import { RelaySubscribeOpBatch } from '@/services/relay-operation-log.service' +import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike' import type { Filter, Event as NEvent } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent } from 'nostr-tools' import type { AbstractRelay } from 'nostr-tools/abstract-relay' @@ -37,6 +40,8 @@ export interface QueryOptions { * {@link FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT} (and not replaceableRace / immediateReturn / single-event fetch). */ firstRelayResultGraceMs?: number | false + /** Label for {@link RelaySubscribeOpBatch} when this query opens REQs. */ + relayOpSource?: string } export interface SubscribeCallbacks { @@ -52,6 +57,8 @@ export type QueryServiceRelaySessionOptions = { shouldSkipRelayForSession?: (normalizedUrl: string) => boolean /** After failed `ensureRelay` (timeout / connection error), increment client session strike counter. */ onRelayConnectionFailure?: (normalizedUrl: string) => void + /** NOTICE "failed to fetch events" (and similar) → same strike treatment as connection failure. */ + onRelayNoticeStrike?: (normalizedUrl: string, noticeMessage: string) => void } export class QueryService { @@ -62,9 +69,10 @@ export class QueryService { private onRelayConnectionFailure?: (normalizedUrl: string) => void /** Optional: ingest every resolved `query()` result (e.g. session event LRU). */ private onQueryResultIngest?: (events: NEvent[]) => void + private onRelayNoticeStrike?: (normalizedUrl: string, noticeMessage: string) => void - /** Max concurrent REQ subscriptions per relay URL */ - private static readonly MAX_CONCURRENT_SUBS_PER_RELAY = MAX_CONCURRENT_RELAY_CONNECTIONS + /** Max concurrent REQ subscriptions per relay URL (see {@link MAX_CONCURRENT_SUBS_PER_RELAY}). */ + private static readonly SUB_SLOT_CAP_PER_RELAY = MAX_CONCURRENT_SUBS_PER_RELAY private activeSubCountByRelay = new Map() private subSlotWaitQueueByRelay = new Map void>>() private eventSeenOnRelays = new Map>() @@ -96,6 +104,7 @@ export class QueryService { this.pool = pool this.shouldSkipRelayForSession = relaySession?.shouldSkipRelayForSession this.onRelayConnectionFailure = relaySession?.onRelayConnectionFailure + this.onRelayNoticeStrike = relaySession?.onRelayNoticeStrike } /** Wire after {@link EventService} exists so all `query()` / `fetchEvents` results populate the session cache. */ @@ -116,7 +125,7 @@ export class QueryService { async acquireSubSlot(relayKey: string): Promise { const count = this.activeSubCountByRelay.get(relayKey) ?? 0 - if (count < QueryService.MAX_CONCURRENT_SUBS_PER_RELAY) { + if (count < QueryService.SUB_SLOT_CAP_PER_RELAY) { this.activeSubCountByRelay.set(relayKey, count + 1) return Promise.resolve() } @@ -258,7 +267,10 @@ export class QueryService { resolve(resolvedList) } - const sub = this.subscribe(urls, filter, { + const sub = this.subscribe( + urls, + filter, + { onevent(evt) { eventCount++ onevent?.(evt) @@ -342,7 +354,9 @@ export class QueryService { resolveTimeout = setTimeout(() => resolveWithEvents(), 1000) } } - }) + }, + { source: options?.relayOpSource ?? 'QueryService.query', logLevel: 'debug' } + ) globalTimeoutId = setTimeout(() => resolveWithEvents(), globalTimeout) }) @@ -354,7 +368,8 @@ export class QueryService { subscribe( urls: string[], filter: Filter | Filter[], - callbacks: SubscribeCallbacks + callbacks: SubscribeCallbacks, + relayOpMeta?: { source: string; logLevel?: 'info' | 'debug' } ): { close: () => void } { let relays = Array.from(new Set(urls)) const filters = Array.isArray(filter) ? filter : [filter] @@ -395,11 +410,19 @@ export class QueryService { return { url, filters: filtersForRelay } }) + const opSource = relayOpMeta?.source ?? 'QueryService.subscribe' + const opBatch = + groupedRequests.length > 0 + ? new RelaySubscribeOpBatch(opSource, groupedRequests, { logLevel: relayOpMeta?.logLevel }) + : null + opBatch?.logBegin() + const eosesReceived: boolean[] = [] const closesReceived: (string | undefined)[] = [] const handleEose = (i: number) => { if (eosesReceived[i]) return eosesReceived[i] = true + opBatch?.setTerminal(i, 'eose') if (eosesReceived.filter(Boolean).length === groupedRequests.length) { callbacks.oneose?.(true) } @@ -408,6 +431,7 @@ export class QueryService { if (closesReceived[i] !== undefined) return handleEose(i) closesReceived[i] = reason + opBatch?.setTerminal(i, 'closed', reason) const { url } = groupedRequests[i]! callbacks.onclose?.(url, reason) if (closesReceived.every((r) => r !== undefined)) { @@ -439,6 +463,7 @@ export class QueryService { let relay: AbstractRelay try { relay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 }) + patchRelayNoticeForFetchFailures(relay, relayKey, this.onRelayNoticeStrike) } catch (err) { this.onRelayConnectionFailure?.(relayKey) this.releaseSubSlot(relayKey) @@ -474,6 +499,7 @@ export class QueryService { let liveRelay: AbstractRelay try { liveRelay = await this.pool.ensureRelay(url, { connectionTimeout: 5000 }) + patchRelayNoticeForFetchFailures(liveRelay, relayKey, this.onRelayNoticeStrike) } catch (err) { this.onRelayConnectionFailure?.(relayKey) this.releaseSubSlot(relayKey) @@ -542,6 +568,7 @@ export class QueryService { return { close: () => { + opBatch?.finalize('closed', 'subscribe_close') allOpened.then(() => { subs.forEach(({ close: subClose }) => subClose()) }) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 79f1445b..d058b774 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -5,6 +5,7 @@ import { FIRST_RELAY_RESULT_GRACE_MS, KIND_1_BLOCKED_RELAY_URLS, MAX_PUBLISH_RELAYS, + OUTBOX_PUBLISH_RETRY_DELAY_MS, NIP66_DISCOVERY_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, READ_ONLY_RELAY_URLS, @@ -35,6 +36,7 @@ import { isSafari } from '@/lib/utils' import { ISigner, TProfile, + TPublishEventExtras, TPublishOptions, TRelayList, TMailboxRelay, @@ -58,6 +60,8 @@ import { import { AbstractRelay } from 'nostr-tools/abstract-relay' import indexedDb from './indexed-db.service' import nip66Service from './nip66.service' +import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike' +import { compactFilterForRelayLog, RelayPublishOpBatch, RelaySubscribeOpBatch } from '@/services/relay-operation-log.service' import { QueryService } from './client-query.service' /** Live timeline REQ: dead relays fail fast; EOSE caps “connected but silent” relays. */ @@ -149,7 +153,9 @@ class ClientService extends EventTarget { shouldSkipRelayForSession: (normalizedUrl) => (this.publishStrikeCount.get(normalizedUrl) ?? 0) >= ClientService.SESSION_RELAY_FAILURE_STRIKE_THRESHOLD, - onRelayConnectionFailure: (normalizedUrl) => this.recordSessionRelayFailure(normalizedUrl) + onRelayConnectionFailure: (normalizedUrl) => this.recordSessionRelayFailure(normalizedUrl), + onRelayNoticeStrike: (normalizedUrl, noticeMessage) => + this.recordRelayNoticeFetchFailure(normalizedUrl, noticeMessage) }) this.eventService = new EventService(this.queryService) this.replaceableEventService = new ReplaceableEventService( @@ -313,6 +319,41 @@ class ClientService extends EventTarget { ) } + /** NIP-65 `write` URLs for `event.pubkey`, filtered for publish (no read-only / kind-1 blocks). */ + private async getUserOutboxRelayUrlsForPublish(event: NEvent): Promise { + try { + const relayList = await this.fetchRelayList(event.pubkey) + const raw = dedupeNormalizeRelayUrlsOrdered( + (relayList?.write ?? []).map((u) => normalizeUrl(u) || u).filter((u): u is string => !!u) + ) + return this.filterPublishingRelays(raw, event) + } catch { + return [] + } + } + + private async retryFailedOutboxPublishesOnce( + event: NEvent, + userOutboxUrls: string[], + relayStatuses: { url: string; success: boolean; error?: string }[] + ): Promise { + const norm = (u: string) => normalizeUrl(u) || u + const hadSuccess = new Set() + for (const r of relayStatuses) { + if (r.success) hadSuccess.add(norm(r.url)) + } + const failedOutboxes = userOutboxUrls.filter((u) => !hadSuccess.has(norm(u))) + if (failedOutboxes.length === 0) return + logger.info('[PublishEvent] Outbox relay(s) failed; retrying once after delay', { + eventId: event.id?.slice(0, 8), + kind: event.kind, + failedCount: failedOutboxes.length, + delayMs: OUTBOX_PUBLISH_RETRY_DELAY_MS + }) + await new Promise((r) => setTimeout(r, OUTBOX_PUBLISH_RETRY_DELAY_MS)) + await this.publishEvent(failedOutboxes, event, { skipOutboxRetry: true }) + } + private async prioritizePublishUrlList( relayUrls: string[], event: NEvent, @@ -674,6 +715,15 @@ class ClientService extends EventTarget { } /** One failed publish or subscribe connection per normalized URL (accumulates until {@link SESSION_RELAY_FAILURE_STRIKE_THRESHOLD}). */ + /** NOTICE "failed to fetch events" (relay DB/backend) — same session strike as a failed connection. */ + private recordRelayNoticeFetchFailure(url: string, noticeMessage: string) { + logger.info('[Relay] NOTICE failed-fetch → session strike', { + url, + noticeSnippet: noticeMessage.slice(0, 220) + }) + this.recordSessionRelayFailure(url) + } + private recordSessionRelayFailure(url: string) { const n = normalizeUrl(url) || url if (!n) return @@ -810,14 +860,21 @@ class ClientService extends EventTarget { return result.slice(0, count) } - async publishEvent( - relayUrls: string[], - event: NEvent, - publishExtras?: { favoriteRelayUrls?: string[] } - ) { + async publishEvent(relayUrls: string[], event: NEvent, publishExtras?: TPublishEventExtras) { + const skipOutboxRetry = publishExtras?.skipOutboxRetry === true + let userOutboxUrls: string[] = [] + let mergedRelayUrls = relayUrls + if (!skipOutboxRetry) { + userOutboxUrls = await this.getUserOutboxRelayUrlsForPublish(event) + mergedRelayUrls = + userOutboxUrls.length > 0 + ? dedupeNormalizeRelayUrlsOrdered([...userOutboxUrls, ...relayUrls]) + : relayUrls + } + const readOnlySet = new Set(READ_ONLY_RELAY_URLS.map((u) => normalizeUrl(u) || u)) const kind1BlockedSet = new Set(KIND_1_BLOCKED_RELAY_URLS.map((u) => normalizeUrl(u) || u)) - let filtered = relayUrls.filter((url) => { + let filtered = mergedRelayUrls.filter((url) => { const n = normalizeUrl(url) || url if (readOnlySet.has(n)) return false if (event.kind === kinds.ShortTextNote && kind1BlockedSet.has(n)) return false @@ -836,10 +893,21 @@ class ClientService extends EventTarget { eventId: event.id?.substring(0, 8), kind: event.kind, relayCount: filtered.length, - skippedStrikes: relayUrls.length - filtered.length + skippedStrikes: mergedRelayUrls.length - filtered.length }) const uniqueRelayUrls = filtered + if (uniqueRelayUrls.length === 0) { + const emptyBatch = new RelayPublishOpBatch('ClientService.publishEvent', event.id, []) + emptyBatch.logBegin() + emptyBatch.logEnd('no_targets') + return Promise.resolve({ + success: false, + relayStatuses: [], + successCount: 0, + totalCount: 0 + }) + } if ( event.kind === kinds.RelayList || event.kind === ExtendedKind.FAVORITE_RELAYS || @@ -856,17 +924,29 @@ class ClientService extends EventTarget { } const relayStatuses: { url: string; success: boolean; error?: string }[] = [] - + const publishOpBatch = new RelayPublishOpBatch('ClientService.publishEvent', event.id, uniqueRelayUrls) + publishOpBatch.logBegin() + // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this return new Promise<{ success: boolean; relayStatuses: typeof relayStatuses; successCount: number; totalCount: number }>((resolve) => { let successCount = 0 let finishedCount = 0 const errors: { url: string; error: any }[] = [] - + let publishOpBatchFlushed = false + const flushPublishOpBatch = (status: string) => { + if (publishOpBatchFlushed) return + publishOpBatchFlushed = true + uniqueRelayUrls.forEach((url, idx) => { + const rs = [...relayStatuses].reverse().find((r) => r.url === url) + publishOpBatch.record(idx, url, rs?.success === true, rs?.error) + }) + publishOpBatch.logEnd(status) + } + logger.debug('[PublishEvent] Setting up global timeout (30 seconds)') let hasResolved = false - + // Add a global timeout to prevent hanging - use 30 seconds for faster feedback const globalTimeout = setTimeout(() => { if (hasResolved) { @@ -901,6 +981,7 @@ class ClientService extends EventTarget { totalCount: uniqueRelayUrls.length, relayStatuses: relayStatuses.length }) + flushPublishOpBatch('global_timeout') resolve({ success: successCount >= uniqueRelayUrls.length / 3, relayStatuses, @@ -909,9 +990,9 @@ class ClientService extends EventTarget { }) } }, 30_000) // 30 seconds global timeout (reduced from 2 minutes) - + logger.debug('[PublishEvent] Starting Promise.allSettled for all relays') - Promise.allSettled( + const relayPublishAllSettled = Promise.allSettled( uniqueRelayUrls.map(async (url, index) => { const startMs = Date.now() logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) @@ -948,7 +1029,11 @@ class ClientService extends EventTarget { relay = await connectionPromise logger.debug(`[PublishEvent] Relay connected`, { url }) - + const relayKeyPub = normalizeUrl(url) || url + patchRelayNoticeForFetchFailures(relay as unknown as AbstractRelay, relayKeyPub, (u, m) => + that.recordRelayNoticeFetchFailure(u, m) + ) + relay.publishTimeout = publishTimeout logger.debug(`[PublishEvent] Publishing to relay`, { url }) @@ -1038,6 +1123,7 @@ class ClientService extends EventTarget { relayStatusesCount: relayStatuses.length }) clearTimeout(globalTimeout) + flushPublishOpBatch('all_relays_finished') resolve({ success: successCount >= uniqueRelayUrls.length / 3, relayStatuses, @@ -1061,6 +1147,7 @@ class ClientService extends EventTarget { relayStatusesCount: relayStatuses.length }) clearTimeout(globalTimeout) + flushPublishOpBatch('early_success_threshold') resolve({ success: true, relayStatuses, @@ -1073,6 +1160,18 @@ class ClientService extends EventTarget { } }) ) + + if (!skipOutboxRetry && userOutboxUrls.length > 0) { + void relayPublishAllSettled.then(() => { + void client + .retryFailedOutboxPublishesOnce(event, userOutboxUrls, relayStatuses) + .catch((err) => + logger.warn('[PublishEvent] Outbox retry pass failed', { + error: err instanceof Error ? err.message : String(err) + }) + ) + }) + } }) } @@ -1152,6 +1251,17 @@ class ClientService extends EventTarget { ) { const timelineBatchId = `tl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}` const timelineT0 = performance.now() + logger.info('[RelayOp] timeline_wave_begin', { + timelineBatchId, + shardCount: subRequests.length, + relayCountsPerShard: subRequests.map((r) => r.urls.length), + shards: subRequests.map((s, shardIndex) => ({ + shardIndex, + relayCount: s.urls.length, + relaysSample: [...new Set(s.urls.map((u) => normalizeUrl(u) || u))].slice(0, 40), + filter: compactFilterForRelayLog(s.filter as Filter) + })) + }) logger.debug('[relay-req] timeline_batch_start', { timelineBatchId, subRequestCount: subRequests.length, @@ -1377,6 +1487,8 @@ class ClientService extends EventTarget { const reqGroupId = relayReqLog?.groupId ?? `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` + const opBatch = new RelaySubscribeOpBatch(reqGroupId, groupedRequests) + opBatch.logBegin() const reqT0 = performance.now() let firstRelayResponseLogged = false /** When the first `first_response` was `eose` (no row yet), log once when the first EVENT arrives. */ @@ -1405,18 +1517,12 @@ class ClientService extends EventTarget { }) } - logger.debug('[relay-req] batch_start', { - reqGroupId, - relayCount: groupedRequests.length, - relays: groupedRequests.map((r) => r.url), - filterSummary: summarizeFiltersForRelayLog(filters) - }) - const eosesReceived: boolean[] = [] const closesReceived: (string | undefined)[] = [] const handleEose = (i: number) => { if (eosesReceived[i]) return eosesReceived[i] = true + opBatch.setTerminal(i, 'eose') logFirstRelayResponse('eose', groupedRequests[i]!.url) if (eosesReceived.filter(Boolean).length === groupedRequests.length) { oneose?.(true) @@ -1426,6 +1532,7 @@ class ClientService extends EventTarget { if (closesReceived[i] !== undefined) return handleEose(i) closesReceived[i] = reason + opBatch.setTerminal(i, 'closed', reason) const { url } = groupedRequests[i]! onclose?.(url, reason) if (closesReceived.every((r) => r !== undefined)) { @@ -1457,6 +1564,9 @@ class ClientService extends EventTarget { let relay: AbstractRelay try { relay = await that.pool.ensureRelay(url, { connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS }) + patchRelayNoticeForFetchFailures(relay, relayKey, (u, m) => + that.recordRelayNoticeFetchFailure(u, m) + ) } catch (err) { that.recordSessionRelayFailure(url) that.queryService.releaseSubSlot(relayKey) @@ -1500,6 +1610,9 @@ class ClientService extends EventTarget { liveRelay = await that.pool.ensureRelay(url, { connectionTimeout: SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS }) + patchRelayNoticeForFetchFailures(liveRelay, relayKey, (u, m) => + that.recordRelayNoticeFetchFailure(u, m) + ) } catch (err) { that.recordSessionRelayFailure(url) that.queryService.releaseSubSlot(relayKey) @@ -1599,6 +1712,7 @@ class ClientService extends EventTarget { return { close: () => { + opBatch.finalize('closed', 'subscription_closed') this.removeEventListener('newEvent', handleNewEventFromInternal) allOpened.then(() => { subs.forEach(({ close: subClose }) => subClose()) diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 18dd53a7..96fdea32 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -57,6 +57,7 @@ const SETTINGS_KEYS = [ StorageKey.SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS, StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL, StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH, + StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS, StorageKey.DEFAULT_EXPIRATION_ENABLED, StorageKey.DEFAULT_EXPIRATION_MONTHS, StorageKey.DEFAULT_QUIET_ENABLED, @@ -109,6 +110,7 @@ class LocalStorageService { private showRssFeed: boolean = true private panelMode: 'single' | 'double' = 'single' private addRandomRelaysToPublish: boolean = false + private showPublishSuccessToasts: boolean = true constructor() { if (!LocalStorageService.instance) { @@ -384,6 +386,9 @@ class LocalStorageService { const addRandomRelaysStr = window.localStorage.getItem(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH) this.addRandomRelaysToPublish = addRandomRelaysStr === null ? false : addRandomRelaysStr === 'true' + const showPublishSuccessStr = window.localStorage.getItem(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS) + this.showPublishSuccessToasts = showPublishSuccessStr !== 'false' + // Clean up deprecated data window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) @@ -489,6 +494,8 @@ class LocalStorageService { this.dismissedTooManyRelaysAlert = get(StorageKey.DISMISSED_TOO_MANY_RELAYS_ALERT) === 'true' this.showRecommendedRelaysPanel = get(StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL) === 'true' this.addRandomRelaysToPublish = get(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH) === 'true' + const showPublishSuccessStr = get(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS) + if (showPublishSuccessStr != null) this.showPublishSuccessToasts = showPublishSuccessStr !== 'false' const showKindsStr = get(StorageKey.SHOW_KINDS) if (showKindsStr != null) this.showKinds = JSON.parse(showKindsStr) as number[] const showKind1OPsStr = get(StorageKey.SHOW_KIND_1_OPs) @@ -927,6 +934,15 @@ class LocalStorageService { this.persistSetting(StorageKey.SHOW_RSS_FEED, show.toString()) } + getShowPublishSuccessToasts(): boolean { + return this.showPublishSuccessToasts + } + + setShowPublishSuccessToasts(show: boolean) { + this.showPublishSuccessToasts = show + this.persistSetting(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS, show.toString()) + } + getPanelMode(): 'single' | 'double' { return this.panelMode } diff --git a/src/services/relay-notice-strike.ts b/src/services/relay-notice-strike.ts new file mode 100644 index 00000000..67ce5464 --- /dev/null +++ b/src/services/relay-notice-strike.ts @@ -0,0 +1,30 @@ +import type { AbstractRelay } from 'nostr-tools/abstract-relay' + +const patched = new WeakSet() + +/** NOTICE bodies that indicate the relay backend failed to serve the REQ — count as a session strike. */ +const FAILED_FETCH_EVENTS = /failed to fetch events/i + +/** + * One-time patch: relay NOTICE "failed to fetch events" → session strike (same as connection failure). + * Safe to call on every ensureRelay; only the first patch per relay instance applies. + */ +export function patchRelayNoticeForFetchFailures( + relay: AbstractRelay, + relayKey: string, + onStrike?: (normalizedUrl: string, noticeMessage: string) => void +): void { + if (!onStrike || patched.has(relay as object)) return + patched.add(relay as object) + const previous = relay.onnotice.bind(relay) + relay.onnotice = (msg: string) => { + if (typeof msg === 'string' && FAILED_FETCH_EVENTS.test(msg)) { + try { + onStrike(relayKey, msg) + } catch { + /* ignore */ + } + } + previous(msg) + } +} diff --git a/src/services/relay-operation-log.service.ts b/src/services/relay-operation-log.service.ts new file mode 100644 index 00000000..41d9f199 --- /dev/null +++ b/src/services/relay-operation-log.service.ts @@ -0,0 +1,237 @@ +import logger from '@/lib/logger' +import type { Filter } from 'nostr-tools' + +let batchSeq = 0 + +function nextBatchId(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${(++batchSeq).toString(36)}` +} + +/** Compact filter for logs (avoid huge author/id arrays). */ +export function compactFilterForRelayLog(f: Filter): Record { + const out: Record = {} + if (f.kinds != null) out.kinds = f.kinds + if (f.limit != null) out.limit = f.limit + if (f.since != null) out.since = f.since + if (f.until != null) out.until = f.until + if (f.ids?.length) out.idCount = f.ids.length + if (f.authors?.length) out.authorCount = f.authors.length + if (f['#p']?.length) out.pTagCount = f['#p'].length + if (f['#e']?.length) out.eTagCount = f['#e'].length + if (f['#t']?.length) out.tTagCount = f['#t'].length + if (f.search) out.search = true + return out +} + +export type RelayOpTerminalOutcome = 'eose' | 'closed' | 'skipped' | 'timeout' + +export interface RelayOpTerminalRow { + cmdIndex: number + relayUrl: string + outcome: RelayOpTerminalOutcome + /** Error / close / NOTICE reason */ + detail?: string + msFromBatchStart: number +} + +type GroupedRelayRow = { url: string; filters: Filter[] } + +function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record { + const map = new Map() + for (const r of rows) { + const key = `${r.outcome}${r.detail ? `:${r.detail.slice(0, 120)}` : ''}` + const cur = map.get(key) ?? { relays: [], cmdIndices: [] } + cur.relays.push(r.relayUrl) + cur.cmdIndices.push(r.cmdIndex) + map.set(key, cur) + } + const out: Record = {} + for (const [k, v] of map) { + out[k] = { count: v.relays.length, relays: v.relays, cmdIndices: v.cmdIndices } + } + return out +} + +/** + * Tracks one logical subscribe/query wave: one `batch_begin` and one `batch_end` with per-relay outcomes. + */ +export type RelaySubscribeOpBatchOptions = { + /** `debug` hides high-volume query REQs unless jumble-debug / VITE_DEBUG is on. */ + logLevel?: 'info' | 'debug' +} + +export class RelaySubscribeOpBatch { + readonly batchId: string + private readonly t0: number + private readonly source: string + private readonly grouped: GroupedRelayRow[] + private readonly logLevel: 'info' | 'debug' + private readonly terminal = new Map() + private endLogged = false + + constructor(source: string, grouped: GroupedRelayRow[], options?: RelaySubscribeOpBatchOptions) { + this.batchId = nextBatchId('sub') + this.t0 = typeof performance !== 'undefined' ? performance.now() : Date.now() + this.source = source + this.grouped = grouped + this.logLevel = options?.logLevel ?? 'info' + } + + private logLine(message: string, payload: Record): void { + if (this.logLevel === 'debug') { + logger.debug(message, payload) + } else { + logger.info(message, payload) + } + } + + logBegin(): void { + const uniqueRelays = [...new Set(this.grouped.map((g) => g.url))] + this.logLine('[RelayOp] batch_begin', { + batchId: this.batchId, + source: this.source, + relaySlotCount: this.grouped.length, + uniqueRelayCount: uniqueRelays.length, + uniqueRelays, + commands: this.grouped.map((g, cmdIndex) => ({ + cmdIndex, + relay: g.url, + filters: g.filters.map(compactFilterForRelayLog) + })) + }) + } + + /** Last write wins per relay index (e.g. eose then closed overwrites). */ + setTerminal(cmdIndex: number, outcome: RelayOpTerminalOutcome, detail?: string): void { + if (cmdIndex < 0 || cmdIndex >= this.grouped.length) return + const msFromBatchStart = Math.round( + (typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 + ) + this.terminal.set(cmdIndex, { + cmdIndex, + relayUrl: this.grouped[cmdIndex]!.url, + outcome, + detail, + msFromBatchStart + }) + if (this.terminal.size >= this.grouped.length) { + this.logEnd('complete') + } + } + + /** + * When the subscription is torn down before every relay reported (or for shutdown), fill gaps and log once. + */ + finalize(status: 'closed' | 'timeout', detail?: string): void { + if (this.endLogged) return + const msFromBatchStart = Math.round( + (typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 + ) + for (let i = 0; i < this.grouped.length; i++) { + if (!this.terminal.has(i)) { + this.terminal.set(i, { + cmdIndex: i, + relayUrl: this.grouped[i]!.url, + outcome: status === 'timeout' ? 'timeout' : 'skipped', + detail: detail ?? (status === 'timeout' ? 'batch_finalize_timeout' : 'batch_finalize_closed'), + msFromBatchStart + }) + } + } + this.logEnd(status) + } + + private logEnd(status: string): void { + if (this.endLogged) return + this.endLogged = true + const rows = [...this.terminal.values()].sort((a, b) => a.cmdIndex - b.cmdIndex) + const elapsedMs = Math.round( + (typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 + ) + this.logLine('[RelayOp] batch_end', { + batchId: this.batchId, + source: this.source, + status, + elapsedMs, + terminalCount: rows.length, + byOutcome: groupTerminalsByOutcome(rows), + terminals: rows + }) + } +} + +export type PublishOpResultRow = { + cmdIndex: number + relayUrl: string + ok: boolean + msFromBatchStart: number + error?: string +} + +/** + * One publish wave to many relays: single begin/end log. + */ +export class RelayPublishOpBatch { + readonly batchId: string + private readonly t0: number + private readonly source: string + private readonly eventId: string + private readonly relays: string[] + private readonly results: PublishOpResultRow[] = [] + private endLogged = false + + constructor(source: string, eventId: string, relays: string[]) { + this.batchId = nextBatchId('pub') + this.t0 = typeof performance !== 'undefined' ? performance.now() : Date.now() + this.source = source + this.eventId = eventId + this.relays = relays + } + + logBegin(): void { + logger.info('[RelayOp] publish_batch_begin', { + batchId: this.batchId, + source: this.source, + eventId: this.eventId, + relayCount: this.relays.length, + relays: this.relays, + commands: this.relays.map((relay, cmdIndex) => ({ cmdIndex, relay, eventId: this.eventId })) + }) + } + + record(cmdIndex: number, relayUrl: string, ok: boolean, error?: string): void { + const msFromBatchStart = Math.round( + (typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 + ) + this.results.push({ cmdIndex, relayUrl, ok, msFromBatchStart, error }) + } + + logEnd(status: string): void { + if (this.endLogged) return + this.endLogged = true + const elapsedMs = Math.round( + (typeof performance !== 'undefined' ? performance.now() : Date.now()) - this.t0 + ) + const ok = this.results.filter((r) => r.ok) + const fail = this.results.filter((r) => !r.ok) + logger.info('[RelayOp] publish_batch_end', { + batchId: this.batchId, + source: this.source, + eventId: this.eventId, + status, + elapsedMs, + okCount: ok.length, + failCount: fail.length, + byState: { + ok: { count: ok.length, relays: ok.map((r) => r.relayUrl), cmdIndices: ok.map((r) => r.cmdIndex) }, + fail: { + count: fail.length, + relays: fail.map((r) => r.relayUrl), + cmdIndices: fail.map((r) => r.cmdIndex), + errors: fail.map((r) => r.error ?? '') + } + }, + results: this.results.sort((a, b) => a.cmdIndex - b.cmdIndex) + }) + } +} diff --git a/src/services/session-feed-snapshot.service.ts b/src/services/session-feed-snapshot.service.ts new file mode 100644 index 00000000..5d751248 --- /dev/null +++ b/src/services/session-feed-snapshot.service.ts @@ -0,0 +1,38 @@ +import type { Event } from 'nostr-tools' + +/** Max events stored per feed key (matches typical initial timeline cap). */ +const MAX_EVENTS_PER_FEED = 120 +/** Max distinct feeds kept in memory for the tab session. */ +const MAX_FEED_KEYS = 48 + +const snapshots = new Map() +const accessOrder: string[] = [] + +function bumpAccess(key: string) { + const i = accessOrder.indexOf(key) + if (i >= 0) accessOrder.splice(i, 1) + accessOrder.push(key) + while (accessOrder.length > MAX_FEED_KEYS) { + const oldest = accessOrder.shift() + if (oldest) snapshots.delete(oldest) + } +} + +/** + * In-memory feed rows for the current tab session. Lets NoteList restore immediately when + * remounting the same feed (page / spell / relay) and merge fresh REQ results on top. + */ +export function getSessionFeedSnapshot(key: string): Event[] | undefined { + if (!key) return undefined + const rows = snapshots.get(key) + if (!rows?.length) return undefined + bumpAccess(key) + return rows +} + +export function setSessionFeedSnapshot(key: string, events: readonly Event[]): void { + if (!key) return + const capped = events.slice(0, MAX_EVENTS_PER_FEED).map((e) => ({ ...e })) + snapshots.set(key, capped) + bumpAccess(key) +} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index a9817ccb..ff4e69db 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -181,6 +181,13 @@ export type TPublishOptions = { addClientTag?: boolean } +/** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */ +export type TPublishEventExtras = { + favoriteRelayUrls?: string[] + /** When true (internal): only publish to the given URLs; do not merge outboxes or schedule outbox retry. */ + skipOutboxRetry?: boolean +} + export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | 'bookmarksAndHashtags' export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'