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
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  |
|
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>
|
|
|