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.
507 lines
14 KiB
507 lines
14 KiB
<script lang="ts"> |
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
|
import EventMenu from '../../components/EventMenu.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'; |
|
import { getKindInfo, KIND } from '../../types/kind-lookup.js'; |
|
import { getHighlightsForEvent } from '../../services/nostr/highlight-service.js'; |
|
import { isBookmarked } from '../../services/user-actions.js'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import Icon from '../../components/ui/Icon.svelte'; |
|
import { getEventLink } from '../../services/event-links.js'; |
|
import { goto } from '$app/navigation'; |
|
import IconButton from '../../components/ui/IconButton.svelte'; |
|
|
|
interface Props { |
|
highlight: NostrEvent; // The highlight event (kind 9802) |
|
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer |
|
} |
|
|
|
let { highlight, onOpenEvent }: Props = $props(); |
|
|
|
let sourceEvent = $state<NostrEvent | null>(null); |
|
let loadingSource = $state(false); |
|
|
|
// 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()); |
|
|
|
$effect(() => { |
|
if (isLoggedIn) { |
|
isBookmarked(highlight.id).then(b => { |
|
bookmarked = b; |
|
}); |
|
} else { |
|
bookmarked = false; |
|
} |
|
}); |
|
|
|
// Extract source event ID from e-tag or a-tag |
|
function getSourceEventId(): string | null { |
|
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]); |
|
if (eTag && eTag[1]) { |
|
return eTag[1]; |
|
} |
|
return null; |
|
} |
|
|
|
// Extract source address from a-tag |
|
function getSourceAddress(): string | null { |
|
const aTag = highlight.tags.find(t => t[0] === 'a' && t[1]); |
|
if (aTag && aTag[1]) { |
|
return aTag[1]; |
|
} |
|
return null; |
|
} |
|
|
|
// Parse a-tag to extract kind, pubkey, and d-tag |
|
function parseATag(): { kind: number; pubkey: string; dTag: string } | null { |
|
const aTagValue = getSourceAddress(); |
|
if (!aTagValue) return null; |
|
|
|
const parts = aTagValue.split(':'); |
|
if (parts.length !== 3) return null; |
|
|
|
const kind = parseInt(parts[0], 10); |
|
const pubkey = parts[1]; |
|
const dTag = parts[2]; |
|
|
|
if (isNaN(kind) || !pubkey || !dTag) return null; |
|
|
|
return { kind, pubkey, dTag }; |
|
} |
|
|
|
// Extract author pubkey from p-tag |
|
function getAuthorPubkey(): string | null { |
|
const pTag = highlight.tags.find(t => t[0] === 'p' && t[1]); |
|
return pTag?.[1] || null; |
|
} |
|
|
|
// Get source event URL (handles both e-tag and a-tag) |
|
function getSourceEventUrl(): string | null { |
|
if (sourceEvent) { |
|
return `/event/${sourceEvent.id}`; |
|
} |
|
|
|
const sourceEventId = getSourceEventId(); |
|
if (sourceEventId) { |
|
return `/event/${sourceEventId}`; |
|
} |
|
|
|
const aTagData = parseATag(); |
|
if (aTagData) { |
|
// Use the d-tag for the replaceable route |
|
return `/replaceable/${aTagData.dTag}`; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
// Extract context tag value |
|
function getContext(): string | null { |
|
const contextTag = highlight.tags.find(t => t[0] === 'context' && t[1]); |
|
return contextTag?.[1] || null; |
|
} |
|
|
|
// Extract URL from url or r tag |
|
function getUrl(): string | null { |
|
const urlTag = highlight.tags.find(t => (t[0] === 'url' || t[0] === 'r') && t[1]); |
|
return urlTag?.[1] || null; |
|
} |
|
|
|
// Normalize text for matching (remove extra whitespace, normalize line breaks) |
|
function normalizeText(text: string): string { |
|
return text.replace(/\s+/g, ' ').trim(); |
|
} |
|
|
|
// Escape HTML to prevent XSS |
|
function escapeHtml(text: string): string { |
|
return text |
|
.replace(/&/g, '&') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/"/g, '"') |
|
.replace(/'/g, '''); |
|
} |
|
|
|
// Find content within context and highlight it |
|
function getHighlightedContext(): string { |
|
const context = getContext(); |
|
const content = highlight.content?.trim(); |
|
|
|
// If no context or no content, just return the content |
|
if (!context || !content) { |
|
return escapeHtml(content || ''); |
|
} |
|
|
|
// Escape the context for safety |
|
const escapedContext = escapeHtml(context); |
|
const escapedContent = escapeHtml(content); |
|
|
|
// Normalize whitespace for matching (but preserve original for display) |
|
const normalizeForMatch = (text: string) => text.replace(/\s+/g, ' ').trim(); |
|
const normalizedContext = normalizeForMatch(context); |
|
const normalizedContent = normalizeForMatch(content); |
|
|
|
// Try to find the normalized content within the normalized context |
|
const normalizedIndex = normalizedContext.toLowerCase().indexOf(normalizedContent.toLowerCase()); |
|
|
|
if (normalizedIndex === -1) { |
|
// Content not found in context, just return context |
|
return escapedContext; |
|
} |
|
|
|
// Find the actual position in the original context |
|
// We need to map from normalized position back to original position |
|
// This is approximate - we'll search for the content in the original context |
|
// using a flexible regex that handles whitespace variations |
|
|
|
// Create a regex pattern from the content that allows flexible whitespace |
|
const contentWords = content.trim().split(/\s+/); |
|
const flexiblePattern = contentWords.map(word => |
|
word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') |
|
).join('\\s+'); |
|
|
|
const regex = new RegExp(`(${flexiblePattern})`, 'i'); |
|
const match = context.match(regex); |
|
|
|
if (!match) { |
|
// Fallback: just return context without highlighting |
|
return escapedContext; |
|
} |
|
|
|
// Found match, highlight it |
|
const matchIndex = match.index!; |
|
const matchText = match[1]; |
|
const before = escapeHtml(context.substring(0, matchIndex)); |
|
const highlighted = '<mark class="highlight-text">' + escapeHtml(matchText) + '</mark>'; |
|
const after = escapeHtml(context.substring(matchIndex + matchText.length)); |
|
|
|
return before + highlighted + after; |
|
} |
|
|
|
// Check if we should show context with highlight |
|
let shouldShowContext = $derived(getContext() !== null && highlight.content?.trim() !== ''); |
|
|
|
onMount(async () => { |
|
await loadSourceEvent(); |
|
}); |
|
|
|
async function loadSourceEvent() { |
|
if (loadingSource) return; |
|
|
|
loadingSource = true; |
|
try { |
|
const relays = relayManager.getFeedReadRelays(); |
|
|
|
// Try e-tag first |
|
const sourceEventId = getSourceEventId(); |
|
if (sourceEventId) { |
|
const events = await nostrClient.fetchEvents( |
|
[{ ids: [sourceEventId], limit: 1 }], |
|
relays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
if (events.length > 0) { |
|
sourceEvent = events[0]; |
|
loadingSource = false; |
|
return; |
|
} |
|
} |
|
|
|
// Try a-tag (replaceable event) |
|
const aTagData = parseATag(); |
|
if (aTagData) { |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [aTagData.kind], authors: [aTagData.pubkey], '#d': [aTagData.dTag], limit: 1 }], |
|
relays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
// For replaceable events, get the most recent one |
|
if (events.length > 0) { |
|
const sorted = events.sort((a, b) => b.created_at - a.created_at); |
|
sourceEvent = sorted[0]; |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error loading source event:', error); |
|
} finally { |
|
loadingSource = false; |
|
} |
|
} |
|
|
|
function getRelativeTime(): string { |
|
const now = Math.floor(Date.now() / 1000); |
|
const diff = now - highlight.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 = highlight.tags.find((t) => t[0] === 'client'); |
|
return clientTag?.[1] || null; |
|
} |
|
|
|
function handleCardClick(e: MouseEvent) { |
|
// Don't open drawer if clicking on interactive elements |
|
const target = e.target as HTMLElement; |
|
if ( |
|
target.tagName === 'BUTTON' || |
|
target.tagName === 'A' || |
|
target.closest('button') || |
|
target.closest('a') || |
|
target.closest('.post-actions') |
|
) { |
|
return; |
|
} |
|
|
|
// Open source event if available |
|
const sourceUrl = getSourceEventUrl(); |
|
if (onOpenEvent && sourceEvent) { |
|
onOpenEvent(sourceEvent); |
|
} else if (sourceUrl) { |
|
window.location.href = sourceUrl; |
|
} |
|
} |
|
|
|
function handleCardKeydown(e: KeyboardEvent) { |
|
if (e.key !== 'Enter' && e.key !== ' ') { |
|
return; |
|
} |
|
|
|
const target = e.target as HTMLElement; |
|
if ( |
|
target.tagName === 'BUTTON' || |
|
target.tagName === 'A' || |
|
target.closest('button') || |
|
target.closest('a') || |
|
target.closest('.post-actions') |
|
) { |
|
return; |
|
} |
|
|
|
e.preventDefault(); |
|
|
|
const sourceUrl = getSourceEventUrl(); |
|
if (onOpenEvent && sourceEvent) { |
|
onOpenEvent(sourceEvent); |
|
} else if (sourceUrl) { |
|
window.location.href = sourceUrl; |
|
} |
|
} |
|
</script> |
|
|
|
<article |
|
class="highlight-card" |
|
data-highlight-id={highlight.id} |
|
id="highlight-{highlight.id}" |
|
onclick={handleCardClick} |
|
onkeydown={handleCardKeydown} |
|
class:cursor-pointer={!!(sourceEvent || parseATag())} |
|
{...((sourceEvent || parseATag()) ? { role: "button", tabindex: 0 } : {})} |
|
> |
|
<div class="highlight-header"> |
|
<div class="highlight-meta"> |
|
<ProfileBadge pubkey={highlight.pubkey} /> |
|
{#if getAuthorPubkey() && getAuthorPubkey() !== highlight.pubkey} |
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">highlighting</span> |
|
<ProfileBadge pubkey={getAuthorPubkey()!} /> |
|
{/if} |
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span> |
|
{#if getClientName()} |
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span> |
|
{/if} |
|
<div class="ml-auto flex items-center gap-2"> |
|
{#if isLoggedIn && bookmarked} |
|
<span title="Bookmarked"> |
|
<Icon name="bookmark" size={16} class="bookmark-indicator bookmarked" /> |
|
</span> |
|
{/if} |
|
<IconButton |
|
icon="eye" |
|
label="View" |
|
size={16} |
|
onclick={() => goto(getEventLink(highlight))} |
|
/> |
|
{#if isLoggedIn} |
|
<IconButton |
|
icon="message-square" |
|
label="Reply" |
|
size={16} |
|
onclick={() => {}} |
|
/> |
|
{/if} |
|
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} /> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="highlight-content"> |
|
{#if shouldShowContext} |
|
{@html getHighlightedContext()} |
|
{:else} |
|
<MarkdownRenderer content={highlight.content} event={highlight} /> |
|
{/if} |
|
</div> |
|
|
|
{#if getSourceEventUrl()} |
|
<div class="source-event-link"> |
|
<a href={getSourceEventUrl()!} class="source-link" onclick={(e) => e.stopPropagation()}> |
|
View source event → |
|
</a> |
|
</div> |
|
{:else if loadingSource} |
|
<div class="source-event-link"> |
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">Loading source event...</span> |
|
</div> |
|
{/if} |
|
|
|
{#if getUrl()} |
|
<div class="source-event-link"> |
|
<a href={getUrl()} target="_blank" rel="noopener noreferrer" class="source-link" onclick={(e) => e.stopPropagation()}> |
|
{getUrl()} |
|
</a> |
|
</div> |
|
{/if} |
|
|
|
<div class="kind-badge"> |
|
<span class="kind-number">{getKindInfo(highlight.kind).number}</span> |
|
<span class="kind-description">{getKindInfo(highlight.kind).description}</span> |
|
</div> |
|
</article> |
|
|
|
<style> |
|
.highlight-card { |
|
padding: 1rem; |
|
margin-bottom: 1rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.25rem; |
|
position: relative; |
|
} |
|
|
|
:global(.dark) .highlight-card { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.highlight-header { |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
.highlight-meta { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.highlight-content { |
|
margin-bottom: 0.75rem; |
|
padding: 0.75rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
border-left: 3px solid #fbbf24; |
|
} |
|
|
|
:global(.dark) .highlight-content { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-left-color: #fbbf24; |
|
} |
|
|
|
.source-event-link { |
|
padding-top: 0.5rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
margin-top: 0.5rem; |
|
} |
|
|
|
:global(.dark) .source-event-link { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.source-link { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: none; |
|
font-size: 0.875rem; |
|
} |
|
|
|
.source-link:hover { |
|
text-decoration: underline; |
|
} |
|
|
|
:global(.dark) .source-link { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.highlight-card.cursor-pointer { |
|
cursor: pointer; |
|
} |
|
|
|
.highlight-card.cursor-pointer:hover { |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .highlight-card.cursor-pointer:hover { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.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, #52667a); |
|
} |
|
|
|
: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; |
|
} |
|
|
|
:global(.highlight-content mark.highlight-text) { |
|
background-color: #fef3c7; |
|
color: #92400e; |
|
padding: 0.125rem 0.25rem; |
|
border-radius: 0.125rem; |
|
font-weight: 500; |
|
} |
|
|
|
:global(.dark .highlight-content mark.highlight-text) { |
|
background-color: #78350f; |
|
color: #fef3c7; |
|
} |
|
|
|
.bookmark-indicator { |
|
filter: grayscale(100%); |
|
transition: filter 0.2s; |
|
} |
|
|
|
.bookmark-indicator.bookmarked { |
|
filter: grayscale(0%); |
|
} |
|
</style>
|
|
|