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. 92
      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. 40
      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 @@ @@ -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 @@ @@ -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": [
{

2
package.json

@ -1,6 +1,6 @@ @@ -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",

20
src/components/MediaGridItem/index.tsx

@ -1,10 +1,10 @@ @@ -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 }) { @@ -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 }) { @@ -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">
{isAudio ? <Music className="size-8" /> : <Play className="size-8" />}
{isAudio ? (
<Music className="size-8" />
) : isVideo ? (
<Play className="size-8" />
) : (
<ImageIcon className="size-8" />
)}
</div>
)}

92
src/components/NormalFeed/index.tsx

@ -5,11 +5,21 @@ import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' @@ -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<TNoteListRef, {
@ -122,18 +132,33 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -122,18 +132,33 @@ const NormalFeed = forwardRef<TNoteListRef, {
const MEDIA_KINDS = useMemo(() => [...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<TNoteListRef, { @@ -183,29 +208,37 @@ const NormalFeed = forwardRef<TNoteListRef, {
/** Include kind picker deps for single-relay chips (kindless REQ + client-side kinds). */
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(() => {
const kindRowOptions = (
<div className="flex items-center gap-1">
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} />
</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={
<div className="flex items-center gap-1">
{onSubHeaderRefresh != null && <RefreshButton onClick={onSubHeaderRefresh} />}
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} />
</div>
}
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<TNoteListRef, { @@ -236,6 +269,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
isMainFeed,
setSubHeader,
listMode,
isWispTrendingOnlyFeed,
subHeaderFilterDepsKey,
onSubHeaderRefresh,
allowKindlessRelayExplore,

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

@ -3224,7 +3224,7 @@ function parseMarkdownContentMarked( @@ -3224,7 +3224,7 @@ function parseMarkdownContentMarked(
} = options
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 =>
resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, {
resolveFromExtractedMedia: resolveImetaForImageUrl,

3
src/components/Note/index.tsx

@ -11,6 +11,7 @@ import { @@ -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({ @@ -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({ @@ -600,6 +602,7 @@ export default function Note({
) : parentEventId ? (
<ParentNotePreview
eventId={parentEventId}
relayHints={parentFetchRelayHints}
className="mt-2"
onClick={(e) => {
e.stopPropagation()

10
src/components/NoteList/VirtualizedFeedRows.tsx

@ -34,13 +34,14 @@ const WindowRows = memo(function WindowRows({ @@ -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 (
<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() }}
>
{virtualizer.getVirtualItems().map((vi) => (
@ -86,13 +87,14 @@ const ElementRows = memo(function ElementRows({ @@ -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 (
<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() }}
>
{virtualizer.getVirtualItems().map((vi) => (

40
src/components/NoteList/index.tsx

@ -1438,6 +1438,8 @@ const NoteList = forwardRef( @@ -1438,6 +1438,8 @@ const NoteList = forwardRef(
const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState<HTMLElement | null>(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( @@ -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( @@ -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( @@ -3863,7 +3889,7 @@ const NoteList = forwardRef(
}, [clientFilteredEvents, subRequestsKey, feedReasonLabelsTick])
const list = (
<div className="min-h-screen">
<div className="min-h-0 w-full">
{relayWavePendingBannerEl}
{feedClientFilterActive && filteredEvents.length > 0 && clientFilteredEvents.length === 0 ? (
<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' @@ -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({ @@ -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<HTMLDivElement> | 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<Event | undefined>(undefined)
const [isFetchingFallback, setIsFetchingFallback] = useState(false)
/** 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 { @@ -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({ @@ -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({ @@ -155,6 +157,7 @@ export default function ReplyNote({
appearance="subtle"
className="mt-1.5"
eventId={parentEventId}
relayHints={parentFetchRelayHints}
onClick={(e) => {
e.stopPropagation()
onClickParent()

3
src/components/ReplyNoteList/index.tsx

@ -88,7 +88,8 @@ function chunkKindsForThreadReq(list: readonly number[], size = MAX_KINDS_PER_TH @@ -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[]) {

10
src/components/WebPreview/index.tsx

@ -138,7 +138,10 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -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? @@ -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)

14
src/hooks/useFetchWebMetadata.tsx

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

13
src/hooks/useNotificationReactionDisplay.ts

@ -4,6 +4,7 @@ import { @@ -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 @@ -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<NotificationReactionDisplay>(() =>
event.kind === kinds.Reaction ? { status: 'pending' } : { status: 'default' }
)
@ -47,8 +50,10 @@ export function useNotificationReactionDisplay(event: Event): NotificationReacti @@ -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 @@ -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 @@ -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
}

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

@ -26,6 +26,7 @@ import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' @@ -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 }: @@ -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 =

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

@ -25,6 +25,7 @@ export function extractAllMediaFromEvent( @@ -25,6 +25,7 @@ export function extractAllMediaFromEvent(
event: Event,
content?: string
): ExtractedMedia {
const textBody = content ?? event.content ?? ''
const seenUrls = new Set<string>()
const allMedia: TImetaInfo[] = []
@ -124,13 +125,13 @@ export function extractAllMediaFromEvent( @@ -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( @@ -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

Loading…
Cancel
Save