{displayImages.map((image, i) => (
@@ -105,46 +119,58 @@ export default function ImageGallery({
)
}
+ const portal =
+ lightboxPortalActive && typeof document !== 'undefined'
+ ? createPortal(
+
e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+ {
+ 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
+ }}
+ />
+
+
{imageContent}
- {createPortal(
-
e.stopPropagation()}
- onPointerDown={(e) => e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
- {
- 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
- }}
- />
-
,
- document.body
- )}
+ {portal}
)
}
diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx
index cd36a6a5..f31c8934 100644
--- a/src/components/ImageWithLightbox/index.tsx
+++ b/src/components/ImageWithLightbox/index.tsx
@@ -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'
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({
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(
+
e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+ = 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' }
+ }}
+ />
+
,
+ document.body
+ )
+ : null
+
return (
-
- {display ? (
-
handlePhotoClick(e)}
- />
- ) : (
- {
- e.stopPropagation()
- setDisplay(true)
- }}
- >
- [{t('Click to load image')}]
-
- )}
- {createPortal(
- e.stopPropagation()}
- onPointerDown={(e) => e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
- = 0}
- close={() => setIndex(-1)}
- controller={{
- closeOnBackdropClick: false,
- closeOnPullUp: true,
- closeOnPullDown: true
- }}
- render={{
- buttonPrev: () => null,
- buttonNext: () => null
- }}
- styles={{
- toolbar: { paddingTop: '2.25rem' }
- }}
- />
-
,
- document.body
- )}
+
+ handlePhotoClick(e)}
+ />
+ {portal}
)
}
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
index 7767e6cf..34b873e4 100644
--- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
+++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
@@ -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(
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; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } {
const {
@@ -619,7 +655,8 @@ function parseMarkdownContentLegacy(
fullCalendarInvite,
suppressStandaloneWebPreviewCleanedUrls,
containingEvent,
- lazyMedia = true
+ lazyMedia = true,
+ resolveImetaForImageUrl
} = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set()
@@ -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()
- 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(
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; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } {
const {
@@ -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({
// 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({
fullCalendarInvite,
containingEvent: event,
lazyMedia,
+ resolveImetaForImageUrl,
suppressStandaloneWebPreviewCleanedUrls:
webPreviewSuppressCleanedSet.size > 0 ? webPreviewSuppressCleanedSet : undefined
}
@@ -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
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index 2a347f2c..7cdbb31e 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -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({
}
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'
}
diff --git a/src/lib/article-media.ts b/src/lib/article-media.ts
index 3eb2d981..b7b3e9d1 100644
--- a/src/lib/article-media.ts
+++ b/src/lib/article-media.ts
@@ -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'
]
diff --git a/src/lib/compress-upload-media.ts b/src/lib/compress-upload-media.ts
index 65d77697..a806dea7 100644
--- a/src/lib/compress-upload-media.ts
+++ b/src/lib/compress-upload-media.ts
@@ -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
diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts
index d445ec92..d6fb711a 100644
--- a/src/lib/draft-event.ts
+++ b/src/lib/draft-event.ts
@@ -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(
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
})
}
diff --git a/src/lib/image-extraction.ts b/src/lib/image-extraction.ts
index a5d69770..1552104f 100644
--- a/src/lib/image-extraction.ts
+++ b/src/lib/image-extraction.ts
@@ -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 {
* 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',
diff --git a/src/lib/lightbox-slides.ts b/src/lib/lightbox-slides.ts
index 33c619a6..6a9e6bd5 100644
--- a/src/lib/lightbox-slides.ts
+++ b/src/lib/lightbox-slides.ts
@@ -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'
}
diff --git a/src/lib/media-kind-detection.ts b/src/lib/media-kind-detection.ts
index 06df39fa..82a15025 100644
--- a/src/lib/media-kind-detection.ts
+++ b/src/lib/media-kind-detection.ts
@@ -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)
diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx
index cc49693a..d24975be 100644
--- a/src/lib/nostr-parser.tsx
+++ b/src/lib/nostr-parser.tsx
@@ -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
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,
diff --git a/src/lib/tag.ts b/src/lib/tag.ts
index 47d11d5d..19f959c8 100644
--- a/src/lib/tag.ts
+++ b/src/lib/tag.ts
@@ -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
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
+ }
}
}
diff --git a/src/lib/upload-nip94-imeta.ts b/src/lib/upload-nip94-imeta.ts
index 627ad4e0..8928be62 100644
--- a/src/lib/upload-nip94-imeta.ts
+++ b/src/lib/upload-nip94-imeta.ts
@@ -18,6 +18,8 @@ const EXT_TO_MIME: Record = {
'.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',
diff --git a/src/lib/url.ts b/src/lib/url.ts
index 3f714510..e2589eb1 100644
--- a/src/lib/url.ts
+++ b/src/lib/url.ts
@@ -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) {
'.mkv',
'.m4v',
'.3gp',
+ '.3g2',
'.ogv'
]
return videoExtensions.some((ext) => path.endsWith(ext))
diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts
index ed9511b7..7b2688da 100644
--- a/src/services/content-parser.service.ts
+++ b/src/services/content-parser.service.ts
@@ -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 {
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 {
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,
diff --git a/src/services/media-extraction.service.ts b/src/services/media-extraction.service.ts
index 38f9ee76..e7633b2f 100644
--- a/src/services/media-extraction.service.ts
+++ b/src/services/media-extraction.service.ts
@@ -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(
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(
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(
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(
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