9 changed files with 1141 additions and 174 deletions
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
<script lang="ts"> |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
|
||||
interface Props { |
||||
quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId |
||||
quotedEventId?: string; // Optional - used to load quoted event if not provided |
||||
targetId?: string; // Optional ID to scroll to (defaults to quoted event ID) |
||||
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded |
||||
} |
||||
|
||||
let { quotedEvent: providedQuotedEvent, quotedEventId, targetId, onQuotedLoaded }: Props = $props(); |
||||
|
||||
let loadedQuotedEvent = $state<NostrEvent | null>(null); |
||||
let loadingQuoted = $state(false); |
||||
|
||||
// Derive the effective quoted event: prefer provided, fall back to loaded |
||||
let quotedEvent = $derived(providedQuotedEvent || loadedQuotedEvent); |
||||
|
||||
// Sync provided quoted event changes and load if needed |
||||
$effect(() => { |
||||
if (providedQuotedEvent) { |
||||
// If provided quoted event is available, use it |
||||
return; |
||||
} |
||||
|
||||
// If no provided quoted event and we have an ID, try to load it |
||||
if (!loadedQuotedEvent && quotedEventId && !loadingQuoted) { |
||||
loadQuotedEvent(); |
||||
} |
||||
}); |
||||
|
||||
async function loadQuotedEvent() { |
||||
const eventId = quotedEventId || quotedEvent?.id; |
||||
if (!eventId || loadingQuoted) return; |
||||
|
||||
loadingQuoted = true; |
||||
try { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ kinds: [1], ids: [eventId] }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
loadedQuotedEvent = events[0]; |
||||
if (onQuotedLoaded) { |
||||
onQuotedLoaded(loadedQuotedEvent); |
||||
} |
||||
// After loading, try to scroll to it |
||||
setTimeout(() => scrollToQuoted(), 100); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error loading quoted event:', error); |
||||
} finally { |
||||
loadingQuoted = false; |
||||
} |
||||
} |
||||
|
||||
function getQuotedPreview(): string { |
||||
if (!quotedEvent) { |
||||
return loadingQuoted ? 'Loading...' : 'Quoted event not found'; |
||||
} |
||||
// Create preview from quoted event (first 100 chars, plaintext) |
||||
const plaintext = quotedEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim(); |
||||
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); |
||||
} |
||||
|
||||
async function scrollToQuoted() { |
||||
const eventId = quotedEvent?.id || quotedEventId; |
||||
if (!eventId) return; |
||||
|
||||
// If quoted event not loaded yet, load it first |
||||
if (!quotedEvent && quotedEventId) { |
||||
await loadQuotedEvent(); |
||||
} |
||||
|
||||
const elementId = targetId || `event-${eventId}`; |
||||
let element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); |
||||
|
||||
// If still not found, wait a bit for DOM to update |
||||
if (!element) { |
||||
await new Promise(resolve => setTimeout(resolve, 200)); |
||||
element = document.getElementById(elementId) || document.querySelector(`[data-event-id="${eventId}"]`); |
||||
} |
||||
|
||||
if (element) { |
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
||||
element.classList.add('highlight-quoted'); |
||||
setTimeout(() => { |
||||
element?.classList.remove('highlight-quoted'); |
||||
}, 2000); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div |
||||
class="quoted-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light cursor-pointer hover:opacity-80 transition-opacity" |
||||
onclick={scrollToQuoted} |
||||
role="button" |
||||
tabindex="0" |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Enter' || e.key === ' ') { |
||||
e.preventDefault(); |
||||
scrollToQuoted(); |
||||
} |
||||
}} |
||||
> |
||||
<span class="font-semibold">Quoting:</span> {getQuotedPreview()} |
||||
{#if loadingQuoted} |
||||
<span class="text-xs opacity-70"> (loading...)</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.quoted-context { |
||||
border-left: 2px solid var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .quoted-context { |
||||
border-left-color: var(--fog-dark-accent, #64748b); |
||||
} |
||||
|
||||
:global(.highlight-quoted) { |
||||
outline: 2px solid var(--fog-accent, #64748b); |
||||
outline-offset: 2px; |
||||
transition: outline 0.3s ease; |
||||
} |
||||
|
||||
:global(.dark .highlight-quoted) { |
||||
outline-color: var(--fog-dark-accent, #64748b); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,440 @@
@@ -0,0 +1,440 @@
|
||||
<script lang="ts"> |
||||
import { fade, slide } from 'svelte/transition'; |
||||
import FeedPost from './FeedPost.svelte'; |
||||
import ZapReceiptReply from './ZapReceiptReply.svelte'; |
||||
import Comment from '../comments/Comment.svelte'; |
||||
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
opEvent: NostrEvent | null; // The original post/event |
||||
isOpen: boolean; |
||||
onClose: () => void; |
||||
} |
||||
|
||||
let { opEvent, isOpen, onClose }: Props = $props(); |
||||
|
||||
let loading = $state(false); |
||||
let threadEvents = $state<NostrEvent[]>([]); |
||||
let reactions = $state<NostrEvent[]>([]); |
||||
|
||||
// Load thread when drawer opens |
||||
$effect(() => { |
||||
if (isOpen && opEvent) { |
||||
loadThread(); |
||||
} else { |
||||
// Reset when closed |
||||
threadEvents = []; |
||||
reactions = []; |
||||
} |
||||
}); |
||||
|
||||
async function loadThread() { |
||||
if (!opEvent) return; |
||||
|
||||
loading = true; |
||||
try { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const eventId = opEvent.id; |
||||
|
||||
// Load all replies: zap receipts (9735), yak backs (1244), kind 1 replies, kind 1111 comments |
||||
const replyFilters = [ |
||||
{ kinds: [9735], '#e': [eventId] }, // Zap receipts |
||||
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies) |
||||
{ kinds: [1], '#e': [eventId] }, // Kind 1 replies |
||||
{ kinds: [1111], '#e': [eventId] } // Kind 1111 comments |
||||
]; |
||||
|
||||
// Fetch all reply types |
||||
const allReplies = await nostrClient.fetchEvents( |
||||
replyFilters, |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Load reactions (kind 7) for the OP |
||||
const reactionEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [7], '#e': [eventId] }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
reactions = reactionEvents; |
||||
|
||||
// Recursively fetch nested replies |
||||
await fetchNestedReplies(allReplies, relays, eventId); |
||||
|
||||
threadEvents = allReplies; |
||||
} catch (error) { |
||||
console.error('Error loading thread:', error); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function fetchNestedReplies(initialReplies: NostrEvent[], relays: string[], rootEventId: string) { |
||||
let hasNewReplies = true; |
||||
let iterations = 0; |
||||
const maxIterations = 10; |
||||
const allReplies = new Map<string, NostrEvent>(); |
||||
|
||||
// Add initial replies |
||||
for (const reply of initialReplies) { |
||||
allReplies.set(reply.id, reply); |
||||
} |
||||
|
||||
while (hasNewReplies && iterations < maxIterations) { |
||||
iterations++; |
||||
hasNewReplies = false; |
||||
|
||||
const replyIds = Array.from(allReplies.keys()); |
||||
|
||||
if (replyIds.length > 0) { |
||||
// Fetch replies to any of our replies |
||||
const nestedFilters = [ |
||||
{ kinds: [9735], '#e': replyIds }, |
||||
{ kinds: [1244], '#e': replyIds }, |
||||
{ kinds: [1], '#e': replyIds }, |
||||
{ kinds: [1111], '#e': replyIds } |
||||
]; |
||||
|
||||
const nestedReplies = await nostrClient.fetchEvents( |
||||
nestedFilters, |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
for (const reply of nestedReplies) { |
||||
if (!allReplies.has(reply.id)) { |
||||
allReplies.set(reply.id, reply); |
||||
hasNewReplies = true; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
threadEvents = Array.from(allReplies.values()); |
||||
} |
||||
|
||||
function getParentEvent(event: NostrEvent): NostrEvent | undefined { |
||||
// Find parent event in thread |
||||
const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id); |
||||
if (eTag && eTag[1]) { |
||||
// Check if parent is the OP |
||||
if (opEvent && eTag[1] === opEvent.id) { |
||||
return opEvent; |
||||
} |
||||
// Check if parent is another reply |
||||
return threadEvents.find((e) => e.id === eTag[1]); |
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
function sortThreadItems(): Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> { |
||||
const items: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = []; |
||||
|
||||
for (const event of threadEvents) { |
||||
if (event.kind === 9735) { |
||||
items.push({ event, type: 'zap' }); |
||||
} else if (event.kind === 1244) { |
||||
items.push({ event, type: 'yak' }); |
||||
} else if (event.kind === 1) { |
||||
items.push({ event, type: 'reply' }); |
||||
} else if (event.kind === 1111) { |
||||
items.push({ event, type: 'comment' }); |
||||
} |
||||
} |
||||
|
||||
// Build thread structure |
||||
const eventMap = new Map<string, { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }>(); |
||||
const replyMap = new Map<string, string[]>(); |
||||
const rootItems: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = []; |
||||
|
||||
// First pass: build maps |
||||
for (const item of items) { |
||||
eventMap.set(item.event.id, item); |
||||
} |
||||
|
||||
// Second pass: determine parent-child relationships |
||||
for (const item of items) { |
||||
const eTag = item.event.tags.find((t) => t[0] === 'e' && t[1] !== item.event.id); |
||||
const parentId = eTag?.[1]; |
||||
|
||||
if (parentId) { |
||||
// Check if parent is OP or another reply |
||||
if (opEvent && parentId === opEvent.id) { |
||||
// Direct reply to OP |
||||
rootItems.push(item); |
||||
} else if (eventMap.has(parentId)) { |
||||
// Reply to another reply |
||||
if (!replyMap.has(parentId)) { |
||||
replyMap.set(parentId, []); |
||||
} |
||||
replyMap.get(parentId)!.push(item.event.id); |
||||
} else { |
||||
// Parent not found - treat as root |
||||
rootItems.push(item); |
||||
} |
||||
} else { |
||||
// No parent tag - treat as root |
||||
rootItems.push(item); |
||||
} |
||||
} |
||||
|
||||
// Third pass: recursively collect in thread order |
||||
const result: Array<{ event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }> = []; |
||||
const processed = new Set<string>(); |
||||
|
||||
function addThread(item: { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' }) { |
||||
if (processed.has(item.event.id)) return; |
||||
processed.add(item.event.id); |
||||
|
||||
result.push(item); |
||||
|
||||
const replies = replyMap.get(item.event.id) || []; |
||||
const replyItems = replies |
||||
.map(id => eventMap.get(id)) |
||||
.filter((item): item is { event: NostrEvent; type: 'zap' | 'yak' | 'reply' | 'comment' } => item !== undefined) |
||||
.sort((a, b) => a.event.created_at - b.event.created_at); |
||||
|
||||
for (const reply of replyItems) { |
||||
addThread(reply); |
||||
} |
||||
} |
||||
|
||||
// Sort root items by created_at |
||||
rootItems.sort((a, b) => a.event.created_at - b.event.created_at); |
||||
for (const root of rootItems) { |
||||
addThread(root); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
function handleBackdropClick(e: MouseEvent) { |
||||
if (e.target === e.currentTarget) { |
||||
onClose(); |
||||
} |
||||
} |
||||
|
||||
function handleEscape(e: KeyboardEvent) { |
||||
if (e.key === 'Escape') { |
||||
onClose(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<svelte:window onkeydown={handleEscape} /> |
||||
|
||||
{#if isOpen} |
||||
<div |
||||
class="drawer-backdrop" |
||||
onclick={handleBackdropClick} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape') { |
||||
onClose(); |
||||
} |
||||
}} |
||||
role="button" |
||||
tabindex="0" |
||||
aria-label="Close drawer" |
||||
transition:fade={{ duration: 200 }} |
||||
> |
||||
<div class="drawer" transition:slide={{ axis: 'x', duration: 300 }}> |
||||
<div class="drawer-header"> |
||||
<h2 class="drawer-title">Thread</h2> |
||||
<button class="close-button" onclick={onClose} aria-label="Close drawer"> |
||||
× |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="drawer-content"> |
||||
{#if loading} |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> |
||||
{:else if opEvent} |
||||
<!-- OP with reactions --> |
||||
<div class="op-section"> |
||||
<FeedPost post={opEvent} /> |
||||
<div class="reactions-section"> |
||||
<FeedReactionButtons event={opEvent} /> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Threaded replies --> |
||||
{#if threadEvents.length > 0} |
||||
<div class="replies-section"> |
||||
<h3 class="replies-title">Replies</h3> |
||||
<div class="replies-list"> |
||||
{#each sortThreadItems() as item (item.event.id)} |
||||
{@const parentEvent = getParentEvent(item.event)} |
||||
{#if item.type === 'zap'} |
||||
<ZapReceiptReply |
||||
zapReceipt={item.event} |
||||
parentEvent={parentEvent || opEvent} |
||||
/> |
||||
{:else if item.type === 'yak'} |
||||
<!-- Yak back (voice reply) - TODO: create component or use existing --> |
||||
<div class="yak-reply"> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Voice reply (kind 1244) - TODO: implement component</p> |
||||
</div> |
||||
{:else if item.type === 'reply'} |
||||
<FeedPost |
||||
post={item.event} |
||||
parentEvent={parentEvent || opEvent} |
||||
/> |
||||
{:else if item.type === 'comment'} |
||||
<Comment |
||||
comment={item.event} |
||||
parentEvent={parentEvent || opEvent} |
||||
/> |
||||
{/if} |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{:else} |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet.</p> |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.drawer-backdrop { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
z-index: 1000; |
||||
display: flex; |
||||
justify-content: flex-end; |
||||
} |
||||
|
||||
:global(.dark) .drawer-backdrop { |
||||
background: rgba(0, 0, 0, 0.7); |
||||
} |
||||
|
||||
.drawer { |
||||
width: 100%; |
||||
max-width: 600px; |
||||
height: 100%; |
||||
background: var(--fog-post, #ffffff); |
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
} |
||||
|
||||
:global(.dark) .drawer { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3); |
||||
} |
||||
|
||||
.drawer-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .drawer-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.drawer-title { |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
margin: 0; |
||||
color: var(--fog-text, #111827); |
||||
} |
||||
|
||||
:global(.dark) .drawer-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: none; |
||||
border: none; |
||||
font-size: 2rem; |
||||
line-height: 1; |
||||
cursor: pointer; |
||||
color: var(--fog-text-light, #6b7280); |
||||
padding: 0; |
||||
width: 2rem; |
||||
height: 2rem; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
color: var(--fog-text, #111827); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.drawer-content { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.op-section { |
||||
margin-bottom: 2rem; |
||||
padding-bottom: 1rem; |
||||
border-bottom: 2px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .op-section { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.reactions-section { |
||||
margin-top: 1rem; |
||||
padding-top: 1rem; |
||||
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .reactions-section { |
||||
border-top-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.replies-section { |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.replies-title { |
||||
font-size: 1.125rem; |
||||
font-weight: 600; |
||||
margin-bottom: 1rem; |
||||
color: var(--fog-text, #111827); |
||||
} |
||||
|
||||
:global(.dark) .replies-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.replies-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.yak-reply { |
||||
padding: 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border-radius: 0.25rem; |
||||
} |
||||
|
||||
:global(.dark) .yak-reply { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue