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(
+
+
+
+ {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)
}
}