Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/components/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLink, faMagnifyingGlass, faMusic } from '@fortawesome/free-solid-svg-icons';
import { getFilenameFromUrl, isAbsoluteHttpUrl } from '@/lib/utils/urlUtils';

interface AudioPlayerProps {
src: string;
onClickSearch?: () => void;
}

export default function AudioPlayer({ src, onClickSearch }: AudioPlayerProps) {
if (!isAbsoluteHttpUrl(src)) {
return null;
}

const extension = getFilenameFromUrl(src).match(/\.([a-z0-9]+)$/i)?.[1]?.toUpperCase();

return (
<div className="rounded-md border border-[#3d3d3d] bg-[#1f1f1f] p-3">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-gray-400">
<span className="flex items-center gap-2 truncate">
<FontAwesomeIcon icon={faMusic} className="text-[10px]" />
{extension ? <span>{extension}</span> : null}
</span>
<div className="flex items-center gap-2">
{onClickSearch ? (
<button
type="button"
className="text-gray-400 transition-colors hover:text-gray-200"
title="Search for this audio"
onClick={(event) => {
event.stopPropagation();
onClickSearch();
}}
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-xs" />
</button>
) : null}
<button
type="button"
className="text-gray-400 transition-colors hover:text-gray-200"
title="Open audio in new tab"
onClick={(event) => {
event.stopPropagation();
window.open(src, '_blank', 'noopener,noreferrer');
}}
>
<FontAwesomeIcon icon={faExternalLink} className="text-xs" />
</button>
</div>
</div>
<audio controls preload="metadata" className="w-full">
<source src={src} />
Your browser does not support the audio tag.
</audio>
</div>
);
}
13 changes: 12 additions & 1 deletion src/components/NoteMedia.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { extractMediaFromContent, getSearchQueryFromMedia, isValidMediaUrl, getTrimmedMediaUrl } from '@/lib/utils/mediaUtils';
import ImageWithBlurhash from '@/components/ImageWithBlurhash';
import VideoWithBlurhash from '@/components/VideoWithBlurhash';
import AudioPlayer from '@/components/AudioPlayer';
import UrlPreview from '@/components/UrlPreview';

interface NoteMediaProps {
Expand Down Expand Up @@ -45,12 +46,22 @@ export default function NoteMedia({ content, onSearch, onUrlLoaded }: NoteMediaP
className="relative w-full overflow-hidden rounded-md border border-[#3d3d3d] bg-black"
>
<VideoWithBlurhash
src={item.src}
src={getTrimmedMediaUrl(item.src)}
onClickSearch={() => onSearch(getSearchQueryFromMedia(item.src))}
/>
</div>
);
}

if (item.type === 'audio') {
return (
<AudioPlayer
key={key}
src={getTrimmedMediaUrl(item.src)}
onClickSearch={() => onSearch(getSearchQueryFromMedia(item.src))}
/>
);
}

if (item.type === 'url') {
return (
Expand Down
14 changes: 13 additions & 1 deletion src/components/VideoWithBlurhash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { useState, useEffect, useRef } from 'react';
import { Blurhash } from 'react-blurhash';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faImage, faExternalLink, faPlay, faPause } from '@fortawesome/free-solid-svg-icons';
import { isAbsoluteHttpUrl } from '@/lib/utils/urlUtils';
import { isAbsoluteHttpUrl, isAmbiguousAudioVideoUrl } from '@/lib/utils/urlUtils';
import SearchIconButton from './SearchIconButton';
import ReverseImageSearchButton from './ReverseImageSearchButton';
import AudioPlayer from './AudioPlayer';

interface VideoWithBlurhashProps {
src: string;
Expand All @@ -24,19 +25,22 @@ export default function VideoWithBlurhash({
const [statusCode, setStatusCode] = useState<number | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [showControls, setShowControls] = useState(false);
const [renderAsAudio, setRenderAsAudio] = useState(false);
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(() => {
if (dim && dim.width > 0 && dim.height > 0) {
return dim;
}
return null;
});
const videoRef = useRef<HTMLVideoElement>(null);
const allowAudioFallback = isAmbiguousAudioVideoUrl(src);

useEffect(() => {
setVideoLoaded(false);
setVideoError(false);
setIsPlaying(false);
setShowControls(false);
setRenderAsAudio(false);
if (dim && dim.width > 0 && dim.height > 0) {
setDimensions(dim);
}
Expand Down Expand Up @@ -70,6 +74,10 @@ export default function VideoWithBlurhash({
return null;
}

if (renderAsAudio) {
return <AudioPlayer src={src} onClickSearch={onClickSearch} />;
}

const aspectStyle = dimensions && dimensions.width > 0 && dimensions.height > 0
? { aspectRatio: `${dimensions.width} / ${dimensions.height}` }
: { minHeight: '200px' as const };
Expand Down Expand Up @@ -133,6 +141,10 @@ export default function VideoWithBlurhash({
}`}
onLoadedMetadata={(event) => {
const element = event.currentTarget;
if (allowAudioFallback && (!element.videoWidth || !element.videoHeight)) {
setRenderAsAudio(true);
return;
}
if (element?.videoWidth && element?.videoHeight) {
setDimensions({ width: element.videoWidth, height: element.videoHeight });
}
Expand Down
9 changes: 7 additions & 2 deletions src/lib/utils/mediaUtils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { extractImageUrls, extractVideoUrls, extractNonMediaUrls } from '@/lib/utils/urlUtils';
import { extractAudioUrls, extractImageUrls, extractVideoUrls, extractNonMediaUrls } from '@/lib/utils/urlUtils';
import { isAbsoluteHttpUrl, getFilenameFromUrl } from '@/lib/utils/urlUtils';
import { trimImageUrl } from '@/lib/utils';

export interface MediaItem {
type: 'image' | 'video' | 'url';
type: 'image' | 'video' | 'audio' | 'url';
src: string;
index: number;
}
Expand All @@ -21,6 +21,11 @@ export const extractMediaFromContent = (content: string): MediaItem[] => {
items.push({ type: 'video', src: src.trim(), index });
});

// Extract audio links (limit to 2)
extractAudioUrls(content).slice(0, 2).forEach((src, index) => {
items.push({ type: 'audio', src: src.trim(), index });
});

// Extract non-media URLs (limit to 2)
extractNonMediaUrls(content).slice(0, 2).forEach((src, index) => {
items.push({ type: 'url', src: src.trim(), index });
Expand Down
7 changes: 4 additions & 3 deletions src/lib/utils/textUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ export const normalizeWhitespace = (text: string): string => {
};

/**
* Strips media URLs (images and videos) from text content
* Strips media URLs (images, videos, and audio links) from text content
* Removes various media file extensions and query parameters
*/
export const stripMediaUrls = (text: string): string => {
if (!text) return '';
const cleaned = text
.replace(/(https?:\/\/[^\s'"<>]+?\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg))(?:[?#][^\s]*)?/gi, '')
.replace(/(https?:\/\/[^\s'"<>]+?\.(?:m4a|mp3|wav|flac|aac|opus))(?:[?#][^\s]*)?/gi, '')
.replace(/(https?:\/\/[^\s'"<>]+?\.(?:mp4|webm|ogg|ogv|mov|m4v))(?:[?#][^\s]*)?/gi, '')
.replace(/\?[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '')
.replace(/\?name=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '');
.replace(/\?[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '')
.replace(/\?name=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '');
Comment on lines +23 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten query-param stripping to avoid mutating non-media URLs.

Line 23 currently removes any query substring that ends with a media-like extension, which can corrupt unrelated links (e.g., search URLs containing .mp3 in query text). Restrict stripping to explicit filename-style params instead of any ?…ext… pattern.

Proposed fix
-    .replace(/\?[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '')
-    .replace(/\?name=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '');
+    .replace(/\?(?:name|filename)=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.replace(/\?[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '')
.replace(/\?name=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '');
.replace(/\?(?:name|filename)=[^\s]*\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg|m4a|mp3|wav|flac|aac|opus|mp4|webm|ogg|ogv|mov|m4v)[^\s]*/gi, '');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/utils/textUtils.ts` around lines 23 - 24, The regex patterns in the
replace calls are too broad and strip any query parameter substring ending with
media file extensions, which corrupts unrelated URLs that contain extension-like
strings in their query text. Tighten both regex patterns to only match explicit
filename-style parameters by requiring the extension to be part of an actual
filename context (such as after an equals sign or in a filename parameter
format) rather than matching any arbitrary text followed by a media extension.
This ensures that search queries or other non-media URLs containing `.mp3` or
similar in their query values are not inadvertently corrupted.

return normalizeWhitespace(cleaned);
};

Expand Down
27 changes: 22 additions & 5 deletions src/lib/utils/urlUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,23 +256,42 @@ function extractUrlsBase(text: string, filterFn?: (url: string) => boolean): str
return urls;
}

const imageExtRegex = /\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg)(?:$|[?#])/i;
const audioExtRegex = /\.(?:m4a|mp3|wav|flac|aac|opus)(?:$|[?#])/i;
const ambiguousAudioVideoExtRegex = /\.(?:ogg|webm)(?:$|[?#])/i;
const videoExtRegex = /\.(?:mp4|webm|ogg|ogv|mov|m4v)(?:$|[?#])/i;

/**
* Extract image URLs from text content
* @param text - The text to search for image URLs
* @returns Array of image URLs found
*/
export function extractImageUrls(text: string): string[] {
const imageExtRegex = /\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg)(?:$|[?#])/i;
return extractUrlsBase(text, (url) => imageExtRegex.test(url));
}

/**
* Extract audio URLs from text content
* @param text - The text to search for audio URLs
* @returns Array of audio URLs found
*/
export function extractAudioUrls(text: string): string[] {
return extractUrlsBase(text, (url) => audioExtRegex.test(url));
}

/**
* Check if a media URL could be either audio or video based on its extension
*/
export function isAmbiguousAudioVideoUrl(url: string): boolean {
return ambiguousAudioVideoExtRegex.test(url);
}

/**
* Extract video URLs from text content
* @param text - The text to search for video URLs
* @returns Array of video URLs found
*/
export function extractVideoUrls(text: string): string[] {
const videoExtRegex = /\.(?:mp4|webm|ogg|ogv|mov|m4v)(?:$|[?#])/i;
return extractUrlsBase(text, (url) => videoExtRegex.test(url));
}

Expand All @@ -282,9 +301,7 @@ export function extractVideoUrls(text: string): string[] {
* @returns Array of non-media URLs found
*/
export function extractNonMediaUrls(text: string): string[] {
const imageExtRegex = /\.(?:png|jpe?g|gif|gifs|apng|webp|avif|svg)(?:$|[?#])/i;
const videoExtRegex = /\.(?:mp4|webm|ogg|ogv|mov|m4v)(?:$|[?#])/i;
return extractUrlsBase(text, (url) => !imageExtRegex.test(url) && !videoExtRegex.test(url));
return extractUrlsBase(text, (url) => !imageExtRegex.test(url) && !audioExtRegex.test(url) && !videoExtRegex.test(url));
}

/**
Expand Down
Loading