Browse Source

add session snapshot to all feeds

add relay operation batch logging
retry outboxes in a second wave
toggle-off green toasts
imwald
Silberengel 1 month ago
parent
commit
0b8b0aea97
  1. 2
      src/App.tsx
  2. 4
      src/components/NormalFeed/index.tsx
  3. 97
      src/components/NoteList/index.tsx
  4. 12
      src/components/NoteOptions/useMenuActions.tsx
  5. 6
      src/components/Profile/index.tsx
  6. 6
      src/components/ProfileOptions/index.tsx
  7. 55
      src/components/PublishSuccessSubtleIndicator/index.tsx
  8. 11
      src/constants.ts
  9. 5
      src/i18n/locales/de.ts
  10. 5
      src/i18n/locales/en.ts
  11. 70
      src/lib/publishing-feedback.tsx
  12. 21
      src/lib/spell-feed-request-identity.ts
  13. 10
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  14. 14
      src/pages/primary/SpellsPage/index.tsx
  15. 33
      src/pages/secondary/PostSettingsPage/PublishSuccessToastSetting.tsx
  16. 5
      src/pages/secondary/PostSettingsPage/index.tsx
  17. 39
      src/services/client-query.service.ts
  18. 146
      src/services/client.service.ts
  19. 16
      src/services/local-storage.service.ts
  20. 30
      src/services/relay-notice-strike.ts
  21. 237
      src/services/relay-operation-log.service.ts
  22. 38
      src/services/session-feed-snapshot.service.ts
  23. 7
      src/types/index.d.ts

2
src/App.tsx

@ -1,6 +1,7 @@ @@ -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 { @@ -51,6 +52,7 @@ export default function App(): JSX.Element {
<KindFilterProvider>
<UserPreferencesProvider>
<PageManager />
<PublishSuccessSubtleIndicator />
<Toaster />
</UserPreferencesProvider>
</KindFilterProvider>

4
src/components/NormalFeed/index.tsx

@ -11,6 +11,8 @@ import KindFilter from '../KindFilter' @@ -11,6 +11,8 @@ import KindFilter from '../KindFilter'
const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean
/** When false, NoteList waits before opening timeline REQs (relay algo probe). */
relayCapabilityReady?: boolean
isMainFeed?: boolean
/** When set (e.g. on Home), tabs are rendered in layout subHeader instead of in-feed; avoids overlap */
setSubHeader?: (node: React.ReactNode) => void
@ -20,6 +22,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -20,6 +22,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
{
subRequests,
areAlgoRelays = false,
relayCapabilityReady = true,
isMainFeed = false,
setSubHeader,
onSubHeaderRefresh
@ -105,6 +108,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -105,6 +108,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
hideReplies={listMode === 'posts'}
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
relayCapabilityReady={relayCapabilityReady}
/>
</div>
</>

97
src/components/NoteList/index.tsx

@ -24,6 +24,10 @@ import { useNostr } from '@/providers/NostrProvider' @@ -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( @@ -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( @@ -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( @@ -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<ReturnType<typeof setTimeout> | null>(null)
const lastEventsForTimelinePrefetchRef = useRef<Event[]>([])
/**
* {@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<string, TProfile>
@ -282,6 +298,20 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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,14 +581,27 @@ const NoteList = forwardRef( @@ -543,14 +581,27 @@ 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) {
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( @@ -598,7 +649,7 @@ const NoteList = forwardRef(
}
setLoading(false)
setEvents([])
return () => {}
return undefined
}
const narrowLiveBatch = (evs: Event[]) => {
@ -607,10 +658,6 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -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,9 +868,10 @@ const NoteList = forwardRef( @@ -821,9 +868,10 @@ 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
@ -837,21 +885,31 @@ const NoteList = forwardRef( @@ -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( @@ -862,6 +920,7 @@ const NoteList = forwardRef(
showKind1111,
useFilterAsIs,
areAlgoRelays,
relayCapabilityReady,
oneShotFetch,
oneShotMergedCap,
revealBatchSize,

12
src/components/NoteOptions/useMenuActions.tsx

@ -44,7 +44,7 @@ import { useTranslation } from 'react-i18next' @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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}}', {

6
src/components/Profile/index.tsx

@ -15,7 +15,7 @@ import { useFetchProfile } from '@/hooks' @@ -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({ @@ -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({ @@ -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 })

6
src/components/ProfileOptions/index.tsx

@ -22,7 +22,7 @@ import { Bell, BellOff, Copy, Ellipsis, ThumbsUp, MessageCircle, Send, Video, Sa @@ -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({ @@ -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({ @@ -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 })

55
src/components/PublishSuccessSubtleIndicator/index.tsx

@ -0,0 +1,55 @@ @@ -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<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<PublishSuccessSubtleDetail>).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 (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className={cn(
'pointer-events-none fixed bottom-4 right-4 z-[55] flex max-w-[min(90vw,18rem)] items-center gap-2 rounded-lg border border-border',
'bg-background/95 px-3 py-2 text-sm text-foreground shadow-md backdrop-blur-sm',
'animate-in fade-in slide-in-from-bottom-2 duration-200'
)}
>
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-600 dark:text-green-500" aria-hidden />
<span className="min-w-0 leading-snug">{payload.message}</span>
</div>
)
}

11
src/constants.ts

@ -35,9 +35,18 @@ export const DEFAULT_FAVORITE_RELAYS = [ @@ -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 = { @@ -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

5
src/i18n/locales/de.ts

@ -474,6 +474,11 @@ export default { @@ -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',

5
src/i18n/locales/en.ts

@ -469,6 +469,11 @@ export default { @@ -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',

70
src/lib/publishing-feedback.tsx

@ -1,7 +1,37 @@ @@ -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<PublishSuccessSubtleDetail>(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
@ -35,12 +65,20 @@ export function showPublishingFeedback( @@ -35,12 +65,20 @@ export function showPublishingFeedback(
if (relayStatuses.length === 0) {
// Fallback for events without relay status tracking
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( @@ -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) { @@ -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<T>(promise: Promise<T>, 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)
}

21
src/lib/spell-feed-request-identity.ts

@ -1,6 +1,7 @@ @@ -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[ @@ -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).

10
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -20,6 +20,7 @@ const RelaysFeed = forwardRef< @@ -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< @@ -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<never>((_, reject) => {
@ -52,6 +57,8 @@ const RelaysFeed = forwardRef< @@ -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< @@ -96,6 +103,7 @@ const RelaysFeed = forwardRef<
ref={ref}
subRequests={subRequests}
areAlgoRelays={areAlgoRelays}
relayCapabilityReady={relayAlgoReady}
isMainFeed
setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh}

14
src/pages/primary/SpellsPage/index.tsx

@ -45,7 +45,10 @@ import { @@ -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<TPageRef>(function SpellsPage( @@ -746,10 +749,11 @@ const SpellsPage = forwardRef<TPageRef>(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<string>()

33
src/pages/secondary/PostSettingsPage/PublishSuccessToastSetting.tsx

@ -0,0 +1,33 @@ @@ -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 (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="publish-success-toasts">{t('Publish success toasts')}</Label>
<Switch id="publish-success-toasts" checked={enabled} onCheckedChange={onChange} />
</div>
<div className="text-muted-foreground text-xs max-w-xl">
{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.'
)}
</div>
</div>
)
}

5
src/pages/secondary/PostSettingsPage/index.tsx

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next' @@ -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?: @@ -31,6 +32,10 @@ const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
>
<div key={contentKey} className="px-4 pt-3 space-y-6">
<MediaUploadServiceSetting />
<div className="space-y-4">
<h3 className="text-lg font-medium">{t('Publishing feedback')}</h3>
<PublishSuccessToastSetting />
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium">{t('Expiration Tags')}</h3>
<ExpirationSettings />

39
src/services/client-query.service.ts

@ -3,11 +3,14 @@ import { @@ -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 { @@ -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 = { @@ -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 { @@ -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<string, number>()
private subSlotWaitQueueByRelay = new Map<string, Array<() => void>>()
private eventSeenOnRelays = new Map<string, Set<string>>()
@ -96,6 +104,7 @@ export class QueryService { @@ -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 { @@ -116,7 +125,7 @@ export class QueryService {
async acquireSubSlot(relayKey: string): Promise<void> {
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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -542,6 +568,7 @@ export class QueryService {
return {
close: () => {
opBatch?.finalize('closed', 'subscribe_close')
allOpened.then(() => {
subs.forEach(({ close: subClose }) => subClose())
})

146
src/services/client.service.ts

@ -5,6 +5,7 @@ import { @@ -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' @@ -35,6 +36,7 @@ import { isSafari } from '@/lib/utils'
import {
ISigner,
TProfile,
TPublishEventExtras,
TPublishOptions,
TRelayList,
TMailboxRelay,
@ -58,6 +60,8 @@ import { @@ -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 { @@ -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 { @@ -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<string[]> {
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<void> {
const norm = (u: string) => normalizeUrl(u) || u
const hadSuccess = new Set<string>()
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<void>((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 { @@ -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 { @@ -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 { @@ -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,6 +924,8 @@ class ClientService extends EventTarget { @@ -856,6 +924,8 @@ 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
@ -863,6 +933,16 @@ class ClientService extends EventTarget { @@ -863,6 +933,16 @@ class ClientService extends EventTarget {
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
@ -901,6 +981,7 @@ class ClientService extends EventTarget { @@ -901,6 +981,7 @@ class ClientService extends EventTarget {
totalCount: uniqueRelayUrls.length,
relayStatuses: relayStatuses.length
})
flushPublishOpBatch('global_timeout')
resolve({
success: successCount >= uniqueRelayUrls.length / 3,
relayStatuses,
@ -911,7 +992,7 @@ class ClientService extends EventTarget { @@ -911,7 +992,7 @@ 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,6 +1029,10 @@ class ClientService extends EventTarget { @@ -948,6 +1029,10 @@ 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
@ -1038,6 +1123,7 @@ class ClientService extends EventTarget { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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())

16
src/services/local-storage.service.ts

@ -57,6 +57,7 @@ const SETTINGS_KEYS = [ @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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
}

30
src/services/relay-notice-strike.ts

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
import type { AbstractRelay } from 'nostr-tools/abstract-relay'
const patched = new WeakSet<object>()
/** 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)
}
}

237
src/services/relay-operation-log.service.ts

@ -0,0 +1,237 @@ @@ -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<string, unknown> {
const out: Record<string, unknown> = {}
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<string, { count: number; relays: string[]; cmdIndices: number[] }> {
const map = new Map<string, { relays: string[]; cmdIndices: number[] }>()
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<string, { count: number; relays: string[]; cmdIndices: number[] }> = {}
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<number, RelayOpTerminalRow>()
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<string, unknown>): 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)
})
}
}

38
src/services/session-feed-snapshot.service.ts

@ -0,0 +1,38 @@ @@ -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<string, Event[]>()
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)
}

7
src/types/index.d.ts vendored

@ -181,6 +181,13 @@ export type TPublishOptions = { @@ -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'

Loading…
Cancel
Save