From b89d803cbe8118e7c2dada3ec351d58b65d7108f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 4 Apr 2026 11:31:39 +0200 Subject: [PATCH] fix blossom link bug-fixes --- package-lock.json | 39 ++++++++++---- package.json | 4 +- .../FavoriteRelaysFeedPicker/index.tsx | 47 ++++++++++++++++- src/components/Image/index.tsx | 51 +++++++++++++++---- src/components/ImageGallery/index.tsx | 3 +- src/components/ImageWithLightbox/index.tsx | 13 +++-- .../Note/AsciidocArticle/AsciidocArticle.tsx | 16 ++++-- .../Note/MarkdownArticle/MarkdownArticle.tsx | 9 ++-- src/components/NoteList/index.tsx | 27 +++++++++- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/lib/url.ts | 38 ++++++++++++++ src/lib/wisp-trending-relay.ts | 17 +++++++ .../primary/NoteListPage/FollowingFeed.tsx | 10 ++++ src/pages/primary/NoteListPage/RelaysFeed.tsx | 30 ++++++++++- .../client-replaceable-events.service.ts | 19 +++++-- 16 files changed, 282 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index f0f6e32f..69a9e870 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,7 @@ "wait-on": "^8.0.1" }, "optionalDependencies": { - "electron": "^35.7.5", + "electron": "^41.1.1", "electron-builder": "^26.8.1" } }, @@ -6418,9 +6418,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "license": "MIT", "optional": true, "engines": { @@ -8454,15 +8454,15 @@ } }, "node_modules/electron": { - "version": "35.7.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz", - "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "version": "41.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz", + "integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==", "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", + "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -8625,6 +8625,23 @@ "node": ">= 4.0.0" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT", + "optional": true + }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -11028,9 +11045,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "devOptional": true, "license": "MIT" }, diff --git a/package.json b/package.json index aa2b73ef..a9c69449 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "wait-on": "^8.0.1" }, "optionalDependencies": { - "electron": "^35.7.5", + "electron": "^41.1.1", "electron-builder": "^26.8.1" }, "build": { @@ -164,7 +164,7 @@ "glob": "^11.1.0" }, "minimatch": "^10.0.1", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "vite-plugin-pwa": { "workbox-build": { "@rollup/plugin-terser": { diff --git a/src/components/FavoriteRelaysFeedPicker/index.tsx b/src/components/FavoriteRelaysFeedPicker/index.tsx index 5cc7aadf..c42490ff 100644 --- a/src/components/FavoriteRelaysFeedPicker/index.tsx +++ b/src/components/FavoriteRelaysFeedPicker/index.tsx @@ -12,6 +12,7 @@ import { import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' import { toRelaySettings } from '@/lib/link' import { normalizeUrl, simplifyUrl } from '@/lib/url' +import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -32,7 +33,7 @@ function selectValueToRelaySetId(v: string) { return decodeURIComponent(v.slice(3)) } -/** Top-of-feed control: all favorites, relay sets, then single relays. */ +/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, then single relays. */ export default function FavoriteRelaysFeedPicker() { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() @@ -51,11 +52,24 @@ export default function FavoriteRelaysFeedPicker() { [favoriteRelays, blockedRelays] ) + const wispTrendingRelayUrl = useMemo(() => buildWispTrendingNotesRelayUrl(), []) + const wispTrendingRelayKey = useMemo( + () => normalizeUrl(wispTrendingRelayUrl) || wispTrendingRelayUrl, + [wispTrendingRelayUrl] + ) + const trendingUrlInFavoriteList = useMemo( + () => urls.some((u) => (normalizeUrl(u) || u) === wispTrendingRelayKey), + [urls, wispTrendingRelayKey] + ) + const currentRelayKey = feedInfo.feedType === 'relay' && feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : null const allActive = feedInfo.feedType === 'all-favorites' + const trendingRelayActive = + feedInfo.feedType === 'relay' && currentRelayKey === wispTrendingRelayKey + const relaySetIdActive = feedInfo.feedType === 'relays' && feedInfo.id ? feedInfo.id : null const orphanRelaySetId = @@ -72,6 +86,9 @@ export default function FavoriteRelaysFeedPicker() { /** Values that exist in the mobile Select (for controlled `value` validation). */ const selectItems = useMemo(() => { const items: { value: string }[] = [{ value: ALL_FAVORITES_VALUE }] + if (!trendingUrlInFavoriteList) { + items.push({ value: wispTrendingRelayKey }) + } for (const set of relaySets) { items.push({ value: relaySetToSelectValue(set.id) }) } @@ -97,7 +114,9 @@ export default function FavoriteRelaysFeedPicker() { feedInfo.id, currentRelayKey, relaySets, - orphanRelaySetId + orphanRelaySetId, + trendingUrlInFavoriteList, + wispTrendingRelayKey ]) const resolvedSelectValue = selectItems.some((i) => i.value === selectValue) @@ -115,6 +134,10 @@ export default function FavoriteRelaysFeedPicker() { void switchFeed('all-favorites') return } + if (v === wispTrendingRelayKey) { + void switchFeed('relay', { relay: wispTrendingRelayUrl }) + return + } const setId = selectValueToRelaySetId(v) if (setId) { void switchFeed('relays', { activeRelaySetId: setId }) @@ -158,6 +181,11 @@ export default function FavoriteRelaysFeedPicker() { {t('All favorite relays')} + {!trendingUrlInFavoriteList ? ( + + {t('Trending on Nostr')} + + ) : null} {relaySets.length > 0 || orphanRelaySetId ? ( <> @@ -223,6 +251,21 @@ export default function FavoriteRelaysFeedPicker() { > {t('All favorite relays')} + {!trendingUrlInFavoriteList ? ( + + ) : null} {(relaySets.length > 0 || orphanRelaySetId) && (
)} diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index 6cb4fa2a..be71fd94 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -2,6 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { cn } from '@/lib/utils' import client from '@/services/client.service' import { TImetaInfo } from '@/types' +import { preferBlossomPrimalDisplayUrl, primalR2aMirrorForBlossomPrimalUrl } from '@/lib/url' import { getHashFromURL } from 'blossom-client-sdk' import { decode } from 'blurhash' import { ImageOff } from 'lucide-react' @@ -29,7 +30,7 @@ export default function Image({ const [isLoading, setIsLoading] = useState(true) const [displaySkeleton, setDisplaySkeleton] = useState(true) const [hasError, setHasError] = useState(false) - const [imageUrl, setImageUrl] = useState(url) + const [imageUrl, setImageUrl] = useState(() => preferBlossomPrimalDisplayUrl(url)) const [tried, setTried] = useState(new Set()) const [fallbackIndex, setFallbackIndex] = useState(0) @@ -37,7 +38,7 @@ export default function Image({ const finalAlt = imetaAlt || alt useEffect(() => { - setImageUrl(url) + setImageUrl(preferBlossomPrimalDisplayUrl(url)) setIsLoading(true) setHasError(false) setDisplaySkeleton(true) @@ -52,7 +53,7 @@ export default function Image({ if (fallback && fallbackIndex < fallback.length) { const nextFallbackUrl = fallback[fallbackIndex] setFallbackIndex(prev => prev + 1) - setImageUrl(nextFallbackUrl) + setImageUrl(preferBlossomPrimalDisplayUrl(nextFallbackUrl)) return } @@ -65,14 +66,45 @@ export default function Image({ } catch (error) { logger.error('Invalid image URL', { error, imageUrl }) } - if (!pubkey || !hash || !oldImageUrl) { + if (!hash || !oldImageUrl) { setIsLoading(false) setHasError(true) return } - const ext = oldImageUrl.pathname.match(/\.\w+$/i) - setTried((prev) => new Set(prev.add(oldImageUrl.hostname))) + // r2a failed: try canonical blossom URL from props (some networks only allow one hop). + if ( + oldImageUrl.hostname === 'r2a.primal.net' && + url && + url !== imageUrl && + url.includes('blossom.primal.net') && + !tried.has('blossom.primal.net-direct') + ) { + setTried((prev) => new Set(prev).add('blossom.primal.net-direct')) + setImageUrl(url) + return + } + + // Primal: only mirror blossom → r2a when we did not already open the note with that CDN URL (avoids r2a↔blossom loops). + if (oldImageUrl.hostname === 'blossom.primal.net') { + const r2a = primalR2aMirrorForBlossomPrimalUrl(oldImageUrl) + const noteAlreadyUsesPrimalCdnFirst = preferBlossomPrimalDisplayUrl(url) !== url + if (r2a && !noteAlreadyUsesPrimalCdnFirst && !tried.has('blossom.primal.net')) { + setTried((prev) => new Set(prev).add('blossom.primal.net')) + setImageUrl(r2a) + return + } + } + + if (!pubkey) { + setIsLoading(false) + setHasError(true) + return + } + + const extMatch = oldImageUrl.pathname.match(/\.\w+$/i) + const extStr = extMatch?.[0] ?? '' + setTried((prev) => new Set(prev).add(oldImageUrl.hostname)) const blossomServerList = await client.fetchBlossomServerList(pubkey) const urls = blossomServerList @@ -84,7 +116,7 @@ export default function Image({ return undefined } }) - .filter((url) => !!url && !tried.has(url.hostname)) + .filter((u) => !!u && !tried.has(u.hostname)) const nextUrl = urls[0] if (!nextUrl) { setIsLoading(false) @@ -92,8 +124,8 @@ export default function Image({ return } - nextUrl.pathname = '/' + hash + ext - setImageUrl(nextUrl.toString()) + nextUrl.pathname = '/' + hash + extStr + setImageUrl(preferBlossomPrimalDisplayUrl(nextUrl.toString())) } const handleLoad = () => { @@ -129,6 +161,7 @@ export default function Image({ src={imageUrl} alt={finalAlt} title={finalAlt || undefined} + referrerPolicy="no-referrer" decoding="async" loading="lazy" draggable={false} diff --git a/src/components/ImageGallery/index.tsx b/src/components/ImageGallery/index.tsx index ef894a3c..add09225 100644 --- a/src/components/ImageGallery/index.tsx +++ b/src/components/ImageGallery/index.tsx @@ -1,4 +1,5 @@ import { randomString } from '@/lib/random' +import { preferBlossomPrimalDisplayUrl } from '@/lib/url' import { cn } from '@/lib/utils' import logger from '@/lib/logger' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -113,7 +114,7 @@ export default function ImageGallery({ index={index} slides={(() => { const slides = images.map(({ url, alt }) => ({ - src: url, + src: preferBlossomPrimalDisplayUrl(url), alt: alt || url, title: alt || undefined })) diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx index a977e37b..1ad14d36 100644 --- a/src/components/ImageWithLightbox/index.tsx +++ b/src/components/ImageWithLightbox/index.tsx @@ -6,6 +6,7 @@ import modalManager from '@/services/modal-manager.service' import { TImetaInfo } from '@/types' import { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' +import { preferBlossomPrimalDisplayUrl } from '@/lib/url' import { useTranslation } from 'react-i18next' import Lightbox from 'yet-another-react-lightbox' import Captions from 'yet-another-react-lightbox/plugins/captions' @@ -142,11 +143,13 @@ export default function ImageWithLightbox({ > = 0} close={() => { diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 6fb7d18a..9d0a9a18 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -5,7 +5,15 @@ import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' import { useMediaExtraction } from '@/hooks' -import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url' +import { + cleanUrl, + isImage, + isMedia, + isVideo, + isAudio, + isWebsocketUrl, + preferBlossomPrimalDisplayUrl +} from '@/lib/url' import { getImetaInfosFromEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' import { useMemo, useState, useCallback, useEffect, useRef } from 'react' @@ -2117,9 +2125,9 @@ export default function AsciidocArticle({
e.stopPropagation()}> ({ - src: url, - alt: alt || url + slides={allImages.map(({ url, alt }) => ({ + src: preferBlossomPrimalDisplayUrl(url), + alt: alt || url }))} plugins={[Zoom]} open={lightboxIndex >= 0} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 1cb0e806..3fe549df 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -16,7 +16,8 @@ import { isAudio, isWebsocketUrl, isPseudoNostrHttpsUrl, - isSafeMediaUrl + isSafeMediaUrl, + preferBlossomPrimalDisplayUrl } from '@/lib/url' import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' import { canonicalizeRssArticleUrl } from '@/lib/rss-article' @@ -5086,9 +5087,9 @@ export default function MarkdownArticle({ > ({ - src: url, - alt: alt || url + slides={allImages.map(({ url, alt }) => ({ + src: preferBlossomPrimalDisplayUrl(url), + alt: alt || url }))} plugins={[Zoom]} open={lightboxOpen} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index d5feff92..31977afe 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -592,6 +592,12 @@ const NoteList = forwardRef( * (Loading clears when subscribe wires; merged EOSE arrives later.) */ const [feedEmptyToastGateTick, setFeedEmptyToastGateTick] = useState(0) + /** + * Bumped when live relays may have updated {@link client.getSeenEventRelayUrls} for visible rows (e.g. trending + * shard EOSE after follows — duplicates merged out of the list but seen-on metadata still changes). + * Drives recomputation of {@link eventReasonLabelMap}. + */ + const [feedReasonLabelsTick, setFeedReasonLabelsTick] = 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. @@ -1904,6 +1910,16 @@ const NoteList = forwardRef( } } } + + if ( + effectActive && + eosed && + subRequestsRef.current.some( + (r) => r.reasonLabelIfSeenOnRelay && r.reasonLabel?.trim() + ) + ) { + setFeedReasonLabelsTick((n) => n + 1) + } }, onNew: (event: Event) => { if (!effectActive) return @@ -2143,6 +2159,15 @@ const NoteList = forwardRef( if (!areAlgoRelays && eosed) { setHasMore(true) } + if ( + deltaActive && + eosed && + subRequestsRef.current.some( + (r) => r.reasonLabelIfSeenOnRelay && r.reasonLabel?.trim() + ) + ) { + setFeedReasonLabelsTick((n) => n + 1) + } }, onNew: (event: Event) => { if (!deltaActive) return @@ -3005,7 +3030,7 @@ const NoteList = forwardRef( } } return map - }, [clientFilteredEvents, subRequestsKey]) + }, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick]) const list = (
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 5cfeca9c..8f6c41e1 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1060,6 +1060,8 @@ export default { 'Keine aktuellen Beiträge von diesem Nutzer in dieser Abfrage', 'Loading trending notes from your relays...': 'Trendende Notizen werden geladen …', 'Trending on Nostr': 'Trending auf Nostr', + 'Home trending slice notice': + 'Dieser Feed enthält eine Trending-Spur (nostrarchives). Notizen, die von diesem Relay kamen, zeigen unter der Karte „Trending auf Nostr“.', Sort: 'Sortierung', newest: 'neueste', oldest: 'älteste', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 09a13e83..ebffb132 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1058,6 +1058,8 @@ export default { 'No recent posts from this user in the current fetch', 'Loading trending notes from your relays...': 'Loading trending notes from your relays...', 'Trending on Nostr': 'Trending on Nostr', + 'Home trending slice notice': + 'This feed includes a trending slice (nostrarchives). Notes that reached you from that relay show “Trending on Nostr” under the card.', Sort: 'Sort', newest: 'newest', oldest: 'oldest', diff --git a/src/lib/url.ts b/src/lib/url.ts index 54b73723..a324acb1 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -336,6 +336,44 @@ export function isSafeMediaUrl(url: string): boolean { return t.startsWith('http://') || t.startsWith('https://') } +/** + * Primal R2A CDN URL for media keyed by SHA-256 (same object as `https://blossom.primal.net/{hash}.ext`). + * Used when the blossom host fails in-browser; aligns with NIP-B7-style alternate retrieval. + */ +export function primalR2aUploads2UrlFromSha256(hash: string, extensionWithDot?: string): string | null { + const h = hash.toLowerCase() + if (!/^[0-9a-f]{64}$/.test(h)) return null + const ext = + extensionWithDot && extensionWithDot.startsWith('.') ? extensionWithDot.toLowerCase() : '' + const a = h.slice(0, 1) + const b = h.slice(1, 3) + const c = h.slice(3, 5) + return `https://r2a.primal.net/uploads2/${a}/${b}/${c}/${h}${ext}` +} + +/** + * If `url` is on `blossom.primal.net` with a 64-hex blob id in the path, return the r2a CDN mirror URL. + */ +export function primalR2aMirrorForBlossomPrimalUrl(url: string | URL): string | null { + try { + const u = url instanceof URL ? url : new URL(url) + if (u.hostname !== 'blossom.primal.net') return null + const m = u.pathname.match(/([0-9a-f]{64})(\.\w+)?$/i) + if (!m) return null + return primalR2aUploads2UrlFromSha256(m[1].toLowerCase(), m[2] || '') + } catch { + return null + } +} + +/** + * Prefer Primal’s CDN URL for `img src` when the note points at `blossom.primal.net/…`. + * Same file as the blossom URL; avoids browsers that block or hang on the blossom host (Primal/Wisp-style delivery). + */ +export function preferBlossomPrimalDisplayUrl(url: string): string { + return primalR2aMirrorForBlossomPrimalUrl(url) ?? url +} + /** * Remove tracking parameters from URLs * Removes common tracking parameters like utm_*, fbclid, gclid, etc. diff --git a/src/lib/wisp-trending-relay.ts b/src/lib/wisp-trending-relay.ts index 8ccda9e1..68977879 100644 --- a/src/lib/wisp-trending-relay.ts +++ b/src/lib/wisp-trending-relay.ts @@ -1,3 +1,5 @@ +import { normalizeUrl } from '@/lib/url' + /** * Trending notes stream from nostrarchives, consumed by * {@link https://github.com/barrydeen/wisp | Wisp} (Android). Same URL shape as Wisp’s @@ -16,3 +18,18 @@ export function buildWispTrendingNotesRelayUrl( /** Wisp `FeedSubscriptionManager` FEED_KINDS when subscribing to trending notes. */ export const WISP_TRENDING_FEED_KINDS: readonly number[] = [1, 6, 1068, 6969, 30023, 20, 21, 22] + +/** True when `url` is any nostrarchives notes trending WebSocket feed (path `/notes/trending/...`). */ +export function isWispTrendingNotesRelayUrl(url: string): boolean { + const raw = (normalizeUrl(url) || url).trim() + const forParse = raw.replace(/^ws:\/\//i, 'http://').replace(/^wss:\/\//i, 'https://') + try { + const u = new URL(forParse) + return ( + u.hostname.toLowerCase() === 'feeds.nostrarchives.com' && + u.pathname.toLowerCase().startsWith('/notes/trending/') + ) + } catch { + return false + } +} diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index f0f930bd..123a01ff 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -169,6 +169,15 @@ const FollowingFeed = forwardRef< i18n.language ]) + const trendingFeedNotice = useMemo( + () => ( +

+ {t('Home trending slice notice')} +

+ ), + [t] + ) + return ( ) }) diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 582e7592..62914202 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -2,6 +2,10 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants' import { checkAlgoRelay } from '@/lib/relay' +import { + isWispTrendingNotesRelayUrl, + WISP_TRENDING_FEED_KINDS +} from '@/lib/wisp-trending-relay' import { normalizeUrl } from '@/lib/url' import { useFeed } from '@/providers/FeedProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' @@ -93,6 +97,12 @@ const RelaysFeed = forwardRef< return undefined }, [feedInfo.feedType, feedInfo.id]) + const wispTrendingSingleRelay = + feedInfo.feedType === 'relay' && + relayUrls.length === 1 && + !!relayUrls[0] && + isWispTrendingNotesRelayUrl(relayUrls[0]) + /** New relay chip / set: try kindless first again. */ useEffect(() => { setSingleRelayKindFallback(false) @@ -110,7 +120,8 @@ const RelaysFeed = forwardRef< feedInfo.feedType === 'relay' && relayUrls.length === 1 && !kindsOverride?.length && - !singleRelayKindFallback + !singleRelayKindFallback && + !wispTrendingSingleRelay const feedTopNotice = singleRelayKindFallback ? (

{t('singleRelayKindFallbackNotice')}

@@ -119,6 +130,14 @@ const RelaysFeed = forwardRef< // Hooks must run every render — never place useMemo after conditional returns. const subRequests = useMemo(() => { if (!canRenderFeed) return [] + if (wispTrendingSingleRelay) { + return [ + { + urls: relayUrls, + filter: { kinds: [...WISP_TRENDING_FEED_KINDS], limit: 100 } + } + ] + } if (singleRelayKindlessExplore) { return [{ urls: relayUrls, filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT } }] } @@ -130,7 +149,14 @@ const RelaysFeed = forwardRef< } } ] - }, [canRenderFeed, relayUrls, defaultKinds, kindsOverride, singleRelayKindlessExplore]) + }, [ + canRenderFeed, + relayUrls, + defaultKinds, + kindsOverride, + singleRelayKindlessExplore, + wispTrendingSingleRelay + ]) if (!canRenderFeed) { return null diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 7a55df89..4a975203 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -6,12 +6,13 @@ import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, PROFILE_FETCH_RELAY_URLS, - READ_ONLY_RELAY_URLS + READ_ONLY_RELAY_URLS, + RECOMMENDED_BLOSSOM_SERVERS } from '@/constants' import { kinds, nip19 } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools' import DataLoader from 'dataloader' -import { normalizeUrl } from '@/lib/url' +import { normalizeHttpUrl, normalizeUrl } from '@/lib/url' import { getProfileFromEvent } from '@/lib/event-metadata' import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' @@ -1128,8 +1129,18 @@ export class ReplaceableEventService { */ async fetchBlossomServerList(pubkey: string): Promise { const evt = await this.fetchBlossomServerListEvent(pubkey) - if (!evt) return [] - return getServersFromServerTags(evt.tags) + const fromEvent = evt ? getServersFromServerTags(evt.tags) : [] + const seen = new Set() + const out: string[] = [] + const add = (raw: string) => { + const n = normalizeHttpUrl(raw) + if (!n || seen.has(n)) return + seen.add(n) + out.push(n) + } + fromEvent.forEach(add) + RECOMMENDED_BLOSSOM_SERVERS.forEach(add) + return out } /**