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.
206 lines
5.7 KiB
206 lines
5.7 KiB
<script lang="ts"> |
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
|
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; |
|
import CommentThread from '../comments/CommentThread.svelte'; |
|
import ReactionButtons from '../reactions/ReactionButtons.svelte'; |
|
import ZapButton from '../zaps/ZapButton.svelte'; |
|
import ZapReceipt from '../zaps/ZapReceipt.svelte'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
interface Props { |
|
threadId: string; |
|
} |
|
|
|
let { threadId }: Props = $props(); |
|
|
|
let thread = $state<NostrEvent | null>(null); |
|
let loading = $state(true); |
|
let expanded = $state(false); |
|
let contentElement: HTMLElement | null = $state(null); |
|
let needsExpansion = $state(false); |
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
loadThread(); |
|
}); |
|
|
|
$effect(() => { |
|
if (threadId) { |
|
loadThread(); |
|
} |
|
}); |
|
|
|
async function loadThread() { |
|
loading = true; |
|
try { |
|
const relays = relayManager.getThreadReadRelays(); |
|
const event = await nostrClient.getEventById(threadId, relays); |
|
thread = event; |
|
} catch (error) { |
|
console.error('Error loading thread:', error); |
|
} finally { |
|
loading = false; |
|
} |
|
} |
|
|
|
function getTitle(): string { |
|
if (!thread) return ''; |
|
const titleTag = thread.tags.find((t) => t[0] === 'title'); |
|
return titleTag?.[1] || 'Untitled'; |
|
} |
|
|
|
function getTopics(): string[] { |
|
if (!thread) return []; |
|
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3); |
|
} |
|
|
|
function getClientName(): string | null { |
|
if (!thread) return null; |
|
const clientTag = thread.tags.find((t) => t[0] === 'client'); |
|
return clientTag?.[1] || null; |
|
} |
|
|
|
function getRelativeTime(): string { |
|
if (!thread) return ''; |
|
const now = Math.floor(Date.now() / 1000); |
|
const diff = now - thread.created_at; |
|
const hours = Math.floor(diff / 3600); |
|
const days = Math.floor(diff / 86400); |
|
|
|
if (days > 0) return `${days}d ago`; |
|
if (hours > 0) return `${hours}h ago`; |
|
return 'just now'; |
|
} |
|
|
|
$effect(() => { |
|
if (contentElement && thread) { |
|
checkContentHeight(); |
|
// Use ResizeObserver to detect when content changes (e.g., images loading) |
|
const observer = new ResizeObserver(() => { |
|
checkContentHeight(); |
|
}); |
|
observer.observe(contentElement); |
|
return () => observer.disconnect(); |
|
} |
|
}); |
|
|
|
function checkContentHeight() { |
|
if (contentElement) { |
|
// Use requestAnimationFrame to ensure DOM is fully updated |
|
requestAnimationFrame(() => { |
|
if (contentElement) { |
|
needsExpansion = contentElement.scrollHeight > 500; |
|
} |
|
}); |
|
} |
|
} |
|
|
|
function toggleExpanded() { |
|
expanded = !expanded; |
|
} |
|
</script> |
|
|
|
{#if loading} |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p> |
|
{:else if thread} |
|
<article class="thread-view"> |
|
<div class="thread-header mb-4"> |
|
<h1 class="text-2xl font-bold mb-2">{getTitle()}</h1> |
|
<div class="flex items-center gap-2 mb-2"> |
|
<ProfileBadge pubkey={thread.pubkey} /> |
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
|
{#if getClientName()} |
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
|
{/if} |
|
</div> |
|
{#if getTopics().length > 0} |
|
<div class="flex gap-2 mb-2"> |
|
{#each getTopics() as topic} |
|
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<div class="card-content" class:expanded bind:this={contentElement}> |
|
<div class="thread-content mb-4"> |
|
<MediaAttachments event={thread} /> |
|
<MarkdownRenderer content={thread.content} /> |
|
</div> |
|
|
|
<div class="thread-actions flex items-center gap-4 mb-6"> |
|
<ReactionButtons event={thread} /> |
|
<ZapButton event={thread} /> |
|
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} /> |
|
</div> |
|
</div> |
|
|
|
{#if needsExpansion} |
|
<button |
|
onclick={toggleExpanded} |
|
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2" |
|
> |
|
{expanded ? 'Show less' : 'Show more'} |
|
</button> |
|
{/if} |
|
|
|
<div class="comments-section"> |
|
<CommentThread threadId={thread.id} /> |
|
</div> |
|
</article> |
|
{:else} |
|
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p> |
|
{/if} |
|
|
|
<style> |
|
.thread-view { |
|
max-width: var(--content-width); |
|
margin: 0 auto; |
|
} |
|
|
|
.thread-content { |
|
line-height: 1.6; |
|
} |
|
|
|
.thread-actions { |
|
padding-top: 1rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .thread-actions { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.comments-section { |
|
margin-top: 2rem; |
|
padding-top: 2rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .comments-section { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.card-content { |
|
max-height: 500px; |
|
overflow: hidden; |
|
transition: max-height 0.3s ease; |
|
} |
|
|
|
.card-content.expanded { |
|
max-height: none; |
|
} |
|
|
|
.show-more-button { |
|
width: 100%; |
|
text-align: center; |
|
padding: 0.5rem; |
|
background: transparent; |
|
border: none; |
|
cursor: pointer; |
|
} |
|
</style>
|
|
|