diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index 8854319c..909e88b3 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -297,7 +297,8 @@ export default function AdvancedEventLabDialog({ setUndoUiTick((n) => n + 1) }, []) - const flushLabDraftNow = useCallback((key: string) => { + /** Writes the live CodeMirror doc into the draft cache. `urgent` flushes localStorage synchronously (tab hide / unload only). */ + const flushLabDraftNow = useCallback((key: string, urgent = false) => { const v = markupView.current const s = sliceRef.current if (!v || !s) return @@ -310,14 +311,14 @@ export default function AdvancedEventLabDialog({ content: v.state.doc.toString(), tags: s.tags.map((row) => [...row]) }) - postEditorCache.flushPersist() + if (urgent) postEditorCache.flushPersist() }, []) useEffect(() => { if (!open || !draftPersistenceKey) return const key = draftPersistenceKey const onPageLeave = () => { - flushLabDraftNow(key) + flushLabDraftNow(key, true) } window.addEventListener('pagehide', onPageLeave) window.addEventListener('beforeunload', onPageLeave) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index b949535d..d0e100ee 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -120,6 +120,14 @@ import { import { parseLabSlice, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' +/** Let the UI paint before heavy work. `requestAnimationFrame` alone can stall indefinitely in hidden or throttled documents. */ +function yieldForPaintBeforeHeavyWork(): Promise { + return Promise.race([ + new Promise((resolve) => requestAnimationFrame(() => resolve())), + new Promise((resolve) => setTimeout(resolve, 50)) + ]) +} + function stripUrlForImageExtensionCheck(url: string): string { return url.trim().split(/[#?]/)[0].toLowerCase() } @@ -1217,6 +1225,8 @@ export default function PostContent({ return } try { + // Let the browser paint any loading/disabled UI before draft build + CodeMirror mount (can be heavy). + await yieldForPaintBeforeHeavyWork() const body = textareaRef.current?.getText() ?? text const cleanedText = rewritePlainTextHttpUrls(body) let d = await createDraftEvent(cleanedText) @@ -1307,7 +1317,10 @@ export default function PostContent({ setPosting(true) let newEvent: any = null let draftEvent: any = null - + + // Allow "Publishing…" (and other posting UI) to paint before draft build + network work. + await yieldForPaintBeforeHeavyWork() + try { // Clean tracking parameters from URLs in the post content const cleanedText = rewritePlainTextHttpUrls(text) diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index 24eee9f9..b2184f5e 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -19,6 +19,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import logger from '@/lib/logger' import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' +import client from '@/services/client.service' /** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */ const NO_MENTIONS: string[] = [] @@ -265,6 +266,22 @@ export default function PostRelaySelector({ } }, [selectedRelayUrls, hasManualSelection, isLoading, describeRelaySelection]) + /** Picker lists exclude global read-only relays; session-strike skips are cleared when a relay is newly chosen so publishes honor the list. */ + const prevSelectedNormalizedRef = useRef>(new Set()) + useEffect(() => { + const norm = (u: string) => normalizeAnyRelayUrl(u) || u + const prev = prevSelectedNormalizedRef.current + const newlyAdded: string[] = [] + for (const url of selectedRelayUrls) { + const n = norm(url) + if (!prev.has(n)) newlyAdded.push(url) + } + prevSelectedNormalizedRef.current = new Set(selectedRelayUrls.map(norm)) + if (newlyAdded.length > 0) { + client.clearSessionRelayStrikesForUrls(newlyAdded) + } + }, [selectedRelayUrls]) + // Update parent component with selected relays useEffect(() => { // An event is "protected" if we have selected relays that aren't the default user write relays diff --git a/src/services/client.service.ts b/src/services/client.service.ts index f6fa77b1..2bc766f7 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1164,6 +1164,26 @@ class ClientService extends EventTarget { return had } + /** + * Clear session strikes for several URLs at once (e.g. publish relay picker). One UI notification. + */ + clearSessionRelayStrikesForUrls(urls: string[]): number { + let cleared = 0 + for (const url of urls) { + const n = normalizeAnyRelayUrl(url) || url + if (!n) continue + if (this.publishStrikeCount.delete(n)) cleared += 1 + } + if (cleared > 0) { + logger.info('[Relay] Session strikes cleared for relays (added to publish selection)', { + cleared, + urlCount: urls.length + }) + this.notifySessionRelayStrikesChanged() + } + return cleared + } + /** * Apply strike filter; if that removes all candidates while some were provided, clear strikes **for those URLs * only** and retry once. (A global clear here caused storms: e.g. NIP-65 outbox retry with 2 relays wiped strikes diff --git a/src/services/post-editor-cache.service.ts b/src/services/post-editor-cache.service.ts index f61705d3..00a3582e 100644 --- a/src/services/post-editor-cache.service.ts +++ b/src/services/post-editor-cache.service.ts @@ -263,11 +263,10 @@ class PostEditorCacheService { clearAdvancedLabDraft(key: string) { this.restoreFromStorageIfNeeded() if (!this.advancedLabDrafts.delete(key)) return - if (this.persistTimeoutId) { - clearTimeout(this.persistTimeoutId) - this.persistTimeoutId = null - } - this.persistNow() + // Avoid synchronous JSON.stringify(localStorage) of the full draft blob here — that blocks + // the main thread when TipTap caches are large. Debounced persist is enough; tab close still + // uses {@link flushPersist} via beforeunload. + this.schedulePersist() } clearPostCache({ kind, defaultContent, parentEvent }: TCacheKeyParams) { @@ -276,11 +275,7 @@ class PostEditorCacheService { this.postContentCache.delete(cacheKey) this.postSettingsCache.delete(cacheKey) this.advancedLabDrafts.delete(cacheKey) - if (this.persistTimeoutId) { - clearTimeout(this.persistTimeoutId) - this.persistTimeoutId = null - } - this.persistNow() + this.schedulePersist() } /** Clear all post and settings drafts. Use when user explicitly clears caches. */ @@ -289,11 +284,7 @@ class PostEditorCacheService { this.postContentCache.clear() this.postSettingsCache.clear() this.advancedLabDrafts.clear() - if (this.persistTimeoutId) { - clearTimeout(this.persistTimeoutId) - this.persistTimeoutId = null - } - this.persistNow() + this.schedulePersist() } generateCacheKey({ kind, parentEvent }: TCacheKeyParams): string { @@ -315,11 +306,7 @@ class PostEditorCacheService { clearThreadDraft(): void { this.threadDraftCache = null - if (this.persistTimeoutId) { - clearTimeout(this.persistTimeoutId) - this.persistTimeoutId = null - } - this.persistNow() + this.schedulePersist() } }