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. 85
      src/lib/components/EventDetails.svelte
  7. 8
      src/lib/components/Notifications.svelte
  8. 9
      src/lib/components/Preview.svelte
  9. 72
      src/lib/components/cards/ProfileHeader.svelte
  10. 6
      src/lib/components/util/CardActions.svelte
  11. 263
      src/lib/utils/markup/embeddedMarkupParser.ts
  12. 113
      src/lib/utils/markup/markupServices.ts
  13. 13
      src/lib/utils/nostrUtils.ts
  14. 55
      src/lib/utils/notification_utils.ts
  15. 28
      src/routes/events/+page.svelte

13
src/app.css

@ -549,4 +549,17 @@
.toc-highlight:hover { .toc-highlight:hover {
@apply bg-primary-300 dark:bg-primary-600; @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 @@
{#if node.children.length > 0} {#if node.children.length > 0}
<div class="space-y-4"> <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)} {@render CommentItem(childNode)}
{/each} {/each}
</div> </div>
@ -867,7 +867,7 @@
</div> </div>
{:else} {:else}
<div class="space-y-4"> <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)} {@render CommentItem(node)}
{/each} {/each}
</div> </div>

100
src/lib/components/ContentWithEmbeddedEvents.svelte

@ -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 @@
<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 @@
<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>

85
src/lib/components/EventDetails.svelte

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

8
src/lib/components/Notifications.svelte

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

9
src/lib/components/Preview.svelte

@ -22,6 +22,7 @@
import BlogHeader from "$components/cards/BlogHeader.svelte"; import BlogHeader from "$components/cards/BlogHeader.svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils"; import { getMatchingTags } from "$lib/utils/nostrUtils";
import { onMount } from "svelte"; import { onMount } from "svelte";
import LazyImage from "$components/util/LazyImage.svelte";
// TODO: Fix move between parents. // TODO: Fix move between parents.
@ -250,8 +251,14 @@
{#snippet coverImage(rootId: string, index: number, depth: number)} {#snippet coverImage(rootId: string, index: number, depth: number)}
{#if hasCoverImage(rootId, index)} {#if hasCoverImage(rootId, index)}
{@const event = blogEntries[index][1]}
<div class="coverImage depth-{depth}"> <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> </div>
{/if} {/if}
{/snippet} {/snippet}

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

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

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

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

263
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 = /(?<!["'=])(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>`;
}
}

113
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"; import * as emoji from "node-emoji";
// Media URL patterns // 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 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<]*)?/; 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 { export function processImageWithReveal(src: string, alt: string = "Image"): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) { if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `<img src="${src}" alt="${alt}">`; return `<img src="${src}" alt="${alt}">`;
} }
return `<div class="relative inline-block w-[300px] my-4 group"> return `<div class="relative inline-block w-[300px] h-48 my-2 group">
<!-- Pastel placeholder background --> <img
<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"> src="${src}"
<!-- Decorative pattern --> alt="${alt}"
<div class="absolute inset-0 opacity-20"> class="w-full h-full object-contain rounded-lg shadow-lg"
<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> loading="lazy"
</div> decoding="async"
<!-- 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 --> <!-- Expand button -->
<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"> <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"
<!-- 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"
onclick="window.open('${src}', '_blank')" onclick="window.open('${src}', '_blank')"
title="Open image in full size" 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"> <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> <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> </svg>
@ -57,16 +47,16 @@ export function processMediaUrl(url: string, alt?: string): string {
if (YOUTUBE_URL_REGEX.test(clean)) { if (YOUTUBE_URL_REGEX.test(clean)) {
const videoId = extractYouTubeVideoId(clean); const videoId = extractYouTubeVideoId(clean);
if (videoId) { 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)) { 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)) { 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])) { if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
@ -81,11 +71,10 @@ export function processMediaUrl(url: string, alt?: string): string {
* Shared service for processing nostr identifiers * Shared service for processing nostr identifiers
*/ */
export async function processNostrIdentifiersInText(text: string): Promise<string> { 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; let processedText = text;
// Find all nostr addresses // Find all profile-related nostr addresses (only npub and nprofile)
const matches = Array.from(processedText.matchAll(nostrPattern)); const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX));
// Process them in reverse order to avoid index shifting issues // Process them in reverse order to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) { for (let i = matches.length - 1; i >= 0; i--) {
@ -93,11 +82,65 @@ export async function processNostrIdentifiersInText(text: string): Promise<strin
const [fullMatch] = match; const [fullMatch] = match;
const matchIndex = match.index ?? 0; const matchIndex = match.index ?? 0;
// Process the nostr identifier // Skip if part of a URL
const processedMatch = await processNostrIdentifiers(fullMatch); 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 // Replace the match in the text
processedText = processedText.slice(0, matchIndex) + processedMatch + processedText.slice(matchIndex + fullMatch.length); 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--) {
const match = matches[i];
const [fullMatch] = match;
const matchIndex = match.index ?? 0;
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) + replacement + processedText.slice(matchIndex + fullMatch.length);
} }
return processedText; return processedText;
@ -220,4 +263,4 @@ export function processAsciiDocAnchors(text: string): string {
const url = `/events?d=${normalized}`; const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`; return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`;
}); });
} }

13
src/lib/utils/nostrUtils.ts

@ -51,6 +51,13 @@ function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]); 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) * Get user metadata for a nostr identifier (npub or nprofile)
*/ */
@ -279,7 +286,8 @@ export async function processNostrIdentifiers(
const metadata = await getUserMetadata(identifier); const metadata = await getUserMetadata(identifier);
const displayText = metadata.displayName || metadata.name; const displayText = metadata.displayName || metadata.name;
const link = createProfileLink(identifier, displayText); 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) // Process notes (nevent, note, naddr)
@ -295,7 +303,8 @@ export async function processNostrIdentifiers(
identifier = "nostr:" + identifier; identifier = "nostr:" + identifier;
} }
const link = createNoteLink(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; return processedContent;

55
src/lib/utils/notification_utils.ts

@ -1,4 +1,3 @@
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import type { NDKEvent } from "$lib/utils/nostrUtils"; import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils"; import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils";
import { get } from "svelte/store"; import { get } from "svelte/store";
@ -9,6 +8,7 @@ import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { neventEncode } from "$lib/utils"; import { neventEncode } from "$lib/utils";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import type NDK from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk";
import { parseEmbeddedMarkup } from "./markup/embeddedMarkupParser";
// AI-NOTE: Notification-specific utility functions that don't exist elsewhere // AI-NOTE: Notification-specific utility functions that don't exist elsewhere
@ -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> { export async function parseContent(content: string): Promise<string> {
if (!content) return ""; if (!content) return "";
return await parseBasicmarkup(content); return await parseEmbeddedMarkup(content, 0);
} }
/** /**
@ -87,31 +87,54 @@ export async function parseRepostContent(content: string): Promise<string> {
const originalContent = originalEvent.content || ""; const originalContent = originalEvent.content || "";
const originalAuthor = originalEvent.pubkey || ""; const originalAuthor = originalEvent.pubkey || "";
const originalCreatedAt = originalEvent.created_at || 0; const originalCreatedAt = originalEvent.created_at || 0;
const originalKind = originalEvent.kind || 1;
// Parse the original content with basic markup // Parse the original content with embedded markup support
const parsedOriginalContent = await parseBasicmarkup(originalContent); 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 formattedDate = originalCreatedAt ? new Date(originalCreatedAt * 1000).toLocaleDateString() : "Unknown date";
const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown"; const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown";
return ` 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="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 my-2">
<div class="flex items-center gap-2 mb-2 text-xs text-gray-600 dark:text-gray-400"> <!-- Event header -->
<span class="font-medium">Reposted by:</span> <div class="flex items-center justify-between mb-3 min-w-0">
<span class="font-mono">${shortAuthor}</span> <div class="flex items-center space-x-2 min-w-0">
<span></span> <span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0">
<span>${formattedDate}</span> 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> </div>
<!-- Reposted content -->
<div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed"> <div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
${parsedOriginalContent} ${parsedOriginalContent}
</div> </div>
</div> </div>
`; `;
} catch (error) { } catch (error) {
// If JSON parsing fails, fall back to basic markup // If JSON parsing fails, fall back to embedded markup
console.warn("Failed to parse repost content as JSON, falling back to basic markup:", error); console.warn("Failed to parse repost content as JSON, falling back to embedded markup:", error);
return await parseBasicmarkup(content); return await parseEmbeddedMarkup(content, 0);
} }
} }
@ -155,7 +178,7 @@ export async function renderQuotedContent(message: NDKEvent, publicMessages: NDK
if (quotedMessage) { if (quotedMessage) {
const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"; 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>`; 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 { } else {
// Fallback to nevent link - only if eventId is valid // Fallback to nevent link - only if eventId is valid

28
src/routes/events/+page.svelte

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

Loading…
Cancel
Save