Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
e11bdb6d3a
  1. 138
      src/components/ImageGallery/index.tsx
  2. 131
      src/components/ImageWithLightbox/index.tsx
  3. 87
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 4
      src/components/PostEditor/PostContent.tsx
  5. 2
      src/lib/article-media.ts
  6. 2
      src/lib/compress-upload-media.ts
  7. 24
      src/lib/draft-event.ts
  8. 4
      src/lib/image-extraction.ts
  9. 2
      src/lib/lightbox-slides.ts
  10. 2
      src/lib/media-kind-detection.ts
  11. 4
      src/lib/nostr-parser.tsx
  12. 53
      src/lib/tag.ts
  13. 2
      src/lib/upload-nip94-imeta.ts
  14. 3
      src/lib/url.ts
  15. 6
      src/services/content-parser.service.ts
  16. 64
      src/services/media-extraction.service.ts

138
src/components/ImageGallery/index.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types'
import { ReactNode, useEffect, useMemo, useState } from 'react'
@ -15,6 +15,9 @@ import 'yet-another-react-lightbox/plugins/captions.css' @@ -15,6 +15,9 @@ import 'yet-another-react-lightbox/plugins/captions.css'
import Image from '../Image'
import ImageWithLightbox from '../ImageWithLightbox'
const galleryImageWrapper = (className?: string) =>
cn('w-full max-w-full min-w-0', className)
export default function ImageGallery({
className,
images,
@ -29,8 +32,11 @@ export default function ImageGallery({ @@ -29,8 +32,11 @@ export default function ImageGallery({
mustLoad?: boolean
}) {
const id = useMemo(() => `image-gallery-${randomString()}`, [])
const { autoLoadMedia } = useContentPolicy()
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [index, setIndex] = useState(-1)
const [lightboxPortalActive, setLightboxPortalActive] = useState(false)
useEffect(() => {
if (index >= 0) {
modalManager.register(id, () => {
@ -39,18 +45,38 @@ export default function ImageGallery({ @@ -39,18 +45,38 @@ export default function ImageGallery({
} else {
modalManager.unregister(id)
}
}, [index])
}, [id, index])
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
event.stopPropagation()
event.preventDefault()
const newIndex = start + current
logger.debug('[ImageGallery] Click:', { start, current, newIndex, totalImages: images.length, displayImages: displayImages.length })
logger.debug('[ImageGallery] Click:', {
start,
current,
newIndex,
totalImages: images.length,
displayImages: displayImages.length
})
setLightboxPortalActive(true)
setIndex(newIndex)
}
const displayImages = images.slice(start, end)
if (displayImages.length === 1) {
return (
<ImageWithLightbox
image={displayImages[0]}
mustLoad={mustLoad}
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
wrapper: galleryImageWrapper(className)
}}
/>
)
}
if (!mustLoad && !autoLoadMedia) {
return displayImages.map((image, i) => (
<ImageWithLightbox
@ -58,26 +84,14 @@ export default function ImageGallery({ @@ -58,26 +84,14 @@ export default function ImageGallery({
image={image}
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
classNames={{
wrapper: cn('w-fit max-w-full', className)
wrapper: galleryImageWrapper(className)
}}
/>
))
}
let imageContent: ReactNode | null = null
if (displayImages.length === 1) {
imageContent = (
<Image
key={0}
className="max-h-[80vh] sm:max-h-[50vh] cursor-zoom-in object-contain max-w-[400px]"
classNames={{
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={displayImages[0]}
onClick={(e) => handlePhotoClick(e, 0)}
/>
)
} else if (displayImages.length === 2 || displayImages.length === 4) {
if (displayImages.length === 2 || displayImages.length === 4) {
imageContent = (
<div className="grid grid-cols-2 gap-2 w-full max-w-[400px]">
{displayImages.map((image, i) => (
@ -105,46 +119,58 @@ export default function ImageGallery({ @@ -105,46 +119,58 @@ export default function ImageGallery({
)
}
const portal =
lightboxPortalActive && typeof document !== 'undefined'
? createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={index}
slides={(() => {
const slides = images.map((img) => lightboxSlideFromImeta(img))
logger.debug('[ImageGallery] Lightbox slides:', {
index,
slidesCount: slides.length,
slides
})
return slides
})()}
plugins={[Video, Zoom, Captions]}
open={index >= 0}
close={() => setIndex(-1)}
on={{
exited: () => setLightboxPortalActive(false)
}}
controller={{
closeOnBackdropClick: false,
closeOnPullUp: true,
closeOnPullDown: true
}}
render={{
buttonPrev: images.length <= 1 ? () => null : undefined,
buttonNext: images.length <= 1 ? () => null : undefined
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
carousel={{
finite: false
}}
/>
</div>,
document.body
)
: null
return (
<div className={cn(displayImages.length === 1 ? 'w-fit max-w-[400px]' : 'w-full', className)}>
<div className={cn('w-full', className)}>
{imageContent}
{createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={index}
slides={(() => {
const slides = images.map((img) => lightboxSlideFromImeta(img))
logger.debug('[ImageGallery] Lightbox slides:', { index, slidesCount: slides.length, slides })
return slides
})()}
plugins={[Video, Zoom, Captions]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: false,
closeOnPullUp: true,
closeOnPullDown: true
}}
render={{
buttonPrev: images.length <= 1 ? () => null : undefined,
buttonNext: images.length <= 1 ? () => null : undefined
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
carousel={{
finite: false
}}
/>
</div>,
document.body
)}
{portal}
</div>
)
}

131
src/components/ImageWithLightbox/index.tsx

@ -1,12 +1,11 @@ @@ -1,12 +1,11 @@
import { randomString } from '@/lib/random'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import modalManager from '@/services/modal-manager.service'
import { TImetaInfo } from '@/types'
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import { useTranslation } from 'react-i18next'
import Lightbox from 'yet-another-react-lightbox'
import Captions from 'yet-another-react-lightbox/plugins/captions'
import Video from 'yet-another-react-lightbox/plugins/video'
@ -17,26 +16,22 @@ import Image from '../Image' @@ -17,26 +16,22 @@ import Image from '../Image'
export default function ImageWithLightbox({
image,
className,
classNames = {}
classNames = {},
/** When true, load inline image immediately (ignore tap-to-load policy). */
mustLoad = false
}: {
image: TImetaInfo
className?: string
classNames?: {
wrapper?: string
}
mustLoad?: boolean
}) {
const id = useMemo(() => `image-with-lightbox-${randomString()}`, [])
const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy()
const [display, setDisplay] = useState(autoLoadMedia)
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
const [index, setIndex] = useState(-1)
useEffect(() => {
setDisplay(autoLoadMedia)
if (!autoLoadMedia) {
setIndex(-1)
}
}, [autoLoadMedia])
const [lightboxPortalActive, setLightboxPortalActive] = useState(false)
useEffect(() => {
if (index >= 0) {
@ -51,67 +46,63 @@ export default function ImageWithLightbox({ @@ -51,67 +46,63 @@ export default function ImageWithLightbox({
const handlePhotoClick = (event: React.MouseEvent) => {
event.stopPropagation()
event.preventDefault()
setLightboxPortalActive(true)
setIndex(0)
}
// The portal is always mounted (not conditional on `index >= 0`) so that React
// never removes it while yet-another-react-lightbox is mid-cleanup, which would
// otherwise cause "Node.removeChild: The node to be removed is not a child of
// this node". Visibility is controlled via the `open` prop instead.
const holdUntilClick = !mustLoad && !autoLoadMedia
const portal =
lightboxPortalActive && typeof document !== 'undefined'
? createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={index}
slides={[lightboxSlideFromImeta(image)]}
plugins={[Video, Zoom, Captions]}
open={index >= 0}
close={() => setIndex(-1)}
on={{
exited: () => setLightboxPortalActive(false)
}}
controller={{
closeOnBackdropClick: false,
closeOnPullUp: true,
closeOnPullDown: true
}}
render={{
buttonPrev: () => null,
buttonNext: () => null
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
/>
</div>,
document.body
)
: null
return (
<div className="max-w-[400px]">
{display ? (
<Image
key={0}
className={className}
classNames={{
wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={image}
onClick={(e) => handlePhotoClick(e)}
/>
) : (
<span
className="text-primary hover:underline truncate w-fit cursor-pointer inline-block"
onClick={(e) => {
e.stopPropagation()
setDisplay(true)
}}
>
[{t('Click to load image')}]
</span>
)}
{createPortal(
<div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox
index={index}
slides={[lightboxSlideFromImeta(image)]}
plugins={[Video, Zoom, Captions]}
open={index >= 0}
close={() => setIndex(-1)}
controller={{
closeOnBackdropClick: false,
closeOnPullUp: true,
closeOnPullDown: true
}}
render={{
buttonPrev: () => null,
buttonNext: () => null
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
/>
</div>,
document.body
)}
<div className="w-full max-w-[400px]">
<Image
key={0}
className={className}
classNames={{
wrapper: cn('rounded-lg cursor-zoom-in', classNames.wrapper),
errorPlaceholder: 'aspect-square h-[30vh]'
}}
image={image}
holdUntilClick={holdUntilClick}
onClick={(e) => handlePhotoClick(e)}
/>
{portal}
</div>
)
}

87
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -46,6 +46,40 @@ import '@/styles/katex-bundle.css' @@ -46,6 +46,40 @@ import '@/styles/katex-bundle.css'
import { isContentSpacingDebug, reprString } from '@/lib/content-spacing-debug'
import logger from '@/lib/logger'
/**
* Inline/block image metadata: use merged rows from {@link extractAllMediaFromEvent} first
* (imeta + content + upload cache), then raw `imeta` tags on the event with URL / filename fallback.
*/
function resolveImetaForMarkdownImageUrl(
cleaned: string,
eventPubkey: string,
args: {
resolveFromExtractedMedia?: (cleaned: string) => TImetaInfo | undefined
containingEvent?: Event
getImageIdentifier?: (url: string) => string | null
}
): TImetaInfo {
const fromExtracted = args.resolveFromExtractedMedia?.(cleaned)
if (fromExtracted) return { ...fromExtracted, url: cleaned }
if (args.containingEvent) {
const infos = getImetaInfosFromEvent(args.containingEvent)
const hit = infos.find((i) => cleanUrl(i.url) === cleaned)
if (hit) return { ...hit, url: cleaned }
if (args.getImageIdentifier) {
const id = args.getImageIdentifier(cleaned)
if (id) {
const byId = infos.find((i) => {
const ic = cleanUrl(i.url)
return !!ic && args.getImageIdentifier!(ic) === id
})
if (byId) return { ...byId, url: cleaned }
}
}
}
return { url: cleaned, pubkey: eventPubkey }
}
/**
* Truncate link display text to 200 characters, adding ellipsis if truncated
*/
@ -604,6 +638,8 @@ function parseMarkdownContentLegacy( @@ -604,6 +638,8 @@ function parseMarkdownContentLegacy(
containingEvent?: Event
/** Hold images as placeholders until clicked (lightbox). False in detail/full views. */
lazyMedia?: boolean
/** Prefer rows from {@link useMediaExtraction} / {@link extractAllMediaFromEvent} for blurHash etc. */
resolveImetaForImageUrl?: (cleaned: string) => TImetaInfo | undefined
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const {
@ -619,7 +655,8 @@ function parseMarkdownContentLegacy( @@ -619,7 +655,8 @@ function parseMarkdownContentLegacy(
fullCalendarInvite,
suppressStandaloneWebPreviewCleanedUrls,
containingEvent,
lazyMedia = true
lazyMedia = true,
resolveImetaForImageUrl
} = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
@ -627,16 +664,12 @@ function parseMarkdownContentLegacy( @@ -627,16 +664,12 @@ function parseMarkdownContentLegacy(
const citations: Array<{ id: string; type: string; citationId: string }> = []
let lastIndex = 0
// Build imeta lookup map once for blurHash and other NIP-94 metadata
const imetaByCleanedUrl = new Map<string, TImetaInfo>()
if (containingEvent) {
getImetaInfosFromEvent(containingEvent).forEach((info) => {
const cleaned = cleanUrl(info.url)
if (cleaned) imetaByCleanedUrl.set(cleaned, info)
})
}
const imetaInfoForUrl = (cleaned: string): TImetaInfo =>
imetaByCleanedUrl.get(cleaned) ?? { url: cleaned, pubkey: eventPubkey }
resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, {
resolveFromExtractedMedia: resolveImetaForImageUrl,
containingEvent,
getImageIdentifier
})
// Helper function to check if an index range falls within any block-level pattern
const isWithinBlockPattern = (start: number, end: number, blockPatterns: Array<{ index: number; end: number }>): boolean => {
@ -2932,6 +2965,7 @@ function parseMarkdownContentMarked( @@ -2932,6 +2965,7 @@ function parseMarkdownContentMarked(
containingEvent?: Event
/** Hold images as placeholders until clicked (lightbox). False in detail/full views. */
lazyMedia?: boolean
resolveImetaForImageUrl?: (cleaned: string) => TImetaInfo | undefined
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; citations: Array<{ id: string; type: string; citationId: string }> } {
const {
@ -2946,18 +2980,17 @@ function parseMarkdownContentMarked( @@ -2946,18 +2980,17 @@ function parseMarkdownContentMarked(
fullCalendarInvite,
suppressStandaloneWebPreviewCleanedUrls,
containingEvent,
lazyMedia = true
lazyMedia = true,
resolveImetaForImageUrl
} = options
/** Direct image URLs on their own line: render Image (NIP-94 / Amethyst-style), not WebPreview — WebPreview returns null when autoLoadMedia is off. */
const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo => {
if (containingEvent) {
const infos = getImetaInfosFromEvent(containingEvent)
const hit = infos.find((i) => cleanUrl(i.url) === cleaned)
if (hit) return { ...hit, url: cleaned }
}
return { url: cleaned, pubkey: eventPubkey }
}
const imetaInfoForStandaloneImageUrl = (cleaned: string): TImetaInfo =>
resolveImetaForMarkdownImageUrl(cleaned, eventPubkey, {
resolveFromExtractedMedia: resolveImetaForImageUrl,
containingEvent,
getImageIdentifier
})
const renderStandaloneHttpsImageBlock = (cleaned: string, reactKey: string) => {
let imageIndex = imageIndexMap.get(cleaned)
@ -4801,6 +4834,18 @@ export default function MarkdownArticle({ @@ -4801,6 +4834,18 @@ export default function MarkdownArticle({
// Parse markdown content with post-processing for nostr: links and hashtags
const { nodes: parsedContent, hashtagsInContent } = useMemo(() => {
const resolveImetaForImageUrl = (cleaned: string): TImetaInfo | undefined => {
for (const img of extractedMedia.images) {
const ic = cleanUrl(img.url)
if (!ic) continue
if (ic === cleaned) return { ...img, url: cleaned }
const idC = getImageIdentifier(cleaned)
const idI = getImageIdentifier(ic)
if (idC && idI && idC === idI) return { ...img, url: cleaned }
}
return undefined
}
const parseOptions = {
eventPubkey: event.pubkey,
imageIndexMap,
@ -4814,6 +4859,7 @@ export default function MarkdownArticle({ @@ -4814,6 +4859,7 @@ export default function MarkdownArticle({
fullCalendarInvite,
containingEvent: event,
lazyMedia,
resolveImetaForImageUrl,
suppressStandaloneWebPreviewCleanedUrls:
webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined
}
@ -4840,7 +4886,8 @@ export default function MarkdownArticle({ @@ -4840,7 +4886,8 @@ export default function MarkdownArticle({
emojiInfos,
fullCalendarInvite,
lazyMedia,
webPreviewSuppressCleanedSet
webPreviewSuppressCleanedSet,
extractedMedia.images
])
// Filter metadata tags to only show what's not already in content

4
src/components/PostEditor/PostContent.tsx

@ -1311,7 +1311,7 @@ export default function PostContent({ @@ -1311,7 +1311,7 @@ export default function PostContent({
const path = url.split(/[?#]/)[0].toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE
if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE
if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg)$/i.test(path)) return ExtendedKind.SHORT_VIDEO
if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg|3gp|3g2)$/i.test(path)) return ExtendedKind.SHORT_VIDEO
return null
}
@ -1331,6 +1331,8 @@ export default function PostContent({ @@ -1331,6 +1331,8 @@ export default function PostContent({
}
if (path.endsWith('.mkv')) return 'video/x-matroska'
if (path.endsWith('.webm')) return 'video/webm'
if (path.endsWith('.3gp')) return 'video/3gpp'
if (path.endsWith('.3g2')) return 'video/3gpp2'
return 'video/mp4'
}

2
src/lib/article-media.ts

@ -61,7 +61,7 @@ function extractUrlsFromContent(content: string): string[] { @@ -61,7 +61,7 @@ function extractUrlsFromContent(content: string): string[] {
// Check if it's a media file
const mediaExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.tiff',
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv',
'.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.flv', '.3gp', '.3g2',
'.mp3', '.wav', '.flac', '.aac', '.m4a', '.mka'
]

2
src/lib/compress-upload-media.ts

@ -40,7 +40,7 @@ const VIDEO_TARGET_BITRATE_MIN = 450_000 @@ -40,7 +40,7 @@ const VIDEO_TARGET_BITRATE_MIN = 450_000
const VIDEO_AUDIO_BITRATE = 96_000
/** Browsers often leave `File.type` empty for some paths; still treat as video. */
const VIDEO_FILENAME_RE = /\.(mp4|m4v|mov|mkv|webm|ogv|avi|mpeg|mpg)$/i
const VIDEO_FILENAME_RE = /\.(mp4|m4v|mov|mkv|webm|ogv|avi|mpeg|mpg|3gp|3g2)$/i
/** Image/audio extensions for drag/drop and paste when `File.type` is empty (common on Linux). */
const IMAGE_FILENAME_RE = /\.(jpe?g|png|gif|webp|bmp|svg|ico|heic|heif|avif)$/i

24
src/lib/draft-event.ts

@ -33,7 +33,7 @@ import { @@ -33,7 +33,7 @@ import {
import { cleanUrl } from '@/lib/url'
import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag'
import { generateBech32IdFromETag, getImetaInfoFromImetaTag, tagNameEquals } from './tag'
function canonicalizeHttpUrlForITags(url: string): string {
if (!url.startsWith('http://') && !url.startsWith('https://')) return url
@ -1812,22 +1812,34 @@ export async function createPictureDraftEvent( @@ -1812,22 +1812,34 @@ export async function createPictureDraftEvent(
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
tags.push(...imetaTags)
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.addExpirationTag && options.expirationMonths) {
tags.push(buildExpirationTag(options.expirationMonths))
}
if (options.addQuietTag && options.quietDays) {
tags.push(buildQuietTag(options.quietDays))
}
// Kind 20 caption is user text only; the file URL lives in `imeta`. Many indexers and caches
// still deliver full tags, but mirroring the URL in `content` matches kind-1-style clients and
// keeps {@link Content} / URL extraction working when tags are missing or non-standard.
const mediaUrlFromImeta = imetaTags
.map((t) => getImetaInfoFromImetaTag(t))
.find((info) => info?.url)?.url
let pictureContent = transformedEmojisContent
if (mediaUrlFromImeta && !pictureContent.includes(mediaUrlFromImeta)) {
const trimmed = pictureContent.trimEnd()
pictureContent = trimmed ? `${trimmed}\n\n${mediaUrlFromImeta}` : mediaUrlFromImeta
}
return setDraftEventCache({
kind: ExtendedKind.PICTURE,
content: transformedEmojisContent,
content: pictureContent,
tags
})
}

4
src/lib/image-extraction.ts

@ -77,7 +77,7 @@ export function extractAllImagesFromEvent(event: Event): TImetaInfo[] { @@ -77,7 +77,7 @@ export function extractAllImagesFromEvent(event: Event): TImetaInfo[] {
// 7. Extract from content - general URL patterns that look like media
const mediaUrlRegex =
/https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv|mka)(?:\?[^\s<>"']*)?/gi
/https?:\/\/[^\s<>"']+\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|ico|mp4|webm|ogg|avi|mov|wmv|flv|mkv|mka|3gp|3g2|ogv)(?:\?[^\s<>"']*)?/gi
while ((match = mediaUrlRegex.exec(event.content)) !== null) {
addMedia(match[0])
}
@ -160,7 +160,7 @@ export function isImageUrl(url: string): boolean { @@ -160,7 +160,7 @@ export function isImageUrl(url: string): boolean {
* Check if URL is likely a video
*/
function isVideoUrl(url: string): boolean {
const videoExtensions = /\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp|ogv)(\?.*)?$/i
const videoExtensions = /\.(mp4|webm|ogg|avi|mov|wmv|flv|mkv|m4v|3gp|3g2|ogv)(\?.*)?$/i
const videoDomains = [
'youtube.com',
'youtu.be',

2
src/lib/lightbox-slides.ts

@ -17,6 +17,8 @@ function sourceTypeFromPath(url: string, kind: 'video' | 'audio'): string { @@ -17,6 +17,8 @@ function sourceTypeFromPath(url: string, kind: 'video' | 'audio'): string {
if (path.endsWith('.webm')) return 'video/webm'
if (path.endsWith('.mkv')) return 'video/x-matroska'
if (path.endsWith('.ogv')) return 'video/ogg'
if (path.endsWith('.3gp')) return 'video/3gpp'
if (path.endsWith('.3g2')) return 'video/3gpp2'
return 'video/mp4'
}

2
src/lib/media-kind-detection.ts

@ -28,7 +28,7 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false) @@ -28,7 +28,7 @@ export async function getMediaKindFromFile(file: File, isReply: boolean = false)
fileType === 'audio/x-matroska'
const isVideoMime = fileType.startsWith('video/')
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName)
const isVideoExt = /\.(mp4|ogg|mov|avi|mkv|m4v)$/i.test(fileName)
const isVideoExt = /\.(mp4|ogg|mov|avi|mkv|m4v|3gp|3g2)$/i.test(fileName)
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this)
const isM4aFile = /\.m4a$/i.test(fileName)

4
src/lib/nostr-parser.tsx

@ -167,7 +167,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo @@ -167,7 +167,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
// Check if it's media (video/audio)
else if (isMedia(cleanedUrl)) {
// Determine if it's video or audio based on extension
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(cleanedUrl)
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|3gp|3g2|ogv)$/i.test(cleanedUrl)
allMatches.push({
type: isVideo ? 'video' : 'audio',
match: urlMatch,
@ -416,7 +416,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo @@ -416,7 +416,7 @@ export function parseNostrContent(content: string, event?: Event): ParsedNostrCo
if (!alreadyProcessed) {
// Determine if it's video or audio based on extension
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v)$/i.test(imetaInfo.url)
const isVideo = /\.(mp4|webm|ogg|mov|avi|wmv|flv|mkv|m4v|3gp|3g2|ogv)$/i.test(imetaInfo.url)
elements.push({
type: isVideo ? 'video' : 'audio',
content: imetaInfo.url,

53
src/lib/tag.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { TEmoji, TImetaInfo } from '@/types'
import { cleanUrl } from './url'
import { cleanUrl, isImage, isMedia } from './url'
import { isBlurhashValid } from 'blurhash'
import { nip19 } from 'nostr-tools'
import { isValidPubkey } from './pubkey'
@ -78,20 +78,59 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta @@ -78,20 +78,59 @@ export function getImetaInfoFromImetaTag(tag: string[], pubkey?: string): TImeta
url = tag[urlIndex + 1]
}
}
// Some publishers use a bare https URL as a tag value (e.g. ["imeta", "https://…"]) without `url `.
if (!url) {
const spaceMime = tag.find((item) => typeof item === 'string' && item.startsWith('m '))?.slice(2)
const mIdx = tag.findIndex((item) => item === 'm')
const sepMime =
mIdx !== -1 && mIdx + 1 < tag.length && typeof tag[mIdx + 1] === 'string'
? tag[mIdx + 1]
: undefined
const mimeHint = spaceMime || sepMime
for (let i = 1; i < tag.length; i++) {
const item = tag[i]
if (typeof item !== 'string') continue
const t = item.trim()
if (!/^https?:\/\//i.test(t)) continue
if (
isImage(t) ||
isMedia(t) ||
(mimeHint &&
(mimeHint.startsWith('image/') ||
mimeHint.startsWith('video/') ||
mimeHint.startsWith('audio/')))
) {
url = t
break
}
}
}
if (!url) return null
// Clean the URL to remove tracking parameters
const cleanedUrl = cleanUrl(url)
const imeta: TImetaInfo = { url: cleanedUrl, pubkey }
// Parse blurhash
// Parse blurhash (`blurhash …` NIP-94; some publishers use `bh …` only)
const blurHashItem = tag.find((item) => item.startsWith('blurhash '))
const blurHash = blurHashItem?.slice(9)
if (blurHash) {
const validRes = isBlurhashValid(blurHash)
const blurHashFromTag = blurHashItem?.slice(9)?.trim()
if (blurHashFromTag) {
const validRes = isBlurhashValid(blurHashFromTag)
if (validRes.result) {
imeta.blurHash = blurHash
imeta.blurHash = blurHashFromTag
}
}
if (!imeta.blurHash) {
const bhItem = tag.find((item) => item.startsWith('bh '))
const bh = bhItem?.slice(3)?.trim()
if (bh) {
const validRes = isBlurhashValid(bh)
if (validRes.result) {
imeta.blurHash = bh
}
}
}

2
src/lib/upload-nip94-imeta.ts

@ -18,6 +18,8 @@ const EXT_TO_MIME: Record<string, string> = { @@ -18,6 +18,8 @@ const EXT_TO_MIME: Record<string, string> = {
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.3gp': 'video/3gpp',
'.3g2': 'video/3gpp2',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.mka': 'audio/x-matroska',

3
src/lib/url.ts

@ -294,6 +294,8 @@ export function isMedia(url: string) { @@ -294,6 +294,8 @@ export function isMedia(url: string) {
'.mov',
'.mkv',
'.mka',
'.3gp',
'.3g2',
'.mp3',
'.wav',
'.flac',
@ -344,6 +346,7 @@ export function isVideo(url: string) { @@ -344,6 +346,7 @@ export function isVideo(url: string) {
'.mkv',
'.m4v',
'.3gp',
'.3g2',
'.ogv'
]
return videoExtensions.some((ext) => path.endsWith(ext))

6
src/services/content-parser.service.ts

@ -774,7 +774,7 @@ class ContentParserService { @@ -774,7 +774,7 @@ class ContentParserService {
imageMatches.forEach(match => {
const url = match.match(/!\[[^\]]*\]\(([^)]+)\)/)?.[1]
if (url && !seenUrls.has(url)) {
const isVideo = /\.(mp4|webm|ogg)$/i.test(url)
const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url)
media.push({
url,
pubkey: event?.pubkey || '',
@ -789,7 +789,7 @@ class ContentParserService { @@ -789,7 +789,7 @@ class ContentParserService {
asciidocImageMatches.forEach(match => {
const url = match.match(/image::([^\[]+)\[/)?.[1]
if (url && !seenUrls.has(url)) {
const isVideo = /\.(mp4|webm|ogg)$/i.test(url)
const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url)
media.push({
url,
pubkey: event?.pubkey || '',
@ -804,7 +804,7 @@ class ContentParserService { @@ -804,7 +804,7 @@ class ContentParserService {
rawUrls.forEach(url => {
if (!seenUrls.has(url)) {
const isImage = /\.(jpeg|jpg|png|gif|webp|svg)$/i.test(url)
const isVideo = /\.(mp4|webm|ogg)$/i.test(url)
const isVideo = /\.(mp4|webm|ogg|ogv|mov|mkv|m4v|3gp|3g2)$/i.test(url)
if (isImage || isVideo) {
media.push({
url,

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

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
import { Event } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
/** Any URL we may embed or extract from note bodies (incl. video-only extensions like .3gp). */
function isEmbeddableMediaUrl(cleaned: string): boolean {
return isImage(cleaned) || isMedia(cleaned) || isVideo(cleaned) || isAudio(cleaned)
}
import { TImetaInfo } from '@/types'
import mediaUpload from './media-upload.service'
import { getImetaInfoFromImetaTag } from '@/lib/tag'
@ -29,8 +34,7 @@ export function extractAllMediaFromEvent( @@ -29,8 +34,7 @@ export function extractAllMediaFromEvent(
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
// Only add if it's actually an image or media file
if (!isImage(cleaned) && !isMedia(cleaned)) return
if (!isEmbeddableMediaUrl(cleaned)) return
seenUrls.add(cleaned)
@ -60,18 +64,47 @@ export function extractAllMediaFromEvent( @@ -60,18 +64,47 @@ export function extractAllMediaFromEvent(
imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return
const nip94Signals = !!(info.blurHash || info.dim || info.x)
if (
info.m?.startsWith('image/') ||
info.m?.startsWith('video/') ||
info.m?.startsWith('audio/') ||
isImage(info.url) ||
isMedia(info.url)
isMedia(info.url) ||
isVideo(info.url) ||
isAudio(info.url) ||
// Blossom / NIP-94 URLs often have no file extension; metadata still identifies the blob.
(nip94Signals && !!info.url)
) {
seenUrls.add(cleaned)
allMedia.push({ ...info, url: cleaned })
}
})
// Non-standard imeta layouts (no `url ` prefix, concatenated fields, etc.)
const looseHttpsFromImetaValue = (s: string): string[] => {
const out: string[] = []
const re = /https?:\/\/[^\s<>"'[\]()]+/gi
let m: RegExpExecArray | null
re.lastIndex = 0
while ((m = re.exec(s)) !== null) {
out.push(m[0])
}
return out
}
event.tags.forEach((tag) => {
if (tag[0] !== 'imeta') return
if (getImetaInfoFromImetaTag(tag, event.pubkey)) return
for (let i = 1; i < tag.length; i++) {
const part = tag[i]
if (typeof part !== 'string') continue
for (const raw of looseHttpsFromImetaValue(part)) {
addMedia(raw, event.pubkey)
}
}
})
// 2. Extract from image tag
const imageTag = event.tags.find((tag) => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
@ -87,7 +120,7 @@ export function extractAllMediaFromEvent( @@ -87,7 +120,7 @@ export function extractAllMediaFromEvent(
while ((imgMatch = markdownImageRegex.exec(content)) !== null) {
if (imgMatch[1]) {
const url = imgMatch[1]
if (isImage(url) || isMedia(url)) {
if (isEmbeddableMediaUrl(cleanUrl(url) || url)) {
addMedia(url)
}
}
@ -98,17 +131,36 @@ export function extractAllMediaFromEvent( @@ -98,17 +131,36 @@ export function extractAllMediaFromEvent(
const urlMatches = content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
if (isImage(url) || isMedia(url)) {
const c = cleanUrl(url) || url
if (isEmbeddableMediaUrl(c)) {
addMedia(url)
}
}
}
// 5. Try to match content URLs with imeta tags for better metadata (alt, dim, blurHash, m)
const imageIdentityKey = (url: string): string | null => {
try {
const u = cleanUrl(url)
if (!u) return null
const pathname = new URL(u).pathname
const filename = pathname.split('/').pop() || ''
if (filename && /^[a-f0-9]{32,}\.(png|jpg|jpeg|gif|webp|svg|avif|apng)$/i.test(filename)) {
return filename.toLowerCase()
}
return u
} catch {
return cleanUrl(url) || null
}
}
imetaInfos.forEach((imeta) => {
const imetaUrl = cleanUrl(imeta.url)
const imetaKey = imetaUrl ? imageIdentityKey(imetaUrl) : null
allMedia.forEach((media, index) => {
if (imetaUrl === media.url) {
if (imetaUrl && imetaUrl === media.url) {
allMedia[index] = { ...media, ...imeta, url: media.url }
} else if (imetaKey && imetaKey === imageIdentityKey(media.url)) {
allMedia[index] = { ...media, ...imeta, url: media.url }
} else {
// Try to get imeta from media upload service

Loading…
Cancel
Save