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
}
/**