Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
161097ebff
  1. 235
      src/lib/components/content/EmbeddedEvent.svelte
  2. 197
      src/lib/components/content/MarkdownRenderer.svelte
  3. 31
      src/lib/components/content/MediaAttachments.svelte
  4. 39
      src/lib/components/content/mount-component-action.ts
  5. 159
      src/lib/modules/feed/FeedPage.svelte
  6. 83
      src/lib/modules/feed/ThreadDrawer.svelte
  7. 26
      src/lib/services/nostr/nip21-parser.ts
  8. 7
      src/lib/services/security/sanitizer.ts
  9. 9
      src/lib/types/kind-lookup.ts

235
src/lib/components/content/EmbeddedEvent.svelte

@ -0,0 +1,235 @@ @@ -0,0 +1,235 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { stripMarkdown } from '../../services/text-utils.js';
import { goto } from '$app/navigation';
interface Props {
eventId: string; // Can be hex, note, nevent, naddr
}
let { eventId }: Props = $props();
let event = $state<NostrEvent | null>(null);
let loading = $state(true);
let error = $state(false);
onMount(async () => {
await loadEvent();
});
async function loadEvent() {
loading = true;
error = false;
try {
// Decode event ID
let hexId: string | null = null;
// Check if it's already hex
if (/^[0-9a-f]{64}$/i.test(eventId)) {
hexId = eventId.toLowerCase();
} else {
// Try to decode bech32
try {
const decoded = nip19.decode(eventId);
if (decoded.type === 'note') {
hexId = String(decoded.data);
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
hexId = String(decoded.data.id);
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
// For naddr, we need to fetch by kind+pubkey+d
// This is more complex, for now just try to get the identifier
console.warn('naddr fetching not fully implemented');
error = true;
return;
}
} catch (e) {
console.error('Failed to decode event ID:', e);
error = true;
return;
}
}
if (!hexId) {
error = true;
return;
}
const relays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...relays, ...feedRelays])];
const loadedEvent = await nostrClient.getEventById(hexId, allRelays);
event = loadedEvent;
} catch (err) {
console.error('Error loading embedded event:', err);
error = true;
} finally {
loading = false;
}
}
function getTitle(): string {
if (!event) return '';
if (event.kind === 11) {
const titleTag = event.tags.find(t => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
const firstLine = event.content.split('\n')[0].trim();
if (firstLine.length > 0 && firstLine.length < 100) {
return firstLine;
}
return 'Event';
}
function getSubject(): string | null {
if (!event) return null;
const subjectTag = event.tags.find(t => t[0] === 'subject');
return subjectTag?.[1] || null;
}
function getImageUrl(): string | null {
if (!event) return null;
const imageTag = event.tags.find(t => t[0] === 'image');
return imageTag?.[1] || null;
}
function getPreview(): string {
if (!event) return '';
const preview = stripMarkdown(event.content).slice(0, 150);
return preview.length < event.content.length ? preview + '...' : preview;
}
function getThreadUrl(): string {
if (!event) return '#';
return `/thread/${event.id}`;
}
function handleClick(e: MouseEvent) {
e.preventDefault();
goto(getThreadUrl());
}
</script>
{#if loading}
<div class="embedded-event loading">
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading event...</span>
</div>
{:else if error}
<div class="embedded-event error">
<span class="text-fog-text-light dark:text-fog-dark-text-light">Failed to load event</span>
</div>
{:else if event}
<div class="embedded-event" onclick={handleClick} role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(e as any); } }}>
{#if getImageUrl()}
<div class="embedded-event-image">
<img src={getImageUrl()} alt={getTitle()} loading="lazy" />
</div>
{/if}
<div class="embedded-event-content">
<h4 class="embedded-event-title">{getTitle()}</h4>
{#if getSubject()}
<p class="embedded-event-subject">{getSubject()}</p>
{/if}
<p class="embedded-event-preview">{getPreview()}</p>
<a href={getThreadUrl()} class="embedded-event-link" onclick={(e) => e.stopPropagation()}>View thread →</a>
</div>
</div>
{/if}
<style>
.embedded-event {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
margin: 0.5rem 0;
cursor: pointer;
transition: background-color 0.2s;
background: var(--fog-post, #ffffff);
}
:global(.dark) .embedded-event {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.embedded-event:hover {
background: var(--fog-highlight, #f1f5f9);
}
:global(.dark) .embedded-event:hover {
background: var(--fog-dark-highlight, #374151);
}
.embedded-event-image {
width: 100%;
max-height: 200px;
overflow: hidden;
border-radius: 0.25rem;
margin-bottom: 0.75rem;
}
.embedded-event-image img {
width: 100%;
height: auto;
object-fit: cover;
}
.embedded-event-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.embedded-event-title {
font-weight: 600;
font-size: 1.125rem;
margin: 0;
color: var(--fog-text, #1f2937);
}
:global(.dark) .embedded-event-title {
color: var(--fog-dark-text, #f9fafb);
}
.embedded-event-subject {
font-size: 0.875rem;
color: var(--fog-text-light, #64748b);
margin: 0;
}
:global(.dark) .embedded-event-subject {
color: var(--fog-dark-text-light, #9ca3af);
}
.embedded-event-preview {
font-size: 0.875rem;
color: var(--fog-text, #475569);
margin: 0;
line-height: 1.5;
}
:global(.dark) .embedded-event-preview {
color: var(--fog-dark-text, #cbd5e1);
}
.embedded-event-link {
font-size: 0.875rem;
color: var(--fog-accent, #64748b);
text-decoration: none;
margin-top: 0.25rem;
}
.embedded-event-link:hover {
text-decoration: underline;
}
.embedded-event.loading,
.embedded-event.error {
padding: 1rem;
text-align: center;
}
</style>

197
src/lib/components/content/MarkdownRenderer.svelte

@ -3,7 +3,10 @@ @@ -3,7 +3,10 @@
import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js';
interface Props {
content?: string;
@ -13,6 +16,43 @@ @@ -13,6 +16,43 @@
let rendered = $state('');
let containerElement: HTMLDivElement | null = $state(null);
// Track profile badges and embedded events to render
let profileBadges = $state<Map<string, string>>(new Map()); // placeholder -> pubkey
let embeddedEvents = $state<Map<string, string>>(new Map()); // placeholder -> eventId
// Process placeholder divs after HTML is rendered and mount components
$effect(() => {
if (!containerElement || !rendered) return;
tick().then(() => {
if (!containerElement) return;
// Mount profile badge components
const badgeElements = containerElement.querySelectorAll('.nostr-profile-badge-placeholder');
badgeElements.forEach((el) => {
const pubkey = el.getAttribute('data-pubkey');
const placeholder = el.getAttribute('data-placeholder');
if (pubkey && placeholder && profileBadges.has(placeholder)) {
// Clear the element and mount component
el.innerHTML = '';
mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey });
}
});
// Mount embedded event components
const eventElements = containerElement.querySelectorAll('.nostr-embedded-event-placeholder');
eventElements.forEach((el) => {
const eventId = el.getAttribute('data-event-id');
const placeholder = el.getAttribute('data-placeholder');
if (eventId && placeholder && embeddedEvents.has(placeholder)) {
// Clear the element and mount component
el.innerHTML = '';
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId });
}
});
});
});
// Process rendered HTML to add lazy loading and prevent autoplay
function processMediaElements(html: string): string {
@ -95,30 +135,77 @@ @@ -95,30 +135,77 @@
// Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml);
// Replace placeholders with actual NIP-21 links
// Replace placeholders with actual NIP-21 links/components
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
try {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = String(decoded.data);
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
// Handle hexID type (no decoding needed)
if (parsed.type === 'hexID') {
const eventId = parsed.data;
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
// Use custom element that will be replaced with ProfileBadge component
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
// Use custom element for embedded event
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
// For naddr, we'd need to fetch by kind+pubkey+d, but for now use the bech32 string
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
}
} catch {
// Fallback to generic link if decoding fails
if (parsed.type === 'npub') {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
const parsedType = parsed.type;
if (parsedType === 'npub' || parsedType === 'nprofile') {
// Try to extract pubkey from bech32
try {
const decoded: any = nip19.decode(parsed.data);
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
}
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
}
}
@ -138,28 +225,70 @@ @@ -138,28 +225,70 @@
// Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml);
// Replace placeholders with actual NIP-21 links
// Replace placeholders with actual NIP-21 links/components
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
try {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
const pubkey = String(decoded.data);
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`;
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} catch {
try {
// Handle hexID type (no decoding needed)
if (parsed.type === 'hexID') {
const eventId = parsed.data;
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} else {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data);
replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
}
} catch {
// Fallback to generic link if decoding fails
if (parsed.type === 'npub') {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`;
if (parsed.type === 'npub' || parsed.type === 'nprofile') {
try {
const decoded: any = nip19.decode(parsed.data);
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}

31
src/lib/components/content/MediaAttachments.svelte

@ -139,34 +139,9 @@ @@ -139,34 +139,9 @@
}
}
// 5. Extract plain image URLs from content (URLs ending in image extensions)
// Match URLs that end with common image extensions, handling various formats
// This regex matches URLs that:
// - Start with http:// or https://
// - Contain valid URL characters
// - End with image file extensions
// - May have query parameters
const imageUrlRegex = /https?:\/\/[^\s<>"'\n\r]+\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?[^\s<>"'\n\r]*)?/gi;
let urlMatch;
const processedContent = event.content;
while ((urlMatch = imageUrlRegex.exec(processedContent)) !== null) {
let url = urlMatch[0];
// Clean up URL - remove trailing punctuation that might have been captured
url = url.replace(/[.,;:!?]+$/, '');
// Remove closing parentheses if URL was in parentheses
if (url.endsWith(')') && !url.includes('(')) {
url = url.slice(0, -1);
}
const normalized = normalizeUrl(url);
if (!seen.has(normalized) && url.length > 10) { // Basic validation
media.push({
url,
type: 'image',
source: 'content'
});
seen.add(normalized);
}
}
// 5. Don't extract plain image URLs from content - let markdown render them inline
// This ensures images appear where the URL is in the content, not at the top
// Only extract images from tags (image, imeta, file) which are handled above
return media;
}

39
src/lib/components/content/mount-component-action.ts

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/**
* Svelte action to mount a Svelte component into a DOM element
*/
import type { ComponentType, Snippet } from 'svelte';
export function mountComponent(
node: HTMLElement,
component: ComponentType<any>,
props: Record<string, any>
) {
let instance: any = null;
// Mount the component
if (component && typeof component === 'function') {
// For Svelte 5, we need to use the component constructor differently
try {
// Create a new instance
instance = new (component as any)({
target: node,
props
});
} catch (e) {
console.error('Failed to mount component:', e);
}
}
return {
update(newProps: Record<string, any>) {
if (instance && typeof instance.$set === 'function') {
instance.$set(newProps);
}
},
destroy() {
if (instance && typeof instance.$destroy === 'function') {
instance.$destroy();
}
}
};
}

159
src/lib/modules/feed/FeedPage.svelte

@ -1,16 +1,14 @@ @@ -1,16 +1,14 @@
<script lang="ts">
import FeedPost from './FeedPost.svelte';
import ReplaceableEventCard from './ReplaceableEventCard.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js';
import { getFeedKinds } from '../../types/kind-lookup.js';
let posts = $state<NostrEvent[]>([]);
let replaceableEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let hasMore = $state(true);
let loadingMore = $state(false);
@ -21,7 +19,6 @@ @@ -21,7 +19,6 @@
let isLoadingFeed = false; // Guard to prevent concurrent loads
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button
let pendingNewReplaceable = $state<NostrEvent[]>([]); // Store new replaceable events
let loadedParentQuotedEvents = $state<Map<string, NostrEvent>>(new Map()); // Store loaded parent/quoted events separately (doesn't trigger feed re-render)
// Thread drawer state
@ -79,7 +76,6 @@ @@ -79,7 +76,6 @@
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds();
const replaceableKinds = getReplaceableKinds();
// Phase 1: Fetch all feed kinds - one request per relay, sent in parallel
// Update cache in background (10 second timeout), view updates when cache is done
@ -116,25 +112,13 @@ @@ -116,25 +112,13 @@
// This prevents feed jumping and allows user to control when to refresh
// Works the same whether drawer is open or closed - events go to pending arrays
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1);
const updatedReplaceable = updated.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
const updatedOtherFeedEvents = updated.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
// NEVER update the feed automatically from onUpdate callback
// This prevents feed jumping - user must click button to see updates
// Only store new posts in pending arrays (works for both drawer open and closed)
if (!isReset) {
// Store new posts in pending arrays instead of updating feed automatically
const existingIds = new Set([...posts, ...pendingNewPosts].map(p => p.id));
const allNewEvents = [...updatedRegularPosts, ...updatedOtherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
const newPosts = updated.filter(e => !existingIds.has(e.id) && feedKinds.includes(e.kind));
if (newPosts.length > 0) {
// Add to pending posts instead of directly to feed
@ -157,16 +141,7 @@ @@ -157,16 +141,7 @@
}
}
}
// Store new replaceable events in pending array
const existingReplaceableIds = new Set([...replaceableEvents, ...pendingNewReplaceable].map(e => e.id));
const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
pendingNewReplaceable = [...pendingNewReplaceable, ...newReplaceable];
}
}
allFeedEvents = updated;
}, 1000); // Debounce to 1 second to reduce update frequency
};
@ -197,27 +172,16 @@ @@ -197,27 +172,16 @@
// Process cached events
// Load ALL feed events into posts array (including replies and kind 1111)
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1);
const replaceable = cachedEvents.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
// Include all other feed kinds (including kind 1111 comments)
const otherFeedEvents = cachedEvents.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
const allFeedEvents = cachedEvents.filter((e: NostrEvent) => feedKinds.includes(e.kind));
if (reset) {
// For initial load, batch all updates at once to prevent scrolling
// Load ALL events into posts array - filtering happens client-side
// Only sort if we have posts to prevent unnecessary re-renders
if (regularPosts.length > 0 || otherFeedEvents.length > 0) {
posts = sortPosts([...regularPosts, ...otherFeedEvents]);
if (allFeedEvents.length > 0) {
posts = sortPosts(allFeedEvents);
lastPostId = posts[0].id;
}
replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null;
} else {
// For infinite scroll (loading more), add new posts directly to feed
// This is a user-initiated action, so update immediately
@ -225,25 +189,16 @@ @@ -225,25 +189,16 @@
if (!drawerOpen) {
// Don't re-sort existing posts - just append new ones to prevent jumping
const existingIds = new Set(posts.map(p => p.id));
const allNewEvents = [...regularPosts, ...otherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
const newPosts = allFeedEvents.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Sort only the new posts, then append to existing (preserve existing order)
const sortedNewPosts = sortPosts(newPosts);
posts = [...posts, ...sortedNewPosts];
}
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
// Append new replaceable events without re-sorting existing ones
replaceableEvents = [...replaceableEvents, ...newReplaceable.sort((a, b) => b.created_at - a.created_at)];
}
}
}
allFeedEvents = cachedEvents;
}
// For initial load, wait a moment to ensure all data is processed before showing feed
@ -261,7 +216,7 @@ @@ -261,7 +216,7 @@
// Only fetch if we're not in a loading state to prevent excessive requests
// Don't fetch during initial load to prevent scrolling
if (!isLoadingFeed && !loading && !loadingMore && !reset) {
const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id);
const displayedEventIds = posts.map(e => e.id);
if (displayedEventIds.length > 0) {
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
const secondaryFilter = [{
@ -288,7 +243,7 @@ @@ -288,7 +243,7 @@
// Batch fetch all at once to prevent individual requests from ProfileBadge components
// This runs on both initial load and background refresh
const uniquePubkeys = new Set<string>();
for (const event of [...posts, ...replaceableEvents]) {
for (const event of posts) {
uniquePubkeys.add(event.pubkey);
}
@ -466,11 +421,6 @@ @@ -466,11 +421,6 @@
pendingNewPosts = [];
}
if (pendingNewReplaceable.length > 0) {
replaceableEvents = [...pendingNewReplaceable, ...replaceableEvents].sort((a, b) => b.created_at - a.created_at);
pendingNewReplaceable = [];
}
// Scroll to top and reset new posts count
window.scrollTo({ top: 0, behavior: 'smooth' });
newPostsCount = 0;
@ -557,7 +507,7 @@ @@ -557,7 +507,7 @@
drawerOpen = false;
selectedEvent = null;
// Events that arrived while drawer was open are already in pendingNewPosts/pendingNewReplaceable
// Events that arrived while drawer was open are already in pendingNewPosts
// Update the counter now that drawer is closed so user sees the notification
if (pendingNewPosts.length > 0 && lastPostId) {
const newCount = pendingNewPosts.filter(e => e.id !== lastPostId).length;
@ -571,29 +521,18 @@ @@ -571,29 +521,18 @@
// Use $derived to make this reactive and prevent infinite loops
let allFeedItems = $derived.by(() => {
const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = [];
const items: Array<{ id: string; event: NostrEvent; created_at: number }> = [];
// Add filtered posts
// Add filtered posts (all events are in posts array now)
const filteredPosts = getFilteredPosts();
for (const post of filteredPosts) {
items.push({
id: post.id,
event: post,
type: 'post',
created_at: post.created_at
});
}
// Add replaceable events
for (const event of replaceableEvents) {
items.push({
id: event.id,
event: event,
type: 'replaceable',
created_at: event.created_at
});
}
// Sort by created_at, newest first
return items.sort((a, b) => b.created_at - a.created_at);
});
@ -626,7 +565,7 @@ @@ -626,7 +565,7 @@
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p>
{:else if posts.length === 0 && replaceableEvents.length === 0}
{:else if posts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p>
{:else}
{#if newPostsCount > 0}
@ -641,50 +580,44 @@ @@ -641,50 +580,44 @@
{/if}
<div class="posts-list">
{#each allFeedItems as item (item.id)}
{#if item.type === 'post'}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? (posts.find(p => p.id === parentId) || loadedParentQuotedEvents.get(parentId)) : undefined}
{@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]}
{@const quotedEvent = quotedId ? (posts.find(p => p.id === quotedId) || loadedParentQuotedEvents.get(quotedId)) : undefined}
<div
data-post-id={item.event.id}
class="post-wrapper"
class:keyboard-selected={false}
onclick={(e) => openThreadDrawer(item.event, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(item.event);
<div
data-post-id={item.event.id}
class="post-wrapper"
class:keyboard-selected={false}
onclick={(e) => openThreadDrawer(item.event, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(item.event);
}
}}
>
<FeedPost
post={item.event}
parentEvent={parentEvent}
quotedEvent={quotedEvent}
onParentLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
>
<FeedPost
post={item.event}
parentEvent={parentEvent}
quotedEvent={quotedEvent}
onParentLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
onQuotedLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
/>
</div>
{:else if item.type === 'replaceable'}
<div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
<ReplaceableEventCard event={item.event} />
onQuotedLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
/>
</div>
{/if}
{/each}
</div>
{#if loadingMore}
@ -693,7 +626,7 @@ @@ -693,7 +626,7 @@
{#if !hasMore && allFeedItems.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if}
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0}
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">
{#if showResponsesToMe && showOPsOnly}
No original posts responding to you found. Try unchecking the filters.

83
src/lib/modules/feed/ThreadDrawer.svelte

@ -44,6 +44,7 @@ @@ -44,6 +44,7 @@
/**
* Find the root OP event by traversing up the reply chain
* Uses a visited set to prevent infinite loops
* Optimized to use cache-first lookup for speed
*/
async function findRootEvent(event: NostrEvent, visited: Set<string> = new Set()): Promise<NostrEvent> {
// Prevent infinite loops
@ -60,14 +61,11 @@ @@ -60,14 +61,11 @@
return event;
}
// Use getEventById which checks cache first, only hits network if not found
const relays = relayManager.getFeedReadRelays();
const rootEvents = await nostrClient.fetchEvents(
[{ ids: [rootTag[1]] }], // Don't filter by kind, root could be any kind
relays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);
if (rootEvents.length > 0) {
return rootEvents[0];
const rootEvent = await nostrClient.getEventById(rootTag[1], relays);
if (rootEvent) {
return rootEvent;
}
}
@ -90,20 +88,14 @@ @@ -90,20 +88,14 @@
return event;
}
// Fetch parent event
// Use getEventById which checks cache first, only hits network if not found
const relays = relayManager.getFeedReadRelays();
const parentEvents = await nostrClient.fetchEvents(
[{ ids: [parentId] }],
relays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);
const parent = await nostrClient.getEventById(parentId, relays);
if (parentEvents.length === 0) {
if (!parent) {
// Parent not found - treat current event as root
return event;
}
const parent = parentEvents[0];
// Recursively find root
return findRootEvent(parent, visited);
@ -145,12 +137,19 @@ @@ -145,12 +137,19 @@
console.log('Loading thread:', { eventId, isThread, rootEventKind: rootEvent.kind });
console.log('Reply filters:', JSON.stringify(replyFilters, null, 2));
// Fetch all reply types
const allReplies = await nostrClient.fetchEvents(
replyFilters,
relays,
{ useCache: true, cacheResults: true }
);
// Check cache first for speed - only fetch from network if cache is empty
let allReplies = await nostrClient.getByFilters(replyFilters);
// If cache has results, use them immediately
// Only fetch from network if cache is empty or we need fresh data
if (allReplies.length === 0) {
// Cache miss - fetch from network
allReplies = await nostrClient.fetchEvents(
replyFilters,
relays,
{ useCache: true, cacheResults: true }
);
}
// Filter comments to ensure they match the thread (for threads, check #E tag and #K tag)
const filteredReplies = allReplies.filter(reply => {
@ -173,12 +172,17 @@ @@ -173,12 +172,17 @@
tags: r.tags.filter(t => ['E', 'e', 'K', 'k'].includes(t[0])).map(t => [t[0], t[1]])
})));
// Load reactions (kind 7) for the OP
const reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [eventId] }],
relays,
{ useCache: true, cacheResults: true }
);
// Load reactions (kind 7) for the OP - check cache first
let reactionEvents = await nostrClient.getByFilters([{ kinds: [7], '#e': [eventId] }]);
// Only fetch from network if cache is empty
if (reactionEvents.length === 0) {
reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [eventId] }],
relays,
{ useCache: true, cacheResults: true }
);
}
reactions = reactionEvents;
@ -213,9 +217,8 @@ @@ -213,9 +217,8 @@
const replyIds = Array.from(allReplies.keys());
if (replyIds.length > 0) {
// Fetch replies to any of our replies
// For kind 1111 comments: use #E tag for threads, #e tag for other events
// Query with both uppercase (NIP-22) and lowercase (fallback) tag names
// Check cache first before making network requests
// This significantly speeds up loading when data is already cached
const nestedFilters = [
{ kinds: [9735], '#e': replyIds },
{ kinds: [1244], '#e': replyIds },
@ -229,11 +232,19 @@ @@ -229,11 +232,19 @@
)
];
const nestedReplies = await nostrClient.fetchEvents(
nestedFilters,
relays,
{ useCache: true, cacheResults: true }
);
// Check cache first - this is much faster than fetchEvents which may trigger network requests
let nestedReplies = await nostrClient.getByFilters(nestedFilters);
// If cache has results, use them immediately
// Only fetch from network if cache is empty or we need fresh data
if (nestedReplies.length === 0) {
// Cache miss - fetch from network
nestedReplies = await nostrClient.fetchEvents(
nestedFilters,
relays,
{ useCache: true, cacheResults: true }
);
}
// Filter nested comments to ensure they match correctly
const filteredNested = nestedReplies.filter(reply => {

26
src/lib/services/nostr/nip21-parser.ts

@ -6,8 +6,8 @@ @@ -6,8 +6,8 @@
import { nip19 } from 'nostr-tools';
export interface ParsedNIP21 {
type: 'npub' | 'note' | 'nevent' | 'naddr' | 'nprofile';
data: string; // The bech32 string without nostr: prefix
type: 'npub' | 'note' | 'nevent' | 'naddr' | 'nprofile' | 'hexID';
data: string; // The bech32 string or hex ID without nostr: prefix
entity?: any; // Decoded entity data
}
@ -20,15 +20,24 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { @@ -20,15 +20,24 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
return null;
}
const bech32 = uri.slice(6); // Remove 'nostr:' prefix
const identifier = uri.slice(6); // Remove 'nostr:' prefix
// Check if it's a hex event ID (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(identifier)) {
return {
type: 'hexID',
data: identifier.toLowerCase(),
entity: null
};
}
// Validate bech32 format
if (!/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(bech32)) {
if (!/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(identifier)) {
return null;
}
// Extract type
const typeMatch = bech32.match(/^(npub|note|nevent|naddr|nprofile)/);
const typeMatch = identifier.match(/^(npub|note|nevent|naddr|nprofile)/);
if (!typeMatch) return null;
const type = typeMatch[1] as ParsedNIP21['type'];
@ -36,7 +45,7 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { @@ -36,7 +45,7 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
// Try to decode (optional, for validation)
let entity: any = null;
try {
const decoded = nip19.decode(bech32);
const decoded = nip19.decode(identifier);
entity = decoded;
} catch {
// If decoding fails, we can still use the bech32 string
@ -44,7 +53,7 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { @@ -44,7 +53,7 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
return {
type,
data: bech32,
data: identifier,
entity
};
}
@ -56,7 +65,8 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number @@ -56,7 +65,8 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number
const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = [];
// Match nostr: URIs (case-insensitive)
const regex = /nostr:(npub|note|nevent|naddr|nprofile)1[a-z0-9]+/gi;
// 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;
let match;
while ((match = regex.exec(text)) !== null) {

7
src/lib/services/security/sanitizer.ts

@ -31,10 +31,11 @@ export function sanitizeHtml(dirty: string): string { @@ -31,10 +31,11 @@ export function sanitizeHtml(dirty: string): string {
'h6',
'img',
'video',
'audio'
'audio',
'div'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload'],
ALLOW_DATA_ATTR: false
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'data-pubkey', 'data-event-id', 'data-placeholder'],
ALLOW_DATA_ATTR: true
});
}

9
src/lib/types/kind-lookup.ts

@ -21,13 +21,10 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -21,13 +21,10 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
1063: { number: 1063, description: 'File Metadata (GIFs)', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Replaceable events
// Articles
30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
30041: { number: 30041, description: 'Publication Content', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
30040: { number: 30040, description: 'Curated Publication or E-Book', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
30817: { number: 30817, description: 'Wiki Page (Markdown)', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
30818: { number: 30818, description: 'Wiki Page (Asciidoc)', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
9802: { number: 9802, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
// Threads and comments
11: { number: 11, description: 'Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true },

Loading…
Cancel
Save