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

<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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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>