Browse Source

fix urls

fix and expand red toast
imwald
Silberengel 1 month ago
parent
commit
de974743b7
  1. 10
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  2. 104
      src/components/NoteList/index.tsx
  3. 24
      src/components/PostEditor/PostContent.tsx
  4. 13
      src/components/PostEditor/PostTextarea/Preview.tsx
  5. 22
      src/components/RelayStatusDisplay/index.tsx
  6. 13
      src/components/UniversalContent/SimpleContent.tsx
  7. 9
      src/constants.ts
  8. 4
      src/i18n/locales/de.ts
  9. 2
      src/i18n/locales/en.ts
  10. 33
      src/lib/url.ts
  11. 45
      src/services/client.service.ts
  12. 42
      src/services/relay-operation-log.service.ts

10
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1492,7 +1492,15 @@ function parseMarkdownContent( @@ -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 <p>; otherwise we render bare <a> then <p> for the tail
// and the block <p> forces a visual line break.
// Get the original pattern syntax from the content
const patternMarkdown = content.substring(pattern.index, pattern.end)

104
src/components/NoteList/index.tsx

@ -32,6 +32,11 @@ import type { TFeedSubRequest, TSubRequestFilter } from '@/types' @@ -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 { @@ -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' @@ -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( @@ -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<RelayOpTerminalRow[]>([])
/**
* 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<string, TProfile>
@ -267,6 +298,11 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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(
<div className="w-full min-w-0">
<div className="flex items-center gap-2 mb-3">
<CircleAlert className="w-5 h-5 text-red-500 shrink-0" />
<div className="font-semibold">{title}</div>
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Per-relay timeline results ({{count}} connections)', {
count: uiStatuses.length
})}
</div>
<RelayStatusDisplay
relayStatuses={uiStatuses}
successCount={successCount}
totalCount={uiStatuses.length}
aggregateSummary={false}
/>
</div>,
{ duration: 12_000, className: 'max-w-lg w-full' }
)
}
}, debounceMs)
return () => window.clearTimeout(timer)
}, [
@ -1183,6 +1268,8 @@ const NoteList = forwardRef( @@ -1183,6 +1268,8 @@ const NoteList = forwardRef(
subRequests.length,
timelineSubscriptionKey,
refreshCount,
feedEmptyToastGateTick,
feedSubscribeRelayOutcomes,
t
])
@ -1585,7 +1672,8 @@ const NoteList = forwardRef( @@ -1585,7 +1672,8 @@ const NoteList = forwardRef(
filterMutedNotes={filterMutedNotes}
/>
))}
{events.length === 0 && loading ? (
{events.length === 0 &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div
ref={bottomRef}
className="min-h-[40vh] space-y-2 px-1 py-4"
@ -1612,7 +1700,7 @@ const NoteList = forwardRef( @@ -1612,7 +1700,7 @@ const NoteList = forwardRef(
</div>
) : events.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
) : !loading && subRequests.length > 0 ? (
) : !loading && feedTimelineEmptyUiReady && subRequests.length > 0 ? (
<div
ref={bottomRef}
className="mt-6 flex min-h-[35vh] flex-col items-center justify-start gap-4 px-4 text-center text-sm text-muted-foreground"

24
src/components/PostEditor/PostContent.tsx

@ -38,7 +38,7 @@ import { useNostr } from '@/providers/NostrProvider' @@ -38,7 +38,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { normalizeUrl, cleanUrl } from '@/lib/url'
import { normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service'
@ -813,16 +813,7 @@ export default function PostContent({ @@ -813,16 +813,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)
const draftEvent = await createDraftEvent(cleanedText)
return JSON.stringify(draftEvent, null, 2)
@ -864,16 +855,7 @@ export default function PostContent({ @@ -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[] = []

13
src/components/PostEditor/PostTextarea/Preview.tsx

@ -4,7 +4,7 @@ import { transformCustomEmojisInContent } from '@/lib/draft-event' @@ -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({ @@ -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)

22
src/components/RelayStatusDisplay/index.tsx

@ -100,23 +100,37 @@ interface RelayStatusDisplayProps { @@ -100,23 +100,37 @@ 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 = (
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
Published to {successCount} of {totalCount} relays
</div>
)
return (
<div className={`space-y-2 ${className}`}>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
Published to {successCount} of {totalCount} relays
</div>
{aggregateSummary === false
? null
: aggregateSummary !== undefined
? aggregateSummary
: defaultSummary}
<div className="space-y-1 max-w-full">
{relayStatuses.map((status, index) => (

13
src/components/UniversalContent/SimpleContent.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { cleanUrl } from '@/lib/url'
import { rewritePlainTextHttpUrls } from '@/lib/url'
import { Event } from 'nostr-tools'
import { logContentSpacing, reprString } from '@/lib/content-spacing-debug'
import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx'
@ -20,16 +20,7 @@ export default function SimpleContent({ @@ -20,16 +20,7 @@ export default function SimpleContent({
const rawContent = content || event?.content || ''
// Clean URLs to remove tracking parameters
const cleaned = rawContent.replace(
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
const cleaned = rewritePlainTextHttpUrls(rawContent)
if (rawContent.includes('nostr:')) {
logContentSpacing('SimpleContent:processedContent', {

9
src/constants.ts

@ -452,10 +452,15 @@ export const FAUX_SPELL_ORDER = [ @@ -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 {

4
src/i18n/locales/de.ts

@ -651,6 +651,10 @@ export default { @@ -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)',

2
src/i18n/locales/en.ts

@ -683,6 +683,8 @@ export default { @@ -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)',

33
src/lib/url.ts

@ -1,5 +1,17 @@ @@ -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,6 +29,7 @@ export function normalizeUrl(url: string): string { @@ -17,6 +29,7 @@ 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)
@ -72,6 +85,7 @@ export function normalizeHttpUrl(url: string): string { @@ -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,6 +321,7 @@ export function isSafeMediaUrl(url: string): boolean { @@ -307,6 +321,7 @@ 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 = [
@ -386,3 +401,21 @@ export function cleanUrl(url: string): string { @@ -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
}
})
}

45
src/services/client.service.ts

@ -63,7 +63,12 @@ import { AbstractRelay } from 'nostr-tools/abstract-relay' @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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))

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

@ -53,7 +53,7 @@ function relayHostForSubscribeLog(url: string): string { @@ -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<string, { c @@ -137,6 +137,42 @@ function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record<string, { c
export type RelaySubscribeOpBatchOptions = {
/** `debug` hides high-volume query REQs unless jumble-debug / VITE_DEBUG is on. */
logLevel?: 'info' | 'debug'
/** Invoked once when this REQ wave finishes (same `rows` as `batch_end` / `terminals`). */
onBatchEnd?: (rows: RelayOpTerminalRow[]) => 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 { @@ -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<number, RelayOpTerminalRow>()
private endLogged = false
@ -154,6 +191,7 @@ export class RelaySubscribeOpBatch { @@ -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<string, unknown>): void {
@ -257,6 +295,8 @@ export class RelaySubscribeOpBatch { @@ -257,6 +295,8 @@ export class RelaySubscribeOpBatch {
} else {
logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact)
}
this.onBatchEnd?.(rows)
}
}

Loading…
Cancel
Save