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 ? ( +
+ {title} 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 ( ) })