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