Browse Source

bug-fix markdown

imwald
Silberengel 5 months ago
parent
commit
a0e3ad7cad
  1. 36
      src/PageManager.tsx
  2. 6
      src/components/Image/index.tsx
  3. 22
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 239
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 43
      src/components/Note/MarkdownArticle/remarkUnwrapImages.ts
  6. 123
      src/pages/secondary/NoteListPage/index.tsx
  7. 15
      src/services/media-extraction.service.ts

36
src/PageManager.tsx

@ -99,6 +99,7 @@ const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(un
const PrimaryNoteViewContext = createContext<{ const PrimaryNoteViewContext = createContext<{
setPrimaryNoteView: (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => void 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 primaryViewType: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null
getNavigationCounter: () => number
} | undefined>(undefined) } | undefined>(undefined)
export function usePrimaryPage() { export function usePrimaryPage() {
@ -184,12 +185,28 @@ export function useSmartProfileNavigation() {
// Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled // Fixed: Hashtag navigation now uses primary note view since secondary panel is disabled
export function useSmartHashtagNavigation() { export function useSmartHashtagNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView() const { setPrimaryNoteView, getNavigationCounter } = usePrimaryNoteView()
const navigateToHashtag = (url: string) => { const navigateToHashtag = (url: string) => {
// Use primary note view to show hashtag feed since secondary panel is disabled // Use primary note view to show hashtag feed since secondary panel is disabled
window.history.pushState(null, '', url) // Update URL first - do this synchronously before setting the view
setPrimaryNoteView(<SecondaryNoteListPage hideTitlebar={true} />, 'hashtag') 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(<SecondaryNoteListPage key={key} hideTitlebar={true} />, 'hashtag')
// Dispatch custom event as a fallback for components that might be reused
window.dispatchEvent(new CustomEvent('hashtag-navigation', { detail: { url: parsedUrl } }))
} }
return { navigateToHashtag } return { navigateToHashtag }
@ -410,6 +427,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null) const [primaryNoteView, setPrimaryNoteViewState] = useState<ReactNode | null>(null)
const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null) const [primaryViewType, setPrimaryViewType] = useState<'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings' | null>(null)
const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null) const [savedPrimaryPage, setSavedPrimaryPage] = useState<TPrimaryPageName | null>(null)
const navigationCounterRef = useRef(0)
const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => { const setPrimaryNoteView = (view: ReactNode | null, type?: 'note' | 'settings' | 'settings-sub' | 'profile' | 'hashtag' | 'relay' | 'following' | 'mute' | 'others-relay-settings') => {
if (view && !primaryNoteView) { if (view && !primaryNoteView) {
@ -417,6 +435,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
setSavedPrimaryPage(currentPrimaryPage) 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) setPrimaryNoteViewState(view)
setPrimaryViewType(type || null) setPrimaryViewType(type || null)
@ -702,7 +728,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
<NotificationProvider> <NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType }}> <PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current }}>
{primaryNoteView ? ( {primaryNoteView ? (
// Show primary note view with back button on mobile // Show primary note view with back button on mobile
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
@ -794,7 +820,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
> >
<CurrentRelaysProvider> <CurrentRelaysProvider>
<NotificationProvider> <NotificationProvider>
<PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType }}> <PrimaryNoteViewContext.Provider value={{ setPrimaryNoteView, primaryViewType, getNavigationCounter: () => navigationCounterRef.current }}>
<div className="flex flex-col items-center bg-surface-background"> <div className="flex flex-col items-center bg-surface-background">
<div <div
className="flex h-[var(--vh)] w-full bg-surface-background" className="flex h-[var(--vh)] w-full bg-surface-background"

6
src/components/Image/index.tsx

@ -15,7 +15,7 @@ export default function Image({
hideIfError = false, hideIfError = false,
errorPlaceholder = <ImageOff />, errorPlaceholder = <ImageOff />,
...props ...props
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLSpanElement> & {
classNames?: { classNames?: {
wrapper?: string wrapper?: string
errorPlaceholder?: string errorPlaceholder?: string
@ -102,7 +102,7 @@ export default function Image({
} }
return ( return (
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}> <span className={cn('relative overflow-hidden block', classNames.wrapper)} {...props}>
{displaySkeleton && ( {displaySkeleton && (
<span className="absolute inset-0 z-10 inline-block"> <span className="absolute inset-0 z-10 inline-block">
{blurHash ? ( {blurHash ? (
@ -153,7 +153,7 @@ export default function Image({
{errorPlaceholder} {errorPlaceholder}
</div> </div>
)} )}
</div> </span>
) )
} }

22
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 ImageWithLightbox from '@/components/ImageWithLightbox'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
@ -26,6 +26,7 @@ export default function AsciidocArticle({
hideImagesAndInfo?: boolean hideImagesAndInfo?: boolean
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { navigateToHashtag } = useSmartHashtagNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isInfoOpen, setIsInfoOpen] = useState(false) 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 // Process wikilinks
const wikilinks = contentRef.current?.querySelectorAll('.wikilink') const wikilinks = contentRef.current?.querySelectorAll('.wikilink')
wikilinks?.forEach((wikilink) => { wikilinks?.forEach((wikilink) => {

239
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,5 +1,5 @@
import { SecondaryPageLink, useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' import { SecondaryPageLink, useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox' import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink' import Wikilink from '@/components/UniversalContent/Wikilink'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
@ -8,14 +8,18 @@ import { useMediaExtraction } from '@/hooks'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' 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 Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' 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 'katex/dist/katex.min.css'
import NostrNode from './NostrNode' import NostrNode from './NostrNode'
import { remarkNostr } from './remarkNostr' import { remarkNostr } from './remarkNostr'
import { remarkHashtags } from './remarkHashtags' import { remarkHashtags } from './remarkHashtags'
import { remarkUnwrapImages } from './remarkUnwrapImages'
import { Components } from './types' import { Components } from './types'
export default function MarkdownArticle({ export default function MarkdownArticle({
@ -47,41 +51,36 @@ export default function MarkdownArticle({
return hashtags return hashtags
}, [event.content]) }, [event.content])
// Track which image URLs appear in the markdown content (for deduplication) // All images from useMediaExtraction are already cleaned and deduplicated
// Use cleaned URLs for comparison with extractedMedia // This includes images from content, tags, imeta, r tags, etc.
const imagesInContent = useMemo(() => { const allImages = extractedMedia.images
const imageUrls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g // Handle image clicks to open carousel
const urlMatches = event.content.matchAll(urlRegex) const [lightboxIndex, setLightboxIndex] = useState(-1)
for (const match of urlMatches) {
const url = match[0] useEffect(() => {
// Check if it's an image URL if (!contentRef.current || allImages.length === 0) return
const extension = url.split('.').pop()?.toLowerCase()
if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) { const handleImageClick = (event: MouseEvent) => {
const cleaned = cleanUrl(url) const target = event.target as HTMLElement
if (cleaned) { if (target.tagName === 'IMG' && target.hasAttribute('data-markdown-image')) {
imageUrls.add(cleaned) 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 const contentElement = contentRef.current
let imgMatch contentElement.addEventListener('click', handleImageClick)
while ((imgMatch = markdownImageRegex.exec(event.content)) !== null) {
if (imgMatch[1]) { return () => {
const cleaned = cleanUrl(imgMatch[1]) contentElement.removeEventListener('click', handleImageClick)
if (cleaned) {
imageUrls.add(cleaned)
}
}
} }
return imageUrls }, [allImages.length])
}, [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])
// Initialize highlight.js for syntax highlighting // Initialize highlight.js for syntax highlighting
useEffect(() => { 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 <a><img/></a>
// The img component handler will convert <img> to <Image> 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 (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-block"
onClick={(e) => {
// 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}
</a>
)
}
return ( return (
<a <a
{...props} {...props}
@ -207,15 +243,52 @@ export default function MarkdownArticle({
) )
}, },
p: (props) => { p: (props) => {
// 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 const children = props.children
if (React.Children.count(children) === 1 && React.isValidElement(children)) { const childrenArray = React.Children.toArray(children)
const child = children as React.ReactElement
if (child.type === ImageWithLightbox) { // Fast path: check if paragraph has only one child that might be an image
// Render image outside paragraph context if (childrenArray.length === 1) {
return <div {...props} className="break-words" /> 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 <div {...props} className="break-words" />
}
// 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 <div {...props} className="break-words" />
}
}
} }
} }
// 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 <div {...props} className="break-words" />
}
// 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 <div {...props} className="break-words" />
}
}
}
}
return <p {...props} className="break-words" /> return <p {...props} className="break-words" />
}, },
div: (props) => <div {...props} className="break-words" />, div: (props) => <div {...props} className="break-words" />,
@ -290,17 +363,35 @@ export default function MarkdownArticle({
img: ({ src }) => { img: ({ src }) => {
if (!src) return null 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 // 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 ( return (
<ImageWithLightbox <Image
image={{ url: src, pubkey: event.pubkey }} image={{ url: src, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg my-2" className="max-w-[400px] rounded-lg my-2 cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
data-markdown-image="true"
data-image-index={imageIndex >= 0 ? imageIndex.toString() : undefined}
onClick={(e) => {
e.stopPropagation()
if (imageIndex >= 0) {
setLightboxIndex(imageIndex)
}
}}
/> />
) )
} }
}) as Components, }) as Components,
[showImageGallery, event.pubkey, event.kind, contentHashtags] [showImageGallery, event.pubkey, event.kind, contentHashtags, allImages, navigateToHashtag]
) )
return ( return (
@ -406,13 +497,33 @@ export default function MarkdownArticle({
<p className="break-words">{metadata.summary}</p> <p className="break-words">{metadata.summary}</p>
</blockquote> </blockquote>
)} )}
{metadata.image && ( {metadata.image && (() => {
<ImageWithLightbox // Find the index of the metadata image in allImages
image={{ url: metadata.image, pubkey: event.pubkey }} const cleanedMetadataImage = cleanUrl(metadata.image)
className="w-full max-w-[400px] aspect-[3/1] object-cover my-0" const metadataImageIndex = cleanedMetadataImage
/> ? allImages.findIndex(img => cleanUrl(img.url) === cleanedMetadataImage)
)} : -1
<Markdown remarkPlugins={[remarkGfm, remarkMath, remarkNostr, remarkHashtags]} components={components}>
return (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto my-0 cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
data-markdown-image="true"
data-image-index={metadataImageIndex >= 0 ? metadataImageIndex.toString() : undefined}
onClick={(e) => {
e.stopPropagation()
if (metadataImageIndex >= 0) {
setLightboxIndex(metadataImageIndex)
}
}}
/>
)
})()}
<Markdown remarkPlugins={[remarkGfm, remarkMath, remarkUnwrapImages, remarkNostr, remarkHashtags]} components={components}>
{event.content} {event.content}
</Markdown> </Markdown>
@ -452,6 +563,34 @@ export default function MarkdownArticle({
</div> </div>
)} )}
</div> </div>
{/* Image carousel lightbox - shows all images (content + tags), already cleaned and deduplicated */}
{allImages.length > 0 && lightboxIndex >= 0 && createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({
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
}}
/>
</div>,
document.body
)}
</> </>
) )
} }

43
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 <div> (Image component) appears inside <p>
*
* Markdown wraps standalone images in paragraphs. This plugin unwraps them at the AST level
* so they render directly without a <p> 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
})
}
}

123
src/pages/secondary/NoteListPage/index.tsx

@ -13,7 +13,7 @@ import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import { UserRound, Plus } from 'lucide-react' import { UserRound, Plus } from 'lucide-react'
import { nip19 } from 'nostr-tools' 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' import { useTranslation } from 'react-i18next'
interface NoteListPageProps { interface NoteListPageProps {
@ -58,45 +58,48 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
return isSubscribed(hashtag) return isSubscribed(hashtag)
}, [hashtag, isSubscribed]) }, [hashtag, isSubscribed])
// Add hashtag to interest list // Add hashtag to interest list - wrapped in useCallback to prevent circular dependencies
const handleSubscribeHashtag = async () => { const handleSubscribeHashtag = useCallback(async () => {
const searchParams = new URLSearchParams(window.location.search)
const hashtag = searchParams.get('t')
if (!hashtag) return if (!hashtag) return
await subscribe(hashtag) await subscribe(hashtag)
} }, [subscribe])
useEffect(() => { // Extract initialization logic into a reusable function
const init = async () => { const initializeFromUrl = useCallback(async () => {
const searchParams = new URLSearchParams(window.location.search) const searchParams = new URLSearchParams(window.location.search)
const kinds = searchParams const kinds = searchParams
.getAll('k') .getAll('k')
.map((k) => parseInt(k)) .map((k) => parseInt(k))
.filter((k) => !isNaN(k)) .filter((k) => !isNaN(k))
const hashtag = searchParams.get('t') const hashtag = searchParams.get('t')
if (hashtag) { if (hashtag) {
setData({ type: 'hashtag' }) setData({ type: 'hashtag' })
setTitle(`# ${hashtag}`) setTitle(`# ${hashtag}`)
setSubRequests([ setSubRequests([
{ {
filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) },
urls: BIG_RELAY_URLS urls: BIG_RELAY_URLS
}
])
// Set controls for hashtag subscribe button
if (pubkey) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isHashtagSubscribed}
>
{isHashtagSubscribed ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
} }
return ])
// Set controls for hashtag subscribe button - check subscription status
const isSubscribedToHashtag = isSubscribed(hashtag)
if (pubkey) {
setControls(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isSubscribedToHashtag}
>
{isSubscribedToHashtag ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
} }
const search = searchParams.get('s') return
}
const search = searchParams.get('s')
if (search) { if (search) {
setData({ type: 'search' }) setData({ type: 'search' })
setTitle(`${t('Search')}: ${search}`) setTitle(`${t('Search')}: ${search}`)
@ -344,43 +347,29 @@ const NoteListPage = forwardRef<HTMLDivElement, NoteListPageProps>(({ index, hid
]) ])
return return
} }
} }, [pubkey, relayList, handleSubscribeHashtag, push, t, isSubscribed, subscribe, client])
init()
}, []) // Initialize on mount
useEffect(() => {
initializeFromUrl()
}, [initializeFromUrl])
// Listen for URL changes to re-initialize the page // Listen for URL changes to re-initialize the page
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handleLocationChange = () => {
const searchParams = new URLSearchParams(window.location.search) initializeFromUrl()
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(
<Button
variant="ghost"
className="h-10 [&_svg]:size-3"
onClick={handleSubscribeHashtag}
disabled={isHashtagSubscribed}
>
{isHashtagSubscribed ? t('Subscribed') : t('Subscribe')} <Plus />
</Button>
)
}
}
} }
window.addEventListener('popstate', handlePopState) // Listen for browser back/forward navigation
return () => window.removeEventListener('popstate', handlePopState) window.addEventListener('popstate', handleLocationChange)
}, [pubkey, isHashtagSubscribed, t]) // 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 // Update controls when subscription status changes
useEffect(() => { useEffect(() => {

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

@ -85,7 +85,20 @@ export function extractAllMediaFromEvent(
// 4. Extract from content (if provided) // 4. Extract from content (if provided)
if (content) { 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 urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = content.matchAll(urlRegex) const urlMatches = content.matchAll(urlRegex)
for (const match of urlMatches) { for (const match of urlMatches) {

Loading…
Cancel
Save