Browse Source

fix blossom link

bug-fixes
imwald
Silberengel 4 weeks ago
parent
commit
b89d803cbe
  1. 39
      package-lock.json
  2. 4
      package.json
  3. 47
      src/components/FavoriteRelaysFeedPicker/index.tsx
  4. 51
      src/components/Image/index.tsx
  5. 3
      src/components/ImageGallery/index.tsx
  6. 13
      src/components/ImageWithLightbox/index.tsx
  7. 12
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  8. 5
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  9. 27
      src/components/NoteList/index.tsx
  10. 2
      src/i18n/locales/de.ts
  11. 2
      src/i18n/locales/en.ts
  12. 38
      src/lib/url.ts
  13. 17
      src/lib/wisp-trending-relay.ts
  14. 10
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  15. 30
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  16. 19
      src/services/client-replaceable-events.service.ts

39
package-lock.json generated

@ -112,7 +112,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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"
},

4
package.json

@ -134,7 +134,7 @@ @@ -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 @@ @@ -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": {

47
src/components/FavoriteRelaysFeedPicker/index.tsx

@ -12,6 +12,7 @@ import { @@ -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) { @@ -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() { @@ -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() { @@ -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() { @@ -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() { @@ -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() { @@ -158,6 +181,11 @@ export default function FavoriteRelaysFeedPicker() {
<SelectItem value={ALL_FAVORITES_VALUE} className="text-xs">
{t('All favorite relays')}
</SelectItem>
{!trendingUrlInFavoriteList ? (
<SelectItem value={wispTrendingRelayKey} className="text-xs font-sans">
{t('Trending on Nostr')}
</SelectItem>
) : null}
{relaySets.length > 0 || orphanRelaySetId ? (
<>
<SelectSeparator />
@ -223,6 +251,21 @@ export default function FavoriteRelaysFeedPicker() { @@ -223,6 +251,21 @@ export default function FavoriteRelaysFeedPicker() {
>
{t('All favorite relays')}
</button>
{!trendingUrlInFavoriteList ? (
<button
type="button"
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs font-semibold transition-colors',
trendingRelayActive
? 'border-primary bg-primary/15 text-foreground'
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent'
)}
title={wispTrendingRelayUrl}
onClick={() => void switchFeed('relay', { relay: wispTrendingRelayUrl })}
>
{t('Trending on Nostr')}
</button>
) : null}
{(relaySets.length > 0 || orphanRelaySetId) && (
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden />
)}

51
src/components/Image/index.tsx

@ -2,6 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -129,6 +161,7 @@ export default function Image({
src={imageUrl}
alt={finalAlt}
title={finalAlt || undefined}
referrerPolicy="no-referrer"
decoding="async"
loading="lazy"
draggable={false}

3
src/components/ImageGallery/index.tsx

@ -1,4 +1,5 @@ @@ -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({ @@ -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
}))

13
src/components/ImageWithLightbox/index.tsx

@ -6,6 +6,7 @@ import modalManager from '@/services/modal-manager.service' @@ -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({ @@ -142,11 +143,13 @@ export default function ImageWithLightbox({
>
<Lightbox
index={index}
slides={[{
src: image.url,
alt: image.alt || image.url,
title: image.alt || undefined
}]}
slides={[
{
src: preferBlossomPrimalDisplayUrl(image.url),
alt: image.alt || image.url,
title: image.alt || undefined
}
]}
plugins={[Zoom, Captions]}
open={index >= 0}
close={() => {

12
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -5,7 +5,15 @@ import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' @@ -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'
@ -2118,7 +2126,7 @@ export default function AsciidocArticle({ @@ -2118,7 +2126,7 @@ export default function AsciidocArticle({
<Lightbox
index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({
src: url,
src: preferBlossomPrimalDisplayUrl(url),
alt: alt || url
}))}
plugins={[Zoom]}

5
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -16,7 +16,8 @@ import { @@ -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'
@ -5087,7 +5088,7 @@ export default function MarkdownArticle({ @@ -5087,7 +5088,7 @@ export default function MarkdownArticle({
<Lightbox
index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({
src: url,
src: preferBlossomPrimalDisplayUrl(url),
alt: alt || url
}))}
plugins={[Zoom]}

27
src/components/NoteList/index.tsx

@ -592,6 +592,12 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -3005,7 +3030,7 @@ const NoteList = forwardRef(
}
}
return map
}, [clientFilteredEvents, subRequestsKey])
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick])
const list = (
<div className="min-h-screen">

2
src/i18n/locales/de.ts

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

2
src/i18n/locales/en.ts

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

38
src/lib/url.ts

@ -336,6 +336,44 @@ export function isSafeMediaUrl(url: string): boolean { @@ -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 Primals 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.

17
src/lib/wisp-trending-relay.ts

@ -1,3 +1,5 @@ @@ -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( @@ -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
}
}

10
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -169,6 +169,15 @@ const FollowingFeed = forwardRef< @@ -169,6 +169,15 @@ const FollowingFeed = forwardRef<
i18n.language
])
const trendingFeedNotice = useMemo(
() => (
<p className="mb-2 px-1 text-xs text-muted-foreground leading-snug">
{t('Home trending slice notice')}
</p>
),
[t]
)
return (
<NormalFeed
ref={ref}
@ -181,6 +190,7 @@ const FollowingFeed = forwardRef< @@ -181,6 +190,7 @@ const FollowingFeed = forwardRef<
onSubHeaderRefresh={onSubHeaderRefresh}
showFeedClientFilter={false}
hostPrimaryPageName="feed"
feedTopNotice={trendingFeedNotice}
/>
)
})

30
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -2,6 +2,10 @@ import NormalFeed from '@/components/NormalFeed' @@ -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< @@ -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< @@ -110,7 +120,8 @@ const RelaysFeed = forwardRef<
feedInfo.feedType === 'relay' &&
relayUrls.length === 1 &&
!kindsOverride?.length &&
!singleRelayKindFallback
!singleRelayKindFallback &&
!wispTrendingSingleRelay
const feedTopNotice = singleRelayKindFallback ? (
<p className="leading-snug">{t('singleRelayKindFallbackNotice')}</p>
@ -119,6 +130,14 @@ const RelaysFeed = forwardRef< @@ -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< @@ -130,7 +149,14 @@ const RelaysFeed = forwardRef<
}
}
]
}, [canRenderFeed, relayUrls, defaultKinds, kindsOverride, singleRelayKindlessExplore])
}, [
canRenderFeed,
relayUrls,
defaultKinds,
kindsOverride,
singleRelayKindlessExplore,
wispTrendingSingleRelay
])
if (!canRenderFeed) {
return null

19
src/services/client-replaceable-events.service.ts

@ -6,12 +6,13 @@ import { @@ -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 { @@ -1128,8 +1129,18 @@ export class ReplaceableEventService {
*/
async fetchBlossomServerList(pubkey: string): Promise<string[]> {
const evt = await this.fetchBlossomServerListEvent(pubkey)
if (!evt) return []
return getServersFromServerTags(evt.tags)
const fromEvent = evt ? getServersFromServerTags(evt.tags) : []
const seen = new Set<string>()
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
}
/**

Loading…
Cancel
Save