You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1464 lines
45 KiB

<script lang="ts">
import CardHeader from '../../components/layout/CardHeader.svelte';
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MetadataCard from '../../components/content/MetadataCard.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte';
import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import CommentForm from '../comments/CommentForm.svelte';
import PollCard from '../../components/content/PollCard.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js';
import { isBookmarked } from '../../services/user-actions.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import { onMount } from 'svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
import { page } from '$app/stores';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
interface Props {
post: NostrEvent;
fullView?: boolean; // If true, show full content (markdown, media, profile pics, reactions)
preloadedReactions?: NostrEvent[]; // Pre-loaded reactions to avoid duplicate fetches
parentEvent?: NostrEvent; // Optional parent event if already loaded
quotedEvent?: NostrEvent; // Optional quoted event if already loaded
hideTitle?: boolean; // If true, don't render the title (useful when title is rendered elsewhere)
preloadedReferencedEvent?: NostrEvent | null; // Preloaded referenced event from e/a/q tags
}
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, hideTitle = false, preloadedReferencedEvent }: Props = $props();
// Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in
let bookmarked = $state(false);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Collapse state for feed view
let isExpanded = $state(false);
let shouldCollapse = $state(false);
let cardElement: HTMLElement | null = $state(null);
let contentElement: HTMLElement | null = $state(null);
// Reply state
let showReplyForm = $state(false);
// Keyboard shortcuts - register when component is focused
let isFocused = $state(false);
onMount(() => {
// Register keyboard shortcuts for this post when it's focused
const unregisterShortcuts = keyboardShortcuts.register('r', (e) => {
if (isFocused && isLoggedIn && !showReplyForm) {
e.preventDefault();
showReplyForm = true;
return false;
}
});
return () => {
unregisterShortcuts();
};
});
// Media kinds that should auto-render media (except on /feed)
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY, KIND.FILE_METADATA];
const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind));
const isOnFeedPage = $derived($page.url.pathname === '/feed');
const isOnEventPage = $derived($page.url.pathname.startsWith('/event/'));
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage);
// Check if card should be collapsed (only in feed view)
let isMounted = $state(true);
// Don't collapse media kinds when they should auto-render (they need full space for images/videos)
const shouldNeverCollapse = $derived(fullView || shouldAutoRenderMedia);
$effect(() => {
if (shouldNeverCollapse) {
shouldCollapse = false;
return;
}
isMounted = true;
// Wait for content to render, then check height
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let rafId1: number | null = null;
let rafId2: number | null = null;
const checkHeight = () => {
if (!isMounted || !cardElement) return;
// Measure the full card height (using scrollHeight to get full content height)
const cardHeight = cardElement.scrollHeight;
shouldCollapse = cardHeight > 500;
};
// Check after content is rendered
timeoutId = setTimeout(() => {
if (!isMounted) return;
rafId1 = requestAnimationFrame(() => {
if (!isMounted) return;
rafId2 = requestAnimationFrame(checkHeight);
});
}, 200);
return () => {
isMounted = false;
if (timeoutId) clearTimeout(timeoutId);
if (rafId1) cancelAnimationFrame(rafId1);
if (rafId2) cancelAnimationFrame(rafId2);
};
});
function toggleExpand() {
isExpanded = !isExpanded;
}
$effect(() => {
let cancelled = false;
if (isLoggedIn) {
isBookmarked(post.id).then(b => {
if (!cancelled) {
bookmarked = b;
}
});
} else {
bookmarked = false;
}
return () => {
cancelled = true;
};
});
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - post.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
const minutes = Math.floor(diff / 60);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function getClientName(): string | null {
const clientTag = post.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
function getTitle(): string | null {
const titleTag = post.tags.find((t) => t[0] === 'title');
const title = titleTag?.[1];
return title && title.trim() ? title.trim() : null;
}
function getSummary(): string | null {
const summaryTag = post.tags.find((t) => t[0] === 'summary');
const summary = summaryTag?.[1];
return summary && summary.trim() ? summary.trim() : null;
}
function getPlaintextContent(): string {
// Plaintext only (no markdown/images) - keep full length
return stripMarkdown(post.content);
}
// Check if this is a highlight event with context tag
function hasContextTag(): boolean {
if (post.kind !== KIND.HIGHLIGHTED_ARTICLE) return false;
return post.tags.some(t => t[0] === 'context' && t[1]);
}
// Get highlight content to highlight (for highlight events with context)
function getHighlightContent(): string | null {
if (!hasContextTag()) return null;
return post.content.trim() || null;
}
// Parse NIP-21 links and create segments for rendering
interface ContentSegment {
type: 'text' | 'profile' | 'event' | 'url' | 'wikilink' | 'hashtag' | 'greentext';
content: string; // Display text (without nostr: prefix for links)
pubkey?: string; // For profile badges
eventId?: string; // For event links (bech32 or hex)
url?: string; // For regular HTTP/HTTPS URLs
wikilink?: string; // For wikilink d-tag
hashtag?: string; // For hashtag topic name
}
// Process text to detect greentext (lines starting with >)
function processGreentext(text: string): ContentSegment[] {
const lines = text.split('\n');
const segments: ContentSegment[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trimStart();
// Check if line starts with > (greentext)
if (trimmed.startsWith('>') && trimmed.length > 1) {
// Preserve leading whitespace before >
const leadingWhitespace = line.substring(0, line.length - trimmed.length);
segments.push({
type: 'greentext',
content: leadingWhitespace + trimmed
});
} else {
// Regular text line
segments.push({
type: 'text',
content: line
});
}
// Add newline between lines (except for last line)
if (i < lines.length - 1) {
segments.push({
type: 'text',
content: '\n'
});
}
}
return segments;
}
function parseContentWithNIP21Links(): ContentSegment[] {
const plaintext = getPlaintextContent();
const links = findNIP21Links(plaintext);
const segments: ContentSegment[] = [];
let lastIndex = 0;
for (const link of links) {
// Add text before the link
if (link.start > lastIndex) {
segments.push({
type: 'text',
content: plaintext.slice(lastIndex, link.start)
});
}
// Add the link as appropriate segment
if (link.parsed.type === 'npub' || link.parsed.type === 'nprofile') {
// Extract pubkey from bech32
let pubkey: string | null = null;
try {
const decoded = nip19.decode(link.parsed.data);
if (decoded.type === 'npub') {
pubkey = decoded.data as string;
} else if (decoded.type === 'nprofile') {
pubkey = (decoded.data as any).pubkey;
}
} catch (e) {
// If decoding fails, just show as text
segments.push({
type: 'text',
content: link.uri
});
lastIndex = link.end;
continue;
}
if (pubkey) {
segments.push({
type: 'profile',
content: link.uri,
pubkey
});
} else {
segments.push({
type: 'text',
content: link.uri
});
}
} else if (link.parsed.type === 'nevent' || link.parsed.type === 'naddr' || link.parsed.type === 'note' || link.parsed.type === 'hexID') {
// Event link - use bech32 string or hex ID
const eventId = link.parsed.type === 'hexID' ? link.parsed.data : link.parsed.data;
// Display without "nostr:" prefix
const displayText = link.parsed.data;
segments.push({
type: 'event',
content: displayText,
eventId
});
} else {
// Unknown type, show as text
segments.push({
type: 'text',
content: link.uri
});
}
lastIndex = link.end;
}
// Add remaining text
if (lastIndex < plaintext.length) {
segments.push({
type: 'text',
content: plaintext.slice(lastIndex)
});
}
// If no links found, return single text segment
if (segments.length === 0) {
segments.push({
type: 'text',
content: plaintext
});
}
// Normalize d-tag according to NIP-54
function normalizeDTag(text: string): string {
let normalized = text;
// Convert to lowercase (preserving non-ASCII characters)
normalized = normalized.toLowerCase();
// Convert whitespace to `-`
normalized = normalized.replace(/\s+/g, '-');
// Remove punctuation and symbols (but preserve non-ASCII letters and numbers)
normalized = normalized.replace(/[^\p{L}\p{N}-]/gu, '');
// Collapse multiple consecutive `-` to a single `-`
normalized = normalized.replace(/-+/g, '-');
// Remove leading and trailing `-`
normalized = normalized.replace(/^-+|-+$/g, '');
return normalized;
}
// Now parse wikilinks [[wikilink]], hashtags #topic, and regular HTTP/HTTPS URLs from text segments
const finalSegments: ContentSegment[] = [];
// Match [[target]] or [[target|display text]]
const wikilinkRegex = /\[\[([^\]]+)\]\]/g;
// Match hashtags: # followed by word characters, but not if it's part of a URL
const hashtagRegex = /#([\p{L}\p{N}_]+)/gu;
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/gi;
for (const segment of segments) {
if (segment.type === 'text') {
let textIndex = 0;
let match;
const text = segment.content;
// First, parse wikilinks according to NIP-54
const wikilinkMatches: Array<{ index: number; length: number; dTag: string; displayText: string }> = [];
while ((match = wikilinkRegex.exec(text)) !== null) {
const content = match[1];
// Check if it has pipe syntax: [[target|display]]
const pipeIndex = content.indexOf('|');
let targetText: string;
let displayText: string;
if (pipeIndex !== -1) {
targetText = content.slice(0, pipeIndex).trim();
displayText = content.slice(pipeIndex + 1).trim();
} else {
targetText = content.trim();
displayText = content.trim();
}
// Normalize the d-tag using the function defined above
const normalizedDTag = normalizeDTag(targetText);
wikilinkMatches.push({
index: match.index,
length: match[0].length,
dTag: normalizedDTag,
displayText: displayText
});
}
// Then parse hashtags
const hashtagMatches: Array<{ index: number; length: number; hashtag: string }> = [];
hashtagRegex.lastIndex = 0; // Reset regex
while ((match = hashtagRegex.exec(text)) !== null) {
hashtagMatches.push({
index: match.index,
length: match[0].length,
hashtag: match[1]
});
}
// Then parse URLs
const urlMatches: Array<{ index: number; length: number; url: string }> = [];
urlRegex.lastIndex = 0; // Reset regex
while ((match = urlRegex.exec(text)) !== null) {
urlMatches.push({
index: match.index,
length: match[0].length,
url: match[1]
});
}
// Merge and sort all matches by index
const allMatches = [
...wikilinkMatches.map(m => ({ ...m, type: 'wikilink' as const })),
...hashtagMatches.map(m => ({ ...m, type: 'hashtag' as const })),
...urlMatches.map(m => ({ ...m, type: 'url' as const }))
].sort((a, b) => a.index - b.index);
// Filter out hashtags that are inside URLs or wikilinks
const filteredMatches = allMatches.filter((match, idx) => {
if (match.type === 'hashtag') {
// Check if this hashtag is inside any URL
const insideUrl = urlMatches.some(urlMatch =>
match.index >= urlMatch.index &&
match.index < urlMatch.index + urlMatch.length
);
if (insideUrl) return false;
// Check if this hashtag is inside any wikilink
const insideWikilink = wikilinkMatches.some(wikilinkMatch =>
match.index >= wikilinkMatch.index &&
match.index < wikilinkMatch.index + wikilinkMatch.length
);
if (insideWikilink) return false;
return true;
}
return true;
});
// Process matches in order
for (const match of filteredMatches) {
// Add text before the match
if (match.index > textIndex) {
finalSegments.push({
type: 'text',
content: text.slice(textIndex, match.index)
});
}
// Add the match
if (match.type === 'wikilink') {
const wikilinkMatch = match as typeof wikilinkMatches[0] & { type: 'wikilink' };
finalSegments.push({
type: 'wikilink',
content: wikilinkMatch.displayText || wikilinkMatch.dTag,
wikilink: wikilinkMatch.dTag
});
} else if (match.type === 'hashtag') {
const hashtagMatch = match as typeof hashtagMatches[0] & { type: 'hashtag' };
finalSegments.push({
type: 'hashtag',
content: `#${hashtagMatch.hashtag}`,
hashtag: hashtagMatch.hashtag
});
} else {
finalSegments.push({
type: 'url',
content: match.url,
url: match.url
});
}
textIndex = match.index + match.length;
}
// Add remaining text after last match
if (textIndex < text.length) {
finalSegments.push({
type: 'text',
content: text.slice(textIndex)
});
}
} else {
// Keep non-text segments as-is
finalSegments.push(segment);
}
}
// Process greentext on final text segments (only in feed view)
if (!fullView && finalSegments.length > 0) {
const processedSegments: ContentSegment[] = [];
for (const segment of finalSegments) {
if (segment.type === 'text') {
const greentextSegments = processGreentext(segment.content);
processedSegments.push(...greentextSegments);
} else {
processedSegments.push(segment);
}
}
return processedSegments.length > 0 ? processedSegments : finalSegments;
}
return finalSegments.length > 0 ? finalSegments : segments;
}
function getEventUrl(eventId: string): string {
// Decode bech32 or use hex directly to get the event ID
try {
if (eventId.length === 64 && /^[a-f0-9]{64}$/i.test(eventId)) {
// Already hex - use it directly
return `/event/${eventId}`;
} else {
// Try to decode bech32
const decoded = nip19.decode(eventId);
if (decoded.type === 'note' || decoded.type === 'nevent') {
const id = decoded.type === 'note' ? String(decoded.data) : (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data ? String(decoded.data.id) : eventId);
return `/event/${id}`;
} else if (decoded.type === 'naddr') {
// For naddr, use the bech32 string in the URL
return `/event/${eventId}`;
} else {
return `/event/${eventId}`;
}
}
} catch (decodeError) {
// If decoding fails, use the ID as-is
return `/event/${eventId}`;
}
}
function getTopics(): string[] {
return post.tags.filter(t => t[0] === 't').map(t => t[1]);
}
function isReply(): boolean {
// Check for all event reference tags: e, E, a, A, i, I, q
return post.tags.some((t) => {
const tagName = t[0];
if (tagName === 'e' || tagName === 'E' || tagName === 'q') {
return t[1] && t[1] !== post.id;
}
if (tagName === 'a' || tagName === 'A' || tagName === 'i' || tagName === 'I') {
return t[1] && t[1].includes(':');
}
return false;
});
}
// Get reply-to event ID from e/E tags only (excluding q tags)
function getReplyToEventId(): string | null {
// 1. Check for e/E tags (NIP-10 replies)
const replyTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[3] === 'reply');
if (replyTag && replyTag[1]) return replyTag[1];
const rootId = getRootEventId();
const eTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[1] && t[1] !== rootId && t[1] !== post.id);
if (eTag && eTag[1]) return eTag[1];
return null;
}
// Get reply-to a-tag value from a/A tags
function getReplyToATag(): string | null {
const aTag = post.tags.find((t) => (t[0] === 'a' || t[0] === 'A') && t[1]);
return aTag?.[1] || null;
}
function getReplyEventId(): string | null {
// Priority order: e/E (reply), q (quote), a/A (parameterized), i/I (identifier)
// 1. Check for e/E tags (NIP-10 replies)
const replyToEventId = getReplyToEventId();
if (replyToEventId) return replyToEventId;
// 2. Check for q tag (quoted event)
const qTag = post.tags.find((t) => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) return qTag[1];
// 3. Check for a/A tags (parameterized replaceable events)
const aTagValue = getReplyToATag();
if (aTagValue) return aTagValue;
// 4. Check for i/I tags (identifier references)
const iTag = post.tags.find((t) => (t[0] === 'i' || t[0] === 'I') && t[1]);
if (iTag && iTag[1]) return iTag[1];
return null;
}
function getReplyTagType(): 'e' | 'E' | 'a' | 'A' | 'i' | 'I' | 'q' | null {
// Return the tag type for the reply, in priority order
const replyTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[3] === 'reply');
if (replyTag) return replyTag[0] as 'e' | 'E';
const rootId = getRootEventId();
const eTag = post.tags.find((t) => (t[0] === 'e' || t[0] === 'E') && t[1] && t[1] !== rootId && t[1] !== post.id);
if (eTag) return eTag[0] as 'e' | 'E';
const qTag = post.tags.find((t) => t[0] === 'q' && t[1]);
if (qTag) return 'q';
const aTag = post.tags.find((t) => (t[0] === 'a' || t[0] === 'A') && t[1]);
if (aTag) return aTag[0] as 'a' | 'A';
const iTag = post.tags.find((t) => (t[0] === 'i' || t[0] === 'I') && t[1]);
if (iTag) return iTag[0] as 'i' | 'I';
return null;
}
// Check if a-tag references the same event as the quoted event
function doesATagMatchQuotedEvent(aTagValue: string, quotedEvent: NostrEvent | null | undefined): boolean {
if (!aTagValue || !quotedEvent) return false;
// Parse a-tag: format is "kind:pubkey:d-tag"
const parts = aTagValue.split(':');
if (parts.length !== 3) return false;
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr, 10);
// Check if quoted event matches a-tag reference
if (quotedEvent.kind !== kind || quotedEvent.pubkey !== pubkey) {
return false;
}
// Check d-tag
const quotedDTag = quotedEvent.tags.find((t) => t[0] === 'd' && t[1]);
if (!quotedDTag || quotedDTag[1] !== dTag) {
return false;
}
return true;
}
// Check if reply-to and quote reference the same event
function shouldShowReply(): boolean {
const replyToEventId = getReplyToEventId();
const quotedEventId = getQuotedEventId();
// If e/E tag and q tag reference the same event ID, only show quote
if (replyToEventId && quotedEventId && replyToEventId === quotedEventId) {
return false;
}
// Check a-tags: if a-tag and q-tag reference the same event
const replyToATag = getReplyToATag();
if (replyToATag && quotedEventId) {
// If we have the quoted event loaded, check if it matches the a-tag reference
if (providedQuotedEvent && doesATagMatchQuotedEvent(replyToATag, providedQuotedEvent)) {
return false; // Same event referenced, only show quote
}
// Also check if the quoted event ID matches the a-tag value directly (unlikely but possible)
if (quotedEventId === replyToATag) {
return false;
}
}
return isReply();
}
function getRootEventId(): string | null {
const rootTag = post.tags.find((t) => t[0] === 'root');
return rootTag?.[1] || null;
}
function hasQuotedEvent(): boolean {
return post.tags.some((t) => t[0] === 'q');
}
function getQuotedEventId(): string | null {
const quotedTag = post.tags.find((t) => t[0] === 'q');
return quotedTag?.[1] || null;
}
// Check if URL appears in content (as plain URL or in markdown)
function isUrlInContent(url: string): boolean {
const normalized = normalizeUrl(url);
const content = post.content.toLowerCase();
// Check if URL appears as plain text (with or without protocol)
const urlWithoutProtocol = normalized.replace(/^https?:\/\//i, '');
if (content.includes(normalized.toLowerCase()) || content.includes(urlWithoutProtocol.toLowerCase())) {
return true;
}
// Check if URL appears in markdown image syntax ![alt](url)
const markdownImageRegex = /!\[.*?\]\((.*?)\)/gi;
let match;
while ((match = markdownImageRegex.exec(post.content)) !== null) {
const markdownUrl = normalizeUrl(match[1]);
if (markdownUrl === normalized) {
return true;
}
}
// Check if URL appears in HTML img/video/audio tags
const htmlTagRegex = /<(img|video|audio)[^>]+src=["']([^"']+)["']/gi;
while ((match = htmlTagRegex.exec(post.content)) !== null) {
const htmlUrl = normalizeUrl(match[2]);
if (htmlUrl === normalized) {
return true;
}
}
return false;
}
function normalizeUrl(url: string): string {
if (!url || typeof url !== 'string') return url;
try {
const parsed = new URL(url);
// Normalize for comparison (same as MediaAttachments)
const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, '');
return normalized;
} catch {
return url.trim().replace(/\/$/, '').toLowerCase();
}
}
// Get media URLs that MediaAttachments will display (for fullView)
// This matches the logic in MediaAttachments.extractMedia()
function getMediaAttachmentUrls(): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const forceRender = isMediaKind; // Same as what we pass to MediaAttachments
// 1. Image tag (NIP-23)
const imageTag = post.tags.find((t) => t[0] === 'image');
if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized)) {
urls.push(imageTag[1]);
seen.add(normalized);
}
}
// 2. imeta tags (NIP-92)
for (const tag of post.tags) {
if (tag[0] === 'imeta') {
let url: string | undefined;
for (let i = 1; i < tag.length; i++) {
const item = tag[i];
if (item.startsWith('url ')) {
url = item.substring(4).trim();
break;
}
}
if (url) {
const normalized = normalizeUrl(url);
// Skip if already displayed in content (imeta is just metadata reference)
// UNLESS forceRender is true (for media kinds where media is the primary content)
if (!forceRender && isUrlInContent(url)) {
continue;
}
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
}
}
// 3. file tags (NIP-94)
for (const tag of post.tags) {
if (tag[0] === 'file' && tag[1]) {
const normalized = normalizeUrl(tag[1]);
if (!seen.has(normalized)) {
urls.push(tag[1]);
seen.add(normalized);
}
}
}
// 4. Extract from markdown content (images in markdown syntax)
const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match;
while ((match = imageRegex.exec(post.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
urls.push(url);
seen.add(normalized);
}
}
return urls;
}
// Extract media URLs from event tags (image, imeta, file) - for feed view only
// Excludes URLs that are already in the content
function getMediaUrls(): string[] {
const urls: string[] = [];
const seen = new Set<string>();
// 1. Image tag (NIP-23)
const imageTag = post.tags.find((t) => t[0] === 'image');
if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized) && !isUrlInContent(imageTag[1])) {
urls.push(imageTag[1]);
seen.add(normalized);
}
}
// 2. imeta tags (NIP-92)
for (const tag of post.tags) {
if (tag[0] === 'imeta') {
for (let i = 1; i < tag.length; i++) {
const item = tag[i];
if (item.startsWith('url ')) {
const url = item.substring(4).trim();
const normalized = normalizeUrl(url);
if (!seen.has(normalized) && !isUrlInContent(url)) {
urls.push(url);
seen.add(normalized);
}
break;
}
}
}
}
// 3. file tags (NIP-94)
for (const tag of post.tags) {
if (tag[0] === 'file' && tag[1]) {
const normalized = normalizeUrl(tag[1]);
if (!seen.has(normalized) && !isUrlInContent(tag[1])) {
urls.push(tag[1]);
seen.add(normalized);
}
}
}
return urls;
}
// Media viewer state
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
function handleMediaUrlClick(e: MouseEvent, url: string) {
e.stopPropagation(); // Don't open drawer when clicking media URL
e.preventDefault();
mediaViewerUrl = url;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
}
</script>
<div
tabindex="0"
role="button"
aria-label="Post by {post.pubkey.slice(0, 8)}"
onfocus={() => isFocused = true}
onblur={() => isFocused = false}
onkeydown={(e) => {
// Handle shortcuts when post is focused
if (e.key === 'r' && isLoggedIn && !showReplyForm) {
e.preventDefault();
showReplyForm = true;
} else if (e.key === 'Enter' && !showReplyForm) {
// Enter to view full event
e.preventDefault();
goto(getEventLink(post));
}
}}
>
<article
bind:this={cardElement}
class="Feed-post"
data-post-id={post.id}
id="event-{post.id}"
data-event-id={post.id}
class:collapsed={!fullView && shouldCollapse && !isExpanded}
>
{#if fullView}
<!-- Full view: show complete content with markdown, media, profile pics, reactions -->
<CardHeader
pubkey={post.pubkey}
relativeTime={getRelativeTime()}
clientName={getClientName()}
>
{#snippet actions()}
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<IconButton
icon="eye"
label="View"
size={16}
onclick={() => goto(getEventLink(post))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
{/snippet}
</CardHeader>
{#if shouldShowReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
parentEventTagType={getReplyTagType() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
/>
{/if}
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
/>
{/if}
<MetadataCard event={post} hideTitle={true} hideSummary={true} />
{#if getTitle()}
<div class="event-title-row">
<h2 class="event-title">{getTitle()}</h2>
</div>
{/if}
{#if getSummary()}
<div class="event-summary-row">
<p class="event-summary">{getSummary()}</p>
</div>
{/if}
<div class="post-content mb-2">
{#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim() || isMediaKind)}
<MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} />
{/if}
{#if post.kind === KIND.POLL && fullView}
<PollCard pollEvent={post} />
{:else if post.content && post.content.trim()}
{@const mediaAttachmentUrls = getMediaAttachmentUrls()}
<MarkdownRenderer content={post.content} event={post} excludeMediaUrls={mediaAttachmentUrls} />
{:else if !isMediaKind && post.kind !== KIND.POLL}
<!-- Show empty content message for non-media kinds without content -->
<p class="text-fog-text-light dark:text-fog-dark-text-light italic text-sm">No content</p>
{/if}
</div>
<div class="post-actions flex flex-wrap items-center justify-between gap-2 sm:gap-4">
<div class="post-actions-left flex items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} preloadedReactions={preloadedReactions} />
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</div>
{:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs -->
<CardHeader
pubkey={post.pubkey}
relativeTime={getRelativeTime()}
clientName={getClientName()}
inline={true}
>
{#snippet actions()}
<IconButton
icon="eye"
label="View"
size={16}
onclick={() => goto(getEventLink(post))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
{/snippet}
</CardHeader>
{#if getTitle()}
<div class="event-title-row">
<h2 class="event-title">{getTitle()}</h2>
</div>
{/if}
{#if getSummary()}
<div class="event-summary-row">
<p class="event-summary">{getSummary()}</p>
</div>
{/if}
<!-- Show referenced event preview in feed view -->
<ReferencedEventPreview event={post} preloadedReferencedEvent={preloadedReferencedEvent} />
<!-- Show metadata in feed view when content is empty, but skip for media kinds (MediaAttachments handles those) -->
{#if !fullView && (!post.content || !post.content.trim()) && !isMediaKind}
<MetadataCard event={post} hideTitle={true} hideSummary={true} />
{/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
{#if shouldAutoRenderMedia}
<MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} />
{/if}
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment}
{@const highlightContent = getHighlightContent()}
{#if segment.type === 'text'}
{#if highlightContent && segment.content.includes(highlightContent)}
{@const parts = segment.content.split(highlightContent)}
{#each parts as part, i}
{part}
{#if i < parts.length - 1}
<mark class="highlighted-text">{highlightContent}</mark>
{/if}
{/each}
{:else}
{segment.content}
{/if}
{:else if segment.type === 'greentext'}
<span class="greentext">{segment.content}</span>
{:else if segment.type === 'profile' && segment.pubkey}
<ProfileBadge pubkey={segment.pubkey} inline={true} />
{:else if segment.type === 'event' && segment.eventId}
<a
href={getEventUrl(segment.eventId)}
target="_blank"
rel="noopener noreferrer"
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline"
>
{segment.content}
</a>
{:else if segment.type === 'url' && segment.url}
{@const isMediaUrl = /\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|ogg|flac|aac|m4a)(\?|#|$)/i.test(segment.url)}
{#if isMediaUrl}
<button
type="button"
class="text-fog-accent dark:text-fog-dark-accent hover:underline bg-transparent border-none p-0 cursor-pointer"
onclick={(e) => handleMediaUrlClick(e, segment.url!)}
>
{segment.content}
</button>
{:else}
<a
href={segment.url}
target="_blank"
rel="noopener noreferrer"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={(e) => e.stopPropagation()}
>
{segment.content}
</a>
{/if}
{:else if segment.type === 'wikilink' && segment.wikilink}
<a
href="/find?q={encodeURIComponent(segment.wikilink)}"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={(e) => e.stopPropagation()}
>
{segment.content}
</a>
{:else if segment.type === 'hashtag' && segment.hashtag}
<a
href="/topics/{segment.hashtag}"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
onclick={(e) => e.stopPropagation()}
>
{segment.content}
</a>
{/if}
{/each}
</p>
{#if getMediaUrls().length > 0}
{@const mediaUrls = getMediaUrls()}
<div class="media-urls mt-2">
{#each mediaUrls as url}
<button
type="button"
onclick={(e) => handleMediaUrlClick(e, url)}
class="media-url-link text-fog-accent dark:text-fog-dark-accent hover:underline break-all bg-transparent border-none p-0 cursor-pointer text-left"
style="font-size: 0.875em;"
>
{url}
</button>
{/each}
</div>
{/if}
</div>
{#if !fullView && shouldCollapse}
<div class="show-more-container">
<button
onclick={toggleExpand}
class="show-more-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
</div>
{/if}
{#if !fullView}
<div class="feed-card-footer flex items-center justify-between flex-wrap gap-2">
<div class="feed-card-reactions">
<FeedReactionButtons event={post} preloadedReactions={preloadedReactions} />
</div>
<div class="kind-badge feed-card-kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</div>
{/if}
{/if}
</article>
</div>
{#if isLoggedIn && showReplyForm}
<div class="reply-form-container mb-4">
<CommentForm
threadId={post.id}
rootEvent={post}
onPublished={() => {
showReplyForm = false;
}}
onCancel={() => {
showReplyForm = false;
}}
/>
</div>
{/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
<style>
.Feed-post {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
position: relative;
overflow: hidden;
width: 100%;
max-width: 100%;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: anywhere;
}
@media (max-width: 640px) {
.Feed-post {
padding: 0.75rem;
width: 100%;
max-width: 100%;
}
}
.Feed-post.collapsed {
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Ensure header and other non-content elements are always fully visible when collapsed */
.Feed-post.collapsed :global(.card-header),
.Feed-post.collapsed .event-title-row,
.Feed-post.collapsed .event-summary-row,
.Feed-post.collapsed :global(.reply-context),
.Feed-post.collapsed :global(.quoted-context),
.Feed-post.collapsed :global(.metadata-card) {
flex-shrink: 0;
min-height: fit-content;
overflow: visible;
}
.Feed-post.collapsed .post-content.collapsed-content {
flex: 1;
min-height: 0;
overflow: hidden;
}
:global(.dark) .Feed-post {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.post-content.collapsed-content {
max-height: 350px;
overflow: hidden;
position: relative;
}
.post-content.collapsed-content::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(to bottom, transparent, var(--fog-post, #ffffff));
pointer-events: none;
}
:global(.dark) .post-content.collapsed-content::after {
background: linear-gradient(to bottom, transparent, var(--fog-dark-post, #1f2937));
}
.show-more-container {
text-align: center;
padding: 0.5rem 0;
}
.show-more-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-weight: 500;
}
.post-content {
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: anywhere;
word-break: break-word;
padding-top: 1rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.post-content :global(.nostr-event-link),
.post-content :global(a[href^="nostr:"]) {
word-break: break-all !important;
overflow-wrap: anywhere !important;
word-wrap: break-word !important;
white-space: normal !important;
max-width: 100% !important;
display: inline-block !important;
box-sizing: border-box !important;
}
.post-content :global(.profile-badge) {
max-width: 100% !important;
min-width: 0 !important;
flex-shrink: 1 !important;
word-break: break-word !important;
overflow-wrap: anywhere !important;
flex-wrap: wrap !important;
}
.word-wrap {
word-wrap: break-word;
overflow-wrap: anywhere;
word-break: break-word;
}
.media-urls {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start; /* Left-align URLs */
}
.media-url-link {
word-break: break-all;
overflow-wrap: anywhere;
text-align: left; /* Ensure left alignment */
max-width: 100%;
}
.nostr-event-link {
word-break: break-all !important;
overflow-wrap: anywhere !important;
word-wrap: break-word !important;
white-space: normal !important;
text-decoration: underline;
max-width: 100% !important;
display: inline-block !important;
box-sizing: border-box !important;
}
.nostr-event-link:hover {
text-decoration: underline;
}
.post-actions {
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
.post-actions-left {
flex: 1;
min-width: 0;
}
:global(.dark) .post-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.feed-card-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .feed-card-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.feed-card-reactions {
flex: 1;
min-width: 0;
}
.event-title-row {
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.event-title {
font-size: 1.5em;
font-weight: bold;
color: var(--fog-text, #1f2937);
margin: 0;
line-height: 1.3;
}
:global(.dark) .event-title {
color: var(--fog-dark-text, #f9fafb);
}
.event-summary-row {
margin-top: 0.5rem;
margin-bottom: 0.75rem;
}
.event-summary {
font-size: 0.9375em;
color: var(--fog-text-light, #52667a);
margin: 0;
line-height: 1.5;
}
:global(.dark) .event-summary {
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-badge {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #52667a);
flex-shrink: 0;
white-space: nowrap;
}
.feed-card-kind-badge {
flex-shrink: 0;
white-space: nowrap;
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {
font-weight: 600;
}
.kind-description {
font-size: 0.625rem;
opacity: 0.8;
}
.Feed-post {
position: relative;
}
@media (max-width: 640px) {
.event-title {
word-break: break-word;
overflow-wrap: anywhere;
max-width: 100%;
}
}
.bookmark-indicator {
display: inline-block;
font-size: 1rem;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
}
.bookmark-indicator.bookmarked {
filter: grayscale(0%);
}
.highlighted-text {
background-color: rgba(255, 255, 0, 0.3);
padding: 0.125rem 0.25rem;
border-radius: 0.125rem;
font-weight: 500;
}
:global(.dark) .highlighted-text {
background-color: rgba(255, 255, 0, 0.2);
}
.greentext {
color: #789922;
}
:global(.dark) .greentext {
color: #8ab378;
}
/* Focusable wrapper for keyboard navigation */
div[role="button"] {
outline: none;
cursor: default;
}
div[role="button"]:focus {
outline: 2px solid var(--fog-accent, #64748b);
outline-offset: 2px;
}
:global(.dark) div[role="button"]:focus {
outline-color: var(--fog-dark-accent, #94a3b8);
}
</style>