Files
eranos/src/components/NoteContent.tsx
T
Alex Gleason a9449bd009 Fix jumpy home feed: layout shifts, background refetches, and pre-filtering
- Add min-height placeholder to InlineImage to prevent layout shifts when
  images load from 0px to natural height
- Remove 60s refetchInterval from useFeed that caused all pages to re-sort
  in the background, triggering full feed re-renders
- Pre-filter deprecated/hidden events at Feed level (shouldHideFeedEvent)
  instead of returning null inside NoteCard after hooks have already run
- Stabilize author info skeleton/loaded height with consistent min-h-[42px]
  to prevent cascading layout recalculations
2026-03-20 13:56:26 -05:00

744 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, useCallback, type ReactNode } 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 { LinkEmbed } from '@/components/LinkEmbed';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
import { buildEmojiMap } from '@/lib/customEmoji';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
import { COUNTRIES } from '@/lib/countries';
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
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;
/** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */
hideEmbedImages?: boolean;
}
/** Regex matching `:shortcode:` patterns in text. */
const SHORTCODE_REGEX = /:([a-zA-Z0-9_-]+):/g;
/**
* Replaces `:shortcode:` patterns in text with inline custom emoji images.
*/
function emojify(
text: string,
emojiMap: Map<string, string>,
imgClassName?: string,
): ReactNode[] {
if (emojiMap.size === 0) return [text];
const result: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
SHORTCODE_REGEX.lastIndex = 0;
while ((match = SHORTCODE_REGEX.exec(text)) !== null) {
const [fullMatch, shortcode] = match;
const url = emojiMap.get(shortcode);
if (!url) continue;
if (match.index > lastIndex) {
result.push(text.substring(lastIndex, match.index));
}
result.push(
<CustomEmojiImg
key={`emoji-${match.index}`}
name={shortcode}
url={url}
className={imgClassName}
/>,
);
lastIndex = match.index + fullMatch.length;
}
if (lastIndex < text.length) {
result.push(text.substring(lastIndex));
}
return result.length > 0 ? result : [text];
}
/** 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;
}
/** Regex matching flag emoji: pairs of Regional Indicator Symbol letters (U+1F1E6U+1F1FF). */
const FLAG_EMOJI_REGEX = /([\u{1F1E6}-\u{1F1FF}]{2})/gu;
/**
* Convert a flag emoji (pair of Regional Indicator Symbols) to an ISO 3166-1 alpha-2 code.
* Returns the code if it maps to a known country, otherwise null.
*/
function flagToCountryCode(flag: string): string | null {
const codePoints = [...flag];
if (codePoints.length !== 2) return null;
const a = codePoints[0].codePointAt(0)! - 0x1F1E6 + 65;
const b = codePoints[1].codePointAt(0)! - 0x1F1E6 + 65;
const code = String.fromCharCode(a) + String.fromCharCode(b);
return COUNTRIES[code] ? code : null;
}
/**
* Process an array of ReactNodes (from emojify), splitting any string nodes
* to wrap flag emojis in <Link> elements pointing to /i/iso3166:<CODE>.
*/
function linkifyFlags(nodes: ReactNode[]): ReactNode[] {
const result: ReactNode[] = [];
let keyIdx = 0;
for (const node of nodes) {
if (typeof node !== 'string') {
result.push(node);
continue;
}
let lastIndex = 0;
FLAG_EMOJI_REGEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = FLAG_EMOJI_REGEX.exec(node)) !== null) {
const flag = match[1];
const code = flagToCountryCode(flag);
if (!code) continue;
if (match.index > lastIndex) {
result.push(node.substring(lastIndex, match.index));
}
result.push(
<Link
key={`flag-${keyIdx++}`}
to={`/i/iso3166:${code}`}
className="hover:opacity-70 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
{flag}
</Link>,
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < node.length) {
result.push(node.substring(lastIndex));
} else if (lastIndex === 0) {
result.push(node);
}
}
return result;
}
/** A parsed token from note content. */
type ContentToken =
| { type: 'text'; value: string }
| { type: 'image-embed'; url: string }
| { type: 'image-gallery'; urls: string[] }
| { type: 'link-embed'; url: string }
| { type: 'inline-link'; url: string }
| { type: 'mention'; pubkey: string }
| { type: 'nevent-embed'; eventId: string; relays?: string[]; author?: string }
| { type: 'naddr-embed'; addr: AddrCoords; url?: string }
| { type: 'nostr-link'; id: string; raw: string }
| { type: 'hashtag'; tag: string; raw: string }
| { type: 'relay-link'; url: string };
/**
* Regex segment matching a single visual emoji unit, including:
* - ZWJ sequences (e.g. 👨‍👩‍👧‍👦, 👩‍💻)
* - Skin tone / hair style modifiers (e.g. 👋🏽)
* - Flag sequences (Regional Indicator pairs, e.g. 🇺🇸)
* - Keycap sequences (e.g. 1️⃣)
* - Tag sequences (e.g. 🏴󠁧󠁢󠁷󠁬󠁳󠁿)
* - Basic presentation emojis (with or without VS16)
*/
const EMOJI_UNIT = [
// ZWJ sequences: emoji (+ optional modifier) joined by ZWJ, repeated
'(?:' +
'(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)' + // base emoji
'[\\u{1F3FB}-\\u{1F3FF}]?' + // optional skin tone
'(?:\\u200D(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)[\\u{1F3FB}-\\u{1F3FF}]?)+' + // ZWJ + next
')',
// Flag sequences (two Regional Indicator symbols)
'(?:[\\u{1F1E6}-\\u{1F1FF}]{2})',
// Keycap sequences: digit/# /* + VS16 + combining enclosing keycap
'(?:[0-9#*]\\uFE0F\\u20E3)',
// Tag sequences (subdivision flags): 🏴 + tag chars + cancel tag
'(?:\\u{1F3F4}[\\u{E0020}-\\u{E007E}]+\\u{E007F})',
// Single emoji with optional skin tone modifier
'(?:(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)[\\u{1F3FB}-\\u{1F3FF}]?)',
].join('|');
/** NIP-30 custom emoji shortcode pattern. */
const CUSTOM_EMOJI_SHORTCODE = ':([a-zA-Z0-9_-]+):';
/** Regex matching a string of only NIP-30 custom emoji shortcodes and/or unicode emojis (with optional whitespace). Max 10 visual emojis. */
const EMOJI_OR_CUSTOM_ONLY_REGEX = new RegExp(
`^\\s*(?:(?:${CUSTOM_EMOJI_SHORTCODE}|${EMOJI_UNIT})\\s*){1,10}$`,
'u',
);
/** Check if a string contains only unicode emojis and/or NIP-30 custom emoji shortcodes (and whitespace). */
function isOnlyEmojisOrCustom(text: string, emojiMap: Map<string, string>): boolean {
if (!EMOJI_OR_CUSTOM_ONLY_REGEX.test(text)) return false;
// Verify all shortcodes in the text actually resolve to a custom emoji
const shortcodeMatches = text.matchAll(/:([a-zA-Z0-9_-]+):/g);
for (const m of shortcodeMatches) {
if (!emojiMap.has(m[1])) return false;
}
return true;
}
/** Parses content of text note events so that URLs and hashtags are linkified. */
export function NoteContent({
event,
className,
disableEmbeds = false,
hideEmbedImages = 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?|wss?):\/\/[^\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
}
}
// WebSocket relay URLs → link to internal relay page
if (/^wss?:\/\//i.test(url)) {
result.push({ type: 'relay-link', url });
lastIndex = index + fullMatch.length;
continue;
}
// 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 (EMBED_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) {
// Standalone URL at end of line → rich embed (YouTube, Tweet, or link preview)
result.push({ type: 'link-embed', 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.id,
relays: decoded.data.relays,
author: decoded.data.author,
});
} 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 });
}
// Enrich nevent-embed tokens with relay hints from `q` tags when the
// nevent URI itself didn't include any. This ensures quote posts whose
// inline nevent was encoded without relay hints can still resolve the
// quoted event via the relay hint stored in the q tag.
const qTagMap = new Map<string, { relay?: string; author?: string }>();
for (const tag of event.tags) {
if (tag[0] === 'q' && tag[1]) {
qTagMap.set(tag[1], { relay: tag[2] || undefined, author: tag[3] || undefined });
}
}
if (qTagMap.size > 0) {
for (const token of result) {
if (token.type === 'nevent-embed') {
const qInfo = qTagMap.get(token.eventId);
if (qInfo) {
if ((!token.relays || token.relays.length === 0) && qInfo.relay) {
token.relays = [qInfo.relay];
}
if (!token.author && qInfo.author) {
token.author = qInfo.author;
}
}
}
}
}
// 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-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.
// Merge the event's own emoji tags with the viewer's custom emoji collection
// so shortcodes render even when the published event omitted the tag.
const { emojis: viewerEmojis } = useCustomEmojis();
const emojiMap = useMemo(() => {
const map = buildEmojiMap(event.tags);
// Viewer's collection is a fallback — event tags take priority
for (const e of viewerEmojis) {
if (!map.has(e.shortcode)) {
map.set(e.shortcode, e.url);
}
}
return map;
}, [event.tags, viewerEmojis]);
// Parse imeta tags for dim/blurhash to pass to ImageGallery
const imetaMap = useMemo(() => {
const map = new Map<string, { dim?: string; blurhash?: string }>();
for (const tag of event.tags) {
if (tag[0] !== 'imeta') continue;
const parts: Record<string, string> = {};
for (let i = 1; i < tag.length; i++) {
const p = tag[i];
const sp = p.indexOf(' ');
if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1);
}
if (parts.url) map.set(parts.url, { dim: parts.dim, blurhash: parts.blurhash });
}
return map;
}, [event.tags]);
// Group consecutive image-embed tokens (≥2) into image-gallery tokens
const groupedTokens = useMemo(() => {
const result: ContentToken[] = [];
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (token.type === 'image-embed') {
// Collect this and any immediately following image-embed tokens
const run: string[] = [token.url];
let j = i + 1;
while (j < tokens.length && tokens[j].type === 'image-embed') {
run.push((tokens[j] as { type: 'image-embed'; url: string }).url);
j++;
}
if (run.length >= 2) {
result.push({ type: 'image-gallery', urls: run });
} else {
result.push(token);
}
i = j;
} else {
result.push(token);
i++;
}
}
return result;
}, [tokens]);
// Collect all inline image URLs (in order) for the shared lightbox
const allImages = useMemo(
() => groupedTokens.flatMap((t) => {
if (t.type === 'image-embed') return [t.url];
if (t.type === 'image-gallery') return t.urls;
return [];
}),
[groupedTokens],
);
// 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 — unicode and/or NIP-30 custom emojis (single text token)
const isEmojiOnly = groupedTokens.length === 1 && groupedTokens[0].type === 'text' && isOnlyEmojisOrCustom(groupedTokens[0].value, emojiMap);
// Build a map from grouped token index → starting image list index for lightbox positioning
const tokenImageIndex = useMemo(() => {
const map = new Map<number, number>();
let imgCount = 0;
groupedTokens.forEach((t, i) => {
if (t.type === 'image-embed') {
map.set(i, imgCount++);
} else if (t.type === 'image-gallery') {
map.set(i, imgCount);
imgCount += t.urls.length;
}
});
return map;
}, [groupedTokens]);
return (
<div className={cn('whitespace-pre-wrap break-words overflow-hidden', className, isEmojiOnly && 'text-5xl leading-tight')}>
{groupedTokens.map((token, i) => {
switch (token.type) {
case 'text':
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 align-text-bottom' : undefined))}</span>;
case 'image-embed': {
if (disableEmbeds) {
// In preview contexts (e.g. triple-dot menu), replace image URLs
// with a newline so text flow is preserved without showing raw URLs.
return <span key={i}>{'\n'}</span>;
}
const imgIndex = tokenImageIndex.get(i) ?? 0;
return (
<InlineImage
key={i}
url={token.url}
onClick={(e) => { e.stopPropagation(); setLightboxIndex(imgIndex); }}
/>
);
}
case 'image-gallery': {
if (disableEmbeds) {
return <span key={i}>{token.urls.map(() => '\n').join('')}</span>;
}
const galleryStartIndex = tokenImageIndex.get(i) ?? 0;
const galleryLightboxIndex =
lightboxIndex !== null &&
lightboxIndex >= galleryStartIndex &&
lightboxIndex < galleryStartIndex + token.urls.length
? lightboxIndex - galleryStartIndex
: null;
return (
<ImageGallery
key={i}
images={token.urls}
maxVisible={4}
maxGridHeight="480px"
imetaMap={imetaMap}
lightboxIndex={galleryLightboxIndex}
onLightboxOpen={(idx) => { setLightboxIndex(galleryStartIndex + idx); }}
onLightboxClose={closeLightbox}
/>
);
}
case 'link-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>
);
}
return <LinkEmbed key={i} url={token.url} className="my-2.5" hideImage={hideEmbedImages} />;
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 'nevent-embed':
return <EmbeddedNote key={i} eventId={token.eventId} relays={token.relays} authorHint={token.author} 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 break-all"
onClick={(e) => e.stopPropagation()}
>
{token.raw}
</Link>
);
case 'relay-link':
return (
<Link
key={i}
to={`/r/${encodeURIComponent(token.url)}`}
className="text-primary hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{token.url}
</Link>
);
}
})}
{/* Shared lightbox for standalone inline images (not gallery groups) */}
{lightboxIndex !== null && !groupedTokens.some(
(t, i) =>
t.type === 'image-gallery' &&
lightboxIndex >= (tokenImageIndex.get(i) ?? 0) &&
lightboxIndex < (tokenImageIndex.get(i) ?? 0) + t.urls.length,
) && (
<Lightbox
images={allImages}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</div>
);
}
/** Inline image thumbnail that opens the shared lightbox on click.
* Uses a min-height placeholder to prevent layout shifts while loading. */
function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEvent) => void }) {
const { src, onError } = useBlossomFallback(url);
const [loaded, setLoaded] = useState(false);
return (
<button
type="button"
className="block my-2 rounded-lg overflow-hidden w-full cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={onClick}
>
<div className={cn('relative w-full rounded-lg overflow-hidden', !loaded && 'bg-muted')} style={!loaded ? { minHeight: 200 } : undefined}>
<img
src={src}
alt=""
className="block w-full h-auto rounded-lg hover:opacity-90 transition-opacity"
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => { setLoaded(true); onError(); }}
/>
</div>
</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>
);
}