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 @@
<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 @@
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js'; import { findNIP21Links } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools'; 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 { interface Props {
content?: string; content?: string;
@ -13,6 +16,43 @@
let rendered = $state(''); let rendered = $state('');
let containerElement: HTMLDivElement | null = $state(null); 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 // Process rendered HTML to add lazy loading and prevent autoplay
function processMediaElements(html: string): string { function processMediaElements(html: string): string {
@ -95,30 +135,77 @@
// Process media elements for lazy loading // Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml); 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()) { for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = ''; let replacement = '';
try { try {
const decoded: any = nip19.decode(parsed.data); // Handle hexID type (no decoding needed)
if (decoded.type === 'npub') { if (parsed.type === 'hexID') {
const pubkey = String(decoded.data); const eventId = parsed.data;
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
} else if (decoded.type === 'note') { embeddedEvents.set(eventPlaceholder, eventId);
const eventId = String(decoded.data); replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
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 { } 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 { } catch {
// Fallback to generic link if decoding fails // Fallback to generic link if decoding fails
if (parsed.type === 'npub') { const parsedType = parsed.type;
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`; 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 { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
} }
} }
@ -138,28 +225,70 @@
// Process media elements for lazy loading // Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml); 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()) { for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = ''; let replacement = '';
try { try {
const decoded: any = nip19.decode(parsed.data); // Handle hexID type (no decoding needed)
if (decoded.type === 'npub') { if (parsed.type === 'hexID') {
const pubkey = String(decoded.data); const eventId = parsed.data;
replacement = `<a href="/profile/${pubkey}" class="nostr-link nostr-npub" data-pubkey="${pubkey}">@${pubkey.slice(0, 8)}...</a>`; const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
} else if (decoded.type === 'note') { embeddedEvents.set(eventPlaceholder, eventId);
const eventId = String(decoded.data); replacement = `<div class="nostr-embedded-event-placeholder" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></div>`;
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`; } else {
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { const decoded: any = nip19.decode(parsed.data);
const eventId = String(decoded.data.id); if (decoded.type === 'npub' || decoded.type === 'nprofile') {
replacement = `<a href="/thread/${eventId}" class="nostr-link nostr-note">${uri}</a>`; const pubkey = decoded.type === 'npub'
} else { ? String(decoded.data)
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; : (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
} ? String(decoded.data.pubkey)
} catch { : 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 // Fallback to generic link if decoding fails
if (parsed.type === 'npub') { if (parsed.type === 'npub' || parsed.type === 'nprofile') {
replacement = `<a href="/profile/${parsed.data}" class="nostr-link nostr-npub">${uri}</a>`; 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 { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }

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

@ -139,34 +139,9 @@
} }
} }
// 5. Extract plain image URLs from content (URLs ending in image extensions) // 5. Don't extract plain image URLs from content - let markdown render them inline
// Match URLs that end with common image extensions, handling various formats // This ensures images appear where the URL is in the content, not at the top
// This regex matches URLs that: // Only extract images from tags (image, imeta, file) which are handled above
// - 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);
}
}
return media; return media;
} }

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

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

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

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

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

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

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

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

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

@ -21,13 +21,10 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, 7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
1063: { number: 1063, description: 'File Metadata (GIFs)', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, 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 }, 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 }, 9802: { number: 9802, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, 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 },
// Threads and comments // Threads and comments
11: { number: 11, description: 'Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, 11: { number: 11, description: 'Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, 1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true },

Loading…
Cancel
Save