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.
 
 
 
 
 

1016 lines
32 KiB

<script lang="ts">
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 FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import CommentForm from '../comments/CommentForm.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';
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
}
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent }: 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);
// Check if card should be collapsed (only in feed view)
let isMounted = $state(true);
$effect(() => {
if (fullView) {
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 {
const titleTag = post.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
function getPlaintextContent(): string {
// Plaintext only (no markdown/images) - keep full length
return stripMarkdown(post.content);
}
// Parse NIP-21 links and create segments for rendering
interface ContentSegment {
type: 'text' | 'profile' | 'event' | 'url' | 'wikilink' | 'hashtag';
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
}
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);
}
}
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 {
return post.tags.some((t) => t[0] === 'e' && t[1] !== post.id);
}
function getReplyEventId(): string | null {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (replyTag) return replyTag[1];
const rootId = getRootEventId();
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id);
return eTag?.[1] || null;
}
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 {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '');
} catch {
return url;
}
}
// 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>
<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 -->
{#if isReply()}
<ReplyContext
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || 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} hideImageIfInMedia={true} />
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center justify-end gap-2 flex-nowrap">
<div class="post-header-actions flex items-center gap-2 flex-shrink-0">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} />
</div>
</div>
<div class="flex items-center gap-2 flex-nowrap">
<div class="flex-shrink-1 min-w-0">
<ProfileBadge pubkey={post.pubkey} />
</div>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()}
{#if topics.length === 0}
<a href="/topics/General" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">General</a>
{:else}
{#each topics.slice(0, 3) as topic}
<a href="/topics/{topic}" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">{topic}</a>
{/each}
{/if}
{/if}
</div>
<hr class="post-header-divider" />
</div>
<div class="post-content mb-2">
<MediaAttachments event={post} />
<MarkdownRenderer content={post.content} event={post} />
</div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} preloadedReactions={preloadedReactions} />
</div>
{:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs -->
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2 flex-nowrap">
<div class="flex-shrink-1 min-w-0">
<ProfileBadge pubkey={post.pubkey} inline={true} />
</div>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()}
{#if topics.length === 0}
<a href="/topics/General" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">General</a>
{:else}
{#each topics.slice(0, 3) as topic}
<a href="/topics/{topic}" class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light hover:underline" style="font-size: 0.75em;">{topic}</a>
{/each}
{/if}
{/if}
</div>
<hr class="post-header-divider" />
</div>
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title font-bold mb-2 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment}
{#if segment.type === 'text'}
{segment.content}
{: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="/replaceable/{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">
<div class="feed-card-actions flex items-center gap-2">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} />
</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}
{#if fullView}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
{/if}
</article>
{#if isLoggedIn}
<div class="reply-section mb-2">
<button
onclick={() => showReplyForm = !showReplyForm}
class="reply-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
Reply
</button>
</div>
{#if showReplyForm}
<div class="reply-form-container mb-4">
<CommentForm
threadId={post.id}
rootEvent={post}
onPublished={() => {
showReplyForm = false;
}}
onCancel={() => {
showReplyForm = false;
}}
/>
</div>
{/if}
{/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;
}
.Feed-post.collapsed {
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.Feed-post.collapsed .post-content.collapsed-content {
flex: 1;
min-height: 0;
}
: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: break-word;
word-break: break-word;
}
.word-wrap {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.media-urls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.media-url-link {
word-break: break-all;
}
.nostr-event-link {
word-break: break-all;
text-decoration: underline;
}
.nostr-event-link:hover {
text-decoration: underline;
}
.post-actions {
padding-top: 0.5rem;
padding-right: 6rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
: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-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
}
.feed-card-kind-badge {
position: static;
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
}
.kind-number {
font-weight: 600;
}
.kind-description {
font-size: 0.625rem;
opacity: 0.8;
}
.Feed-post {
position: relative;
}
.post-header {
display: flex;
align-items: center;
line-height: 1.5;
}
.post-header-actions {
flex-shrink: 0;
}
.post-header-divider {
margin: 0 0 0.5rem 0;
border: none;
border-top: 1px solid var(--fog-border, #e5e7eb);
width: 100%;
}
:global(.dark) .post-header-divider {
border-top-color: var(--fog-dark-border, #374151);
}
.post-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
width: auto;
max-width: none;
}
.post-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: middle;
}
.bookmark-indicator {
display: inline-block;
font-size: 1rem;
line-height: 1;
filter: grayscale(100%);
transition: filter 0.2s;
}
.bookmark-indicator.bookmarked {
filter: grayscale(0%);
}
</style>