6f5a07c0a9
- Images now render inline via NoteContent at the position of their URL rather than collected into a gallery block below the text - Clicking an inline image opens a full lightbox with navigation across all images in the post; lightbox index is tracked by position so duplicate URLs open at the correct index - Removed separate ImageGallery/extractImages from NoteCard, PostDetailPage, and ComposeBox to prevent double-rendering - Added disableEmbeds to NoteMoreMenu clamped preview to avoid block embeds inside a line-clamp-3 container - Fixed hasPreviewableContent in ComposeBox to detect image-only posts so the Preview tab appears correctly - Removed mp3/ogg/wav/pdf from MEDIA_URL_REGEX suppression list; those URLs now render as clickable links instead of being silently dropped - Added avif to IMAGE_URL_REGEX; added cursor-pointer to InlineImage
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
import { useMemo, useState, useCallback } from 'react';
|
|
import { type NostrEvent } from '@nostrify/nostrify';
|
|
import { Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
|
import { LinkPreview } from '@/components/LinkPreview';
|
|
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
|
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
|
import { YouTubeEmbed } from '@/components/YouTubeEmbed';
|
|
import { Lightbox } from '@/components/ImageGallery';
|
|
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
|
import { buildEmojiMap, emojify, EmojifiedText } from '@/components/CustomEmoji';
|
|
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
|
import { cn } from '@/lib/utils';
|
|
import type { AddrCoords } from '@/hooks/useEvent';
|
|
|
|
interface NoteContentProps {
|
|
event: NostrEvent;
|
|
className?: string;
|
|
/** When true, renders URLs as inline links instead of link preview cards / embeds. */
|
|
disableEmbeds?: boolean;
|
|
}
|
|
|
|
/** Regex to detect image URLs that should be rendered inline. */
|
|
const IMAGE_URL_REGEX = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|avif)(\?[^\s]*)?/i;
|
|
|
|
/** Regex to detect video/webxdc URLs rendered as embeds by the parent component. */
|
|
const MEDIA_URL_REGEX = /https?:\/\/[^\s]+\.(mp4|webm|mov|xdc)(\?[^\s]*)?/i;
|
|
|
|
/** Extract a YouTube video ID from a URL, or null if not a YouTube link. */
|
|
function extractYouTubeId(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
// youtube.com/watch?v=ID
|
|
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') {
|
|
return u.searchParams.get('v');
|
|
}
|
|
// youtube.com/embed/ID
|
|
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) {
|
|
return u.pathname.split('/')[2] || null;
|
|
}
|
|
// youtube.com/shorts/ID
|
|
if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) {
|
|
return u.pathname.split('/')[2] || null;
|
|
}
|
|
// youtu.be/ID
|
|
if (u.hostname === 'youtu.be') {
|
|
return u.pathname.slice(1) || null;
|
|
}
|
|
} catch {
|
|
// not a valid URL
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Bech32 charset used by NIP-19 identifiers. */
|
|
const BECH32_CHARS = '023456789acdefghjklmnpqrstuvwxyz';
|
|
|
|
/** Regex to extract an naddr1 identifier from a URL path. */
|
|
const NADDR_IN_URL_REGEX = new RegExp(`naddr1[${BECH32_CHARS}]{10,}`, 'i');
|
|
|
|
/** Try to extract naddr coordinates from a URL containing an naddr1 identifier. */
|
|
function extractNaddrFromUrl(url: string): AddrCoords | null {
|
|
const match = url.match(NADDR_IN_URL_REGEX);
|
|
if (!match) return null;
|
|
try {
|
|
const decoded = nip19.decode(match[0]);
|
|
if (decoded.type === 'naddr') {
|
|
return decoded.data as AddrCoords;
|
|
}
|
|
} catch {
|
|
// invalid naddr
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** A parsed token from note content. */
|
|
type ContentToken =
|
|
| { type: 'text'; value: string }
|
|
| { type: 'image-embed'; url: string }
|
|
| { type: 'link-preview'; url: string }
|
|
| { type: 'inline-link'; url: string }
|
|
| { type: 'youtube-embed'; videoId: string }
|
|
| { type: 'mention'; pubkey: string }
|
|
| { type: 'nevent-embed'; eventId: string }
|
|
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
|
|
| { type: 'nostr-link'; id: string; raw: string }
|
|
| { type: 'hashtag'; tag: string; raw: string };
|
|
|
|
/** Check if a string contains only emojis (and whitespace). */
|
|
function isOnlyEmojis(text: string): boolean {
|
|
// Remove whitespace
|
|
const trimmed = text.replace(/\s/g, '');
|
|
if (trimmed.length === 0) return false;
|
|
|
|
// Emoji regex - matches emoji characters including skin tone modifiers and ZWJ sequences
|
|
// This is a simplified version - for production you might want a more comprehensive regex
|
|
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+$/u;
|
|
|
|
return emojiRegex.test(trimmed) && trimmed.length <= 12; // Max 12 emoji chars for enlarged display
|
|
}
|
|
|
|
/** Parses content of text note events so that URLs and hashtags are linkified. */
|
|
export function NoteContent({
|
|
event,
|
|
className,
|
|
disableEmbeds = false,
|
|
}: NoteContentProps) {
|
|
const tokens = useMemo(() => {
|
|
const text = event.content;
|
|
// Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
|
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
|
const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#\w+)/g;
|
|
|
|
const result: ContentToken[] = [];
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
let hadMatches = false;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
let [fullMatch, url] = match;
|
|
const hashtag = match[6];
|
|
const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match;
|
|
const index = match.index;
|
|
hadMatches = true;
|
|
|
|
// Add text before this match
|
|
if (index > lastIndex) {
|
|
result.push({ type: 'text', value: text.substring(lastIndex, index) });
|
|
}
|
|
|
|
if (url) {
|
|
// Strip common trailing punctuation that's likely not part of the URL
|
|
// This handles cases like "(https://example.com)" or "Check this: https://example.com."
|
|
const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/);
|
|
if (trailingPunctMatch) {
|
|
const [, urlWithoutPunct] = trailingPunctMatch;
|
|
// Only strip the punctuation if the URL without it is still valid
|
|
if (urlWithoutPunct && urlWithoutPunct.length > 10) {
|
|
url = urlWithoutPunct;
|
|
fullMatch = urlWithoutPunct;
|
|
// The punctuation will be part of the next text token
|
|
}
|
|
}
|
|
// Image URLs → render inline at their position in the text
|
|
if (IMAGE_URL_REGEX.test(url)) {
|
|
if (result.length > 0) {
|
|
const prev = result[result.length - 1];
|
|
if (prev.type === 'text') {
|
|
prev.value = prev.value.replace(/\s+$/, '');
|
|
}
|
|
}
|
|
result.push({ type: 'image-embed', url });
|
|
lastIndex = index + fullMatch.length;
|
|
// Strip leading whitespace that follows the image URL
|
|
const remaining = text.substring(lastIndex);
|
|
const leadingWs = remaining.match(/^\s+/);
|
|
if (leadingWs) {
|
|
lastIndex += leadingWs[0].length;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip non-image media URLs — rendered as embedded media by the parent.
|
|
if (MEDIA_URL_REGEX.test(url)) {
|
|
if (result.length > 0) {
|
|
const prev = result[result.length - 1];
|
|
if (prev.type === 'text') {
|
|
prev.value = prev.value.replace(/\s+$/, '');
|
|
}
|
|
}
|
|
lastIndex = index + fullMatch.length;
|
|
// Also strip leading whitespace that follows the skipped URL
|
|
const remaining = text.substring(lastIndex);
|
|
const leadingWs = remaining.match(/^\s+/);
|
|
if (leadingWs) {
|
|
lastIndex += leadingWs[0].length;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Determine if this URL ends a line (not followed by more text).
|
|
// A URL gets a preview card when nothing meaningful follows it on
|
|
// the same line (i.e., it ends the line or the content).
|
|
const afterUrl = text.substring(index + fullMatch.length);
|
|
const nextNewline = afterUrl.indexOf('\n');
|
|
const lineSuffix = nextNewline === -1 ? afterUrl : afterUrl.substring(0, nextNewline);
|
|
const isEndOfLine = lineSuffix.trim() === '';
|
|
|
|
// Check if the URL contains an naddr1 identifier → embed as Nostr event + preserve link
|
|
const naddrFromUrl = extractNaddrFromUrl(url);
|
|
if (naddrFromUrl) {
|
|
result.push({ type: 'naddr-embed', addr: naddrFromUrl, url });
|
|
} else if (isEndOfLine) {
|
|
// YouTube → playable embed
|
|
const ytId = extractYouTubeId(url);
|
|
if (ytId) {
|
|
result.push({ type: 'youtube-embed', videoId: ytId });
|
|
} else {
|
|
// Standalone URL → link preview card
|
|
result.push({ type: 'link-preview', url });
|
|
}
|
|
} else {
|
|
// Inline URL mid-sentence → plain clickable link
|
|
result.push({ type: 'inline-link', url });
|
|
}
|
|
} else if ((nostrPrefix && nostrData) || (barePrefix && bareData)) {
|
|
// Handle both nostr:-prefixed and bare NIP-19 identifiers
|
|
const prefix = nostrPrefix || barePrefix;
|
|
const data = nostrData || bareData;
|
|
try {
|
|
const nostrId = `${prefix}${data}`;
|
|
const decoded = nip19.decode(nostrId);
|
|
|
|
if (decoded.type === 'npub') {
|
|
result.push({ type: 'mention', pubkey: decoded.data });
|
|
} else if (decoded.type === 'nprofile') {
|
|
result.push({ type: 'mention', pubkey: decoded.data.pubkey });
|
|
} else if (decoded.type === 'note') {
|
|
result.push({ type: 'nevent-embed', eventId: decoded.data as string });
|
|
} else if (decoded.type === 'nevent') {
|
|
result.push({ type: 'nevent-embed', eventId: (decoded.data as { id: string }).id });
|
|
} else if (decoded.type === 'naddr') {
|
|
result.push({ type: 'naddr-embed', addr: decoded.data as AddrCoords });
|
|
} else {
|
|
result.push({ type: 'nostr-link', id: nostrId, raw: fullMatch });
|
|
}
|
|
} catch {
|
|
result.push({ type: 'text', value: fullMatch });
|
|
}
|
|
} else if (hashtag) {
|
|
const tag = hashtag.slice(1);
|
|
result.push({ type: 'hashtag', tag, raw: hashtag });
|
|
}
|
|
|
|
lastIndex = index + fullMatch.length;
|
|
}
|
|
|
|
// Add any remaining text
|
|
if (lastIndex < text.length) {
|
|
result.push({ type: 'text', value: text.substring(lastIndex) });
|
|
}
|
|
|
|
if (result.length === 0 && !hadMatches) {
|
|
result.push({ type: 'text', value: text });
|
|
}
|
|
|
|
// Collapse excessive whitespace around block-level tokens (link-preview, youtube-embed)
|
|
// Preserve formatting but prevent too much stacking with the card's own spacing.
|
|
for (let i = 0; i < result.length; i++) {
|
|
const token = result[i];
|
|
const isBlock = token.type === 'image-embed' || token.type === 'link-preview' || token.type === 'youtube-embed' || token.type === 'nevent-embed'
|
|
|| (token.type === 'naddr-embed' && !token.url);
|
|
|
|
if (isBlock) {
|
|
// Strip all trailing whitespace from the preceding text token.
|
|
// The block's own margin (my-2.5) handles spacing, so preserved
|
|
// newlines just add redundant blank lines under whitespace-pre-wrap.
|
|
if (i > 0) {
|
|
const prev = result[i - 1];
|
|
if (prev.type === 'text') {
|
|
prev.value = prev.value.replace(/\s+$/, '');
|
|
}
|
|
}
|
|
// Strip all leading whitespace from the following text token.
|
|
if (i < result.length - 1) {
|
|
const next = result[i + 1];
|
|
if (next.type === 'text') {
|
|
next.value = next.value.replace(/^\s+/, '');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trim leading/trailing whitespace from edge text tokens
|
|
if (result.length > 0) {
|
|
const first = result[0];
|
|
if (first.type === 'text') {
|
|
first.value = first.value.replace(/^\s+/, '');
|
|
}
|
|
const last = result[result.length - 1];
|
|
if (last.type === 'text') {
|
|
last.value = last.value.replace(/\s+$/, '');
|
|
}
|
|
}
|
|
|
|
// Filter out empty text tokens
|
|
return result.filter((t) => !(t.type === 'text' && t.value === ''));
|
|
}, [event]);
|
|
|
|
// Build emoji map for NIP-30 custom emoji rendering
|
|
const emojiMap = useMemo(() => buildEmojiMap(event.tags), [event.tags]);
|
|
|
|
// Collect all inline image URLs (in order) for the shared lightbox
|
|
const allImages = useMemo(
|
|
() => tokens.filter((t): t is { type: 'image-embed'; url: string } => t.type === 'image-embed').map((t) => t.url),
|
|
[tokens],
|
|
);
|
|
|
|
// Shared lightbox state for inline images
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
const closeLightbox = useCallback(() => setLightboxIndex(null), []);
|
|
const goNext = useCallback(() => setLightboxIndex((p) => (p !== null ? (p + 1) % allImages.length : null)), [allImages.length]);
|
|
const goPrev = useCallback(() => setLightboxIndex((p) => (p !== null ? (p - 1 + allImages.length) % allImages.length : null)), [allImages.length]);
|
|
|
|
// Check if content is only emojis (single text token with only emojis)
|
|
const isEmojiOnly = tokens.length === 1 && tokens[0].type === 'text' && isOnlyEmojis(tokens[0].value);
|
|
|
|
// Build a map from token index → image list index so duplicate URLs get correct positions
|
|
const tokenImageIndex = useMemo(() => {
|
|
const map = new Map<number, number>();
|
|
let imgCount = 0;
|
|
tokens.forEach((t, i) => {
|
|
if (t.type === 'image-embed') map.set(i, imgCount++);
|
|
});
|
|
return map;
|
|
}, [tokens]);
|
|
|
|
return (
|
|
<div className={cn('whitespace-pre-wrap break-words overflow-hidden', isEmojiOnly && 'text-5xl leading-tight', className)}>
|
|
{tokens.map((token, i) => {
|
|
switch (token.type) {
|
|
case 'text':
|
|
return <span key={i}>{emojify(token.value, emojiMap)}</span>;
|
|
case 'image-embed': {
|
|
if (disableEmbeds) {
|
|
return (
|
|
<a
|
|
key={i}
|
|
href={token.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline break-all"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{token.url}
|
|
</a>
|
|
);
|
|
}
|
|
const imgIndex = tokenImageIndex.get(i) ?? 0;
|
|
return (
|
|
<InlineImage
|
|
key={i}
|
|
url={token.url}
|
|
onClick={(e) => { e.stopPropagation(); setLightboxIndex(imgIndex); }}
|
|
/>
|
|
);
|
|
}
|
|
case 'link-preview':
|
|
if (disableEmbeds) {
|
|
return (
|
|
<a
|
|
key={i}
|
|
href={token.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline break-all"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{token.url}
|
|
</a>
|
|
);
|
|
}
|
|
return <LinkPreview key={i} url={token.url} className="my-2.5" />;
|
|
case 'inline-link':
|
|
return (
|
|
<a
|
|
key={i}
|
|
href={token.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline break-all"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{token.url}
|
|
</a>
|
|
);
|
|
case 'youtube-embed':
|
|
return <YouTubeEmbed key={i} videoId={token.videoId} className="my-2.5" />;
|
|
case 'nevent-embed':
|
|
return <EmbeddedNote key={i} eventId={token.eventId} className="my-2.5" />;
|
|
case 'naddr-embed':
|
|
return (
|
|
<span key={i}>
|
|
{token.url && (
|
|
<a
|
|
href={token.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline break-all"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{token.url}
|
|
</a>
|
|
)}
|
|
<EmbeddedNaddr addr={token.addr} className="my-2.5" />
|
|
</span>
|
|
);
|
|
case 'mention':
|
|
return <NostrMention key={i} pubkey={token.pubkey} />;
|
|
case 'nostr-link':
|
|
return (
|
|
<Link
|
|
key={i}
|
|
to={`/${token.id}`}
|
|
className="text-primary hover:underline break-all"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{token.raw}
|
|
</Link>
|
|
);
|
|
case 'hashtag':
|
|
return (
|
|
<Link
|
|
key={i}
|
|
to={`/t/${token.tag}`}
|
|
className="text-primary hover:underline"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{token.raw}
|
|
</Link>
|
|
);
|
|
}
|
|
})}
|
|
|
|
{/* Shared lightbox for all inline images in this post */}
|
|
{lightboxIndex !== null && (
|
|
<Lightbox
|
|
images={allImages}
|
|
currentIndex={lightboxIndex}
|
|
onClose={closeLightbox}
|
|
onNext={goNext}
|
|
onPrev={goPrev}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Inline image thumbnail that opens the shared lightbox on click. */
|
|
function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEvent) => void }) {
|
|
const { src, onError } = useBlossomFallback(url);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="block my-2 rounded-lg overflow-hidden max-w-full cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
onClick={onClick}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className="block max-w-full max-h-[512px] object-contain rounded-lg hover:opacity-90 transition-opacity"
|
|
loading="lazy"
|
|
onError={onError}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NostrMention({ pubkey }: { pubkey: string }) {
|
|
const author = useAuthor(pubkey);
|
|
const hasRealName = !!author.data?.metadata?.name;
|
|
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
|
|
const profileUrl = useProfileUrl(pubkey, author.data?.metadata);
|
|
|
|
return (
|
|
<ProfileHoverCard pubkey={pubkey} asChild>
|
|
<Link
|
|
to={profileUrl}
|
|
className={cn(
|
|
'font-medium hover:underline',
|
|
hasRealName
|
|
? 'text-primary'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
@{author.data?.event ? (
|
|
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
|
) : displayName}
|
|
</Link>
|
|
</ProfileHoverCard>
|
|
);
|
|
}
|