From 32b4e21fcbf85f71f0d6419c1f47b655cacee688 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 8 May 2026 20:31:44 +0200 Subject: [PATCH] bug-fixes --- package-lock.json | 10 +- package.json | 2 +- src/components/MediaGridItem/index.tsx | 20 +++- src/components/NormalFeed/index.tsx | 92 +++++++++++++------ .../Note/MarkdownArticle/MarkdownArticle.tsx | 2 +- src/components/Note/index.tsx | 3 + .../NoteList/VirtualizedFeedRows.tsx | 10 +- src/components/NoteList/index.tsx | 40 ++++++-- src/components/ParentNotePreview/index.tsx | 11 ++- src/components/ReplyNote/index.tsx | 3 + src/components/ReplyNoteList/index.tsx | 3 +- src/components/WebPreview/index.tsx | 10 +- src/hooks/useFetchWebMetadata.tsx | 14 ++- src/hooks/useNotificationReactionDisplay.ts | 13 ++- src/pages/secondary/NotePage/index.tsx | 13 ++- src/services/media-extraction.service.ts | 9 +- 16 files changed, 181 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f7f841e..6a4f0abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.7.0", + "version": "23.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.7.0", + "version": "23.7.1", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", @@ -9567,9 +9567,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index f93be2df..a683d2de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.7.0", + "version": "23.7.1", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/MediaGridItem/index.tsx b/src/components/MediaGridItem/index.tsx index 0e0b060f..81dbbd81 100644 --- a/src/components/MediaGridItem/index.tsx +++ b/src/components/MediaGridItem/index.tsx @@ -1,10 +1,10 @@ -import { isNip71StyleVideoKind } from '@/constants' +import { ExtendedKind, isNip71StyleVideoKind } from '@/constants' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { toNote } from '@/lib/link' import client from '@/services/client.service' import { extractAllMediaFromEvent } from '@/services/media-extraction.service' import { useSmartNoteNavigationOptional } from '@/PageManager' -import { Images, Music, Play } from 'lucide-react' +import { Image as ImageIcon, Images, Music, Play } from 'lucide-react' import { type Event } from 'nostr-tools' import { useMemo } from 'react' @@ -14,8 +14,12 @@ export default function MediaGridItem({ event }: { event: Event }) { const media = useMemo(() => extractAllMediaFromEvent(event), [event]) const first = media.all[0] - const isVideo = first?.m?.startsWith('video/') || isNip71StyleVideoKind(event.kind) - const isAudio = first?.m?.startsWith('audio/') || event.kind === 1222 + /** Kind 20 is always treated as image unless imeta explicitly says video (rare mis-tag). */ + const isPictureKind = event.kind === ExtendedKind.PICTURE + const isVideo = + (!isPictureKind && first?.m?.startsWith('video/')) || + (!isPictureKind && isNip71StyleVideoKind(event.kind)) + const isAudio = first?.m?.startsWith('audio/') || event.kind === ExtendedKind.VOICE const hasMultiple = media.all.length > 1 // For videos prefer the poster image; fall back to video URL (browser extracts frame) @@ -51,7 +55,13 @@ export default function MediaGridItem({ event }: { event: Event }) { ) ) : (
- {isAudio ? : } + {isAudio ? ( + + ) : isVideo ? ( + + ) : ( + + )}
)} diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx index 562cc42f..ff244c8c 100644 --- a/src/components/NormalFeed/index.tsx +++ b/src/components/NormalFeed/index.tsx @@ -5,11 +5,21 @@ import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useUserTrust } from '@/contexts/user-trust-context' import storage from '@/services/local-storage.service' import { PROFILE_MEDIA_TAB_KINDS } from '@/constants' +import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import type { TPrimaryPageName } from '@/PageManager' import { TFeedSubRequest, TNoteListMode } from '@/types' import { cn } from '@/lib/utils' import type { Event } from 'nostr-tools' -import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode +} from 'react' import KindFilter from '../KindFilter' const NormalFeed = forwardRef [...PROFILE_MEDIA_TAB_KINDS], []) - const tabs = useMemo( - (): TabDefinition[] => { - const base: TabDefinition[] = [ - { value: 'posts', label: 'Notes' }, - { value: 'postsAndReplies', label: 'Replies' } - ] - if (isMainFeed) base.push({ value: 'media', label: 'Gallery' }) - return base - }, - [isMainFeed] + /** Every shard URL is a nostrarchives Wisp “trending notes” stream — replies/gallery tabs are not applicable. */ + const isWispTrendingOnlyFeed = useMemo( + () => + subRequests.length > 0 && + subRequests.every( + (req) => req.urls.length > 0 && req.urls.every((u) => isWispTrendingNotesRelayUrl(u)) + ), + [subRequests] ) + useEffect(() => { + if (!isWispTrendingOnlyFeed) return + setListMode((m) => (m === 'posts' ? m : 'posts')) + }, [isWispTrendingOnlyFeed]) + + const tabs = useMemo((): TabDefinition[] => { + if (isMainFeed && isWispTrendingOnlyFeed) { + return [{ value: 'posts', label: 'Notes' }] + } + const base: TabDefinition[] = [ + { value: 'posts', label: 'Notes' }, + { value: 'postsAndReplies', label: 'Replies' } + ] + if (isMainFeed) base.push({ value: 'media', label: 'Gallery' }) + return base + }, [isMainFeed, isWispTrendingOnlyFeed]) + /** When in media mode, replace each shard's kinds with the media set. */ const effectiveSubRequests = useMemo(() => { if (listMode !== 'media') return subRequests @@ -183,29 +208,37 @@ const NormalFeed = forwardRef ( + /** Notes / Replies / Gallery switch, plus refresh + kind filter — on Wisp trending only the tool row (no mode tabs). */ + const tabsElement = useMemo(() => { + const kindRowOptions = ( +
+ {onSubHeaderRefresh != null && } + +
+ ) + if (isMainFeed && isWispTrendingOnlyFeed) { + return ( +
{kindRowOptions}
+ ) + } + return ( - {onSubHeaderRefresh != null && } - - - } + options={kindRowOptions} /> - ), - [ - listMode, - tabs, - handleListModeChange, - showKinds, - onSubHeaderRefresh, - handleShowKindsChange - ] - ) + ) + }, [ + isMainFeed, + isWispTrendingOnlyFeed, + listMode, + tabs, + handleListModeChange, + showKinds, + onSubHeaderRefresh, + handleShowKindsChange + ]) const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore @@ -236,6 +269,7 @@ const NormalFeed = forwardRef resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, { resolveFromExtractedMedia: resolveImetaForImageUrl, diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index b022756c..9e22a60e 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -11,6 +11,7 @@ import { import { shouldHideInteractions } from '@/lib/event-filtering' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' +import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { @@ -135,6 +136,7 @@ export default function Note({ () => (hideParentNotePreview ? undefined : getParentBech32Id(event)), [event, hideParentNotePreview] ) + const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event]) const contentPolicy = useContentPolicyOptional() const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true @@ -600,6 +602,7 @@ export default function Note({ ) : parentEventId ? ( { e.stopPropagation() diff --git a/src/components/NoteList/VirtualizedFeedRows.tsx b/src/components/NoteList/VirtualizedFeedRows.tsx index d8236744..fb7715ea 100644 --- a/src/components/NoteList/VirtualizedFeedRows.tsx +++ b/src/components/NoteList/VirtualizedFeedRows.tsx @@ -34,13 +34,14 @@ const WindowRows = memo(function WindowRows({ estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), overscan: VIRTUAL_OVERSCAN, scrollMargin: scrollMarginTop, + // Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state). getItemKey: (index) => - gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}` + gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`) }) return (
{virtualizer.getVirtualItems().map((vi) => ( @@ -86,13 +87,14 @@ const ElementRows = memo(function ElementRows({ getScrollElement: () => scrollElement, estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), overscan: VIRTUAL_OVERSCAN, + // Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state). getItemKey: (index) => - gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}` + gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`) }) return (
{virtualizer.getVirtualItems().map((vi) => ( diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 9b91c1b3..acbf32dd 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1438,6 +1438,8 @@ const NoteList = forwardRef( const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState(null) const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0) + /** Last applied scroll port — skip redundant setState when RO fires on every row/media resize (fixes feed “shake”). */ + const lastFeedScrollPortRef = useRef<{ parent: HTMLElement | null; marginTop: number } | null>(null) /** * Resolve the scroll container once per feed / refresh — not on every {@link clientFilteredEvents} length tick. * Re-running this on each timeline merge re-set scroll state and interacted badly with the virtualizer while rows @@ -1445,19 +1447,33 @@ const NoteList = forwardRef( */ useLayoutEffect(() => { let alive = true + let resizeCoalesceRaf = 0 + const applyFeedScrollPort = () => { if (!alive) return const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current) if (!anchor) { - setFeedVirtualScrollParent(null) - setFeedVirtualScrollMarginTop(0) + const last = lastFeedScrollPortRef.current + if (!last || last.parent !== null || last.marginTop !== 0) { + lastFeedScrollPortRef.current = { parent: null, marginTop: 0 } + setFeedVirtualScrollParent(null) + setFeedVirtualScrollMarginTop(0) + } return } const layoutEl = primaryScrollAreaRef?.current ?? null - setFeedVirtualScrollParent(resolvePrimaryFeedScrollPort(layoutEl, anchor)) - setFeedVirtualScrollMarginTop(anchor.offsetTop) + const nextParent = resolvePrimaryFeedScrollPort(layoutEl, anchor) + const nextMargin = Math.round(anchor.offsetTop) + const last = lastFeedScrollPortRef.current + if (last && last.parent === nextParent && last.marginTop === nextMargin) { + return + } + lastFeedScrollPortRef.current = { parent: nextParent, marginTop: nextMargin } + setFeedVirtualScrollParent(nextParent) + setFeedVirtualScrollMarginTop(nextMargin) } + lastFeedScrollPortRef.current = null applyFeedScrollPort() let innerRaf = 0 const outerRaf = requestAnimationFrame(() => { @@ -1473,18 +1489,28 @@ const NoteList = forwardRef( applyFeedScrollPort() }, 0) + const scheduleApplyFromResize = () => { + if (!alive) return + if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf) + resizeCoalesceRaf = requestAnimationFrame(() => { + resizeCoalesceRaf = 0 + if (!alive) return + applyFeedScrollPort() + }) + } + let ro: ResizeObserver | null = null const root = feedRootRef.current if (root && typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(() => { - if (!alive) return - applyFeedScrollPort() + scheduleApplyFromResize() }) ro.observe(root) } return () => { alive = false + if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf) cancelAnimationFrame(outerRaf) cancelAnimationFrame(innerRaf) window.clearTimeout(deferTimer) @@ -3863,7 +3889,7 @@ const NoteList = forwardRef( }, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick]) const list = ( -
+
{relayWavePendingBannerEl} {feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
diff --git a/src/components/ParentNotePreview/index.tsx b/src/components/ParentNotePreview/index.tsx index 848599e9..6e7bd274 100644 --- a/src/components/ParentNotePreview/index.tsx +++ b/src/components/ParentNotePreview/index.tsx @@ -4,7 +4,7 @@ import { useFetchEvent } from '@/hooks' import { cn } from '@/lib/utils' import client from '@/services/client.service' import { useTranslation } from 'react-i18next' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event } from 'nostr-tools' import ContentPreview from '../ContentPreview' import UserAvatar from '../UserAvatar' @@ -14,16 +14,23 @@ export default function ParentNotePreview({ eventId, className, onClick, + /** NIP-10 `e` relay hints from the child note — speeds up parent fetch in notifications and feeds. */ + relayHints, /** Inline hint without pill background (e.g. reply thread rows). */ appearance = 'default' }: { eventId: string className?: string onClick?: React.MouseEventHandler | undefined + relayHints?: string[] appearance?: 'default' | 'subtle' }) { const { t } = useTranslation() - const { event, isFetching } = useFetchEvent(eventId) + const fetchOpts = useMemo( + () => (relayHints?.length ? { relayHints } : undefined), + [relayHints] + ) + const { event, isFetching } = useFetchEvent(eventId, undefined, fetchOpts) const [fallbackEvent, setFallbackEvent] = useState(undefined) const [isFetchingFallback, setIsFetchingFallback] = useState(false) /** One automatic searchable-relay attempt per eventId; without this, the effect re-fires forever after each 20s timeout. */ diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index f7097860..b9eb7dd4 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -14,6 +14,7 @@ import { import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event' import { getWebExternalReactionTargetUrl } from '@/lib/rss-article' +import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -68,6 +69,7 @@ export default function ReplyNote({ event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined, [event] ) + const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event]) const headerUserId = useMemo(() => { if (event.kind !== kinds.Zap) return event.pubkey const info = getZapInfoFromEvent(event) @@ -155,6 +157,7 @@ export default function ReplyNote({ appearance="subtle" className="mt-1.5" eventId={parentEventId} + relayHints={parentFetchRelayHints} onClick={(e) => { e.stopPropagation() onClickParent() diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 87a12a39..e00bf82f 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -88,7 +88,8 @@ function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_TH } return out } -const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 50 +/** Short debounce so thread / detail headers populate avatars quickly after events arrive. */ +const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 16 const THREAD_PROFILE_CHUNK = 80 function partitionZapReceipts(items: NEvent[]) { diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index a8edead7..fed9a3f9 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -138,7 +138,10 @@ export default function WebPreview({ url, className }: { url: string; className? const { isSmallScreen } = useScreenSize() const cleanedUrl = useMemo(() => cleanUrl(url), [url]) - const { title, description, image, ogLoading } = useFetchWebMetadata(cleanedUrl) + /** Link cards and URLs in highlights stay visible on cellular; OG fetch is gated by the same policy as heavy media. */ + const { title, description, image, ogLoading } = useFetchWebMetadata(cleanedUrl, { + fetchEnabled: autoLoadMedia + }) const hostname = useMemo(() => { try { @@ -459,11 +462,6 @@ export default function WebPreview({ url, className }: { url: string; className? img.src = image }, [image]) - // Early return after ALL hooks are called - if (!autoLoadMedia) { - return null - } - // Prefer the page's own Open Graph / meta when the fetch returns anything useful. const hasOpengraphData = !isInternalAppLink && (title || description || image) diff --git a/src/hooks/useFetchWebMetadata.tsx b/src/hooks/useFetchWebMetadata.tsx index 3dd2d5a5..efb52fda 100644 --- a/src/hooks/useFetchWebMetadata.tsx +++ b/src/hooks/useFetchWebMetadata.tsx @@ -4,12 +4,18 @@ import webService from '@/services/web.service' import logger from '@/lib/logger' import { isLikelyWebPageUrl } from '@/lib/url' -export function useFetchWebMetadata(url: string) { +export function useFetchWebMetadata( + url: string, + options?: { /** When false, skip OG fetch (e.g. cellular + “Wi‑Fi only” media policy); caller still renders a link card. */ fetchEnabled?: boolean } +) { + const fetchEnabled = options?.fetchEnabled !== false const [metadata, setMetadata] = useState({}) - const [ogLoading, setOgLoading] = useState(() => Boolean(url && isLikelyWebPageUrl(url))) + const [ogLoading, setOgLoading] = useState(() => + Boolean(fetchEnabled && url && isLikelyWebPageUrl(url)) + ) useEffect(() => { - if (!url || !isLikelyWebPageUrl(url)) { + if (!fetchEnabled || !url || !isLikelyWebPageUrl(url)) { setMetadata({}) setOgLoading(false) return @@ -31,7 +37,7 @@ export function useFetchWebMetadata(url: string) { .finally(() => { setOgLoading(false) }) - }, [url]) + }, [url, fetchEnabled]) return { ...metadata, ogLoading } } diff --git a/src/hooks/useNotificationReactionDisplay.ts b/src/hooks/useNotificationReactionDisplay.ts index 3a0bb688..d4101372 100644 --- a/src/hooks/useNotificationReactionDisplay.ts +++ b/src/hooks/useNotificationReactionDisplay.ts @@ -4,6 +4,7 @@ import { isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { getRootEventHexId } from '@/lib/event' +import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { getFirstHexEventIdFromETags } from '@/lib/tag' import { eventService } from '@/services/client.service' import { Event, kinds } from 'nostr-tools' @@ -26,6 +27,8 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti return getFirstHexEventIdFromETags(event.tags) }, [event.kind, event.tags]) + const reactionRelayHints = useMemo(() => relayHintsFromEventTags(event), [event]) + const [state, setState] = useState(() => event.kind === kinds.Reaction ? { status: 'pending' } : { status: 'default' } ) @@ -47,8 +50,10 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti let cancelled = false setState({ status: 'pending' }) + const fetchOpts = reactionRelayHints.length ? { relayHints: reactionRelayHints } : undefined + ;(async () => { - const target = await eventService.fetchEvent(targetId) + const target = await eventService.fetchEvent(targetId, fetchOpts) if (cancelled) return if (!target) { setState({ status: 'default' }) @@ -59,7 +64,9 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti if (!inDiscussion && target.kind === ExtendedKind.COMMENT) { const rootId = getRootEventHexId(target) if (rootId) { - const root = await eventService.fetchEvent(rootId) + const rootHints = relayHintsFromEventTags(target) + const rootOpts = rootHints.length ? { relayHints: rootHints } : fetchOpts + const root = await eventService.fetchEvent(rootId, rootOpts) if (cancelled) return inDiscussion = root?.kind === ExtendedKind.DISCUSSION } @@ -83,7 +90,7 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti return () => { cancelled = true } - }, [event.id, event.kind, event.content, targetId]) + }, [event.id, event.kind, event.content, targetId, reactionRelayHints]) return state } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index f02840b6..e29a122f 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -26,6 +26,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { stripMarkupForPreview } from '@/lib/parent-reply-blurb' import { tagNameEquals } from '@/lib/tag' +import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' import type { Event } from 'nostr-tools' @@ -111,10 +112,18 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: () => (finalEvent?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined), [finalEvent] ) + const threadRelayHints = useMemo( + () => (finalEvent ? relayHintsFromEventTags(finalEvent) : []), + [finalEvent] + ) + const parentRootFetchOpts = useMemo( + () => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined), + [threadRelayHints] + ) const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } = - useFetchEvent(rootEventId) + useFetchEvent(rootEventId, undefined, parentRootFetchOpts) const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = - useFetchEvent(parentEventId) + useFetchEvent(parentEventId, undefined, parentRootFetchOpts) const selfHex = finalEvent?.id?.toLowerCase() const rootEventForStrip = diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts index 05a1cb9b..a77bdc5c 100644 --- a/src/services/media-extraction.service.ts +++ b/src/services/media-extraction.service.ts @@ -25,6 +25,7 @@ export function extractAllMediaFromEvent( event: Event, content?: string ): ExtractedMedia { + const textBody = content ?? event.content ?? '' const seenUrls = new Set() const allMedia: TImetaInfo[] = [] @@ -124,13 +125,13 @@ export function extractAllMediaFromEvent( } }) - // 4. Extract from content (if provided) - if (content) { + // 4. Extract from note content (plain URLs, markdown images) — callers may omit `content`; default to `event.content`. + if (textBody) { // First, extract from markdown image syntax: ![alt](url) or [![](url)](link) // This handles images inside links const markdownImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g let imgMatch - while ((imgMatch = markdownImageRegex.exec(content)) !== null) { + while ((imgMatch = markdownImageRegex.exec(textBody)) !== null) { if (imgMatch[1]) { const url = imgMatch[1] if (isEmbeddableMediaUrl(cleanUrl(url) || url)) { @@ -141,7 +142,7 @@ export function extractAllMediaFromEvent( // Then extract directly from raw content (catch any URLs that weren't parsed) const urlRegex = /https?:\/\/[^\s<>"']+/g - const urlMatches = content.matchAll(urlRegex) + const urlMatches = textBody.matchAll(urlRegex) for (const match of urlMatches) { const url = match[0] const c = cleanUrl(url) || url