diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte
index 6e46d91..4bef69c 100644
--- a/src/lib/components/content/MarkdownRenderer.svelte
+++ b/src/lib/components/content/MarkdownRenderer.svelte
@@ -1,304 +1,268 @@
-
- {@html rendered}
+
+ {@html renderedHtml}
diff --git a/src/lib/components/content/mount-component-action.ts b/src/lib/components/content/mount-component-action.ts
index a385678..9433817 100644
--- a/src/lib/components/content/mount-component-action.ts
+++ b/src/lib/components/content/mount-component-action.ts
@@ -14,26 +14,49 @@ export function mountComponent(
if (component && typeof component === 'function') {
// Using Svelte 4 component API (enabled via compatibility mode in svelte.config.js)
try {
+ // Clear the node first to ensure clean mounting
+ node.innerHTML = '';
+
// Create a new instance
+ // In Svelte 5 with compatibility mode, components are instantiated with the Svelte 4 API
instance = new (component as any)({
target: node,
- props
+ props,
+ // Ensure the component is hydrated and rendered
+ hydrate: false,
+ intro: false
});
+
+ // Verify the component was mounted
+ if (!instance) {
+ console.warn('[mountComponent] Component instance not created', { component, props });
+ }
} catch (e) {
- console.error('Failed to mount component:', e);
+ console.error('[mountComponent] Failed to mount component:', e, { component, props, node });
}
+ } else {
+ console.warn('[mountComponent] Invalid component provided', { component, props });
}
return {
update(newProps: Record
) {
if (instance && typeof instance.$set === 'function') {
- instance.$set(newProps);
+ try {
+ instance.$set(newProps);
+ } catch (e) {
+ console.error('[mountComponent] Failed to update component:', e);
+ }
}
},
destroy() {
if (instance && typeof instance.$destroy === 'function') {
- instance.$destroy();
+ try {
+ instance.$destroy();
+ } catch (e) {
+ console.error('[mountComponent] Failed to destroy component:', e);
+ }
}
+ instance = null;
}
};
}
diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte
index 8e2ac60..83b1dce 100644
--- a/src/lib/components/layout/ProfileBadge.svelte
+++ b/src/lib/components/layout/ProfileBadge.svelte
@@ -15,30 +15,13 @@
let activityMessage = $state(null);
let imageError = $state(false);
- // Debounce requests to allow batching from parent components
- let loadTimeout: ReturnType | null = null;
-
$effect(() => {
if (pubkey) {
imageError = false; // Reset image error when pubkey changes
-
- // Clear any pending timeout
- if (loadTimeout) {
- clearTimeout(loadTimeout);
- }
-
- // Debounce requests by 200ms to allow parent components to batch fetch
- loadTimeout = setTimeout(() => {
- loadProfile();
- loadStatus();
- updateActivityStatus();
- }, 200);
-
- return () => {
- if (loadTimeout) {
- clearTimeout(loadTimeout);
- }
- };
+ // Load immediately - no debounce
+ loadProfile();
+ loadStatus();
+ updateActivityStatus();
}
});
diff --git a/src/lib/services/nostr/nip21-parser.ts b/src/lib/services/nostr/nip21-parser.ts
index 58e4eb4..c6bf8f0 100644
--- a/src/lib/services/nostr/nip21-parser.ts
+++ b/src/lib/services/nostr/nip21-parser.ts
@@ -60,27 +60,108 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
/**
* Find all NIP-21 URIs in text
+ * Also finds plain bech32 mentions (npub1..., note1..., etc.) without nostr: prefix
*/
export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> {
const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = [];
+ const seenPositions = new Set(); // Track positions to avoid duplicates
+ const seenEntities = new Map(); // Track entities to prefer nostr: versions
- // Match nostr: URIs (case-insensitive)
+ // First, match nostr: URIs (case-insensitive) - these take priority
// Also match hex event IDs (64 hex characters) as nostr:hexID
- const regex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi;
+ const nostrUriRegex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi;
let match;
- while ((match = regex.exec(text)) !== null) {
+ while ((match = nostrUriRegex.exec(text)) !== null) {
const uri = match[0];
const parsed = parseNIP21(uri);
+ if (parsed) {
+ const key = `${match.index}-${match.index + uri.length}`;
+ if (!seenPositions.has(key)) {
+ seenPositions.add(key);
+ // Extract the entity identifier (without nostr: prefix)
+ const entityId = uri.slice(6); // Remove 'nostr:' prefix
+ seenEntities.set(entityId.toLowerCase(), { start: match.index, end: match.index + uri.length });
+ links.push({
+ uri,
+ start: match.index,
+ end: match.index + uri.length,
+ parsed
+ });
+ }
+ }
+ }
+
+ // Also match plain bech32 mentions (npub1..., note1..., nevent1..., naddr1..., nprofile1...)
+ // and hex event IDs (64 hex characters) without nostr: prefix
+ // Use word boundaries to avoid matching partial strings
+ // BUT skip if we already found a nostr: version of the same entity
+ const bech32Regex = /\b((npub|note|nevent|naddr|nprofile)1[a-z0-9]{58,})\b/gi;
+ while ((match = bech32Regex.exec(text)) !== null) {
+ const bech32String = match[1];
+ const key = `${match.index}-${match.index + bech32String.length}`;
+
+ // Skip if this position overlaps with a nostr: URI we already found
+ if (seenPositions.has(key)) continue;
+
+ // Skip if we already found a nostr: version of this entity
+ const existing = seenEntities.get(bech32String.toLowerCase());
+ if (existing) {
+ // Check if positions overlap
+ if (!(match.index >= existing.end || match.index + bech32String.length <= existing.start)) {
+ continue; // Overlaps with nostr: version, skip
+ }
+ }
+
+ seenPositions.add(key);
+ // Create a nostr: URI for parsing
+ const uri = `nostr:${bech32String}`;
+ const parsed = parseNIP21(uri);
if (parsed) {
links.push({
- uri,
+ uri: bech32String, // Store without nostr: prefix for display
start: match.index,
- end: match.index + uri.length,
+ end: match.index + bech32String.length,
parsed
});
}
}
+ // Match hex event IDs (64 hex characters) without nostr: prefix
+ // BUT skip if we already found a nostr: version
+ const hexIdRegex = /\b([0-9a-f]{64})\b/gi;
+ while ((match = hexIdRegex.exec(text)) !== null) {
+ const hexId = match[1];
+ const key = `${match.index}-${match.index + hexId.length}`;
+
+ // Skip if this position overlaps with a nostr: URI we already found
+ if (seenPositions.has(key)) continue;
+
+ // Skip if we already found a nostr: version of this hex ID
+ const existing = seenEntities.get(hexId.toLowerCase());
+ if (existing) {
+ // Check if positions overlap
+ if (!(match.index >= existing.end || match.index + hexId.length <= existing.start)) {
+ continue; // Overlaps with nostr: version, skip
+ }
+ }
+
+ seenPositions.add(key);
+ // Create a nostr: URI for parsing
+ const uri = `nostr:${hexId}`;
+ const parsed = parseNIP21(uri);
+ if (parsed) {
+ links.push({
+ uri: hexId, // Store without nostr: prefix
+ start: match.index,
+ end: match.index + hexId.length,
+ parsed
+ });
+ }
+ }
+
+ // Sort by start position
+ links.sort((a, b) => a.start - b.start);
+
return links;
}
diff --git a/src/lib/services/security/sanitizer.ts b/src/lib/services/security/sanitizer.ts
index 406ba7c..762f98a 100644
--- a/src/lib/services/security/sanitizer.ts
+++ b/src/lib/services/security/sanitizer.ts
@@ -3,12 +3,18 @@
*/
import DOMPurify from 'dompurify';
+import { browser } from '$app/environment';
/**
* Sanitize HTML content
*/
export function sanitizeHtml(dirty: string): string {
- return DOMPurify.sanitize(dirty, {
+ // Only sanitize in browser - during SSR, return as-is (will be sanitized on client)
+ if (!browser) {
+ return dirty;
+ }
+
+ const config = {
ALLOWED_TAGS: [
'p',
'br',
@@ -35,10 +41,15 @@ export function sanitizeHtml(dirty: string): string {
'div',
'span'
],
- ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'data-pubkey', 'data-event-id', 'data-placeholder'],
+ ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'width', 'height', 'data-pubkey', 'data-event-id', 'data-placeholder', 'data-nostr-profile', 'data-mounted'],
ALLOW_DATA_ATTR: true,
- KEEP_CONTENT: true
- });
+ KEEP_CONTENT: true,
+ // Ensure images are preserved
+ FORBID_TAGS: [],
+ FORBID_ATTR: []
+ };
+
+ return DOMPurify.sanitize(dirty, config);
}
/**