Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
32b4e21fcb
  1. 10
      package-lock.json
  2. 2
      package.json
  3. 20
      src/components/MediaGridItem/index.tsx
  4. 68
      src/components/NormalFeed/index.tsx
  5. 2
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 3
      src/components/Note/index.tsx
  7. 10
      src/components/NoteList/VirtualizedFeedRows.tsx
  8. 36
      src/components/NoteList/index.tsx
  9. 11
      src/components/ParentNotePreview/index.tsx
  10. 3
      src/components/ReplyNote/index.tsx
  11. 3
      src/components/ReplyNoteList/index.tsx
  12. 10
      src/components/WebPreview/index.tsx
  13. 14
      src/hooks/useFetchWebMetadata.tsx
  14. 13
      src/hooks/useNotificationReactionDisplay.ts
  15. 13
      src/pages/secondary/NotePage/index.tsx
  16. 9
      src/services/media-extraction.service.ts

10
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.7.0", "version": "23.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.7.0", "version": "23.7.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -9567,9 +9567,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

20
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 { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import client from '@/services/client.service' import client from '@/services/client.service'
import { extractAllMediaFromEvent } from '@/services/media-extraction.service' import { extractAllMediaFromEvent } from '@/services/media-extraction.service'
import { useSmartNoteNavigationOptional } from '@/PageManager' 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 { type Event } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -14,8 +14,12 @@ export default function MediaGridItem({ event }: { event: Event }) {
const media = useMemo(() => extractAllMediaFromEvent(event), [event]) const media = useMemo(() => extractAllMediaFromEvent(event), [event])
const first = media.all[0] const first = media.all[0]
const isVideo = first?.m?.startsWith('video/') || isNip71StyleVideoKind(event.kind) /** Kind 20 is always treated as image unless imeta explicitly says video (rare mis-tag). */
const isAudio = first?.m?.startsWith('audio/') || event.kind === 1222 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 const hasMultiple = media.all.length > 1
// For videos prefer the poster image; fall back to video URL (browser extracts frame) // 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 }) {
) )
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground/40"> <div className="flex h-full w-full items-center justify-center text-muted-foreground/40">
{isAudio ? <Music className="size-8" /> : <Play className="size-8" />} {isAudio ? (
<Music className="size-8" />
) : isVideo ? (
<Play className="size-8" />
) : (
<ImageIcon className="size-8" />
)}
</div> </div>
)} )}

68
src/components/NormalFeed/index.tsx

@ -5,11 +5,21 @@ import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { PROFILE_MEDIA_TAB_KINDS } from '@/constants' import { PROFILE_MEDIA_TAB_KINDS } from '@/constants'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools' 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' import KindFilter from '../KindFilter'
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
@ -122,17 +132,32 @@ const NormalFeed = forwardRef<TNoteListRef, {
const MEDIA_KINDS = useMemo(() => [...PROFILE_MEDIA_TAB_KINDS], []) const MEDIA_KINDS = useMemo(() => [...PROFILE_MEDIA_TAB_KINDS], [])
const tabs = useMemo( /** Every shard URL is a nostrarchives Wisp “trending notes” stream — replies/gallery tabs are not applicable. */
(): TabDefinition[] => { 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[] = [ const base: TabDefinition[] = [
{ value: 'posts', label: 'Notes' }, { value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' } { value: 'postsAndReplies', label: 'Replies' }
] ]
if (isMainFeed) base.push({ value: 'media', label: 'Gallery' }) if (isMainFeed) base.push({ value: 'media', label: 'Gallery' })
return base return base
}, }, [isMainFeed, isWispTrendingOnlyFeed])
[isMainFeed]
)
/** When in media mode, replace each shard's kinds with the media set. */ /** When in media mode, replace each shard's kinds with the media set. */
const effectiveSubRequests = useMemo(() => { const effectiveSubRequests = useMemo(() => {
@ -183,29 +208,37 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */ /** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}` const subHeaderFilterDepsKey = `${allowKindlessRelayExplore ? 'kle' : 'std'}|${showKindsKey}|${feedKindFilterBypass}|${showAllKindsProp ? 'allProp' : 'k'}`
const tabsElement = useMemo( /** Notes / Replies / Gallery switch, plus refresh + kind filter — on Wisp trending only the tool row (no mode tabs). */
() => ( const tabsElement = useMemo(() => {
<Tabs const kindRowOptions = (
value={listMode}
tabs={tabs}
onTabChange={handleListModeChange}
options={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />} {onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} /> <KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} />
</div> </div>
)
if (isMainFeed && isWispTrendingOnlyFeed) {
return (
<div className="flex w-full min-w-0 items-center justify-end gap-1 py-1">{kindRowOptions}</div>
)
} }
return (
<Tabs
value={listMode}
tabs={tabs}
onTabChange={handleListModeChange}
options={kindRowOptions}
/> />
), )
[ }, [
isMainFeed,
isWispTrendingOnlyFeed,
listMode, listMode,
tabs, tabs,
handleListModeChange, handleListModeChange,
showKinds, showKinds,
onSubHeaderRefresh, onSubHeaderRefresh,
handleShowKindsChange handleShowKindsChange
] ])
)
const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore const renderTabsInFeed = !(isMainFeed && setSubHeader) && !allowKindlessRelayExplore
@ -236,6 +269,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
isMainFeed, isMainFeed,
setSubHeader, setSubHeader,
listMode, listMode,
isWispTrendingOnlyFeed,
subHeaderFilterDepsKey, subHeaderFilterDepsKey,
onSubHeaderRefresh, onSubHeaderRefresh,
allowKindlessRelayExplore, allowKindlessRelayExplore,

2
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -3224,7 +3224,7 @@ function parseMarkdownContentMarked(
} = options } = options
const emojiLightbox: TInlineEmojiLightbox = { imageIndexMap, openLightbox } const emojiLightbox: TInlineEmojiLightbox = { imageIndexMap, openLightbox }
/** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */ /** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview skips OG fetch when autoLoadMedia is off but still shows a link card. */
const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo => const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo =>
resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, { resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, {
resolveFromExtractedMedia: resolveImetaForImageUrl, resolveFromExtractedMedia: resolveImetaForImageUrl,

3
src/components/Note/index.tsx

@ -11,6 +11,7 @@ import {
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
@ -135,6 +136,7 @@ export default function Note({
() => (hideParentNotePreview ? undefined : getParentBech32Id(event)), () => (hideParentNotePreview ? undefined : getParentBech32Id(event)),
[event, hideParentNotePreview] [event, hideParentNotePreview]
) )
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
@ -600,6 +602,7 @@ export default function Note({
) : parentEventId ? ( ) : parentEventId ? (
<ParentNotePreview <ParentNotePreview
eventId={parentEventId} eventId={parentEventId}
relayHints={parentFetchRelayHints}
className="mt-2" className="mt-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()

10
src/components/NoteList/VirtualizedFeedRows.tsx

@ -34,13 +34,14 @@ const WindowRows = memo(function WindowRows({
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
overscan: VIRTUAL_OVERSCAN, overscan: VIRTUAL_OVERSCAN,
scrollMargin: scrollMarginTop, scrollMargin: scrollMarginTop,
// Stable keys by event id so prepending new feed rows does not remount existing rows (e.g. reply editor state).
getItemKey: (index) => getItemKey: (index) =>
gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}` gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`)
}) })
return ( return (
<div <div
className="relative isolate min-h-0 w-full overflow-x-hidden" className="relative isolate min-h-0 w-full overflow-x-hidden [contain:layout]"
style={{ height: virtualizer.getTotalSize() }} style={{ height: virtualizer.getTotalSize() }}
> >
{virtualizer.getVirtualItems().map((vi) => ( {virtualizer.getVirtualItems().map((vi) => (
@ -86,13 +87,14 @@ const ElementRows = memo(function ElementRows({
getScrollElement: () => scrollElement, getScrollElement: () => scrollElement,
estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX), estimateSize: () => (gridLayout ? ESTIMATE_GRID_ROW_PX : ESTIMATE_NOTE_ROW_PX),
overscan: VIRTUAL_OVERSCAN, 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) => getItemKey: (index) =>
gridLayout ? `grid-${index}` : `${events[index]?.id ?? 'row'}@${index}` gridLayout ? `grid-${index}` : (events[index]?.id ?? `row-${index}`)
}) })
return ( return (
<div <div
className="relative isolate min-h-0 w-full overflow-x-hidden" className="relative isolate min-h-0 w-full overflow-x-hidden [contain:layout]"
style={{ height: virtualizer.getTotalSize() }} style={{ height: virtualizer.getTotalSize() }}
> >
{virtualizer.getVirtualItems().map((vi) => ( {virtualizer.getVirtualItems().map((vi) => (

36
src/components/NoteList/index.tsx

@ -1438,6 +1438,8 @@ const NoteList = forwardRef(
const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(null) const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(null)
const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0) 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. * 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 * 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(() => { useLayoutEffect(() => {
let alive = true let alive = true
let resizeCoalesceRaf = 0
const applyFeedScrollPort = () => { const applyFeedScrollPort = () => {
if (!alive) return if (!alive) return
const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current) const anchor = resolveFeedVirtualScrollAnchor(feedRootRef.current, feedListScrollAnchorRef.current)
if (!anchor) { if (!anchor) {
const last = lastFeedScrollPortRef.current
if (!last || last.parent !== null || last.marginTop !== 0) {
lastFeedScrollPortRef.current = { parent: null, marginTop: 0 }
setFeedVirtualScrollParent(null) setFeedVirtualScrollParent(null)
setFeedVirtualScrollMarginTop(0) setFeedVirtualScrollMarginTop(0)
}
return return
} }
const layoutEl = primaryScrollAreaRef?.current ?? null const layoutEl = primaryScrollAreaRef?.current ?? null
setFeedVirtualScrollParent(resolvePrimaryFeedScrollPort(layoutEl, anchor)) const nextParent = resolvePrimaryFeedScrollPort(layoutEl, anchor)
setFeedVirtualScrollMarginTop(anchor.offsetTop) 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() applyFeedScrollPort()
let innerRaf = 0 let innerRaf = 0
const outerRaf = requestAnimationFrame(() => { const outerRaf = requestAnimationFrame(() => {
@ -1473,18 +1489,28 @@ const NoteList = forwardRef(
applyFeedScrollPort() applyFeedScrollPort()
}, 0) }, 0)
const scheduleApplyFromResize = () => {
if (!alive) return
if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf)
resizeCoalesceRaf = requestAnimationFrame(() => {
resizeCoalesceRaf = 0
if (!alive) return
applyFeedScrollPort()
})
}
let ro: ResizeObserver | null = null let ro: ResizeObserver | null = null
const root = feedRootRef.current const root = feedRootRef.current
if (root && typeof ResizeObserver !== 'undefined') { if (root && typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => { ro = new ResizeObserver(() => {
if (!alive) return scheduleApplyFromResize()
applyFeedScrollPort()
}) })
ro.observe(root) ro.observe(root)
} }
return () => { return () => {
alive = false alive = false
if (resizeCoalesceRaf) cancelAnimationFrame(resizeCoalesceRaf)
cancelAnimationFrame(outerRaf) cancelAnimationFrame(outerRaf)
cancelAnimationFrame(innerRaf) cancelAnimationFrame(innerRaf)
window.clearTimeout(deferTimer) window.clearTimeout(deferTimer)
@ -3863,7 +3889,7 @@ const NoteList = forwardRef(
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick]) }, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick])
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-0 w-full">
{relayWavePendingBannerEl} {relayWavePendingBannerEl}
{feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? ( {feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
<div className="px-2 py-8 text-center text-sm text-muted-foreground"> <div className="px-2 py-8 text-center text-sm text-muted-foreground">

11
src/components/ParentNotePreview/index.tsx

@ -4,7 +4,7 @@ import { useFetchEvent } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import client from '@/services/client.service' import client from '@/services/client.service'
import { useTranslation } from 'react-i18next' 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 { Event } from 'nostr-tools'
import ContentPreview from '../ContentPreview' import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
@ -14,16 +14,23 @@ export default function ParentNotePreview({
eventId, eventId,
className, className,
onClick, 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). */ /** Inline hint without pill background (e.g. reply thread rows). */
appearance = 'default' appearance = 'default'
}: { }: {
eventId: string eventId: string
className?: string className?: string
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
relayHints?: string[]
appearance?: 'default' | 'subtle' appearance?: 'default' | 'subtle'
}) { }) {
const { t } = useTranslation() 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<Event | undefined>(undefined) const [fallbackEvent, setFallbackEvent] = useState<Event | undefined>(undefined)
const [isFetchingFallback, setIsFetchingFallback] = useState(false) const [isFetchingFallback, setIsFetchingFallback] = useState(false)
/** One automatic searchable-relay attempt per eventId; without this, the effect re-fires forever after each 20s timeout. */ /** One automatic searchable-relay attempt per eventId; without this, the effect re-fires forever after each 20s timeout. */

3
src/components/ReplyNote/index.tsx

@ -14,6 +14,7 @@ import {
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event' import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event'
import { getWebExternalReactionTargetUrl } from '@/lib/rss-article' import { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -68,6 +69,7 @@ export default function ReplyNote({
event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined, event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined,
[event] [event]
) )
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const headerUserId = useMemo(() => { const headerUserId = useMemo(() => {
if (event.kind !== kinds.Zap) return event.pubkey if (event.kind !== kinds.Zap) return event.pubkey
const info = getZapInfoFromEvent(event) const info = getZapInfoFromEvent(event)
@ -155,6 +157,7 @@ export default function ReplyNote({
appearance="subtle" appearance="subtle"
className="mt-1.5" className="mt-1.5"
eventId={parentEventId} eventId={parentEventId}
relayHints={parentFetchRelayHints}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onClickParent() onClickParent()

3
src/components/ReplyNoteList/index.tsx

@ -88,7 +88,8 @@ function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_TH
} }
return out 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 const THREAD_PROFILE_CHUNK = 80
function partitionZapReceipts(items: NEvent[]) { function partitionZapReceipts(items: NEvent[]) {

10
src/components/WebPreview/index.tsx

@ -138,7 +138,10 @@ export default function WebPreview({ url, className }: { url: string; className?
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const cleanedUrl = useMemo(() => cleanUrl(url), [url]) 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(() => { const hostname = useMemo(() => {
try { try {
@ -459,11 +462,6 @@ export default function WebPreview({ url, className }: { url: string; className?
img.src = image img.src = image
}, [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. // Prefer the page's own Open Graph / meta when the fetch returns anything useful.
const hasOpengraphData = !isInternalAppLink && (title || description || image) const hasOpengraphData = !isInternalAppLink && (title || description || image)

14
src/hooks/useFetchWebMetadata.tsx

@ -4,12 +4,18 @@ import webService from '@/services/web.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isLikelyWebPageUrl } from '@/lib/url' 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<TWebMetadata>({}) const [metadata, setMetadata] = useState<TWebMetadata>({})
const [ogLoading, setOgLoading] = useState(() => Boolean(url && isLikelyWebPageUrl(url))) const [ogLoading, setOgLoading] = useState(() =>
Boolean(fetchEnabled && url && isLikelyWebPageUrl(url))
)
useEffect(() => { useEffect(() => {
if (!url || !isLikelyWebPageUrl(url)) { if (!fetchEnabled || !url || !isLikelyWebPageUrl(url)) {
setMetadata({}) setMetadata({})
setOgLoading(false) setOgLoading(false)
return return
@ -31,7 +37,7 @@ export function useFetchWebMetadata(url: string) {
.finally(() => { .finally(() => {
setOgLoading(false) setOgLoading(false)
}) })
}, [url]) }, [url, fetchEnabled])
return { ...metadata, ogLoading } return { ...metadata, ogLoading }
} }

13
src/hooks/useNotificationReactionDisplay.ts

@ -4,6 +4,7 @@ import {
isDiscussionUpvoteEmoji isDiscussionUpvoteEmoji
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { getRootEventHexId } from '@/lib/event' import { getRootEventHexId } from '@/lib/event'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { getFirstHexEventIdFromETags } from '@/lib/tag' import { getFirstHexEventIdFromETags } from '@/lib/tag'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
@ -26,6 +27,8 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
return getFirstHexEventIdFromETags(event.tags) return getFirstHexEventIdFromETags(event.tags)
}, [event.kind, event.tags]) }, [event.kind, event.tags])
const reactionRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const [state, setState] = useState<NotificationReactionDisplay>(() => const [state, setState] = useState<NotificationReactionDisplay>(() =>
event.kind === kinds.Reaction ? { status: 'pending' } : { status: 'default' } event.kind === kinds.Reaction ? { status: 'pending' } : { status: 'default' }
) )
@ -47,8 +50,10 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
let cancelled = false let cancelled = false
setState({ status: 'pending' }) setState({ status: 'pending' })
const fetchOpts = reactionRelayHints.length ? { relayHints: reactionRelayHints } : undefined
;(async () => { ;(async () => {
const target = await eventService.fetchEvent(targetId) const target = await eventService.fetchEvent(targetId, fetchOpts)
if (cancelled) return if (cancelled) return
if (!target) { if (!target) {
setState({ status: 'default' }) setState({ status: 'default' })
@ -59,7 +64,9 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
if (!inDiscussion && target.kind === ExtendedKind.COMMENT) { if (!inDiscussion && target.kind === ExtendedKind.COMMENT) {
const rootId = getRootEventHexId(target) const rootId = getRootEventHexId(target)
if (rootId) { 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 if (cancelled) return
inDiscussion = root?.kind === ExtendedKind.DISCUSSION inDiscussion = root?.kind === ExtendedKind.DISCUSSION
} }
@ -83,7 +90,7 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti
return () => { return () => {
cancelled = true cancelled = true
} }
}, [event.id, event.kind, event.content, targetId]) }, [event.id, event.kind, event.content, targetId, reactionRelayHints])
return state return state
} }

13
src/pages/secondary/NotePage/index.tsx

@ -26,6 +26,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { stripMarkupForPreview } from '@/lib/parent-reply-blurb' import { stripMarkupForPreview } from '@/lib/parent-reply-blurb'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react' import { Ellipsis } from 'lucide-react'
import type { Event } from 'nostr-tools' 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?.kind === ExtendedKind.COMMENT ? finalEvent.tags.find(tagNameEquals('I')) : undefined),
[finalEvent] [finalEvent]
) )
const threadRelayHints = useMemo(
() => (finalEvent ? relayHintsFromEventTags(finalEvent) : []),
[finalEvent]
)
const parentRootFetchOpts = useMemo(
() => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined),
[threadRelayHints]
)
const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } = const { isFetching: isFetchingRootEvent, event: rootEvent, refetch: refetchRoot } =
useFetchEvent(rootEventId) useFetchEvent(rootEventId, undefined, parentRootFetchOpts)
const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } = const { isFetching: isFetchingParentEvent, event: parentEvent, refetch: refetchParent } =
useFetchEvent(parentEventId) useFetchEvent(parentEventId, undefined, parentRootFetchOpts)
const selfHex = finalEvent?.id?.toLowerCase() const selfHex = finalEvent?.id?.toLowerCase()
const rootEventForStrip = const rootEventForStrip =

9
src/services/media-extraction.service.ts

@ -25,6 +25,7 @@ export function extractAllMediaFromEvent(
event: Event, event: Event,
content?: string content?: string
): ExtractedMedia { ): ExtractedMedia {
const textBody = content ?? event.content ?? ''
const seenUrls = new Set<string>() const seenUrls = new Set<string>()
const allMedia: TImetaInfo[] = [] const allMedia: TImetaInfo[] = []
@ -124,13 +125,13 @@ export function extractAllMediaFromEvent(
} }
}) })
// 4. Extract from content (if provided) // 4. Extract from note content (plain URLs, markdown images) — callers may omit `content`; default to `event.content`.
if (content) { if (textBody) {
// First, extract from markdown image syntax: ![alt](url) or [![](url)](link) // First, extract from markdown image syntax: ![alt](url) or [![](url)](link)
// This handles images inside links // This handles images inside links
const markdownImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g const markdownImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g
let imgMatch let imgMatch
while ((imgMatch = markdownImageRegex.exec(content)) !== null) { while ((imgMatch = markdownImageRegex.exec(textBody)) !== null) {
if (imgMatch[1]) { if (imgMatch[1]) {
const url = imgMatch[1] const url = imgMatch[1]
if (isEmbeddableMediaUrl(cleanUrl(url) || url)) { 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) // Then extract directly from raw content (catch any URLs that weren't parsed)
const urlRegex = /https?:\/\/[^\s<>"']+/g const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = content.matchAll(urlRegex) const urlMatches = textBody.matchAll(urlRegex)
for (const match of urlMatches) { for (const match of urlMatches) {
const url = match[0] const url = match[0]
const c = cleanUrl(url) || url const c = cleanUrl(url) || url

Loading…
Cancel
Save