From 2d987c0a630dcce818ff5693eb485b6f0724b55b Mon Sep 17 00:00:00 2001 From: silberengel Date: Thu, 14 Aug 2025 23:22:54 +0200 Subject: [PATCH] Fixed embedded events on events page --- src/app.css | 13 + src/lib/components/CommentViewer.svelte | 4 +- .../ContentWithEmbeddedEvents.svelte | 100 +++++ src/lib/components/EmbeddedEvent.svelte | 352 ++++++++++++++++++ .../components/EmbeddedEventRenderer.svelte | 83 +++++ src/lib/components/EventDetails.svelte | 85 +++-- src/lib/components/Notifications.svelte | 8 +- src/lib/components/Preview.svelte | 9 +- src/lib/components/cards/ProfileHeader.svelte | 72 ++-- src/lib/components/util/CardActions.svelte | 6 +- src/lib/utils/markup/embeddedMarkupParser.ts | 263 +++++++++++++ src/lib/utils/markup/markupServices.ts | 113 ++++-- src/lib/utils/nostrUtils.ts | 13 +- src/lib/utils/notification_utils.ts | 55 ++- src/routes/events/+page.svelte | 28 +- 15 files changed, 1067 insertions(+), 137 deletions(-) create mode 100644 src/lib/components/ContentWithEmbeddedEvents.svelte create mode 100644 src/lib/components/EmbeddedEvent.svelte create mode 100644 src/lib/components/EmbeddedEventRenderer.svelte create mode 100644 src/lib/utils/markup/embeddedMarkupParser.ts diff --git a/src/app.css b/src/app.css index b5169ae..2ca3c92 100644 --- a/src/app.css +++ b/src/app.css @@ -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; + } } diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte index 7e9576e..6ed9b4c 100644 --- a/src/lib/components/CommentViewer.svelte +++ b/src/lib/components/CommentViewer.svelte @@ -840,7 +840,7 @@ {#if node.children.length > 0}
- {#each node.children as childNode (childNode.event.id)} + {#each node.children as childNode, index (childNode.event.id + '-' + index)} {@render CommentItem(childNode)} {/each}
@@ -867,7 +867,7 @@ {:else}
- {#each threadedComments as node (node.event.id)} + {#each threadedComments as node, index (node.event.id + '-root-' + index)} {@render CommentItem(node)} {/each}
diff --git a/src/lib/components/ContentWithEmbeddedEvents.svelte b/src/lib/components/ContentWithEmbeddedEvents.svelte new file mode 100644 index 0000000..75d9008 --- /dev/null +++ b/src/lib/components/ContentWithEmbeddedEvents.svelte @@ -0,0 +1,100 @@ + + +
+ {@html parsedContent} + + + {#each embeddedEvents as eventInfo} +
+ +
+ {/each} +
+ + diff --git a/src/lib/components/EmbeddedEvent.svelte b/src/lib/components/EmbeddedEvent.svelte new file mode 100644 index 0000000..f94d68b --- /dev/null +++ b/src/lib/components/EmbeddedEvent.svelte @@ -0,0 +1,352 @@ + + +{#if nestingLevel >= MAX_NESTING_LEVEL} + +
+ { + e.preventDefault(); + goto(`/events?id=${nostrIdentifier}`); + }} + > + {nostrIdentifier} + +
+{:else if loading} + +
+
+
+ Loading event... +
+
+{:else if error} + +
+
+ ⚠️ + Failed to load event +
+ { + e.preventDefault(); + goto(`/events?id=${nostrIdentifier}`); + }} + > + View event directly + +
+{:else if event} + +
+ +
+
+ + Kind {event.kind} + + + ({getEventType(event.kind || 0)}) + + {#if event.pubkey} + + Author: +
+ {#if toNpub(event.pubkey)} + {@render userBadge( + toNpub(event.pubkey) as string, + authorDisplayName, + )} + {:else} + + {authorDisplayName || event.pubkey.slice(0, 8)}...{event.pubkey.slice(-4)} + + {/if} +
+ {/if} +
+ +
+ + + {#if getEventTitle(event)} +

+ {getEventTitle(event)} +

+ {/if} + + + {#if event.kind !== 1 && getEventSummary(event)} +
+

+ {getEventSummary(event)} +

+
+ {/if} + + + {#if event.kind === 1 && parsedContent} +
+ + {#if parsedContent.length > 300} + ... + {/if} +
+ {/if} + + + {#if event.kind === 0 && profile} +
+ {#if profile.picture} + Profile + {/if} + {#if profile.about} +

+ {profile.about.slice(0, 200)} + {#if profile.about.length > 200} + ... + {/if} +

+ {/if} +
+ {/if} + + +
+
+ ID: + + {event.id.slice(0, 8)}...{event.id.slice(-4)} + + {#if isAddressableEvent(event)} + Address: + + {getNaddrUrl(event).slice(0, 12)}...{getNaddrUrl(event).slice(-8)} + + {/if} +
+
+
+{/if} diff --git a/src/lib/components/EmbeddedEventRenderer.svelte b/src/lib/components/EmbeddedEventRenderer.svelte new file mode 100644 index 0000000..d1752e9 --- /dev/null +++ b/src/lib/components/EmbeddedEventRenderer.svelte @@ -0,0 +1,83 @@ + + +
+ {@html renderContent()} + + + {#each embeddedEvents as eventInfo} +
+ +
+ {/each} +
+ + diff --git a/src/lib/components/EventDetails.svelte b/src/lib/components/EventDetails.svelte index c3b14f6..ab3b865 100644 --- a/src/lib/components/EventDetails.svelte +++ b/src/lib/components/EventDetails.svelte @@ -1,5 +1,7 @@ -
+
{#if event.kind !== 0 && getEventTitle(event)} -

+

{getEventTitle(event)}

{/if} @@ -417,33 +438,33 @@ {/if} -
+
{#if toNpub(event.pubkey)} - Author: {@render userBadge( toNpub(event.pubkey) as string, profile?.display_name || undefined, )} {:else} - Author: {profile?.display_name || event.pubkey} {/if}
-
- Kind: - {event.kind} - + Kind: + {event.kind} + ({getEventTypeDisplay(event)})
{#if getEventSummary(event)} -
+
Summary: -

{getEventSummary(event)}

+

{getEventSummary(event)}

{/if} @@ -455,15 +476,21 @@ {#if event.kind !== 0}
-
+
Content: -
- {@html showFullContent ? parsedContent : contentPreview} - {#if !showFullContent && parsedContent.length > 250} - +
+ {#if contentProcessing} +
Processing content...
+ {:else} +
+ +
+ {#if shouldTruncate} + + {/if} {/if}
@@ -491,12 +518,12 @@

Identifiers:

-
+
{#each getIdentifiers(event, profile) as identifier}
{identifier.label}:
- + {identifier.value.slice(0, 20)}...{identifier.value.slice(-8)}

Event Tags:

-
+
{#each event.tags as tag} {@const tagInfo = getTagButtonInfo(tag)} {#if tagInfo.text && tagInfo.gotoValue} @@ -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} @@ -561,7 +588,7 @@

Raw Event JSON:

-
+
 {JSON.stringify(event.rawEvent(), null, 2)}
         
diff --git a/src/lib/components/Notifications.svelte b/src/lib/components/Notifications.svelte index 5086fc9..805ea0e 100644 --- a/src/lib/components/Notifications.svelte +++ b/src/lib/components/Notifications.svelte @@ -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 @@
{#await ((message.kind === 6 || message.kind === 16) ? parseRepostContent(message.content) : parseContent(message.content)) then parsedContent} - {@html parsedContent} + {:catch} {@html message.content} {/await} @@ -930,7 +930,7 @@
{#await ((notification.kind === 6 || notification.kind === 16) ? parseRepostContent(notification.content) : parseContent(notification.content)) then parsedContent} - {@html parsedContent} + {:catch} {@html truncateContent(notification.content)} {/await} @@ -969,7 +969,7 @@
Replying to:
{#await parseContent(quotedContent) then parsedContent} - {@html parsedContent} + {:catch} {@html quotedContent} {/await} diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 036098a..72a02ab 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -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 @@ {#snippet coverImage(rootId: string, index: number, depth: number)} {#if hasCoverImage(rootId, index)} + {@const event = blogEntries[index][1]}
- {title} +
{/if} {/snippet} diff --git a/src/lib/components/cards/ProfileHeader.svelte b/src/lib/components/cards/ProfileHeader.svelte index cc29ae1..c3838d7 100644 --- a/src/lib/components/cards/ProfileHeader.svelte +++ b/src/lib/components/cards/ProfileHeader.svelte @@ -61,7 +61,7 @@ {#if profile} - +
{#if profile.banner} @@ -79,25 +79,27 @@
{/if}
-
+
{#if profile.picture} Profile avatar { (e.target as HTMLImageElement).src = "/favicon.png"; }} /> {/if} -
- {@render userBadge( - toNpub(event.pubkey) as string, - profile.displayName || - profile.display_name || - profile.name || - event.pubkey, - )} +
+
+ {@render userBadge( + toNpub(event.pubkey) as string, + profile.displayName || + profile.display_name || + profile.name || + event.pubkey, + )} +
{#if communityStatus === true}
-
+
{#if profile.name} -
-
Name:
-
{profile.name}
+
+
Name:
+
{profile.name}
{/if} {#if profile.displayName} -
-
Display Name:
-
{profile.displayName}
+
+
Display Name:
+
{profile.displayName}
{/if} {#if profile.about} -
-
About:
-
{profile.about}
+
+
About:
+
{profile.about}
{/if} {#if profile.website} -

Scan the QR code or copy the address

{#if lnurl} -

+

diff --git a/src/lib/components/util/CardActions.svelte b/src/lib/components/util/CardActions.svelte index dddbb8a..89647a6 100644 --- a/src/lib/components/util/CardActions.svelte +++ b/src/lib/components/util/CardActions.svelte @@ -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 @@
- Publication cover
{/if} diff --git a/src/lib/utils/markup/embeddedMarkupParser.ts b/src/lib/utils/markup/embeddedMarkupParser.ts new file mode 100644 index 0000000..548f4ba --- /dev/null +++ b/src/lib/utils/markup/embeddedMarkupParser.ts @@ -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 = /(?"]+)(?!["'])/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 + ""; + i = consumed; + continue; + } + if (lineIndent < indent || itemType !== type) { + break; + } + html += `
  • ${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 += "
  • "; + i++; + } + html += ``; + 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) => + `
    ${text}`, + ); + + // 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 { + 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 ( + /(]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test( + para, + ) + ) { + return para; + } + return `

    ${para}

    `; + }) + .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 `
    Error processing markup: ${(e as Error)?.message ?? "Unknown error"}
    `; + } +} diff --git a/src/lib/utils/markup/markupServices.ts b/src/lib/utils/markup/markupServices.ts index 09157dc..f4ce0a5 100644 --- a/src/lib/utils/markup/markupServices.ts +++ b/src/lib/utils/markup/markupServices.ts @@ -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; 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 `${alt}`; } - return `
    - -
    - -
    -
    -
    - - -
    + return `
    + ${alt} - - ${alt} - - -
    + +
    ${parsedOriginalContent}
    `; } 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 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 `
    ${parsedContent}
    `; } else { // Fallback to nevent link - only if eventId is valid diff --git a/src/routes/events/+page.svelte b/src/routes/events/+page.svelte index 57748b0..fce8b7d 100644 --- a/src/routes/events/+page.svelte +++ b/src/routes/events/+page.svelte @@ -964,11 +964,11 @@ import CommentViewer from "$lib/components/CommentViewer.svelte"; {#if showSidePanel && event} -
    -
    - Event Details +
    +
    + Event Details
    {#if event.kind !== 0} -
    +
    {/if} - - +
    + +
    +
    + +
    - +
    + +
    {#if isLoggedIn && userPubkey} -
    - Add Comment +
    + Add Comment
    {:else} -
    +

    Please sign in to add comments.

    {/if}