diff --git a/src/PageManager.tsx b/src/PageManager.tsx index a44d2f6..afd945e 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -99,6 +99,7 @@ const SecondaryPageContext = createContext(un const PrimaryNoteViewContext = createContext<{ setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => void primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null + getNavigationCounter: () => number } | undefined>(undefined) export function usePrimaryPage() { @@ -184,12 +185,28 @@ export function useSmartProfileNavigation() { // Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled export function useSmartHashtagNavigation() { - const { setPrimaryNoteView } = usePrimaryNoteView() + const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView() const navigateToHashtag = (url: string) => { // Use primary note view to show hashtag feed since secondary panel is disabled - window.history.pushState(null, '', url) - setPrimaryNoteView(, 'hashtag') + // Update URL first - do this synchronously before setting the view + const parsedUrl = url.startsWith('/') ? url : `/${url}` + window.history.pushState(null, '', parsedUrl) + + // Extract hashtag from URL for the key to ensure unique keys for different hashtags + const searchParams = new URLSearchParams(parsedUrl.includes('?') ? parsedUrl.split('?')[1] : '') + const hashtag = searchParams.get('t') || '' + // Get the current navigation counter and use next value for the key + // This ensures unique keys that force remounting - setPrimaryNoteView will increment it + const counter = getNavigationCounter() + const key = `hashtag-${hashtag}-${counter + 1}` + + // Use a key based on the hashtag and navigation counter to force remounting when hashtag changes + // This ensures the component reads the new URL parameters when it mounts + // setPrimaryNoteView will increment the counter, so we use counter + 1 for the key + setPrimaryNoteView(, 'hashtag') + // Dispatch custom event as a fallback for components that might be reused + window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } })) } return { navigateToHashtag } @@ -410,6 +427,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const [primaryNoteView, setPrimaryNoteViewState] = useState(null) const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState(null) + const navigationCounterRef = useRef(0) const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => { if (view && !primaryNoteView) { @@ -417,6 +435,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { setSavedPrimaryPage(currentPrimaryPage) } + // Increment navigation counter when setting a new view to ensure unique keys + // This forces React to remount components even when navigating between items of the same type + if (view) { + navigationCounterRef.current += 1 + } + + // Always update the view state - even if the type is the same, the component might be different + // This ensures that navigation works even when navigating between items of the same type (e.g., different hashtags) setPrimaryNoteViewState(view) setPrimaryViewType(type || null) @@ -702,7 +728,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { > - + navigationCounterRef.current }}> {primaryNoteView ? ( // Show primary note view with back button on mobile
@@ -794,7 +820,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { > - + navigationCounterRef.current }}>
, ...props -}: HTMLAttributes & { +}: HTMLAttributes & { classNames?: { wrapper?: string errorPlaceholder?: string @@ -102,7 +102,7 @@ export default function Image({ } return ( -
+ {displaySkeleton && ( {blurHash ? ( @@ -153,7 +153,7 @@ export default function Image({ {errorPlaceholder}
)} -
+ ) } diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 1c3c7ef..d38e125 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -1,4 +1,4 @@ -import { useSecondaryPage } from '@/PageManager' +import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' import ImageWithLightbox from '@/components/ImageWithLightbox' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' @@ -26,6 +26,7 @@ export default function AsciidocArticle({ hideImagesAndInfo?: boolean }) { const { push } = useSecondaryPage() + const { navigateToHashtag } = useSmartHashtagNavigation() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const [isInfoOpen, setIsInfoOpen] = useState(false) @@ -136,6 +137,25 @@ export default function AsciidocArticle({ } }) + // Process hashtag links in content + const hashtagLinks = contentRef.current?.querySelectorAll('a.hashtag-link, a[href^="/notes?t="], a[href^="notes?t="]') + hashtagLinks?.forEach((link) => { + const href = link.getAttribute('href') + if (href && (href.startsWith('/notes?t=') || href.startsWith('notes?t='))) { + // Normalize href to include leading slash if missing + const normalizedHref = href.startsWith('/') ? href : `/${href}` + // Remove existing click handlers to avoid duplicates + const newLink = link.cloneNode(true) as HTMLElement + link.parentNode?.replaceChild(newLink, link) + + newLink.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + navigateToHashtag(normalizedHref) + }) + } + }) + // Process wikilinks const wikilinks = contentRef.current?.querySelectorAll('.wikilink') wikilinks?.forEach((wikilink) => { diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 1c89d00..cc85c71 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -1,5 +1,5 @@ import { SecondaryPageLink, useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' -import ImageWithLightbox from '@/components/ImageWithLightbox' +import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' @@ -8,14 +8,18 @@ import { useMediaExtraction } from '@/hooks' import { cleanUrl } from '@/lib/url' import { ExternalLink } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import React, { useMemo, useEffect, useRef } from 'react' +import React, { useMemo, useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' +import { createPortal } from 'react-dom' +import Lightbox from 'yet-another-react-lightbox' +import Zoom from 'yet-another-react-lightbox/plugins/zoom' import 'katex/dist/katex.min.css' import NostrNode from './NostrNode' import { remarkNostr } from './remarkNostr' import { remarkHashtags } from './remarkHashtags' +import { remarkUnwrapImages } from './remarkUnwrapImages' import { Components } from './types' export default function MarkdownArticle({ @@ -47,41 +51,36 @@ export default function MarkdownArticle({ return hashtags }, [event.content]) - // Track which image URLs appear in the markdown content (for deduplication) - // Use cleaned URLs for comparison with extractedMedia - const imagesInContent = useMemo(() => { - const imageUrls = new Set() - const urlRegex = /https?:\/\/[^\s<>"']+/g - const urlMatches = event.content.matchAll(urlRegex) - for (const match of urlMatches) { - const url = match[0] - // Check if it's an image URL - const extension = url.split('.').pop()?.toLowerCase() - if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) { - const cleaned = cleanUrl(url) - if (cleaned) { - imageUrls.add(cleaned) + // All images from useMediaExtraction are already cleaned and deduplicated + // This includes images from content, tags, imeta, r tags, etc. + const allImages = extractedMedia.images + + // Handle image clicks to open carousel + const [lightboxIndex, setLightboxIndex] = useState(-1) + + useEffect(() => { + if (!contentRef.current || allImages.length === 0) return + + const handleImageClick = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (target.tagName === 'IMG' && target.hasAttribute('data-markdown-image')) { + event.preventDefault() + event.stopPropagation() + + const imageIndex = target.getAttribute('data-image-index') + if (imageIndex !== null) { + setLightboxIndex(parseInt(imageIndex, 10)) } } } - // Also check markdown image syntax: ![alt](url) - const markdownImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g - let imgMatch - while ((imgMatch = markdownImageRegex.exec(event.content)) !== null) { - if (imgMatch[1]) { - const cleaned = cleanUrl(imgMatch[1]) - if (cleaned) { - imageUrls.add(cleaned) - } - } + + const contentElement = contentRef.current + contentElement.addEventListener('click', handleImageClick) + + return () => { + contentElement.removeEventListener('click', handleImageClick) } - return imageUrls - }, [event.content]) - - // Images that should appear in the carousel (from tags only, not in content) - const carouselImages = useMemo(() => { - return extractedMedia.images.filter(img => !imagesInContent.has(img.url)) - }, [extractedMedia.images, imagesInContent]) + }, [allImages.length]) // Initialize highlight.js for syntax highlighting useEffect(() => { @@ -194,6 +193,43 @@ export default function MarkdownArticle({ } } + // If the link contains an image, handle it specially + // When markdown processes [![](url)](link), it creates + // The img component handler will convert to component + // So we check if children contains an Image component + const hasImage = React.Children.toArray(children).some( + child => React.isValidElement(child) && child.type === Image + ) + + // If link contains an image, let the image handle the click for lightbox + // Just wrap it in an anchor that won't interfere with image clicks + if (hasImage) { + return ( + { + // Only open link if not clicking directly on the image itself + // The image component will handle its own click for the lightbox + const target = e.target as HTMLElement + if (target.tagName === 'IMG' || target.closest('img')) { + // Prevent link navigation when clicking the image + // The image's onClick will handle opening the lightbox + e.preventDefault() + e.stopPropagation() + return + } + // Allow default link behavior for non-image clicks + }} + > + {children} + + ) + } + return ( { - // Check if the paragraph contains only an image + // Check if the paragraph contains an img element or Image component + // Since Image renders a div, we need to convert the paragraph to a div to avoid nesting issues const children = props.children - if (React.Children.count(children) === 1 && React.isValidElement(children)) { - const child = children as React.ReactElement - if (child.type === ImageWithLightbox) { - // Render image outside paragraph context - return
+ const childrenArray = React.Children.toArray(children) + + // Fast path: check if paragraph has only one child that might be an image + if (childrenArray.length === 1) { + const child = childrenArray[0] + if (React.isValidElement(child)) { + // Check for img type (string) before conversion, Image component after, or data attribute + if (child.type === 'img' || child.type === Image || child.props?.['data-markdown-image']) { + return
+ } + // Check if child contains an img/image (for links wrapping images) + if (child.props?.children) { + const grandchildren = React.Children.toArray(child.props.children) + if (grandchildren.some((gc: React.ReactNode) => + React.isValidElement(gc) && + (gc.type === 'img' || gc.type === Image || gc.props?.['data-markdown-image']) + )) { + return
+ } + } } } + + // Check all children for images (for paragraphs with multiple children where one is an image) + for (const child of childrenArray) { + if (React.isValidElement(child)) { + // Direct image check + if (child.type === 'img' || child.type === Image || child.props?.['data-markdown-image']) { + return
+ } + // One-level deep check for nested images (like in links) + if (child.props?.children) { + const grandchildren = React.Children.toArray(child.props.children) + if (grandchildren.some((gc: React.ReactNode) => + React.isValidElement(gc) && + (gc.type === 'img' || gc.type === Image || gc.props?.['data-markdown-image']) + )) { + return
+ } + } + } + } + return

}, div: (props) =>

, @@ -290,17 +363,35 @@ export default function MarkdownArticle({ img: ({ src }) => { if (!src) return null + // Find the index of this image in allImages (includes content and tags, already deduplicated) + const cleanedSrc = cleanUrl(src) + const imageIndex = cleanedSrc + ? allImages.findIndex(img => cleanUrl(img.url) === cleanedSrc) + : -1 + // Always render images inline in their content position - // The carousel at the bottom only shows images from tags that aren't in content + // The shared lightbox will show all images (content + tags) when clicked return ( - = 0 ? imageIndex.toString() : undefined} + onClick={(e) => { + e.stopPropagation() + if (imageIndex >= 0) { + setLightboxIndex(imageIndex) + } + }} /> ) } }) as Components, - [showImageGallery, event.pubkey, event.kind, contentHashtags] + [showImageGallery, event.pubkey, event.kind, contentHashtags, allImages, navigateToHashtag] ) return ( @@ -406,13 +497,33 @@ export default function MarkdownArticle({

{metadata.summary}

)} - {metadata.image && ( - - )} - + {metadata.image && (() => { + // Find the index of the metadata image in allImages + const cleanedMetadataImage = cleanUrl(metadata.image) + const metadataImageIndex = cleanedMetadataImage + ? allImages.findIndex(img => cleanUrl(img.url) === cleanedMetadataImage) + : -1 + + return ( + = 0 ? metadataImageIndex.toString() : undefined} + onClick={(e) => { + e.stopPropagation() + if (metadataImageIndex >= 0) { + setLightboxIndex(metadataImageIndex) + } + }} + /> + ) + })()} + {event.content} @@ -452,6 +563,34 @@ export default function MarkdownArticle({
)}
+ + {/* Image carousel lightbox - shows all images (content + tags), already cleaned and deduplicated */} + {allImages.length > 0 && lightboxIndex >= 0 && createPortal( +
e.stopPropagation()}> + ({ + src: url, + alt: alt || url + }))} + plugins={[Zoom]} + open={lightboxIndex >= 0} + close={() => setLightboxIndex(-1)} + controller={{ + closeOnBackdropClick: true, + closeOnPullUp: true, + closeOnPullDown: true + }} + styles={{ + toolbar: { paddingTop: '2.25rem' } + }} + carousel={{ + finite: false + }} + /> +
, + document.body + )} ) } diff --git a/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts b/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts new file mode 100644 index 0000000..9f6398c --- /dev/null +++ b/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts @@ -0,0 +1,43 @@ +import type { Paragraph, Root, Image, Link, Content } from 'mdast' +import type { Plugin } from 'unified' +import { visit } from 'unist-util-visit' + +/** + * Remark plugin to unwrap images from paragraphs + * This prevents the DOM nesting warning where
(Image component) appears inside

+ * + * Markdown wraps standalone images in paragraphs. This plugin unwraps them at the AST level + * so they render directly without a

wrapper. + */ +export const remarkUnwrapImages: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, 'paragraph', (node: Paragraph, index, parent) => { + if (!parent || typeof index !== 'number') return + + const children = node.children + + // Case 1: Paragraph contains only an image: ![alt](url) + if (children.length === 1 && children[0].type === 'image') { + // Replace the paragraph with the image directly + const image = children[0] as Image + parent.children.splice(index, 1, image) + return + } + + // Case 2: Paragraph contains only a link with an image: [![alt](url)](link) + if (children.length === 1 && children[0].type === 'link') { + const link = children[0] as Link + if (link.children.length === 1 && link.children[0].type === 'image') { + // Keep the link but remove the paragraph wrapper + parent.children.splice(index, 1, link) + return + } + } + + // Case 3: Paragraph contains text and an image (less common but should handle) + // We'll leave these as-is since they're mixed content + // The paragraph handler in the component will still try to convert them to divs + }) + } +} + diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index effeaec..6e26040 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -13,7 +13,7 @@ import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import { UserRound, Plus } from 'lucide-react' import { nip19 } from 'nostr-tools' -import React, { forwardRef, useEffect, useState, useMemo } from 'react' +import React, { forwardRef, useEffect, useState, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' interface NoteListPageProps { @@ -58,45 +58,48 @@ const NoteListPage = forwardRef(({ index, hid return isSubscribed(hashtag) }, [hashtag, isSubscribed]) - // Add hashtag to interest list - const handleSubscribeHashtag = async () => { + // Add hashtag to interest list - wrapped in useCallback to prevent circular dependencies + const handleSubscribeHashtag = useCallback(async () => { + const searchParams = new URLSearchParams(window.location.search) + const hashtag = searchParams.get('t') if (!hashtag) return await subscribe(hashtag) - } + }, [subscribe]) - useEffect(() => { - const init = async () => { - const searchParams = new URLSearchParams(window.location.search) - const kinds = searchParams - .getAll('k') - .map((k) => parseInt(k)) - .filter((k) => !isNaN(k)) - const hashtag = searchParams.get('t') - if (hashtag) { - setData({ type: 'hashtag' }) - setTitle(`# ${hashtag}`) - setSubRequests([ - { - filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, - urls: BIG_RELAY_URLS - } - ]) - // Set controls for hashtag subscribe button - if (pubkey) { - setControls( - - ) + // Extract initialization logic into a reusable function + const initializeFromUrl = useCallback(async () => { + const searchParams = new URLSearchParams(window.location.search) + const kinds = searchParams + .getAll('k') + .map((k) => parseInt(k)) + .filter((k) => !isNaN(k)) + const hashtag = searchParams.get('t') + if (hashtag) { + setData({ type: 'hashtag' }) + setTitle(`# ${hashtag}`) + setSubRequests([ + { + filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, + urls: BIG_RELAY_URLS } - return + ]) + // Set controls for hashtag subscribe button - check subscription status + const isSubscribedToHashtag = isSubscribed(hashtag) + if (pubkey) { + setControls( + + ) } - const search = searchParams.get('s') + return + } + const search = searchParams.get('s') if (search) { setData({ type: 'search' }) setTitle(`${t('Search')}: ${search}`) @@ -344,43 +347,29 @@ const NoteListPage = forwardRef(({ index, hid ]) return } - } - init() - }, []) + }, [pubkey, relayList, handleSubscribeHashtag, push, t, isSubscribed, subscribe, client]) + + // Initialize on mount + useEffect(() => { + initializeFromUrl() + }, [initializeFromUrl]) // Listen for URL changes to re-initialize the page useEffect(() => { - const handlePopState = () => { - const searchParams = new URLSearchParams(window.location.search) - const hashtag = searchParams.get('t') - if (hashtag) { - setData({ type: 'hashtag' }) - setTitle(`# ${hashtag}`) - setSubRequests([ - { - filter: { '#t': [hashtag] }, - urls: BIG_RELAY_URLS - } - ]) - // Set controls for hashtag subscribe button - if (pubkey) { - setControls( - - ) - } - } + const handleLocationChange = () => { + initializeFromUrl() } - window.addEventListener('popstate', handlePopState) - return () => window.removeEventListener('popstate', handlePopState) - }, [pubkey, isHashtagSubscribed, t]) + // Listen for browser back/forward navigation + window.addEventListener('popstate', handleLocationChange) + // Listen for custom hashtag navigation events + window.addEventListener('hashtag-navigation', handleLocationChange) + + return () => { + window.removeEventListener('popstate', handleLocationChange) + window.removeEventListener('hashtag-navigation', handleLocationChange) + } + }, [initializeFromUrl]) // Update controls when subscription status changes useEffect(() => { diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts index 557816f..ebf5815 100644 --- a/src/services/media-extraction.service.ts +++ b/src/services/media-extraction.service.ts @@ -85,7 +85,20 @@ export function extractAllMediaFromEvent( // 4. Extract from content (if provided) if (content) { - // Extract directly from raw content (catch any URLs that weren't parsed) + // 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) { + if (imgMatch[1]) { + const url = imgMatch[1] + if (isImage(url) || isMedia(url)) { + addMedia(url) + } + } + } + + // Then extract directly from raw content (catch any URLs that weren't parsed) const urlRegex = /https?:\/\/[^\s<>"']+/g const urlMatches = content.matchAll(urlRegex) for (const match of urlMatches) {