24a645277e
Custom emoji images with non-1:1 aspect ratios were being stretched into a square. Added object-contain to preserve natural aspect ratio within the bounding box. Moved text sizing classes to parent containers for reaction emoji bubbles so unicode emojis still size correctly.
744 lines
27 KiB
TypeScript
744 lines
27 KiB
TypeScript
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+1F1E6–U+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]+)|(#[\p{L}\p{N}_]+)/gu;
|
||
|
||
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 object-contain 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>
|
||
);
|
||
}
|