diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 1aabdf0a..b819dbbc 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -1492,7 +1492,15 @@ function parseMarkdownContent( } }) } - } else if ((pattern.type === 'markdown-link' || pattern.type === 'relay-url') && (hasTextOnSameLine || hasTextBefore)) { + } else if ( + (pattern.type === 'markdown-link' || pattern.type === 'relay-url') && + (hasTextOnSameLine || + hasTextBefore || + content.substring(pattern.end, lineEndIndex).trim().length > 0) + ) { + // Leading link/relay + text on the same line (e.g. autolink preprocess → "[url](url) rest"): + // merge so parseInlineMarkdown emits one

; otherwise we render bare then

for the tail + // and the block

forces a visual line break. // Get the original pattern syntax from the content const patternMarkdown = content.substring(pattern.index, pattern.end) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 207d8989..5057f2f3 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -32,6 +32,11 @@ import type { TFeedSubRequest, TSubRequestFilter } from '@/types' import dayjs from 'dayjs' import { type Event, type Filter, kinds } from 'nostr-tools' import { decode } from 'nostr-tools/nip19' +import RelayStatusDisplay from '@/components/RelayStatusDisplay' +import { + relayOpTerminalRowsToTimelineRelayUiStatuses, + type RelayOpTerminalRow +} from '@/services/relay-operation-log.service' import { forwardRef, useCallback, @@ -42,6 +47,7 @@ import { useRef, useState } from 'react' +import { CircleAlert } from 'lucide-react' import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import { toast } from 'sonner' @@ -53,6 +59,19 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' const LIMIT = 100 // Increased from 200 to load more events per request const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds + +/** + * Vite HMR replaces this module and remounts NoteList; timeline refs reset while the subscription can briefly look + * empty, which re-triggers the “relays returned no events” toast. Suppress briefly after each HMR cycle (dev only). + */ +let suppressRelayEmptyFeedToastUntilMs = 0 +if (import.meta.env.DEV && import.meta.hot) { + const bumpSuppressRelayEmptyFeedToast = () => { + suppressRelayEmptyFeedToastUntilMs = Date.now() + 6_000 + } + import.meta.hot.on('vite:beforeUpdate', bumpSuppressRelayEmptyFeedToast) + import.meta.hot.on('vite:beforeFullReload', bumpSuppressRelayEmptyFeedToast) +} const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency /** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */ const ONE_SHOT_MERGED_CAP =100 @@ -233,6 +252,18 @@ const NoteList = forwardRef( const feedRelayReturnedAnyEventRef = useRef(false) /** Dedupe {@link toast.error} when relays return nothing for a feed load. */ const emptyRelayNoHitsToastKeyRef = useRef('') + /** Per-relay outcomes for the current subscribe wave (merged shards); drives empty-feed toast detail. */ + const [feedSubscribeRelayOutcomes, setFeedSubscribeRelayOutcomes] = useState([]) + /** + * Bumped when {@link feedPaintLiveRelayDoneRef} becomes true so the empty-feed toast effect re-runs. + * (Loading clears when subscribe wires; merged EOSE arrives later.) + */ + const [feedEmptyToastGateTick, setFeedEmptyToastGateTick] = useState(0) + /** + * Mirrors {@link feedPaintLiveRelayDoneRef} in React state so the list can show a skeleton until the first + * merged `onEvents` (rows or EOSE). {@link loading} clears when subscribe wires, which is earlier than REQ/EOSE. + */ + const [feedTimelineEmptyUiReady, setFeedTimelineEmptyUiReady] = useState(false) const [feedProfileBatch, setFeedProfileBatch] = useState<{ profiles: Map @@ -267,6 +298,11 @@ const NoteList = forwardRef( /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ const timelineEffectLastRefreshCountRef = useRef(refreshCount) + useLayoutEffect(() => { + setFeedTimelineEmptyUiReady(false) + setFeedSubscribeRelayOutcomes([]) + }, [timelineSubscriptionKey, refreshCount]) + useEffect(() => { feedProfileBatchGenRef.current += 1 feedProfileLoadedRef.current.clear() @@ -753,6 +789,9 @@ const NoteList = forwardRef( subRequestsKey: timelineSubscriptionKey }) } + feedPaintLiveRelayDoneRef.current = true + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) setLoading(false) setEvents([]) return undefined @@ -846,6 +885,9 @@ const NoteList = forwardRef( } } finally { if (effectActive) { + feedPaintLiveRelayDoneRef.current = true + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) setLoading(false) setHasMore(false) setTimelineKey(undefined) @@ -887,6 +929,7 @@ const NoteList = forwardRef( feedRelayReturnedAnyEventRef.current = true } const narrowed = narrowLiveBatch(batch) + const paintDoneBefore = feedPaintLiveRelayDoneRef.current if (!feedPaintLiveRelayDoneRef.current) { if (narrowed.length > 0) { feedPaintLiveRelayDoneRef.current = true @@ -909,6 +952,10 @@ const NoteList = forwardRef( } } } + if (!paintDoneBefore && feedPaintLiveRelayDoneRef.current) { + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } if (batch.length > 0) { if (narrowed.length > 0) { if (preserveTimelineOnSubRequestsChange) { @@ -1010,7 +1057,11 @@ const NoteList = forwardRef( { startLogin, needSort: !areAlgoRelays, - firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS + firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS, + onRelaySubscribeWaveComplete: (rows) => { + if (!effectActive) return + setFeedSubscribeRelayOutcomes(rows) + } } ) @@ -1030,6 +1081,11 @@ const NoteList = forwardRef( return closer } catch (_error) { setLoading(false) + if (effectActive) { + feedPaintLiveRelayDoneRef.current = true + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } // Race timeout or subscribe failure: if the timeline promise later resolves, close or subs leak (relay slots + stale setEvents). if (timelineSubscribePromise) { void timelineSubscribePromise @@ -1160,21 +1216,50 @@ const NoteList = forwardRef( useEffect(() => { if (loading || events.length > 0) return if (!subRequests.length) return + // Do not toast until merged timeline reports first paint or all shards EOSE (see subscribeTimeline + // `allEosed`); `loading` is cleared earlier when the subscribe promise resolves. + if (!feedPaintLiveRelayDoneRef.current) return const toastKey = `${timelineSubscriptionKey}|${refreshCount}` - const debounceMs = 1_600 + const debounceMs = 900 const timer = window.setTimeout(() => { if (loadingRef.current) return if (eventsRef.current.length > 0) return if (!subRequestsRef.current.length) return + if (!feedPaintLiveRelayDoneRef.current) return if (feedRelayReturnedAnyEventRef.current) return + if (Date.now() < suppressRelayEmptyFeedToastUntilMs) return if (emptyRelayNoHitsToastKeyRef.current === toastKey) return emptyRelayNoHitsToastKeyRef.current = toastKey - toast.error( - t( - 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.' - ) + const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes) + const successCount = uiStatuses.filter((s) => s.success).length + const title = t( + 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.' ) + if (uiStatuses.length === 0) { + toast.error(title, { duration: 8000 }) + } else { + toast.error( +

+
+ +
{title}
+
+
+ {t('Per-relay timeline results ({{count}} connections)', { + count: uiStatuses.length + })} +
+ +
, + { duration: 12_000, className: 'max-w-lg w-full' } + ) + } }, debounceMs) return () => window.clearTimeout(timer) }, [ @@ -1183,6 +1268,8 @@ const NoteList = forwardRef( subRequests.length, timelineSubscriptionKey, refreshCount, + feedEmptyToastGateTick, + feedSubscribeRelayOutcomes, t ]) @@ -1585,7 +1672,8 @@ const NoteList = forwardRef( filterMutedNotes={filterMutedNotes} /> ))} - {events.length === 0 && loading ? ( + {events.length === 0 && + (loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
) : events.length > 0 ? (
{t('no more notes')}
- ) : !loading && subRequests.length > 0 ? ( + ) : !loading && feedTimelineEmptyUiReady && subRequests.length > 0 ? (
{ - try { - return cleanUrl(url) - } catch { - return url - } - } - ) + const cleanedText = rewritePlainTextHttpUrls(text) const draftEvent = await createDraftEvent(cleanedText) return JSON.stringify(draftEvent, null, 2) @@ -864,16 +855,7 @@ export default function PostContent({ try { // Clean tracking parameters from URLs in the post content - const cleanedText = text.replace( - /(https?:\/\/[^\s]+)/g, - (url) => { - try { - return cleanUrl(url) - } catch { - return url - } - } - ) + const cleanedText = rewritePlainTextHttpUrls(text) // Determine relay URLs for private events let privateRelayUrls: string[] = [] diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx index ccd04966..b8e08516 100644 --- a/src/components/PostEditor/PostTextarea/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -4,7 +4,7 @@ import { transformCustomEmojisInContent } from '@/lib/draft-event' import { normalizeTopic } from '@/lib/discussion-topics' import { createFakeEvent } from '@/lib/event' import { randomString } from '@/lib/random' -import { cleanUrl } from '@/lib/url' +import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url' import { cn } from '@/lib/utils' import { TPollCreateData } from '@/types' import { kinds, nip19 } from 'nostr-tools' @@ -48,16 +48,7 @@ export default function Preview({ const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( () => { // Clean tracking parameters from URLs in the preview - const cleanedContent = content.replace( - /(https?:\/\/[^\s]+)/g, - (url) => { - try { - return cleanUrl(url) - } catch { - return url - } - } - ) + const cleanedContent = rewritePlainTextHttpUrls(content) const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent) const customShortcodes = tags.map((t) => t[1]).filter(Boolean) const withNativeEmojis = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) diff --git a/src/components/RelayStatusDisplay/index.tsx b/src/components/RelayStatusDisplay/index.tsx index 74b6029f..7735085f 100644 --- a/src/components/RelayStatusDisplay/index.tsx +++ b/src/components/RelayStatusDisplay/index.tsx @@ -100,24 +100,38 @@ interface RelayStatusDisplayProps { successCount: number totalCount: number className?: string + /** + * When `false`, hides the aggregate line. When a node, renders it instead of the default + * “Published to …” copy (e.g. timeline REQ outcomes). + */ + aggregateSummary?: React.ReactNode | false } export default function RelayStatusDisplay({ relayStatuses, successCount, totalCount, - className = '' + className = '', + aggregateSummary }: RelayStatusDisplayProps) { if (relayStatuses.length === 0) { return null } + const defaultSummary = ( +
+ Published to {successCount} of {totalCount} relays +
+ ) + return (
-
- Published to {successCount} of {totalCount} relays -
- + {aggregateSummary === false + ? null + : aggregateSummary !== undefined + ? aggregateSummary + : defaultSummary} +
{relayStatuses.map((status, index) => (
{ - try { - return cleanUrl(url) - } catch { - return url - } - } - ) + const cleaned = rewritePlainTextHttpUrls(rawContent) if (rawContent.includes('nostr:')) { logContentSpacing('SimpleContent:processedContent', { diff --git a/src/constants.ts b/src/constants.ts index c09f0c71..1bb465bb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -452,10 +452,15 @@ export const FAUX_SPELL_ORDER = [ 'calendar' ] as const +/** + * Trailing lookahead must not be `(?=\\.)` alone: that matches between host labels (e.g. imwald . eu). + * Use `\\.(?:\\s|$)` for sentence-ending dots; `,(?=/|\\s|$)` ends before a comma that is not part of a + * comma-separated URL segment (e.g. typo `eu,/` or `eu, `). + */ export const URL_REGEX = - /https?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+(?:,[^\s.][\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,]*)*[^\s.,;:'")\]}!?,。;:"'!?】)](?=\.|,\s|$|[^\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,])/giu + /https?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+(?:,[^\s.][\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,]*)*[^\s.,;:'")\]}!?,。;:"'!?】)](?=\.(?:\s|$)|,\s|,(?=\/|\s|$)|$|[^\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,])/giu export const WS_URL_REGEX = - /wss?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+[^\s.,;:'")\]}!?,。;:"'!?】)]/giu + /wss?:\/\/[\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*]+[^\s.,;:'")\]}!?,。;:"'!?】)](?=\.(?:\s|$)|,\s|,(?=\/|\s|$)|$|[^\w\p{L}\p{N}\p{M}&.\-/?=#@%+_:!~*,])/giu export const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ /** @see {@link '@/lib/content-patterns'} — single source for emoji + nostr regexes */ export { diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 08d109e2..ecec732c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -651,6 +651,10 @@ export default { 'Nothing to load for this feed.': 'Für diesen Feed gibt es nichts zu laden.', 'No posts loaded for this feed. Try refreshing.': 'Keine Beiträge für diesen Feed geladen. Bitte aktualisieren.', + 'Per-relay timeline results ({{count}} connections)': + 'Ergebnis je Relay ({{count}} Verbindungen)', + 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.': + 'Die Relays haben keine Ereignisse für diesen Feed geliefert. Sie können offline sein, langsam antworten oder diese Notizen nicht indexieren.', 'Republish to ...': 'Erneut veröffentlichen zu ...', 'All available relays': 'All available relays', 'All active relays (monitoring list)': 'All active relays (monitoring list)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7b8572a4..09a6127a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -683,6 +683,8 @@ export default { 'No posts loaded for this feed. Try refreshing.', 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.': 'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.', + 'Per-relay timeline results ({{count}} connections)': + 'Per-relay timeline results ({{count}} connections)', 'Republish to ...': 'Republish to ...', 'All available relays': 'All available relays', 'All active relays (monitoring list)': 'All active relays (monitoring list)', diff --git a/src/lib/url.ts b/src/lib/url.ts index a7522c83..5a4ab930 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -1,5 +1,17 @@ +import { URL_REGEX } from '@/constants' import logger from '@/lib/logger' +/** + * A comma after the host (easy typo next to `.`) is not valid in a hostname, but `new URL()` still + * parses it and then serializes to a bogus `,/` before the path (e.g. `https://a.com,` → `https://a.com,/`). + * Strip trailing commas from the parsed hostname before further normalization. + */ +function stripTrailingCommasFromHostname(url: URL): void { + const h = url.hostname + if (!h.includes(',')) return + url.hostname = h.replace(/,+$/g, '') +} + export function isWebsocketUrl(url: string): boolean { return /^wss?:\/\/.+$/.test(url) } @@ -17,7 +29,8 @@ export function normalizeUrl(url: string): string { // Parse the URL first to validate it const p = new URL(url) - + stripTrailingCommasFromHostname(p) + // Check if URL has hash fragments (these are not valid for relay URLs) // Note: Query parameters are allowed (e.g., filter.nostr.wine uses ?broadcast=true/false) const hasHashFragment = url.includes('#') @@ -72,6 +85,7 @@ export function normalizeHttpUrl(url: string): string { try { if (url.indexOf('://') === -1) url = 'https://' + url const p = new URL(url) + stripTrailingCommasFromHostname(p) p.pathname = p.pathname.replace(/\/+/g, '/') if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1) if (p.protocol === 'wss:') { @@ -307,7 +321,8 @@ export function isSafeMediaUrl(url: string): boolean { export function cleanUrl(url: string): string { try { const parsedUrl = new URL(url) - + stripTrailingCommasFromHostname(parsedUrl) + // List of tracking parameter prefixes and exact names to remove const trackingParams = [ // Google Analytics & Ads @@ -386,3 +401,21 @@ export function cleanUrl(url: string): string { return url } } + +/** + * Rewrite http(s) URLs in a plain string using {@link URL_REGEX} (same boundary rules as the feed parser), then + * {@link cleanUrl}. Avoids greedy `https?:\\/\\/[^\\s]+`, which swallows trailing punctuation like `https://a.com, and`. + */ +export function rewritePlainTextHttpUrls( + content: string, + transform: (url: string) => string = cleanUrl +): string { + const re = new RegExp(URL_REGEX.source, URL_REGEX.flags) + return content.replace(re, (match) => { + try { + return transform(match) + } catch { + return match + } + }) +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 946be013..ef5d6033 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -63,7 +63,12 @@ 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 { + compactFilterForRelayLog, + RelayOpTerminalRow, + 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. */ @@ -1342,12 +1347,15 @@ class ClientService extends EventTarget { { startLogin, needSort = true, - firstRelayResultGraceMs = FIRST_RELAY_RESULT_GRACE_MS + firstRelayResultGraceMs = FIRST_RELAY_RESULT_GRACE_MS, + onRelaySubscribeWaveComplete }: { startLogin?: () => void needSort?: boolean /** Passed to each shard’s {@link ClientService._subscribeTimeline}: 2s after first event completes initial load if EOSE is slower. */ firstRelayResultGraceMs?: number + /** After every timeline shard’s REQ wave has ended (per-relay EOSE / close / timeout), merged rows in shard order. */ + onRelaySubscribeWaveComplete?: (rows: RelayOpTerminalRow[]) => void } = {} ) { const timelineBatchId = `tl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}` @@ -1422,6 +1430,19 @@ class ClientService extends EventTarget { } } + let subscribeWaveShardsRemaining = subRequests.length + const subscribeWaveAcc: RelayOpTerminalRow[] = [] + const onShardSubscribeBatchEnd = + onRelaySubscribeWaveComplete != null + ? (rows: RelayOpTerminalRow[]) => { + subscribeWaveAcc.push(...rows) + subscribeWaveShardsRemaining-- + if (subscribeWaveShardsRemaining === 0) { + onRelaySubscribeWaveComplete(subscribeWaveAcc.slice()) + } + } + : undefined + const subs = await Promise.all( subRequests.map(({ urls, filter }, shardIndex) => { return this._subscribeTimeline( @@ -1456,7 +1477,12 @@ class ClientService extends EventTarget { startLogin, needSort, firstRelayResultGraceMs, - relayReqLog: { groupId: `${timelineBatchId}:shard${shardIndex}` } + relayReqLog: { + groupId: `${timelineBatchId}:shard${shardIndex}`, + ...(onShardSubscribeBatchEnd + ? { onBatchEnd: onShardSubscribeBatchEnd } + : {}) + } } ) }) @@ -1539,7 +1565,7 @@ class ClientService extends EventTarget { startLogin?: () => void onAllClose?: (reasons: string[]) => void }, - relayReqLog?: { groupId?: string } + relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { let relays = Array.from(new Set(urls)) const filters = Array.isArray(filter) ? filter : [filter] @@ -1581,7 +1607,10 @@ class ClientService extends EventTarget { reason: 'no_relays_after_filters', filterSummary: summarizeFiltersForRelayLog(filters) }) - queueMicrotask(() => oneose?.(true)) + queueMicrotask(() => { + oneose?.(true) + relayReqLog?.onBatchEnd?.([]) + }) return { close: () => {} } @@ -1590,7 +1619,9 @@ 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) + const opBatch = new RelaySubscribeOpBatch(reqGroupId, groupedRequests, { + onBatchEnd: relayReqLog?.onBatchEnd + }) opBatch.logBegin() const reqT0 = performance.now() let firstRelayResponseLogged = false @@ -1851,7 +1882,7 @@ class ClientService extends EventTarget { needSort?: boolean firstRelayResultGraceMs?: number /** Correlate {@link ClientService.subscribe} logs with a timeline shard */ - relayReqLog?: { groupId: string } + relayReqLog?: { groupId: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } } = {} ) { const relays = Array.from(new Set(urls)) diff --git a/src/services/relay-operation-log.service.ts b/src/services/relay-operation-log.service.ts index 7129b286..e560f0e9 100644 --- a/src/services/relay-operation-log.service.ts +++ b/src/services/relay-operation-log.service.ts @@ -53,7 +53,7 @@ function relayHostForSubscribeLog(url: string): string { return relayHostForPublishLog(url) } -function humanizeSubscribeTerminalDetail(outcome: RelayOpTerminalOutcome, detail?: string): string { +export function humanizeSubscribeTerminalDetail(outcome: RelayOpTerminalOutcome, detail?: string): string { const d = (detail ?? '').trim() if (!d) { if (outcome === 'eose') return 'end of stored events' @@ -137,6 +137,42 @@ function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record void +} + +/** Shape compatible with `RelayStatusDisplay` / publish feedback toasts. */ +export type TimelineSubscribeRelayUiStatus = { + url: string + success: boolean + error?: string + message?: string +} + +export function relayOpTerminalRowsToTimelineRelayUiStatuses( + rows: RelayOpTerminalRow[] +): TimelineSubscribeRelayUiStatus[] { + return rows.map((row) => { + if (row.outcome === 'eose') { + return { + url: row.relayUrl, + success: true, + message: humanizeSubscribeTerminalDetail('eose', row.detail) + } + } + if (row.outcome === 'timeout') { + return { + url: row.relayUrl, + success: false, + error: humanizeSubscribeTerminalDetail('timeout', row.detail) + } + } + return { + url: row.relayUrl, + success: false, + error: humanizeSubscribeTerminalDetail('closed', row.detail) + } + }) } export class RelaySubscribeOpBatch { @@ -145,6 +181,7 @@ export class RelaySubscribeOpBatch { private readonly source: string private readonly grouped: GroupedRelayRow[] private readonly logLevel: 'info' | 'debug' + private readonly onBatchEnd?: (rows: RelayOpTerminalRow[]) => void private readonly terminal = new Map() private endLogged = false @@ -154,6 +191,7 @@ export class RelaySubscribeOpBatch { this.source = source this.grouped = grouped this.logLevel = options?.logLevel ?? 'info' + this.onBatchEnd = options?.onBatchEnd } private logLine(message: string, payload: Record): void { @@ -257,6 +295,8 @@ export class RelaySubscribeOpBatch { } else { logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact) } + + this.onBatchEnd?.(rows) } }