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.'
+ )}
+