Browse Source

fix image and media rendering kind 1

imwald
Silberengel 5 months ago
parent
commit
10ea704004
  1. 277
      src/components/Content/index.tsx
  2. 3
      src/components/Note/index.tsx
  3. 329
      src/components/UniversalContent/EnhancedContent.tsx
  4. 37
      src/lib/url.ts

277
src/components/Content/index.tsx

@ -13,10 +13,11 @@ import logger from '@/lib/logger' @@ -13,10 +13,11 @@ import logger from '@/lib/logger'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl } from '@/lib/url'
import { cleanUrl, isImage, isMedia } from '@/lib/url'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { tagNameEquals } from '@/lib/tag'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
@ -58,59 +59,108 @@ export default function Content({ @@ -58,59 +59,108 @@ export default function Content({
EmbeddedEmojiParser
])
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
// Always ensure we have a valid image info object
const imageInfo = imetaInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
// Collect all images from multiple sources and deduplicate using cleaned URLs
const seenUrls = new Set<string>()
const allImages: TImetaInfo[] = []
// Helper to add image if not already seen (using cleaned URL for comparison)
const addImage = (url: string, pubkey?: string, mimeType?: string) => {
if (!url) return
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
seenUrls.add(cleaned)
allImages.push({
url: cleaned,
pubkey: pubkey || event?.pubkey,
m: mimeType || (isImage(cleaned) ? 'image/*' : 'media/*')
})
}
// 1. Extract from imeta tags
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => {
if (info.m?.startsWith('image/') || info.m?.startsWith('video/') || isImage(info.url) || isMedia(info.url)) {
addImage(info.url, info.pubkey, info.m)
}
})
}
// 2. Extract from r tags (reference/URL tags)
if (event) {
event.tags.filter(tagNameEquals('r')).forEach(([, url]) => {
if (url && (isImage(url) || isMedia(url))) {
addImage(url)
}
})
}
// 2b. Extract from image tag
if (event) {
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addImage(imageTag[1])
}
}
// 3. Extract from content nodes (already parsed URLs)
nodes.forEach((node) => {
if (node.type === 'image') {
addImage(node.data)
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => addImage(url))
} else if (node.type === 'url') {
// Check if URL is an image/media file
if (isImage(node.data) || isMedia(node.data)) {
addImage(node.data)
}
}
})
// 4. Extract directly from raw content (catch any URLs that weren't parsed)
// This ensures we don't miss any image URLs in the content
if (_content) {
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = _content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
if (isImage(url) || isMedia(url)) {
addImage(url)
}
}
}
// 5. Try to match content URLs with imeta tags for better metadata
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
allImages.forEach((img, index) => {
// Try to find matching imeta info
const matchedImeta = imetaInfos.find(imeta => cleanUrl(imeta.url) === img.url)
if (matchedImeta && matchedImeta.m) {
allImages[index] = { ...img, m: matchedImeta.m }
} else {
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(node.data)
const tag = mediaUpload.getImetaTagByUrl(img.url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey)
const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey)
if (parsedImeta) {
return parsedImeta
allImages[index] = parsedImeta
}
}
// Fallback: always create a basic image info object with cleaned URL
return { url: cleanUrl(node.data), pubkey: event?.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imetaInfos.find((image) => image.url === url)
if (imageInfo) {
return imageInfo
}
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey)
if (parsedImeta) {
return parsedImeta
}
}
// Fallback: always create a basic image info object with cleaned URL
return { url: cleanUrl(url), pubkey: event?.pubkey }
})
}
return null
})
.filter(Boolean)
.flat() as TImetaInfo[]
}
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
@ -119,36 +169,155 @@ export default function Content({ @@ -119,36 +169,155 @@ export default function Content({
return null
}
let imageIndex = 0
logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, allImages: allImages.length, nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) })
// Create maps for quick lookup of images/media by cleaned URL
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
allImages.forEach((img) => {
if (img.m?.startsWith('image/')) {
imageMap.set(img.url, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') {
mediaMap.set(img.url, img)
} else if (isImage(img.url)) {
imageMap.set(img.url, img)
} else if (isMedia(img.url)) {
mediaMap.set(img.url, img)
}
})
logger.debug('[Content] Parsed content:', {
nodeCount: nodes.length,
allImages: allImages.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
allImageUrls: allImages.map(img => img.url),
nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data }))
})
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{/* Render all images/media once in a single carousel if we have any */}
{allImages.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="all-images-gallery"
images={allImages}
start={0}
end={allImages.length}
mustLoad={mustLoadMedia}
/>
)}
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
logger.debug('[Content] Creating ImageGallery:', { nodeType: node.type, start, end, totalImages: allImages.length, nodeData: Array.isArray(node.data) ? node.data.length : node.data })
// Render images individually in their content position
if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data)
const imageInfo = imageMap.get(cleanedUrl)
logger.debug('[Content] Rendering image node:', { cleanedUrl, hasImageInfo: !!imageInfo, imageMapKeys: Array.from(imageMap.keys()) })
// Always render, use imageInfo if available
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
key={`img-${index}`}
images={imageInfo ? [imageInfo] : [{ url: cleanedUrl, pubkey: event?.pubkey }]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
const imageInfos = urls
.map(url => {
const cleaned = cleanUrl(url)
return imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey }
})
.filter(Boolean) as TImetaInfo[]
if (imageInfos.length > 0) {
return (
<ImageGallery
className="mt-2"
key={`imgs-${index}`}
images={imageInfos}
start={0}
end={imageInfos.length}
mustLoad={mustLoadMedia}
/>
)
}
return null
}
// Render media individually in their content position
if (node.type === 'media') {
const cleanedUrl = cleanUrl(node.data)
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
<MediaPlayer
className="mt-2"
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
// Check if it's an image or media that should be rendered inline
// Also check if it's an image/media file even if not in our maps
const isImageUrl = isImage(cleanedUrl)
const isMediaUrl = isMedia(cleanedUrl)
if (imageMap.has(cleanedUrl)) {
const imageInfo = imageMap.get(cleanedUrl)!
return (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[imageInfo]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
if (isImageUrl) {
// It's an image URL but not in our map, render it anyway
logger.debug('[Content] Rendering image URL node:', { cleanedUrl, isImageUrl })
return (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[{ url: cleanedUrl, pubkey: event?.pubkey }]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
if (mediaMap.has(cleanedUrl)) {
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
if (isMediaUrl) {
// It's a media URL but not in our map, render it anyway
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
// Regular URL, not an image or media
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {

3
src/components/Note/index.tsx

@ -164,6 +164,9 @@ export default function Note({ @@ -164,6 +164,9 @@ export default function Note({
content = <EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
} else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) {
// Plain text notes use EnhancedContent for proper image/media rendering
content = <EnhancedContent className="mt-2" event={event} mustLoadMedia={false} />
} else {
// Use MarkdownArticle for all other kinds
// Only 30023, 30041, 30817, and 30818 will show image gallery and article info

329
src/components/UniversalContent/EnhancedContent.tsx

@ -16,9 +16,9 @@ import { @@ -16,9 +16,9 @@ import {
} from '@/lib/content-parser'
import logger from '@/lib/logger'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag, tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl } from '@/lib/url'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
@ -83,59 +83,123 @@ export default function EnhancedContent({ @@ -83,59 +83,123 @@ export default function EnhancedContent({
EmbeddedEmojiParser
])
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
// Always ensure we have a valid image info object
const imageInfo = imetaInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
// Collect all images from multiple sources and deduplicate using cleaned URLs
const seenUrls = new Set<string>()
const allImages: TImetaInfo[] = []
// Helper to add image/media if not already seen (using cleaned URL for comparison)
const addImage = (url: string, pubkey?: string, mimeType?: string) => {
if (!url) return
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
seenUrls.add(cleaned)
// Determine mime type if not provided
let mime = mimeType
if (!mime) {
if (isImage(cleaned)) {
mime = 'image/*'
} else if (isAudio(cleaned)) {
mime = 'audio/*'
} else if (isVideo(cleaned)) {
mime = 'video/*'
} else {
mime = 'media/*'
}
}
allImages.push({
url: cleaned,
pubkey: pubkey || event?.pubkey,
m: mime
})
}
// 1. Extract from imeta tags
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => {
if (info.m?.startsWith('image/') || info.m?.startsWith('video/') || info.m?.startsWith('audio/') || isImage(info.url) || isMedia(info.url)) {
addImage(info.url, info.pubkey, info.m)
}
})
}
// 2. Extract from r tags (reference/URL tags)
if (event) {
event.tags.filter(tagNameEquals('r')).forEach(([, url]) => {
if (url && (isImage(url) || isMedia(url))) {
addImage(url)
}
})
}
// 2b. Extract from image tag
if (event) {
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
addImage(imageTag[1])
}
}
// 3. Extract from content nodes (already parsed URLs)
nodes.forEach((node) => {
if (node.type === 'image') {
addImage(node.data)
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => addImage(url))
} else if (node.type === 'url') {
// Check if URL is an image/media file
if (isImage(node.data) || isMedia(node.data)) {
addImage(node.data)
}
}
})
// 4. Extract directly from raw content (catch any URLs that weren't parsed)
// This ensures we don't miss any image URLs in the content
if (_content) {
const urlRegex = /https?:\/\/[^\s<>"']+/g
const urlMatches = _content.matchAll(urlRegex)
for (const match of urlMatches) {
const url = match[0]
if (isImage(url) || isMedia(url)) {
addImage(url)
}
}
}
// 5. Try to match content URLs with imeta tags for better metadata
if (event) {
const imetaInfos = getImetaInfosFromEvent(event)
allImages.forEach((img, index) => {
// Try to find matching imeta info
const matchedImeta = imetaInfos.find(imeta => cleanUrl(imeta.url) === img.url)
if (matchedImeta && matchedImeta.m) {
allImages[index] = { ...img, m: matchedImeta.m }
} else {
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(node.data)
const tag = mediaUpload.getImetaTagByUrl(img.url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey)
const parsedImeta = getImetaInfoFromImetaTag(tag, event.pubkey)
if (parsedImeta) {
return parsedImeta
allImages[index] = parsedImeta
}
}
// Fallback: always create a basic image info object with cleaned URL
return { url: cleanUrl(node.data), pubkey: event?.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imetaInfos.find((image) => image.url === url)
if (imageInfo) {
return imageInfo
}
// Try to get imeta from media upload service
const tag = mediaUpload.getImetaTagByUrl(url)
if (tag) {
const parsedImeta = getImetaInfoFromImetaTag(tag, event?.pubkey)
if (parsedImeta) {
return parsedImeta
}
}
// Fallback: always create a basic image info object with cleaned URL
return { url: cleanUrl(url), pubkey: event?.pubkey }
})
}
return null
})
.filter(Boolean)
.flat() as TImetaInfo[]
}
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
@ -144,36 +208,181 @@ export default function EnhancedContent({ @@ -144,36 +208,181 @@ export default function EnhancedContent({
return null
}
let imageIndex = 0
logger.debug('[Content] Parsed content:', { nodeCount: nodes.length, allImages: allImages.length, nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data })) })
// Create maps for quick lookup of images/media by cleaned URL
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
allImages.forEach((img) => {
if (img.m?.startsWith('image/')) {
imageMap.set(img.url, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') {
mediaMap.set(img.url, img)
} else if (isImage(img.url)) {
imageMap.set(img.url, img)
} else if (isMedia(img.url)) {
mediaMap.set(img.url, img)
}
})
logger.debug('[EnhancedContent] Parsed content:', {
nodeCount: nodes.length,
allImages: allImages.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
allImageUrls: allImages.map(img => img.url),
nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data }))
})
// Track which images/media have been rendered individually to prevent duplicates
const renderedUrls = new Set<string>()
// First pass: find which images/media appear in the content (will be rendered in a single carousel)
const mediaInContent = new Set<string>()
const imagesInContent: TImetaInfo[] = [] // Collect actual image info for carousel
nodes.forEach((node) => {
if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => {
const cleaned = cleanUrl(url)
mediaInContent.add(cleaned)
const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleaned)) {
imagesInContent.push(imageInfo)
}
})
} else if (node.type === 'media') {
mediaInContent.add(cleanUrl(node.data))
} else if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
if (isImage(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (isMedia(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
}
}
})
// Filter carousel: only show IMAGES that DON'T appear in content
// (videos and audio should never be in carousel - they're rendered individually)
// (images in content will be rendered in a single carousel, not individually)
const carouselImages = allImages.filter(img => {
// Never include videos or audio in carousel
if (isVideo(img.url) || isAudio(img.url) || img.m?.startsWith('video/') || img.m?.startsWith('audio/')) {
return false
}
// Only include images that don't appear in content
return !mediaInContent.has(img.url) && isImage(img.url)
})
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{/* Render images that appear in content in a single carousel at the top */}
{imagesInContent.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="content-images-gallery"
images={imagesInContent}
start={0}
end={imagesInContent.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Render images/media that aren't in content in a single carousel */}
{carouselImages.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="all-images-gallery"
images={carouselImages}
start={0}
end={carouselImages.length}
mustLoad={mustLoadMedia}
/>
)}
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
// Skip image nodes - they're rendered in the carousel at the top
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
logger.debug('[Content] Creating ImageGallery:', { nodeType: node.type, start, end, totalImages: allImages.length, nodeData: Array.isArray(node.data) ? node.data.length : node.data })
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
mustLoad={mustLoadMedia}
/>
)
return null
}
// Render media individually in their content position (only once per URL)
if (node.type === 'media') {
const cleanedUrl = cleanUrl(node.data)
// Skip if already rendered
if (renderedUrls.has(cleanedUrl)) {
return null
}
renderedUrls.add(cleanedUrl)
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
<MediaPlayer
className="mt-2"
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
// Check if it's an image, video, or audio that should be rendered inline
const isImageUrl = isImage(cleanedUrl)
const isVideoUrl = isVideo(cleanedUrl)
const isAudioUrl = isAudio(cleanedUrl)
// Skip if already rendered (regardless of type)
if (renderedUrls.has(cleanedUrl)) {
return null
}
// Check video/audio first - never put them in ImageGallery
if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) {
renderedUrls.add(cleanedUrl)
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
// Skip image URLs - they're rendered in the carousel at the top if they're in content
// Only render if they're NOT in content (from r tags, etc.)
if (isImageUrl) {
// If it's in content, skip it (already in carousel)
if (mediaInContent.has(cleanedUrl)) {
return null
}
// Otherwise it's an image from r tags not in content, render it
renderedUrls.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
return (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[imageInfo]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
// Regular URL, not an image or media
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {

37
src/lib/url.ts

@ -173,6 +173,43 @@ export function isMedia(url: string) { @@ -173,6 +173,43 @@ export function isMedia(url: string) {
}
}
export function isAudio(url: string) {
try {
const audioExtensions = [
'.mp3',
'.wav',
'.flac',
'.aac',
'.m4a',
'.opus',
'.wma',
'.ogg' // ogg can be audio
]
return audioExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}
export function isVideo(url: string) {
try {
const videoExtensions = [
'.mp4',
'.webm',
'.mov',
'.avi',
'.wmv',
'.flv',
'.mkv',
'.m4v',
'.3gp'
]
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
} catch {
return false
}
}
/**
* Remove tracking parameters from URLs
* Removes common tracking parameters like utm_*, fbclid, gclid, etc.

Loading…
Cancel
Save