Browse Source

Fixed embedded events on events page

master
silberengel 7 months ago
parent
commit
2d987c0a63
  1. 13
      src/app.css
  2. 4
      src/lib/components/CommentViewer.svelte
  3. 100
      src/lib/components/ContentWithEmbeddedEvents.svelte
  4. 352
      src/lib/components/EmbeddedEvent.svelte
  5. 83
      src/lib/components/EmbeddedEventRenderer.svelte
  6. 77
      src/lib/components/EventDetails.svelte
  7. 8
      src/lib/components/Notifications.svelte
  8. 9
      src/lib/components/Preview.svelte
  9. 58
      src/lib/components/cards/ProfileHeader.svelte
  10. 6
      src/lib/components/util/CardActions.svelte
  11. 263
      src/lib/utils/markup/embeddedMarkupParser.ts
  12. 111
      src/lib/utils/markup/markupServices.ts
  13. 13
      src/lib/utils/nostrUtils.ts
  14. 55
      src/lib/utils/notification_utils.ts
  15. 22
      src/routes/events/+page.svelte

13
src/app.css

@ -549,4 +549,17 @@ @@ -549,4 +549,17 @@
.toc-highlight:hover {
@apply bg-primary-300 dark:bg-primary-600;
}
/* Override prose first-line bold styling */
.prose p:first-line {
font-weight: normal !important;
}
.prose-sm p:first-line {
font-weight: normal !important;
}
.prose-invert p:first-line {
font-weight: normal !important;
}
}

4
src/lib/components/CommentViewer.svelte

@ -840,7 +840,7 @@ @@ -840,7 +840,7 @@
{#if node.children.length > 0}
<div class="space-y-4">
{#each node.children as childNode (childNode.event.id)}
{#each node.children as childNode, index (childNode.event.id + '-' + index)}
{@render CommentItem(childNode)}
{/each}
</div>
@ -867,7 +867,7 @@ @@ -867,7 +867,7 @@
</div>
{:else}
<div class="space-y-4">
{#each threadedComments as node (node.event.id)}
{#each threadedComments as node, index (node.event.id + '-root-' + index)}
{@render CommentItem(node)}
{/each}
</div>

100
src/lib/components/ContentWithEmbeddedEvents.svelte

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
<script lang="ts">
import { onMount } from "svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import EmbeddedEvent from "./EmbeddedEvent.svelte";
const {
content,
nestingLevel = 0,
} = $props<{
content: string;
nestingLevel?: number;
}>();
let parsedContent = $state("");
let embeddedEvents = $state<Array<{
id: string;
nostrId: string;
nestingLevel: number;
}>>([]);
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// AI-NOTE: 2025-01-24 - Component for rendering content with embedded Nostr events
// Processes content and replaces nostr: links with EmbeddedEvent components
$effect(() => {
if (content) {
processContent();
}
});
async function processContent() {
try {
// First parse the basic markup
parsedContent = await parseBasicmarkup(content);
// Then find and extract embedded events
extractEmbeddedEvents();
} catch (error) {
console.error("Error processing content:", error);
parsedContent = content; // Fallback to raw content
}
}
function extractEmbeddedEvents() {
const nostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
const events: Array<{
id: string;
nostrId: string;
nestingLevel: number;
}> = [];
let match;
while ((match = nostrPattern.exec(parsedContent)) !== null) {
const nostrId = match[0];
const componentId = `embedded-event-${Math.random().toString(36).substr(2, 9)}`;
events.push({
id: componentId,
nostrId,
nestingLevel: nestingLevel,
});
// Replace the nostr: link with a placeholder
parsedContent = parsedContent.replace(
nostrId,
`<div class="embedded-event-placeholder" data-component-id="${componentId}"></div>`
);
}
embeddedEvents = events;
}
function renderEmbeddedEvent(eventInfo: { id: string; nostrId: string; nestingLevel: number }) {
if (eventInfo.nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link
return `<a href="/events?id=${eventInfo.nostrId}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${eventInfo.nostrId}</a>`;
}
// Return a placeholder that will be replaced by the component
return `<div class="embedded-event-placeholder" data-component-id="${eventInfo.id}"></div>`;
}
</script>
<div class="content-with-embedded-events min-w-0 overflow-hidden">
{@html parsedContent}
<!-- Render embedded events -->
{#each embeddedEvents as eventInfo}
<div class="my-4 min-w-0 overflow-hidden" data-component-id={eventInfo.id}>
<EmbeddedEvent
nostrIdentifier={eventInfo.nostrId}
nestingLevel={eventInfo.nestingLevel}
/>
</div>
{/each}
</div>

352
src/lib/components/EmbeddedEvent.svelte

@ -0,0 +1,352 @@ @@ -0,0 +1,352 @@
<script lang="ts">
import { onMount } from "svelte";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { fetchEventWithFallback } from "$lib/utils/nostrUtils";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import { parseRepostContent } from "$lib/utils/notification_utils";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation";
import { getEventType } from "$lib/utils/mime";
import { nip19 } from "nostr-tools";
import { get } from "svelte/store";
const {
nostrIdentifier,
nestingLevel = 0,
} = $props<{
nostrIdentifier: string;
nestingLevel?: number;
}>();
let event = $state<NDKEvent | null>(null);
let profile = $state<{
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let parsedContent = $state("");
let authorDisplayName = $state<string | undefined>(undefined);
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// AI-NOTE: 2025-01-24 - Embedded event component for rendering nested Nostr events
// Supports up to 3 levels of nesting, after which it falls back to showing just the link
$effect(() => {
if (nostrIdentifier) {
loadEvent();
}
});
async function loadEvent() {
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, don't load the event, just show the link
loading = false;
return;
}
loading = true;
error = null;
try {
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("No NDK instance available");
}
// Clean the identifier (remove nostr: prefix if present)
const cleanId = nostrIdentifier.replace(/^nostr:/, "");
// Decode the identifier to get the event ID
const decoded = nip19.decode(cleanId);
if (!decoded) {
throw new Error("Failed to decode Nostr identifier");
}
let eventId: string | undefined;
if (decoded.type === "nevent") {
eventId = decoded.data.id;
} else if (decoded.type === "naddr") {
// For naddr, we need to construct a filter
const naddrData = decoded.data as any;
const filter = {
kinds: [naddrData.kind || 0],
authors: [naddrData.pubkey],
"#d": [naddrData.identifier],
};
const foundEvent = await fetchEventWithFallback(ndk, filter);
if (!foundEvent) {
throw new Error("Event not found");
}
event = foundEvent;
} else if (decoded.type === "note") {
// For note, treat it as a nevent
eventId = (decoded.data as any).id;
} else {
throw new Error(`Unsupported identifier type: ${decoded.type}`);
}
// If we have an event ID, fetch the event
if (eventId && !event) {
event = await fetchEventWithFallback(ndk, eventId);
if (!event) {
throw new Error("Event not found");
}
}
// Load profile for the event author
if (event?.pubkey) {
const npub = toNpub(event.pubkey);
if (npub) {
const userProfile = await getUserMetadata(npub);
authorDisplayName =
userProfile.displayName ||
(userProfile as any).display_name ||
userProfile.name ||
event.pubkey;
}
}
// Parse content if available
if (event?.content) {
if (event.kind === 6 || event.kind === 16) {
parsedContent = await parseRepostContent(event.content);
} else {
// Use embedded markup parser for nested events
parsedContent = await parseEmbeddedMarkup(event.content, nestingLevel + 1);
}
}
// Parse profile if it's a profile event
if (event?.kind === 0) {
try {
profile = JSON.parse(event.content);
} catch {
profile = null;
}
}
} catch (err) {
console.error("Error loading embedded event:", err);
error = err instanceof Error ? err.message : "Failed to load event";
} finally {
loading = false;
}
}
function getEventTitle(event: NDKEvent): string {
const titleTag = event.getMatchingTags("title")[0]?.[1];
if (titleTag) return titleTag;
// For profile events, use display name
if (event.kind === 0 && profile) {
return profile.display_name || profile.name || "Profile";
}
// For text events (kind 1), don't show a title if it would duplicate the content
if (event.kind === 1) {
return "";
}
// For other events, use first line of content, but filter out nostr identifiers
if (event.content) {
const firstLine = event.content.split("\n")[0].trim();
if (firstLine) {
// Remove nostr identifiers from the title
const cleanTitle = firstLine.replace(/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g, '').trim();
if (cleanTitle) return cleanTitle.slice(0, 100);
}
}
return "Untitled";
}
function getEventSummary(event: NDKEvent): string {
if (event.kind === 0 && profile?.about) {
return profile.about;
}
if (event.content) {
const lines = event.content.split("\n");
const summaryLines = lines.slice(1, 3).filter(line => line.trim());
if (summaryLines.length > 0) {
return summaryLines.join(" ").slice(0, 200);
}
}
return "";
}
function navigateToEvent() {
if (event) {
goto(`/events?id=${nostrIdentifier}`);
}
}
function getNeventUrl(event: NDKEvent): string {
return neventEncode(event, $activeInboxRelays);
}
function getNaddrUrl(event: NDKEvent): string {
return naddrEncode(event, $activeInboxRelays);
}
function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable";
}
</script>
{#if nestingLevel >= MAX_NESTING_LEVEL}
<!-- At max nesting level, just show the link -->
<div class="embedded-event-max-nesting min-w-0 overflow-hidden">
<a
href="/events?id={nostrIdentifier}"
class="text-primary-600 dark:text-primary-500 hover:underline break-all"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${nostrIdentifier}`);
}}
>
{nostrIdentifier}
</a>
</div>
{:else if loading}
<!-- Loading state -->
<div class="embedded-event-loading bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 min-w-0 overflow-hidden">
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600 flex-shrink-0"></div>
<span class="text-sm text-gray-600 dark:text-gray-400">Loading event...</span>
</div>
</div>
{:else if error}
<!-- Error state -->
<div class="embedded-event-error bg-red-50 dark:bg-red-900/20 rounded-lg p-3 border border-red-200 dark:border-red-800 min-w-0 overflow-hidden">
<div class="flex items-center space-x-2">
<span class="text-red-600 dark:text-red-400 text-sm flex-shrink-0"></span>
<span class="text-sm text-red-600 dark:text-red-400">Failed to load event</span>
</div>
<a
href="/events?id={nostrIdentifier}"
class="text-primary-600 dark:text-primary-500 hover:underline text-sm mt-1 inline-block break-all"
onclick={(e) => {
e.preventDefault();
goto(`/events?id=${nostrIdentifier}`);
}}
>
View event directly
</a>
</div>
{:else if event}
<!-- Event content -->
<div class="embedded-event bg-gray-50 dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 mb-2 min-w-0 overflow-hidden">
<!-- Event header -->
<div class="flex items-center justify-between mb-3 min-w-0">
<div class="flex items-center space-x-2 min-w-0">
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0">
Kind {event.kind}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
({getEventType(event.kind || 0)})
</span>
{#if event.pubkey}
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">Author:</span>
<div class="min-w-0 flex-1">
{#if toNpub(event.pubkey)}
{@render userBadge(
toNpub(event.pubkey) as string,
authorDisplayName,
)}
{:else}
<span class="text-xs text-gray-700 dark:text-gray-300 break-all">
{authorDisplayName || event.pubkey.slice(0, 8)}...{event.pubkey.slice(-4)}
</span>
{/if}
</div>
{/if}
</div>
<button
class="text-xs text-primary-600 dark:text-primary-500 hover:underline flex-shrink-0"
onclick={navigateToEvent}
>
View full event →
</button>
</div>
<!-- Event title -->
{#if getEventTitle(event)}
<h4 class="font-semibold text-gray-900 dark:text-gray-100 mb-2 break-words">
{getEventTitle(event)}
</h4>
{/if}
<!-- Summary for non-content events -->
{#if event.kind !== 1 && getEventSummary(event)}
<div class="mb-2 min-w-0">
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">
{getEventSummary(event)}
</p>
</div>
{/if}
<!-- Content for text events -->
{#if event.kind === 1 && parsedContent}
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 min-w-0 overflow-hidden">
<EmbeddedEventRenderer content={parsedContent.slice(0, 300)} nestingLevel={nestingLevel + 1} />
{#if parsedContent.length > 300}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</div>
{/if}
<!-- Profile content -->
{#if event.kind === 0 && profile}
<div class="space-y-2 min-w-0 overflow-hidden">
{#if profile.picture}
<img
src={profile.picture}
alt="Profile"
class="w-12 h-12 rounded-full object-cover flex-shrink-0"
/>
{/if}
{#if profile.about}
<p class="text-sm text-gray-700 dark:text-gray-300 break-words">
{profile.about.slice(0, 200)}
{#if profile.about.length > 200}
<span class="text-gray-500 dark:text-gray-400">...</span>
{/if}
</p>
{/if}
</div>
{/if}
<!-- Event identifiers -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 min-w-0 overflow-hidden">
<div class="flex flex-wrap gap-2 text-xs min-w-0">
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">ID:</span>
<span class="font-mono text-gray-700 dark:text-gray-300 break-all">
{event.id.slice(0, 8)}...{event.id.slice(-4)}
</span>
{#if isAddressableEvent(event)}
<span class="text-gray-500 dark:text-gray-400 flex-shrink-0">Address:</span>
<span class="font-mono text-gray-700 dark:text-gray-300 break-all">
{getNaddrUrl(event).slice(0, 12)}...{getNaddrUrl(event).slice(-8)}
</span>
{/if}
</div>
</div>
</div>
{/if}

83
src/lib/components/EmbeddedEventRenderer.svelte

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
<script lang="ts">
import { onMount } from "svelte";
import EmbeddedEvent from "./EmbeddedEvent.svelte";
const {
content,
nestingLevel = 0,
} = $props<{
content: string;
nestingLevel?: number;
}>();
let embeddedEvents = $state<Array<{
id: string;
nostrId: string;
nestingLevel: number;
}>>([]);
// AI-NOTE: 2025-01-24 - Component that renders content and replaces embedded event placeholders
// with actual EmbeddedEvent components
$effect(() => {
if (content) {
extractEmbeddedEvents();
}
});
function extractEmbeddedEvents() {
const placeholderPattern = /<div class="embedded-event-placeholder" data-nostr-id="([^"]+)" data-nesting-level="(\d+)" id="([^"]+)"><\/div>/g;
const events: Array<{
id: string;
nostrId: string;
nestingLevel: number;
}> = [];
let match;
while ((match = placeholderPattern.exec(content)) !== null) {
const nostrId = match[1];
const level = parseInt(match[2], 10);
const componentId = match[3];
// Only process event-related identifiers (note, nevent, naddr)
if (nostrId.match(/^nostr:(note|nevent|naddr)/)) {
events.push({
id: componentId,
nostrId,
nestingLevel: level,
});
}
}
embeddedEvents = events;
}
function renderContent() {
let renderedContent = content;
// Replace placeholders with component references
embeddedEvents.forEach(eventInfo => {
const placeholder = `<div class="embedded-event-placeholder" data-nostr-id="${eventInfo.nostrId}" data-nesting-level="${eventInfo.nestingLevel}" id="${eventInfo.id}"></div>`;
const componentRef = `<div class="embedded-event-component" data-component-id="${eventInfo.id}"></div>`;
renderedContent = renderedContent.replace(placeholder, componentRef);
});
return renderedContent;
}
</script>
<div class="embedded-event-renderer">
{@html renderContent()}
<!-- Render embedded events -->
{#each embeddedEvents as eventInfo}
<div class="my-4" data-component-id={eventInfo.id}>
<EmbeddedEvent
nostrIdentifier={eventInfo.nostrId}
nestingLevel={eventInfo.nestingLevel}
/>
</div>
{/each}
</div>

77
src/lib/components/EventDetails.svelte

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
<script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils";
@ -40,9 +42,16 @@ @@ -40,9 +42,16 @@
let showFullContent = $state(false);
let parsedContent = $state("");
let contentPreview = $state("");
let contentProcessing = $state(false);
let authorDisplayName = $state<string | undefined>(undefined);
// Determine if content should be truncated
let shouldTruncate = $state(false);
$effect(() => {
shouldTruncate = event.content.length > 250 && !showFullContent;
});
function getEventTitle(event: NDKEvent): string {
// First try to get title from title tag
const titleTag = getMatchingTags(event, "title")[0]?.[1];
@ -308,18 +317,30 @@ @@ -308,18 +317,30 @@
$effect(() => {
if (event && event.kind !== 0 && event.content) {
contentProcessing = true;
// Use parseRepostContent for kind 6 and 16 events (reposts)
if (event.kind === 6 || event.kind === 16) {
parseRepostContent(event.content).then((html) => {
parsedContent = html;
contentPreview = html.slice(0, 250);
contentProcessing = false;
}).catch((error) => {
console.error('Error parsing repost content:', error);
contentProcessing = false;
});
} else {
parseBasicmarkup(event.content).then((html) => {
// Use embedded markup parser for better Nostr event support
parseEmbeddedMarkup(event.content, 0).then((html) => {
parsedContent = html;
contentPreview = html.slice(0, 250);
contentProcessing = false;
}).catch((error) => {
console.error('Error parsing embedded markup:', error);
contentProcessing = false;
});
}
} else {
contentProcessing = false;
parsedContent = "";
}
});
@ -405,9 +426,9 @@ @@ -405,9 +426,9 @@
});
</script>
<div class="flex flex-col space-y-4">
<div class="flex flex-col space-y-4 min-w-0">
{#if event.kind !== 0 && getEventTitle(event)}
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 break-words">
{getEventTitle(event)}
</h2>
{/if}
@ -417,33 +438,33 @@ @@ -417,33 +438,33 @@
<Notifications {event} />
{/if}
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-2 min-w-0">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400"
<span class="text-gray-600 dark:text-gray-400 min-w-0"
>Author: {@render userBadge(
toNpub(event.pubkey) as string,
profile?.display_name || undefined,
)}</span
>
{:else}
<span class="text-gray-600 dark:text-gray-400"
<span class="text-gray-600 dark:text-gray-400 min-w-0 break-words"
>Author: {profile?.display_name || event.pubkey}</span
>
{/if}
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-700 dark:text-gray-300">Kind:</span>
<span class="font-mono">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300"
<div class="flex items-center space-x-2 min-w-0">
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0">Kind:</span>
<span class="font-mono flex-shrink-0">{event.kind}</span>
<span class="text-gray-700 dark:text-gray-300 flex-shrink-0"
>({getEventTypeDisplay(event)})</span
>
</div>
{#if getEventSummary(event)}
<div class="flex flex-col space-y-1">
<div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300">Summary:</span>
<p class="text-gray-900 dark:text-gray-100">{getEventSummary(event)}</p>
<p class="text-gray-900 dark:text-gray-100 break-words">{getEventSummary(event)}</p>
</div>
{/if}
@ -455,16 +476,22 @@ @@ -455,16 +476,22 @@
<!-- Content -->
{#if event.kind !== 0}
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border max-w-full overflow-hidden">
<div class="flex flex-col space-y-1">
<div class="flex flex-col space-y-1 min-w-0">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere">
{@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250}
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100 break-words overflow-wrap-anywhere min-w-0">
{#if contentProcessing}
<div class="text-gray-500 dark:text-gray-400 italic">Processing content...</div>
{:else}
<div class={shouldTruncate ? 'max-h-32 overflow-hidden' : ''}>
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
</div>
{#if shouldTruncate}
<button
class="mt-2 text-primary-700 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-200"
onclick={() => (showFullContent = true)}>Show more</button
>
{/if}
{/if}
</div>
</div>
</div>
@ -491,12 +518,12 @@ @@ -491,12 +518,12 @@
<!-- Identifiers Section -->
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Identifiers:</h4>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 min-w-0">
{#each getIdentifiers(event, profile) as identifier}
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-600 dark:text-gray-400 flex-shrink-0">{identifier.label}:</span>
<div class="flex-1 min-w-0 flex items-center gap-2">
<span class="font-mono text-sm text-gray-900 dark:text-gray-100" title={identifier.value}>
<span class="font-mono text-sm text-gray-900 dark:text-gray-100 break-all" title={identifier.value}>
{identifier.value.slice(0, 20)}...{identifier.value.slice(-8)}
</span>
<CopyToClipboard
@ -513,7 +540,7 @@ @@ -513,7 +540,7 @@
{#if event.tags && event.tags.length}
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Event Tags:</h4>
<div class="flex flex-wrap gap-2 break-words">
<div class="flex flex-wrap gap-2 break-words min-w-0">
{#each event.tags as tag}
{@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
@ -548,7 +575,7 @@ @@ -548,7 +575,7 @@
goto(`/events?id=${tagInfo.gotoValue!}`);
}
}}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100 break-all"
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100 break-all max-w-full"
>
{tagInfo.text}
</button>
@ -561,7 +588,7 @@ @@ -561,7 +588,7 @@
<!-- Raw Event JSON Section -->
<div class="mb-4 max-w-full overflow-hidden">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Raw Event JSON:</h4>
<div class="relative">
<div class="relative min-w-0">
<div class="absolute top-0 right-0 z-10">
<CopyToClipboard
displayText=""
@ -569,7 +596,7 @@ @@ -569,7 +596,7 @@
/>
</div>
<pre
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono break-words whitespace-pre-wrap"
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono break-words whitespace-pre-wrap min-w-0"
style="line-height: 1.7; font-size: 1rem;">
{JSON.stringify(event.rawEvent(), null, 2)}
</pre>

8
src/lib/components/Notifications.svelte

@ -30,8 +30,8 @@ @@ -30,8 +30,8 @@
import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { formatDate, neventEncode } from "$lib/utils";
import { toNpub, getUserMetadata, NDKRelaySetFromNDK } from "$lib/utils/nostrUtils";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import EmbeddedEventRenderer from "./EmbeddedEventRenderer.svelte";
const { event } = $props<{ event: NDKEvent }>();
@ -849,7 +849,7 @@ @@ -849,7 +849,7 @@
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
<div class="px-2">
{#await ((message.kind === 6 || message.kind === 16) ? parseRepostContent(message.content) : parseContent(message.content)) then parsedContent}
{@html parsedContent}
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
{:catch}
{@html message.content}
{/await}
@ -930,7 +930,7 @@ @@ -930,7 +930,7 @@
<div class="text-sm text-gray-800 dark:text-gray-200 mb-2 leading-relaxed">
<div class="px-2">
{#await ((notification.kind === 6 || notification.kind === 16) ? parseRepostContent(notification.content) : parseContent(notification.content)) then parsedContent}
{@html parsedContent}
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
{:catch}
{@html truncateContent(notification.content)}
{/await}
@ -969,7 +969,7 @@ @@ -969,7 +969,7 @@
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Replying to:</div>
<div class="text-sm text-gray-800 dark:text-gray-200">
{#await parseContent(quotedContent) then parsedContent}
{@html parsedContent}
<EmbeddedEventRenderer content={parsedContent} nestingLevel={0} />
{:catch}
{@html quotedContent}
{/await}

9
src/lib/components/Preview.svelte

@ -22,6 +22,7 @@ @@ -22,6 +22,7 @@
import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { onMount } from "svelte";
import LazyImage from "$components/util/LazyImage.svelte";
// TODO: Fix move between parents.
@ -250,8 +251,14 @@ @@ -250,8 +251,14 @@
{#snippet coverImage(rootId: string, index: number, depth: number)}
{#if hasCoverImage(rootId, index)}
{@const event = blogEntries[index][1]}
<div class="coverImage depth-{depth}">
<img src={hasCoverImage(rootId, index)} alt={title} />
<LazyImage
src={hasCoverImage(rootId, index)}
alt={title || "Cover image"}
eventId={event?.id || rootId}
className="w-full h-full object-cover"
/>
</div>
{/if}
{/snippet}

58
src/lib/components/cards/ProfileHeader.svelte

@ -61,7 +61,7 @@ @@ -61,7 +61,7 @@
</script>
{#if profile}
<Card class="ArticleBox card-leather w-full max-w-2xl">
<Card class="ArticleBox card-leather w-full max-w-2xl overflow-hidden">
<div class="space-y-4">
<div class="ArticleBoxImage flex col justify-center">
{#if profile.banner}
@ -79,18 +79,19 @@ @@ -79,18 +79,19 @@
</div>
{/if}
</div>
<div class="flex flex-row space-x-4 items-center">
<div class="flex flex-row space-x-4 items-center min-w-0">
{#if profile.picture}
<img
src={profile.picture}
alt="Profile avatar"
class="w-16 h-16 rounded-full border"
class="w-16 h-16 rounded-full border flex-shrink-0"
onerror={(e) => {
(e.target as HTMLImageElement).src = "/favicon.png";
}}
/>
{/if}
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="min-w-0 flex-1">
{@render userBadge(
toNpub(event.pubkey) as string,
profile.displayName ||
@ -98,6 +99,7 @@ @@ -98,6 +99,7 @@
profile.name ||
event.pubkey,
)}
</div>
{#if communityStatus === true}
<div
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center"
@ -118,31 +120,31 @@ @@ -118,31 +120,31 @@
{/if}
</div>
</div>
<div>
<div class="min-w-0">
<div class="mt-2 flex flex-col gap-4">
<dl class="grid grid-cols-1 gap-y-2">
{#if profile.name}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Name:</dt>
<dd>{profile.name}</dd>
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Name:</dt>
<dd class="min-w-0 break-words">{profile.name}</dd>
</div>
{/if}
{#if profile.displayName}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Display Name:</dt>
<dd>{profile.displayName}</dd>
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Display Name:</dt>
<dd class="min-w-0 break-words">{profile.displayName}</dd>
</div>
{/if}
{#if profile.about}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">About:</dt>
<dd class="whitespace-pre-line">{profile.about}</dd>
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">About:</dt>
<dd class="min-w-0 break-words whitespace-pre-line">{profile.about}</dd>
</div>
{/if}
{#if profile.website}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">Website:</dt>
<dd>
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Website:</dt>
<dd class="min-w-0 break-all">
<a
href={profile.website}
class="underline text-primary-700 dark:text-primary-200"
@ -152,9 +154,9 @@ @@ -152,9 +154,9 @@
</div>
{/if}
{#if profile.lud16}
<div class="flex items-center gap-2 mt-4">
<dt class="font-semibold min-w-[120px]">Lightning:</dt>
<dd>
<div class="flex items-center gap-2 mt-4 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">Lightning:</dt>
<dd class="min-w-0 break-all">
<Button
class="btn-leather"
color="primary"
@ -165,15 +167,15 @@ @@ -165,15 +167,15 @@
</div>
{/if}
{#if profile.nip05}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">NIP-05:</dt>
<dd>{profile.nip05}</dd>
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">NIP-05:</dt>
<dd class="min-w-0 break-all">{profile.nip05}</dd>
</div>
{/if}
{#each identifiers as id}
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">
<div class="flex gap-2 min-w-0">
<dt class="font-semibold min-w-[120px] flex-shrink-0">{id.label}:</dt>
<dd class="min-w-0 break-all">
{#if id.link}
<button
class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline hover:no-underline transition-colors"
@ -207,12 +209,12 @@ @@ -207,12 +209,12 @@
toNpub(event.pubkey) as string,
profile?.displayName || profile.name || event.pubkey,
)}
<P>{profile.lud16}</P>
<P class="break-all">{profile.lud16}</P>
</div>
<div class="flex flex-col items-center mt-3 space-y-4">
<P>Scan the QR code or copy the address</P>
{#if lnurl}
<P style="overflow-wrap: anywhere">
<P class="break-all overflow-wrap-anywhere">
<CopyToClipboard icon={false} displayText={lnurl}
></CopyToClipboard>
</P>

6
src/lib/components/util/CardActions.svelte

@ -12,6 +12,7 @@ @@ -12,6 +12,7 @@
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import LazyImage from "$components/util/LazyImage.svelte";
// Component props
let { event } = $props<{ event: NDKEvent }>();
@ -191,10 +192,11 @@ @@ -191,10 +192,11 @@
<div
class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
>
<img
<LazyImage
src={image}
alt="Publication cover"
class="rounded w-full h-full object-cover"
eventId={event.id}
className="rounded w-full h-full object-cover"
/>
</div>
{/if}

263
src/lib/utils/markup/embeddedMarkupParser.ts

@ -0,0 +1,263 @@ @@ -0,0 +1,263 @@
import * as emoji from "node-emoji";
import { nip19 } from "nostr-tools";
import {
processImageWithReveal,
processMediaUrl,
processNostrIdentifiersInText,
processEmojiShortcodes,
processWebSocketUrls,
processHashtags,
processBasicTextFormatting,
processBlockquotes,
processWikilinks,
processNostrIdentifiersWithEmbeddedEvents,
stripTrackingParams
} from "./markupServices";
/* Regex constants for basic markup parsing */
// Links and media
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g;
// Add this helper function near the top:
function replaceAlexandriaNostrLinks(text: string): string {
// Regex for Alexandria/localhost URLs
const alexandriaPattern =
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i;
// Regex for bech32 Nostr identifiers
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/;
// Regex for 64-char hex
const hexPattern = /\b[a-fA-F0-9]{64}\b/;
// 1. Alexandria/localhost markup links
text = text.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(match, _label, url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return match;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return match;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
return match;
},
);
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => {
if (alexandriaPattern.test(url)) {
if (/[?&]d=/.test(url)) return url;
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `nostr:${nevent}`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, just return the URL as-is
return url;
});
return text;
}
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string {
function parseList(
start: number,
indent: number,
type: "ol" | "ul",
): [string, number] {
let html = "";
let i = start;
html += `<${type} class="${type === "ol" ? "list-decimal" : "list-disc"} ml-6 mb-2">`;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/);
if (!match) break;
const lineIndent = match[1].replace(/\t/g, " ").length;
const isOrdered = /\d+\./.test(match[2]);
const itemType = isOrdered ? "ol" : "ul";
if (lineIndent > indent) {
// Nested list
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType);
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>";
i = consumed;
continue;
}
if (lineIndent < indent || itemType !== type) {
break;
}
html += `<li class="mb-1">${match[3]}`;
// Check for next line being a nested list
if (i + 1 < lines.length) {
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
if (nextMatch) {
const nextIndent = nextMatch[1].replace(/\t/g, " ").length;
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul";
if (nextIndent > lineIndent) {
const [nestedHtml, consumed] = parseList(
i + 1,
nextIndent,
nextType,
);
html += nestedHtml;
i = consumed - 1;
}
}
}
html += "</li>";
i++;
}
html += `</${type}>`;
return [html, i];
}
if (!lines.length) return "";
const firstLine = lines[0];
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/);
const indent = match ? match[1].replace(/\t/g, " ").length : 0;
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul");
const [html] = parseList(0, indent, type);
return html;
}
function processBasicFormatting(content: string): string {
if (!content) return "";
let processedText = content;
try {
// Sanitize Alexandria Nostr links before further processing
processedText = replaceAlexandriaNostrLinks(processedText);
// Process markup images first
processedText = processedText.replace(MARKUP_IMAGE, (_match, alt, url) => {
return processImageWithReveal(url, alt);
});
// Process markup links
processedText = processedText.replace(
MARKUP_LINK,
(_match, text, url) =>
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
);
// Process WebSocket URLs using shared services
processedText = processWebSocketUrls(processedText);
// Process direct media URLs and auto-link all URLs
processedText = processedText.replace(DIRECT_LINK, (match) => {
return processMediaUrl(match);
});
// Process text formatting using shared services
processedText = processBasicTextFormatting(processedText);
// Process hashtags using shared services
processedText = processHashtags(processedText);
// --- Improved List Grouping and Parsing ---
const lines = processedText.split("\n");
let output = "";
let buffer: string[] = [];
let inList = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) {
buffer.push(line);
inList = true;
} else {
if (inList) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
buffer = [];
inList = false;
}
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n";
}
}
if (buffer.length) {
const firstLine = buffer[0];
const isOrdered = /^\s*\d+\.\s+/.test(firstLine);
output += renderListGroup(buffer, isOrdered ? "ol" : "ul");
}
processedText = output;
// --- End Improved List Grouping and Parsing ---
} catch (e: unknown) {
console.error("Error in processBasicFormatting:", e);
}
return processedText;
}
/**
* Parse markup with support for embedded Nostr events
* AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding
* Up to 3 levels of nesting are supported, after which events are shown as links
*/
export async function parseEmbeddedMarkup(text: string, nestingLevel: number = 0): Promise<string> {
if (!text) return "";
try {
// Process basic text formatting first
let processedText = processBasicFormatting(text);
// Process emoji shortcuts
processedText = processEmojiShortcodes(processedText);
// Process blockquotes
processedText = processBlockquotes(processedText);
// Process paragraphs - split by double newlines and wrap in p tags
// Skip wrapping if content already contains block-level elements
processedText = processedText
.split(/\n\n+/)
.map((para) => para.trim())
.filter((para) => para.length > 0)
.map((para) => {
// Skip wrapping if para already contains block-level elements or math blocks
if (
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test(
para,
)
) {
return para;
}
return `<p class="my-1">${para}</p>`;
})
.join("\n");
// Process profile identifiers (npub, nprofile) first using the regular processor
processedText = await processNostrIdentifiersInText(processedText);
// Then process event identifiers with embedded events (only event-related identifiers)
processedText = processNostrIdentifiersWithEmbeddedEvents(processedText, nestingLevel);
// Replace wikilinks
processedText = processWikilinks(processedText);
return processedText;
} catch (e: unknown) {
console.error("Error in parseEmbeddedMarkup:", e);
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`;
}
}

111
src/lib/utils/markup/markupServices.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { processNostrIdentifiers } from "../nostrUtils.ts";
import { processNostrIdentifiers, NOSTR_PROFILE_REGEX } from "../nostrUtils.ts";
import * as emoji from "node-emoji";
// Media URL patterns
@ -7,40 +7,30 @@ const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; @@ -7,40 +7,30 @@ const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
/**
* Shared service for processing images with reveal/enlarge functionality
* Shared service for processing images with expand functionality
*/
export function processImageWithReveal(src: string, alt: string = "Image"): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`;
}
return `<div class="relative inline-block w-[300px] my-4 group">
<!-- Pastel placeholder background -->
<div class="image-bg-placeholder w-full h-48 bg-gradient-to-br from-pink-100 via-purple-100 to-blue-100 dark:from-pink-900 dark:via-purple-900 dark:to-blue-900 rounded-lg shadow-lg flex items-center justify-center border border-pink-200 dark:border-pink-700">
<!-- Decorative pattern -->
<div class="absolute inset-0 opacity-20">
<div class="w-full h-full bg-gradient-to-br from-pink-200/30 via-purple-200/30 to-blue-200/30 dark:from-pink-800/30 dark:via-purple-800/30 dark:to-blue-800/30 rounded-lg"></div>
</div>
<!-- Reveal button -->
<button class="relative z-10 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm text-gray-700 dark:text-gray-200 font-medium px-4 py-2 rounded-lg shadow-md hover:bg-white dark:hover:bg-gray-700 hover:shadow-lg transition-all duration-300 border border-gray-200 dark:border-gray-600 hover:scale-105"
onclick="const container = this.closest('.group'); const img = container.querySelector('img'); const placeholder = container.querySelector('.image-bg-placeholder'); const expandBtn = container.querySelector('button[title]'); img.style.opacity='1'; img.style.position='relative'; img.style.zIndex='1'; placeholder.style.display='none'; this.style.display='none'; expandBtn.style.display='flex'; expandBtn.style.opacity='1'; expandBtn.style.pointerEvents='auto'; expandBtn.style.zIndex='20';">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Reveal Image
</button>
</div>
<!-- Hidden image that will be revealed -->
<img src="${src}" alt="${alt}" class="absolute inset-0 w-full h-48 object-contain rounded-lg shadow-lg opacity-0 transition-opacity duration-500" loading="lazy" decoding="async">
return `<div class="relative inline-block w-[300px] h-48 my-2 group">
<img
src="${src}"
alt="${alt}"
class="w-full h-full object-contain rounded-lg shadow-lg"
loading="lazy"
decoding="async"
/>
<!-- Expand button (initially hidden) -->
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 opacity-0 pointer-events-none shadow-lg hover:scale-110 z-20"
<!-- Expand button -->
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 z-20"
onclick="window.open('${src}', '_blank')"
title="Open image in full size"
style="display: none;">
aria-label="Open image in full size">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
@ -57,16 +47,16 @@ export function processMediaUrl(url: string, alt?: string): string { @@ -57,16 +47,16 @@ export function processMediaUrl(url: string, alt?: string): string {
if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean);
if (videoId) {
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-4" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || "YouTube video"}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${alt || "YouTube video"}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
}
}
if (VIDEO_URL_REGEX.test(clean)) {
return `<video controls class="max-w-full rounded-lg shadow-lg my-4" preload="none" playsinline><source src="${clean}">${alt || "Video"}</video>`;
return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${alt || "Video"}</video>`;
}
if (AUDIO_URL_REGEX.test(clean)) {
return `<audio controls class="w-full my-4" preload="none"><source src="${clean}">${alt || "Audio"}</audio>`;
return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${alt || "Audio"}</audio>`;
}
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
@ -81,11 +71,56 @@ export function processMediaUrl(url: string, alt?: string): string { @@ -81,11 +71,56 @@ export function processMediaUrl(url: string, alt?: string): string {
* Shared service for processing nostr identifiers
*/
export async function processNostrIdentifiersInText(text: string): Promise<string> {
const nostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text;
// Find all nostr addresses
const matches = Array.from(processedText.matchAll(nostrPattern));
// Find all profile-related nostr addresses (only npub and nprofile)
const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Skip if part of a URL
const before = processedText.slice(Math.max(0, matchIndex - 12), matchIndex);
if (/https?:\/\/$|www\.$/i.test(before)) {
continue;
}
// Process the nostr identifier directly
let identifier = fullMatch;
if (!identifier.startsWith("nostr:")) {
identifier = "nostr:" + identifier;
}
// Get user metadata and create link
const { getUserMetadata, createProfileLink } = await import("../nostrUtils.ts");
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + link + processedText.slice(matchIndex + fullMatch.length);
}
return processedText;
}
/**
* Shared service for processing nostr identifiers with embedded events
* Replaces nostr: links with embedded event placeholders
* Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile)
*/
export function processNostrIdentifiersWithEmbeddedEvents(text: string, nestingLevel: number = 0): string {
const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g;
let processedText = text;
// Maximum nesting level allowed
const MAX_NESTING_LEVEL = 3;
// Find all event-related nostr addresses
const matches = Array.from(processedText.matchAll(eventPattern));
// Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
@ -93,11 +128,19 @@ export async function processNostrIdentifiersInText(text: string): Promise<strin @@ -93,11 +128,19 @@ export async function processNostrIdentifiersInText(text: string): Promise<strin
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
// Process the nostr identifier
const processedMatch = await processNostrIdentifiers(fullMatch);
let replacement: string;
if (nestingLevel >= MAX_NESTING_LEVEL) {
// At max nesting level, just show the link
replacement = `<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`;
} else {
// Create a placeholder for embedded event
const componentId = `embedded-event-${Math.random().toString(36).substr(2, 9)}`;
replacement = `<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`;
}
// Replace the match in the text
processedText = processedText.slice(0, matchIndex) + processedMatch + processedText.slice(matchIndex + fullMatch.length);
processedText = processedText.slice(0, matchIndex) + replacement + processedText.slice(matchIndex + fullMatch.length);
}
return processedText;

13
src/lib/utils/nostrUtils.ts

@ -51,6 +51,13 @@ function escapeHtml(text: string): string { @@ -51,6 +51,13 @@ function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
}
/**
* Escape regex special characters
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
@ -279,7 +286,8 @@ export async function processNostrIdentifiers( @@ -279,7 +286,8 @@ export async function processNostrIdentifiers(
const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText);
processedContent = processedContent.replace(fullMatch, link);
// Replace all occurrences of this exact match
processedContent = processedContent.replace(new RegExp(escapeRegExp(fullMatch), 'g'), link);
}
// Process notes (nevent, note, naddr)
@ -295,7 +303,8 @@ export async function processNostrIdentifiers( @@ -295,7 +303,8 @@ export async function processNostrIdentifiers(
identifier = "nostr:" + identifier;
}
const link = createNoteLink(identifier);
processedContent = processedContent.replace(fullMatch, link);
// Replace all occurrences of this exact match
processedContent = processedContent.replace(new RegExp(escapeRegExp(fullMatch), 'g'), link);
}
return processedContent;

55
src/lib/utils/notification_utils.ts

@ -1,4 +1,3 @@ @@ -1,4 +1,3 @@
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils";
import { get } from "svelte/store";
@ -9,6 +8,7 @@ import { buildCompleteRelaySet } from "$lib/utils/relay_management"; @@ -9,6 +8,7 @@ import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { neventEncode } from "$lib/utils";
import { nip19 } from "nostr-tools";
import type NDK from "@nostr-dev-kit/ndk";
import { parseEmbeddedMarkup } from "./markup/embeddedMarkupParser";
// AI-NOTE: Notification-specific utility functions that don't exist elsewhere
@ -66,11 +66,11 @@ export function truncateRenderedContent(renderedHtml: string, maxLength: number @@ -66,11 +66,11 @@ export function truncateRenderedContent(renderedHtml: string, maxLength: number
}
/**
* Parses content using basic markup parser
* Parses content with support for embedded events
*/
export async function parseContent(content: string): Promise<string> {
if (!content) return "";
return await parseBasicmarkup(content);
return await parseEmbeddedMarkup(content, 0);
}
/**
@ -87,31 +87,54 @@ export async function parseRepostContent(content: string): Promise<string> { @@ -87,31 +87,54 @@ export async function parseRepostContent(content: string): Promise<string> {
const originalContent = originalEvent.content || "";
const originalAuthor = originalEvent.pubkey || "";
const originalCreatedAt = originalEvent.created_at || 0;
const originalKind = originalEvent.kind || 1;
// Parse the original content with basic markup
const parsedOriginalContent = await parseBasicmarkup(originalContent);
// Parse the original content with embedded markup support
const parsedOriginalContent = await parseEmbeddedMarkup(originalContent, 0);
// Create an embedded event display
// Create an embedded event display with proper structure
const formattedDate = originalCreatedAt ? new Date(originalCreatedAt * 1000).toLocaleDateString() : "Unknown date";
const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown";
return `
<div class="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 my-2">
<div class="flex items-center gap-2 mb-2 text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium">Reposted by:</span>
<span class="font-mono">${shortAuthor}</span>
<span></span>
<span>${formattedDate}</span>
<div class="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 my-2">
<!-- Event header -->
<div class="flex items-center justify-between mb-3 min-w-0">
<div class="flex items-center space-x-2 min-w-0">
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0">
Kind ${originalKind}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
(repost)
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">Author:</span>
<span class="text-xs text-gray-700 dark:text-gray-300 font-mono">
${shortAuthor}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"></span>
<span class="text-xs text-gray-500 dark:text-gray-400">
${formattedDate}
</span>
</div>
<button
class="text-xs text-primary-600 dark:text-primary-500 hover:underline flex-shrink-0"
onclick="window.location.href='/events?id=${originalEvent.id || 'unknown'}'"
>
View full event
</button>
</div>
<!-- Reposted content -->
<div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
${parsedOriginalContent}
</div>
</div>
`;
} catch (error) {
// If JSON parsing fails, fall back to basic markup
console.warn("Failed to parse repost content as JSON, falling back to basic markup:", error);
return await parseBasicmarkup(content);
// If JSON parsing fails, fall back to embedded markup
console.warn("Failed to parse repost content as JSON, falling back to embedded markup:", error);
return await parseEmbeddedMarkup(content, 0);
}
}
@ -155,7 +178,7 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK @@ -155,7 +178,7 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK
if (quotedMessage) {
const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content";
const parsedContent = await parseBasicmarkup(quotedContent);
const parsedContent = await parseEmbeddedMarkup(quotedContent, 0);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.dispatchEvent(new CustomEvent('jump-to-message', { detail: '${eventId}' }))">${parsedContent}</div>`;
} else {
// Fallback to nevent link - only if eventId is valid

22
src/routes/events/+page.svelte

@ -964,11 +964,11 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -964,11 +964,11 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
<!-- Right Panel: Event Details -->
{#if showSidePanel && event}
<div class="w-full lg:flex-1 lg:min-w-0 main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<div class="w-full lg:flex-1 lg:min-w-0 main-leather flex flex-col space-y-6 overflow-hidden">
<div class="flex justify-between items-center min-w-0">
<Heading tag="h2" class="h-leather mb-2 break-words">Event Details</Heading>
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 flex-shrink-0"
onclick={closeSidePanel}
>
@ -976,7 +976,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -976,7 +976,7 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
</div>
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<div class="flex flex-col gap-2 mb-4 break-all min-w-0">
<CopyToClipboard
displayText={shortenAddress(getNeventUrl(event))}
copyText={getNeventUrl(event)}
@ -996,18 +996,24 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; @@ -996,18 +996,24 @@ import CommentViewer from "$lib/components/CommentViewer.svelte";
</div>
{/if}
<div class="min-w-0 overflow-hidden">
<EventDetails {event} {profile} {searchValue} />
</div>
<div class="min-w-0 overflow-hidden">
<RelayActions {event} />
</div>
<div class="min-w-0 overflow-hidden">
<CommentViewer {event} />
</div>
{#if isLoggedIn && userPubkey}
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>
<div class="mt-8 min-w-0 overflow-hidden">
<Heading tag="h3" class="h-leather mb-4 break-words">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg min-w-0">
<P>Please sign in to add comments.</P>
</div>
{/if}

Loading…
Cancel
Save