diff --git a/package-lock.json b/package-lock.json
index 623f1ef9..e3d40c28 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "jumble-imwald",
- "version": "20.0.0",
+ "version": "20.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-imwald",
- "version": "20.0.0",
+ "version": "20.0.1",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",
diff --git a/package.json b/package.json
index 63efefd5..d0b17b78 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
- "version": "20.0.0",
+ "version": "20.0.1",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",
diff --git a/src/components/ContentPreview/FollowPackPreview.tsx b/src/components/ContentPreview/FollowPackPreview.tsx
index f5851c5b..c3c560ca 100644
--- a/src/components/ContentPreview/FollowPackPreview.tsx
+++ b/src/components/ContentPreview/FollowPackPreview.tsx
@@ -1,3 +1,5 @@
+import { resolveHttpMediaUrl } from '@/lib/badge-definition-media'
+import { getImetaInfosFromEvent } from '@/lib/event'
import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger'
import { cn } from '@/lib/utils'
@@ -6,13 +8,28 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { Users } from 'lucide-react'
-import { useCallback, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Button } from '@/components/ui/button'
+/** NIP-style `image` tags on kind 39089; falls back to first NIP-94 `imeta` URL. */
+function followPackBannerUrlFromEvent(event: Event): string | undefined {
+ for (const t of event.tags) {
+ if (t[0] === 'image' && t[1]) {
+ const u = resolveHttpMediaUrl(t[1])
+ if (u) return u
+ }
+ }
+ for (const im of getImetaInfosFromEvent(event)) {
+ const u = resolveHttpMediaUrl(im.url)
+ if (u) return u
+ }
+ return undefined
+}
+
export default function FollowPackPreview({
event,
className
@@ -26,8 +43,14 @@ export default function FollowPackPreview({
const followings = followList?.followings ?? []
const { mutePubkeySet } = useMuteList()
const [busy, setBusy] = useState(false)
+ const [bannerFailed, setBannerFailed] = useState(false)
const packPubkeys = useMemo(() => getPubkeysFromPTags(event.tags), [event.tags])
+ const bannerUrl = useMemo(() => followPackBannerUrlFromEvent(event), [event])
+
+ useEffect(() => {
+ setBannerFailed(false)
+ }, [event.id])
const getPackTitle = (pack: Event): string => {
const titleTag = pack.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name')
@@ -87,7 +110,21 @@ export default function FollowPackPreview({
)
return (
-
+
+ {bannerUrl && !bannerFailed ? (
+
+

setBannerFailed(true)}
+ />
+
+ ) : null}
+
[{t('Follow Pack')}]
@@ -153,6 +190,7 @@ export default function FollowPackPreview({
)}
)}
+
)
}
diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx
index 2d27aef3..9cb5b122 100644
--- a/src/components/NormalFeed/index.tsx
+++ b/src/components/NormalFeed/index.tsx
@@ -24,6 +24,8 @@ const NormalFeed = forwardRef
(function NormalFeed(
{
subRequests,
@@ -33,7 +35,8 @@ const NormalFeed = forwardRef
>
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index 12d36ae5..9f3240be 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -111,6 +111,12 @@ const NoteList = forwardRef(
* avoid a loading reset.
*/
mergeTimelineWhenSubRequestFiltersMatch = false,
+ /**
+ * When set with {@link preserveTimelineOnSubRequestsChange}: home relay chip / feed mode identity.
+ * If this string changes (e.g. single relay → all favorites), the timeline is cleared even when the new
+ * relay URL set is a strict superset of the old one (which would otherwise keep stale rows).
+ */
+ feedTimelineScopeKey,
/**
* Spells / one-shot feeds: when the initial fetch finishes with zero rows, show explicit empty copy
* (see list footer). Does not end loading early — loading stays until EOSE, first events, or safety timeouts.
@@ -174,6 +180,7 @@ const NoteList = forwardRef(
feedSubscriptionKey?: string
preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean
+ feedTimelineScopeKey?: string
/** When set (e.g. spells), use explicit empty-feed copy after load completes with no rows. */
spellFetchTimeoutMs?: number
spellFeedInstrumentToken?: number
@@ -257,6 +264,7 @@ const NoteList = forwardRef(
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const prevSubRequestsKeyForTimelineRef = useRef
(null)
+ const feedTimelineScopePrevRef = useRef(undefined)
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
const timelineEffectLastRefreshCountRef = useRef(refreshCount)
@@ -641,9 +649,23 @@ const NoteList = forwardRef(
if (userPulledRefresh) {
timelineEffectLastRefreshCountRef.current = refreshCount
}
+
+ const prevFeedScope = feedTimelineScopePrevRef.current
+ const feedScopeKey = feedTimelineScopeKey
+ const feedScopeChanged =
+ feedScopeKey !== undefined &&
+ prevFeedScope !== undefined &&
+ prevFeedScope !== feedScopeKey
+ if (feedScopeKey !== undefined) {
+ feedTimelineScopePrevRef.current = feedScopeKey
+ } else {
+ feedTimelineScopePrevRef.current = undefined
+ }
+
const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange &&
!userPulledRefresh &&
+ !feedScopeChanged &&
(prevSubKey === subRequestsKey ||
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
(mergeTimelineWhenSubRequestFiltersMatch &&
@@ -1037,6 +1059,7 @@ const NoteList = forwardRef(
subRequestsKey,
preserveTimelineOnSubRequestsChange,
mergeTimelineWhenSubRequestFiltersMatch,
+ feedTimelineScopeKey,
refreshCount,
showKindsKey,
showKind1OPs,
diff --git a/src/components/Profile/ProfileBadgeDetailDialog.tsx b/src/components/Profile/ProfileBadgeDetailDialog.tsx
index 7bcf8db2..13d8278e 100644
--- a/src/components/Profile/ProfileBadgeDetailDialog.tsx
+++ b/src/components/Profile/ProfileBadgeDetailDialog.tsx
@@ -125,6 +125,16 @@ export default function ProfileBadgeDetailDialog({
) : null}
+ {badge.awardCreatedAt != null ? (
+
+ {t('Awarded on', { defaultValue: 'Awarded on' })}{' '}
+ {new Date(badge.awardCreatedAt * 1000).toLocaleString(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short'
+ })}
+
+ ) : null}
+
{issuerPubkey ? (
{t('Issued by')}
diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx
index bb70be14..c4830d20 100644
--- a/src/components/Profile/ProfileHeaderInteractions.tsx
+++ b/src/components/Profile/ProfileHeaderInteractions.tsx
@@ -184,7 +184,6 @@ function BadgeItem({
alt=""
className="size-full rounded-lg object-cover"
loading="lazy"
- referrerPolicy="no-referrer"
onError={(e) => {
e.currentTarget.style.visibility = 'hidden'
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null
diff --git a/src/components/Sidebar/NotificationButton.tsx b/src/components/Sidebar/NotificationButton.tsx
index 652ff7b8..eb035873 100644
--- a/src/components/Sidebar/NotificationButton.tsx
+++ b/src/components/Sidebar/NotificationButton.tsx
@@ -12,7 +12,7 @@ export default function NotificationButton() {
return (
checkLogin(() => navigate('spells', { spell: 'notifications' }))}
active={
display &&
diff --git a/src/constants.ts b/src/constants.ts
index 363f8ff3..c09f0c71 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -206,7 +206,8 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [
'wss://relay.noswhere.com',
'wss://aggr.nostr.land',
'wss://search.nos.today',
- 'wss://trending.nostr.wine'
+ 'wss://trending.nostr.wine',
+ 'wss://sendit.nosflare.com'
]
/** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */
diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx
index 288214ba..25963a37 100644
--- a/src/hooks/useProfileBadges.tsx
+++ b/src/hooks/useProfileBadges.tsx
@@ -1,12 +1,18 @@
import { ExtendedKind } from '@/constants'
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
+import {
+ fetchNip58BadgeAward,
+ fetchNip58BadgeDefinition,
+ mergeNip58BadgeRelayPool
+} from '@/lib/fetch-badge-nip58'
import {
profileAccordionGetCachedBadges,
profileAccordionInvalidate,
profileAccordionRelayUrlsKey,
profileAccordionSetBadges
} from '@/lib/profile-accordion-session-cache'
-import { queryService, replaceableEventService } from '@/services/client.service'
+import { queryService } from '@/services/client.service'
+import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useRef, useState } from 'react'
import { tagNameEquals } from '@/lib/tag'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@@ -25,6 +31,8 @@ export type TProfileBadge = {
thumb?: string
/** From badge definition (NIP-58) */
description?: string
+ /** Kind 8 award `created_at` when loaded */
+ awardCreatedAt?: number
}
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
@@ -33,7 +41,44 @@ function parseATag(aTag: string): { kind: number; pubkey: string; d: string } |
if (parts.length < 3) return null
const kind = parseInt(parts[0], 10)
if (isNaN(kind)) return null
- return { kind, pubkey: parts[1], d: parts[2] }
+ const pk = parts[1]
+ if (!/^[0-9a-fA-F]{64}$/.test(pk)) return null
+ const d = parts.slice(2).join(':')
+ if (!d) return null
+ return { kind, pubkey: pk.toLowerCase(), d }
+}
+
+/** True when we should re-resolve the badge definition (missing media but coordinate looks like kind 30009). */
+function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean {
+ if (b.thumb || b.image) return false
+ const parsed = parseATag(b.a)
+ return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION)
+}
+
+async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise {
+ return Promise.all(
+ badges.map(async (b) => {
+ if (b.thumb || b.image) return b
+ const parsed = parseATag(b.a)
+ if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return b
+ try {
+ const def = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d)
+ if (!def) return b
+ const name = def.tags.find(tagNameEquals('name'))?.[1]
+ const description = def.tags.find(tagNameEquals('description'))?.[1]
+ const media = extractBadgeDefinitionMedia(def)
+ return {
+ ...b,
+ name: name ?? b.name ?? parsed.d,
+ image: media.image,
+ thumb: media.thumb ?? media.image,
+ description: description ?? b.description
+ }
+ } catch {
+ return b
+ }
+ })
+ )
}
/** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */
@@ -63,11 +108,23 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
if (!force) {
const cached = profileAccordionGetCachedBadges(pubkey, relayKey)
- if (cached) {
- if (myFetchId !== fetchIdRef.current) return
- setBadges(cached)
- setLoading(false)
- return
+ if (cached?.length) {
+ if (cached.some(badgeNeedsDefinitionMedia)) {
+ const enriched = await enrichBadgesFromIndexedDb(cached)
+ if (!enriched.some(badgeNeedsDefinitionMedia)) {
+ if (myFetchId !== fetchIdRef.current) return
+ setBadges(enriched)
+ profileAccordionSetBadges(pubkey, relayKey, enriched)
+ setLoading(false)
+ return
+ }
+ // Session cache was incomplete and IndexedDB has no definitions — fetch from network below.
+ } else {
+ if (myFetchId !== fetchIdRef.current) return
+ setBadges(cached)
+ setLoading(false)
+ return
+ }
}
}
@@ -88,12 +145,18 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
}
const tags = profileBadgesEvent.tags
- const pairs: { a: string; e: string }[] = []
+ const pairs: { a: string; e: string; eRelayHint?: string }[] = []
for (let i = 0; i < tags.length - 1; i++) {
- const [tagNameA, aVal] = tags[i]
- const [tagNameE, eVal] = tags[i + 1]
- if (tagNameA === 'a' && tagNameE === 'e' && aVal && eVal && /^[a-f0-9]{64}$/i.test(eVal)) {
- pairs.push({ a: aVal, e: eVal })
+ const ta = tags[i]
+ const te = tags[i + 1]
+ if (
+ ta[0] === 'a' &&
+ te[0] === 'e' &&
+ ta[1] &&
+ te[1] &&
+ /^[a-f0-9]{64}$/i.test(te[1])
+ ) {
+ pairs.push({ a: ta[1], e: te[1], eRelayHint: te[2] })
}
}
@@ -102,38 +165,51 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
return
}
- const result: TProfileBadge[] = []
- for (const { a, e } of pairs) {
- const parsed = parseATag(a)
- if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
- result.push({ a, awardId: e })
- continue
- }
-
- const defEvent = await replaceableEventService.fetchReplaceableEvent(
- parsed.pubkey,
- parsed.kind,
- parsed.d
- )
-
- if (!defEvent) {
- result.push({ a, awardId: e })
- continue
- }
-
- const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
- const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
- const media = extractBadgeDefinitionMedia(defEvent)
-
- result.push({
- a,
- awardId: e,
- name: name ?? parsed.d,
- image: media.image,
- thumb: media.thumb ?? media.image,
- description
+ const result: TProfileBadge[] = await Promise.all(
+ pairs.map(async ({ a, e, eRelayHint }) => {
+ const parsed = parseATag(a)
+ if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
+ return { a, awardId: e }
+ }
+
+ const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays)
+ const [defEvent, awardEvent] = await Promise.all([
+ fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool),
+ fetchNip58BadgeAward(e, relayPool)
+ ])
+
+ const awardATag = awardEvent?.tags.find(tagNameEquals('a'))?.[1]
+ const awardMatchesDefinition = !awardEvent || awardATag === a
+ const awardCreatedAt =
+ awardMatchesDefinition && awardEvent ? awardEvent.created_at : undefined
+
+ if (defEvent) {
+ try {
+ await indexedDb.putReplaceableEvent(defEvent)
+ } catch {
+ // ignore ingest failures (tombstone / validation)
+ }
+ }
+
+ if (!defEvent) {
+ return { a, awardId: e, awardCreatedAt }
+ }
+
+ const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
+ const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
+ const media = extractBadgeDefinitionMedia(defEvent)
+
+ return {
+ a,
+ awardId: e,
+ name: name ?? parsed.d,
+ image: media.image,
+ thumb: media.thumb ?? media.image,
+ description,
+ awardCreatedAt
+ }
})
- }
+ )
if (myFetchId !== fetchIdRef.current) return
setBadges(result)
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index eb202d3c..8acb7947 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -511,6 +511,7 @@ export default {
'No other recipients found': 'No other recipients found',
'Recipients could not be loaded': 'Recipients could not be loaded',
'View award': 'View award',
+ 'Awarded on': 'Awarded on',
'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users',
diff --git a/src/lib/badge-definition-media.ts b/src/lib/badge-definition-media.ts
index 003bc137..b287328f 100644
--- a/src/lib/badge-definition-media.ts
+++ b/src/lib/badge-definition-media.ts
@@ -15,6 +15,23 @@ export function resolveHttpMediaUrl(raw: string | undefined): string | undefined
}
}
+/** NIP-58 allows multiple `thumb` tags; prefer a medium size for grid tiles when dimensions are tagged. */
+function pickThumbFromDefinitionTags(defEvent: Event): string | undefined {
+ const thumbTags = defEvent.tags.filter(tagNameEquals('thumb'))
+ if (thumbTags.length === 0) return undefined
+ const preferredDims = ['256x256', '512x512', '128x128', '64x64', '32x32', '16x16', '1024x1024']
+ for (const dim of preferredDims) {
+ const row = thumbTags.find((t) => t[2] === dim)
+ const u = row && resolveHttpMediaUrl(row[1])
+ if (u) return u
+ }
+ for (const t of thumbTags) {
+ const u = resolveHttpMediaUrl(t[1])
+ if (u) return u
+ }
+ return undefined
+}
+
/** Resolve `image` / `thumb` / `imeta` URLs from a NIP-58 badge definition (kind 30009). */
export function extractBadgeDefinitionMedia(defEvent: Event | undefined): {
image?: string
@@ -22,14 +39,14 @@ export function extractBadgeDefinitionMedia(defEvent: Event | undefined): {
} {
if (!defEvent) return {}
const tagImage = defEvent.tags.find(tagNameEquals('image'))?.[1]
- const tagThumb = defEvent.tags.find(tagNameEquals('thumb'))?.[1]
+ const tagThumb = pickThumbFromDefinitionTags(defEvent)
const imetaUrls = getImetaInfosFromEvent(defEvent)
.map((i) => i.url)
.filter(Boolean) as string[]
- const orderedThumb = [tagThumb, tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
- const orderedImage = [tagImage, tagThumb, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
+ const imageResolved = [tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
+ const thumbResolved = [tagThumb, tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
return {
- thumb: orderedThumb ?? orderedImage,
- image: orderedImage ?? orderedThumb
+ thumb: thumbResolved ?? imageResolved,
+ image: imageResolved ?? thumbResolved
}
}
diff --git a/src/lib/fetch-badge-nip58.ts b/src/lib/fetch-badge-nip58.ts
new file mode 100644
index 00000000..b6ea44a7
--- /dev/null
+++ b/src/lib/fetch-badge-nip58.ts
@@ -0,0 +1,75 @@
+import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants'
+import { normalizeUrl, isWebsocketUrl } from '@/lib/url'
+import { queryService } from '@/services/client.service'
+import type { Event } from 'nostr-tools'
+
+const BADGE_AWARD_KIND = 8
+
+function addRelayUrl(out: Set, raw: string | undefined, blocked: Set) {
+ if (!raw?.trim()) return
+ const n = normalizeUrl(raw.trim()) || raw.trim()
+ if (!n || !isWebsocketUrl(n) || blocked.has(n)) return
+ out.add(n)
+}
+
+/**
+ * Relay pool for NIP-58 definition + award fetches: profile mirrors, optional `e`-tag hint from kind 30008,
+ * then app profile/fast-read fallbacks. Issuer definitions often live off default “fast read” relays only.
+ */
+export function mergeNip58BadgeRelayPool(
+ profileRelayUrls: string[],
+ awardRelayHint: string | undefined,
+ blockedRelays: string[]
+): string[] {
+ const blocked = new Set(blockedRelays.map((u) => normalizeUrl(u) || u).filter(Boolean))
+ const out = new Set()
+ for (const u of profileRelayUrls) addRelayUrl(out, u, blocked)
+ addRelayUrl(out, awardRelayHint, blocked)
+ for (const u of PROFILE_FETCH_RELAY_URLS) addRelayUrl(out, u, blocked)
+ for (const u of FAST_READ_RELAY_URLS) addRelayUrl(out, u, blocked)
+ return [...out]
+}
+
+export async function fetchNip58BadgeDefinition(
+ issuerPubkey: string,
+ dTag: string,
+ relayUrls: string[]
+): Promise {
+ if (!relayUrls.length) return undefined
+ const hexPk = issuerPubkey.toLowerCase()
+ const events = await queryService.fetchEvents(
+ relayUrls,
+ {
+ authors: [hexPk],
+ kinds: [ExtendedKind.BADGE_DEFINITION],
+ '#d': [dTag]
+ },
+ {
+ replaceableRace: true,
+ eoseTimeout: 4000,
+ globalTimeout: 22_000,
+ firstRelayResultGraceMs: false
+ }
+ )
+ const match = events.filter((e) => {
+ if (e.pubkey.toLowerCase() !== hexPk) return false
+ const d = e.tags.find((t) => t[0] === 'd')?.[1]
+ return d === dTag
+ })
+ return match.sort((a, b) => b.created_at - a.created_at)[0]
+}
+
+export async function fetchNip58BadgeAward(awardId: string, relayUrls: string[]): Promise {
+ if (!relayUrls.length || !/^[a-f0-9]{64}$/i.test(awardId)) return undefined
+ const events = await queryService.fetchEvents(
+ relayUrls,
+ { ids: [awardId.toLowerCase()], kinds: [BADGE_AWARD_KIND] },
+ {
+ immediateReturn: true,
+ eoseTimeout: 4000,
+ globalTimeout: 18_000,
+ firstRelayResultGraceMs: false
+ }
+ )
+ return events.find((e) => e.id.toLowerCase() === awardId.toLowerCase())
+}
diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx
index 53561c02..49f662c7 100644
--- a/src/pages/primary/NoteListPage/RelaysFeed.tsx
+++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx
@@ -81,6 +81,17 @@ const RelaysFeed = forwardRef<
feedInfo.feedType === 'all-favorites') &&
relayUrls.length > 0
+ /** Distinguishes home relay chips so we do not keep the previous timeline on single→all-favorites (strict superset). */
+ const feedTimelineScopeKey = useMemo(() => {
+ if (feedInfo.feedType === 'all-favorites') return 'all-favorites'
+ if (feedInfo.feedType === 'relays') return `relays:${feedInfo.id ?? ''}`
+ if (feedInfo.feedType === 'relay') {
+ const id = feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : ''
+ return `relay:${id}`
+ }
+ return undefined
+ }, [feedInfo.feedType, feedInfo.id])
+
// Hooks must run every render — never place useMemo after conditional returns.
const subRequests = useMemo(() => {
if (!canRenderFeed) return []
@@ -98,6 +109,9 @@ const RelaysFeed = forwardRef<
return null
}
+ // preserveTimeline: merge when relay list grows (e.g. all-favorites list fills in). Do not use
+ // mergeTimelineWhenSubRequestFiltersMatch here — same kinds + different URLs would keep the old
+ // timeline when switching home feed chips (all-favorites ↔ set ↔ single relay).
return (
)
})