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. 100
      src/components/NoteList/index.tsx
  3. 24
      src/components/PostEditor/PostContent.tsx
  4. 13
      src/components/PostEditor/PostTextarea/Preview.tsx
  5. 20
      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(
} }
}) })
} }
} 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 // Get the original pattern syntax from the content
const patternMarkdown = content.substring(pattern.index, pattern.end) const patternMarkdown = content.substring(pattern.index, pattern.end)

100
src/components/NoteList/index.tsx

@ -32,6 +32,11 @@ import type { TFeedSubRequest, TSubRequestFilter } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { type Event, type Filter, kinds } from 'nostr-tools' import { type Event, type Filter, kinds } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19' import { decode } from 'nostr-tools/nip19'
import RelayStatusDisplay from '@/components/RelayStatusDisplay'
import {
relayOpTerminalRowsToTimelineRelayUiStatuses,
type RelayOpTerminalRow
} from '@/services/relay-operation-log.service'
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@ -42,6 +47,7 @@ import {
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner' 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 LIMIT = 100 // Increased from 200 to load more events per request
const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds 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 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). */ /** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */
const ONE_SHOT_MERGED_CAP =100 const ONE_SHOT_MERGED_CAP =100
@ -233,6 +252,18 @@ const NoteList = forwardRef(
const feedRelayReturnedAnyEventRef = useRef(false) const feedRelayReturnedAnyEventRef = useRef(false)
/** Dedupe {@link toast.error} when relays return nothing for a feed load. */ /** Dedupe {@link toast.error} when relays return nothing for a feed load. */
const emptyRelayNoHitsToastKeyRef = useRef('') 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<{ const [feedProfileBatch, setFeedProfileBatch] = useState<{
profiles: Map<string, TProfile> profiles: Map<string, TProfile>
@ -267,6 +298,11 @@ const NoteList = forwardRef(
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
const timelineEffectLastRefreshCountRef = useRef(refreshCount) const timelineEffectLastRefreshCountRef = useRef(refreshCount)
useLayoutEffect(() => {
setFeedTimelineEmptyUiReady(false)
setFeedSubscribeRelayOutcomes([])
}, [timelineSubscriptionKey, refreshCount])
useEffect(() => { useEffect(() => {
feedProfileBatchGenRef.current += 1 feedProfileBatchGenRef.current += 1
feedProfileLoadedRef.current.clear() feedProfileLoadedRef.current.clear()
@ -753,6 +789,9 @@ const NoteList = forwardRef(
subRequestsKey: timelineSubscriptionKey subRequestsKey: timelineSubscriptionKey
}) })
} }
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
setLoading(false) setLoading(false)
setEvents([]) setEvents([])
return undefined return undefined
@ -846,6 +885,9 @@ const NoteList = forwardRef(
} }
} finally { } finally {
if (effectActive) { if (effectActive) {
feedPaintLiveRelayDoneRef.current = true
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
setLoading(false) setLoading(false)
setHasMore(false) setHasMore(false)
setTimelineKey(undefined) setTimelineKey(undefined)
@ -887,6 +929,7 @@ const NoteList = forwardRef(
feedRelayReturnedAnyEventRef.current = true feedRelayReturnedAnyEventRef.current = true
} }
const narrowed = narrowLiveBatch(batch) const narrowed = narrowLiveBatch(batch)
const paintDoneBefore = feedPaintLiveRelayDoneRef.current
if (!feedPaintLiveRelayDoneRef.current) { if (!feedPaintLiveRelayDoneRef.current) {
if (narrowed.length > 0) { if (narrowed.length > 0) {
feedPaintLiveRelayDoneRef.current = true 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 (batch.length > 0) {
if (narrowed.length > 0) { if (narrowed.length > 0) {
if (preserveTimelineOnSubRequestsChange) { if (preserveTimelineOnSubRequestsChange) {
@ -1010,7 +1057,11 @@ const NoteList = forwardRef(
{ {
startLogin, startLogin,
needSort: !areAlgoRelays, 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 return closer
} catch (_error) { } catch (_error) {
setLoading(false) 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). // Race timeout or subscribe failure: if the timeline promise later resolves, close or subs leak (relay slots + stale setEvents).
if (timelineSubscribePromise) { if (timelineSubscribePromise) {
void timelineSubscribePromise void timelineSubscribePromise
@ -1160,21 +1216,50 @@ const NoteList = forwardRef(
useEffect(() => { useEffect(() => {
if (loading || events.length > 0) return if (loading || events.length > 0) return
if (!subRequests.length) 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 toastKey = `${timelineSubscriptionKey}|${refreshCount}`
const debounceMs = 1_600 const debounceMs = 900
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
if (loadingRef.current) return if (loadingRef.current) return
if (eventsRef.current.length > 0) return if (eventsRef.current.length > 0) return
if (!subRequestsRef.current.length) return if (!subRequestsRef.current.length) return
if (!feedPaintLiveRelayDoneRef.current) return
if (feedRelayReturnedAnyEventRef.current) return if (feedRelayReturnedAnyEventRef.current) return
if (Date.now() < suppressRelayEmptyFeedToastUntilMs) return
if (emptyRelayNoHitsToastKeyRef.current === toastKey) return if (emptyRelayNoHitsToastKeyRef.current === toastKey) return
emptyRelayNoHitsToastKeyRef.current = toastKey emptyRelayNoHitsToastKeyRef.current = toastKey
toast.error( const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes)
t( 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.' '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) }, debounceMs)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [ }, [
@ -1183,6 +1268,8 @@ const NoteList = forwardRef(
subRequests.length, subRequests.length,
timelineSubscriptionKey, timelineSubscriptionKey,
refreshCount, refreshCount,
feedEmptyToastGateTick,
feedSubscribeRelayOutcomes,
t t
]) ])
@ -1585,7 +1672,8 @@ const NoteList = forwardRef(
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
/> />
))} ))}
{events.length === 0 && loading ? ( {events.length === 0 &&
(loading || (subRequests.length > 0 && !feedTimelineEmptyUiReady)) ? (
<div <div
ref={bottomRef} ref={bottomRef}
className="min-h-[40vh] space-y-2 px-1 py-4" className="min-h-[40vh] space-y-2 px-1 py-4"
@ -1612,7 +1700,7 @@ const NoteList = forwardRef(
</div> </div>
) : events.length > 0 ? ( ) : events.length > 0 ? (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div> <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 <div
ref={bottomRef} 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" 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'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' 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 logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
@ -813,16 +813,7 @@ export default function PostContent({
try { try {
// Clean tracking parameters from URLs in the post content // Clean tracking parameters from URLs in the post content
const cleanedText = text.replace( const cleanedText = rewritePlainTextHttpUrls(text)
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
const draftEvent = await createDraftEvent(cleanedText) const draftEvent = await createDraftEvent(cleanedText)
return JSON.stringify(draftEvent, null, 2) return JSON.stringify(draftEvent, null, 2)
@ -864,16 +855,7 @@ export default function PostContent({
try { try {
// Clean tracking parameters from URLs in the post content // Clean tracking parameters from URLs in the post content
const cleanedText = text.replace( const cleanedText = rewritePlainTextHttpUrls(text)
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
// Determine relay URLs for private events // Determine relay URLs for private events
let privateRelayUrls: string[] = [] let privateRelayUrls: string[] = []

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

@ -4,7 +4,7 @@ import { transformCustomEmojisInContent } from '@/lib/draft-event'
import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeTopic } from '@/lib/discussion-topics'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cleanUrl } from '@/lib/url' import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
@ -48,16 +48,7 @@ export default function Preview({
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo(
() => { () => {
// Clean tracking parameters from URLs in the preview // Clean tracking parameters from URLs in the preview
const cleanedContent = content.replace( const cleanedContent = rewritePlainTextHttpUrls(content)
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent) const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent)
const customShortcodes = tags.map((t) => t[1]).filter(Boolean) const customShortcodes = tags.map((t) => t[1]).filter(Boolean)
const withNativeEmojis = replaceStandardEmojiShortcodesInContent(processed, customShortcodes) const withNativeEmojis = replaceStandardEmojiShortcodesInContent(processed, customShortcodes)

20
src/components/RelayStatusDisplay/index.tsx

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

13
src/components/UniversalContent/SimpleContent.tsx

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

9
src/constants.ts

@ -452,10 +452,15 @@ export const FAUX_SPELL_ORDER = [
'calendar' 'calendar'
] as const ] 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 = 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 = 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,}$/ 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 */ /** @see {@link '@/lib/content-patterns'} — single source for emoji + nostr regexes */
export { export {

4
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.', 'Nothing to load for this feed.': 'Für diesen Feed gibt es nichts zu laden.',
'No posts loaded for this feed. Try refreshing.': 'No posts loaded for this feed. Try refreshing.':
'Keine Beiträge für diesen Feed geladen. Bitte aktualisieren.', '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 ...', 'Republish to ...': 'Erneut veröffentlichen zu ...',
'All available relays': 'All available relays', 'All available relays': 'All available relays',
'All active relays (monitoring list)': 'All active relays (monitoring list)', 'All active relays (monitoring list)': 'All active relays (monitoring list)',

2
src/i18n/locales/en.ts

@ -683,6 +683,8 @@ export default {
'No posts loaded for this feed. Try refreshing.', '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.':
'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 ...', 'Republish to ...': 'Republish to ...',
'All available relays': 'All available relays', 'All available relays': 'All available relays',
'All active relays (monitoring list)': 'All active relays (monitoring list)', 'All active relays (monitoring list)': 'All active relays (monitoring list)',

33
src/lib/url.ts

@ -1,5 +1,17 @@
import { URL_REGEX } from '@/constants'
import logger from '@/lib/logger' 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 { export function isWebsocketUrl(url: string): boolean {
return /^wss?:\/\/.+$/.test(url) return /^wss?:\/\/.+$/.test(url)
} }
@ -17,6 +29,7 @@ export function normalizeUrl(url: string): string {
// Parse the URL first to validate it // Parse the URL first to validate it
const p = new URL(url) const p = new URL(url)
stripTrailingCommasFromHostname(p)
// Check if URL has hash fragments (these are not valid for relay URLs) // 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) // Note: Query parameters are allowed (e.g., filter.nostr.wine uses ?broadcast=true/false)
@ -72,6 +85,7 @@ export function normalizeHttpUrl(url: string): string {
try { try {
if (url.indexOf('://') === -1) url = 'https://' + url if (url.indexOf('://') === -1) url = 'https://' + url
const p = new URL(url) const p = new URL(url)
stripTrailingCommasFromHostname(p)
p.pathname = p.pathname.replace(/\/+/g, '/') p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1) if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if (p.protocol === 'wss:') { if (p.protocol === 'wss:') {
@ -307,6 +321,7 @@ export function isSafeMediaUrl(url: string): boolean {
export function cleanUrl(url: string): string { export function cleanUrl(url: string): string {
try { try {
const parsedUrl = new URL(url) const parsedUrl = new URL(url)
stripTrailingCommasFromHostname(parsedUrl)
// List of tracking parameter prefixes and exact names to remove // List of tracking parameter prefixes and exact names to remove
const trackingParams = [ const trackingParams = [
@ -386,3 +401,21 @@ export function cleanUrl(url: string): string {
return url 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'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import nip66Service from './nip66.service' import nip66Service from './nip66.service'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike' 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' import { QueryService } from './client-query.service'
/** Live timeline REQ: dead relays fail fast; EOSE caps “connected but silent” relays. */ /** Live timeline REQ: dead relays fail fast; EOSE caps “connected but silent” relays. */
@ -1342,12 +1347,15 @@ class ClientService extends EventTarget {
{ {
startLogin, startLogin,
needSort = true, needSort = true,
firstRelayResultGraceMs = FIRST_RELAY_RESULT_GRACE_MS firstRelayResultGraceMs = FIRST_RELAY_RESULT_GRACE_MS,
onRelaySubscribeWaveComplete
}: { }: {
startLogin?: () => void startLogin?: () => void
needSort?: boolean needSort?: boolean
/** Passed to each shard’s {@link ClientService._subscribeTimeline}: 2s after first event completes initial load if EOSE is slower. */ /** Passed to each shard’s {@link ClientService._subscribeTimeline}: 2s after first event completes initial load if EOSE is slower. */
firstRelayResultGraceMs?: number 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)}` 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( const subs = await Promise.all(
subRequests.map(({ urls, filter }, shardIndex) => { subRequests.map(({ urls, filter }, shardIndex) => {
return this._subscribeTimeline( return this._subscribeTimeline(
@ -1456,7 +1477,12 @@ class ClientService extends EventTarget {
startLogin, startLogin,
needSort, needSort,
firstRelayResultGraceMs, firstRelayResultGraceMs,
relayReqLog: { groupId: `${timelineBatchId}:shard${shardIndex}` } relayReqLog: {
groupId: `${timelineBatchId}:shard${shardIndex}`,
...(onShardSubscribeBatchEnd
? { onBatchEnd: onShardSubscribeBatchEnd }
: {})
}
} }
) )
}) })
@ -1539,7 +1565,7 @@ class ClientService extends EventTarget {
startLogin?: () => void startLogin?: () => void
onAllClose?: (reasons: string[]) => void onAllClose?: (reasons: string[]) => void
}, },
relayReqLog?: { groupId?: string } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
let relays = Array.from(new Set(urls)) let relays = Array.from(new Set(urls))
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
@ -1581,7 +1607,10 @@ class ClientService extends EventTarget {
reason: 'no_relays_after_filters', reason: 'no_relays_after_filters',
filterSummary: summarizeFiltersForRelayLog(filters) filterSummary: summarizeFiltersForRelayLog(filters)
}) })
queueMicrotask(() => oneose?.(true)) queueMicrotask(() => {
oneose?.(true)
relayReqLog?.onBatchEnd?.([])
})
return { return {
close: () => {} close: () => {}
} }
@ -1590,7 +1619,9 @@ class ClientService extends EventTarget {
const reqGroupId = const reqGroupId =
relayReqLog?.groupId ?? relayReqLog?.groupId ??
`sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` `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() opBatch.logBegin()
const reqT0 = performance.now() const reqT0 = performance.now()
let firstRelayResponseLogged = false let firstRelayResponseLogged = false
@ -1851,7 +1882,7 @@ class ClientService extends EventTarget {
needSort?: boolean needSort?: boolean
firstRelayResultGraceMs?: number firstRelayResultGraceMs?: number
/** Correlate {@link ClientService.subscribe} logs with a timeline shard */ /** 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)) const relays = Array.from(new Set(urls))

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

@ -53,7 +53,7 @@ function relayHostForSubscribeLog(url: string): string {
return relayHostForPublishLog(url) return relayHostForPublishLog(url)
} }
function humanizeSubscribeTerminalDetail(outcome: RelayOpTerminalOutcome, detail?: string): string { export function humanizeSubscribeTerminalDetail(outcome: RelayOpTerminalOutcome, detail?: string): string {
const d = (detail ?? '').trim() const d = (detail ?? '').trim()
if (!d) { if (!d) {
if (outcome === 'eose') return 'end of stored events' if (outcome === 'eose') return 'end of stored events'
@ -137,6 +137,42 @@ function groupTerminalsByOutcome(rows: RelayOpTerminalRow[]): Record<string, { c
export type RelaySubscribeOpBatchOptions = { export type RelaySubscribeOpBatchOptions = {
/** `debug` hides high-volume query REQs unless jumble-debug / VITE_DEBUG is on. */ /** `debug` hides high-volume query REQs unless jumble-debug / VITE_DEBUG is on. */
logLevel?: 'info' | 'debug' 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 { export class RelaySubscribeOpBatch {
@ -145,6 +181,7 @@ export class RelaySubscribeOpBatch {
private readonly source: string private readonly source: string
private readonly grouped: GroupedRelayRow[] private readonly grouped: GroupedRelayRow[]
private readonly logLevel: 'info' | 'debug' private readonly logLevel: 'info' | 'debug'
private readonly onBatchEnd?: (rows: RelayOpTerminalRow[]) => void
private readonly terminal = new Map<number, RelayOpTerminalRow>() private readonly terminal = new Map<number, RelayOpTerminalRow>()
private endLogged = false private endLogged = false
@ -154,6 +191,7 @@ export class RelaySubscribeOpBatch {
this.source = source this.source = source
this.grouped = grouped this.grouped = grouped
this.logLevel = options?.logLevel ?? 'info' this.logLevel = options?.logLevel ?? 'info'
this.onBatchEnd = options?.onBatchEnd
} }
private logLine(message: string, payload: Record<string, unknown>): void { private logLine(message: string, payload: Record<string, unknown>): void {
@ -257,6 +295,8 @@ export class RelaySubscribeOpBatch {
} else { } else {
logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact) logger.info(`[RelayOp] batch_end — ${headline}\n${readableSummary}`, compact)
} }
this.onBatchEnd?.(rows)
} }
} }

Loading…
Cancel
Save