Fix media sidebar: link to events, favicon .ico/.svg fallback, fix image rendering

- Media tiles now link to the source event (nevent) instead of opening the raw image
- Favicons try /favicon.ico first, then fall back to /favicon.svg
- Add explicit image-rendering: auto to fix crispy/aliased thumbnails
- Data model now tracks eventId + authorPubkey per media item

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-17 03:52:59 -06:00
parent 518c659dc9
commit 40ea816f5d
+75 -51
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Check, QrCode, ExternalLink, Bitcoin } from 'lucide-react';
@@ -6,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/hooks/useToast';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import QRCode from 'qrcode';
interface ProfileField {
@@ -18,20 +21,29 @@ interface ProfileRightSidebarProps {
fields?: ProfileField[];
}
/** Extracts media URLs (images and videos) from kind 1 event content. */
function extractMediaUrls(content: string): string[] {
const imageRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi;
const videoRegex = /https?:\/\/[^\s]+\.(mp4|webm|mov)(\?[^\s]*)?/gi;
const images = content.match(imageRegex) || [];
const videos = content.match(videoRegex) || [];
return [...images, ...videos];
interface MediaItem {
url: string;
eventId: string;
authorPubkey: string;
}
/** Hook to query media from a user's posts. */
/** Extracts image URLs from content. */
function extractImageUrls(content: string): string[] {
const regex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi;
return content.match(regex) || [];
}
/** Extracts video URLs from content. */
function extractVideoUrls(content: string): string[] {
const regex = /https?:\/\/[^\s]+\.(mp4|webm|mov)(\?[^\s]*)?/gi;
return content.match(regex) || [];
}
/** Hook to query media from a user's posts, returning URL + event ID pairs. */
function useProfileMedia(pubkey: string) {
const { nostr } = useNostr();
return useQuery<string[]>({
return useQuery<MediaItem[]>({
queryKey: ['profile-media', pubkey],
queryFn: async ({ signal }) => {
const events = await nostr.query(
@@ -39,34 +51,65 @@ function useProfileMedia(pubkey: string) {
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const urls: string[] = [];
const items: MediaItem[] = [];
const seen = new Set<string>();
for (const event of events) {
const media = extractMediaUrls(event.content);
for (const url of media) {
const images = extractImageUrls(event.content);
const videos = extractVideoUrls(event.content);
for (const url of [...images, ...videos]) {
if (!seen.has(url)) {
seen.add(url);
urls.push(url);
items.push({ url, eventId: event.id, authorPubkey: event.pubkey });
}
}
}
return urls.slice(0, 9);
return items.slice(0, 9);
},
enabled: !!pubkey,
staleTime: 5 * 60 * 1000,
});
}
/** Get favicon URL from the site's own /favicon.ico. */
function getFaviconUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.origin}/favicon.ico`;
} catch {
return '';
}
/** Build a nevent link for navigating to an event. */
function eventLink(item: MediaItem): string {
return `/${nip19.neventEncode({ id: item.eventId, author: item.authorPubkey })}`;
}
/** Try multiple favicon paths: /favicon.ico, then /favicon.svg. */
function Favicon({ url }: { url: string }) {
const candidates = useMemo(() => {
try {
const origin = new URL(url).origin;
return [`${origin}/favicon.ico`, `${origin}/favicon.svg`];
} catch {
return [];
}
}, [url]);
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
if (candidates.length === 0 || failed) return null;
const src = candidates[index];
return (
<img
src={src}
alt=""
className="size-4 shrink-0"
loading="lazy"
onError={() => {
if (index < candidates.length - 1) {
setIndex(index + 1);
} else {
setFailed(true);
}
}}
/>
);
}
/** Bitcoin QR code modal */
@@ -139,24 +182,6 @@ function BitcoinQRModal({ address }: { address: string }) {
);
}
/** Favicon image with graceful fallback. */
function Favicon({ url }: { url: string }) {
const faviconSrc = getFaviconUrl(url);
const [failed, setFailed] = useState(false);
if (!faviconSrc || failed) return null;
return (
<img
src={faviconSrc}
alt=""
className="size-4 shrink-0"
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
/** A single profile field row. Handles $BTC specially. */
function ProfileFieldRow({ field }: { field: ProfileField }) {
const [copied, setCopied] = useState(false);
@@ -252,32 +277,31 @@ export function ProfileRightSidebar({ pubkey, fields }: ProfileRightSidebarProps
</div>
) : media && media.length > 0 ? (
<div className="grid grid-cols-3 gap-0.5">
{media.map((url, i) => {
const isVideo = /\.(mp4|webm|mov)(\?.*)?$/i.test(url);
{media.map((item, i) => {
const isVideo = /\.(mp4|webm|mov)(\?.*)?$/i.test(item.url);
return (
<a
<Link
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
className="aspect-square rounded-lg overflow-hidden hover:opacity-80 transition-opacity"
to={eventLink(item)}
className="aspect-square rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
>
{isVideo ? (
<video
src={url}
src={item.url}
className="w-full h-full object-cover"
muted
preload="metadata"
/>
) : (
<img
src={url}
src={item.url}
alt=""
className="w-full h-full object-cover"
style={{ imageRendering: 'auto' }}
loading="lazy"
/>
)}
</a>
</Link>
);
})}
</div>