Browse Source

fixed thread drawer

master
Silberengel 1 month ago
parent
commit
19f1288e0c
  1. 4
      public/healthz.json
  2. 34
      src/lib/components/layout/Header.svelte
  3. 12
      src/lib/components/preferences/ThemeToggle.svelte
  4. 5
      src/lib/components/preferences/UserPreferences.svelte
  5. 92
      src/lib/modules/feed/FeedPage.svelte
  6. 472
      src/lib/modules/feed/ThreadDrawer.svelte
  7. 270
      src/lib/modules/profiles/ProfilePage.svelte
  8. 20
      src/lib/services/nostr/gif-service.ts
  9. 9
      src/lib/services/nostr/nostr-client.ts
  10. 3
      src/lib/types/kind-lookup.ts

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.0",
"buildTime": "2026-02-03T15:56:04.366Z", "buildTime": "2026-02-04T04:42:40.028Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770134164366 "timestamp": 1770180160028
} }

34
src/lib/components/layout/Header.svelte

@ -35,24 +35,28 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3"> <nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto"> <div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm"> <div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a> <a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a> <a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
</div>
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
{#if isLoggedIn && currentPubkey} {#if isLoggedIn && currentPubkey}
<UserPreferences />
<ThemeToggle />
<ProfileBadge pubkey={currentPubkey} />
<button <button
onclick={() => sessionManager.clearSession()} onclick={() => sessionManager.clearSession()}
class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text transition-colors" class="px-3 py-1 rounded border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors"
title="Logout"
aria-label="Logout"
> >
Logout <span class="emoji emoji-grayscale">🚪</span>
</button> </button>
{:else} {:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Login</a> <a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Login</a>
{/if} <UserPreferences />
<UserPreferences /> <ThemeToggle />
<ThemeToggle />
{#if isLoggedIn && currentPubkey}
<ProfileBadge pubkey={currentPubkey} />
{/if} {/if}
</div> </div>
</div> </div>
@ -68,6 +72,20 @@
min-width: 0; /* Allow flex items to shrink */ min-width: 0; /* Allow flex items to shrink */
} }
.emoji {
font-size: 1rem;
line-height: 1;
opacity: 0.7;
}
.emoji-grayscale {
filter: grayscale(100%);
}
button:hover .emoji-grayscale {
filter: grayscale(80%);
}
@media (max-width: 768px) { @media (max-width: 768px) {
nav { nav {
padding: 0.5rem 0.75rem; /* Smaller padding on mobile */ padding: 0.5rem 0.75rem; /* Smaller padding on mobile */

12
src/lib/components/preferences/ThemeToggle.svelte

@ -33,5 +33,15 @@
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'} title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
> >
<span class="emoji">{#if isDark}{:else}🌙{/if}</span> <span class="emoji emoji-grayscale">{#if isDark}{:else}🌙{/if}</span>
</button> </button>
<style>
.emoji-grayscale {
filter: grayscale(100%);
}
button:hover .emoji-grayscale {
filter: grayscale(80%);
}
</style>

5
src/lib/components/preferences/UserPreferences.svelte

@ -196,6 +196,11 @@
font-size: 1rem; font-size: 1rem;
line-height: 1; line-height: 1;
opacity: 0.7; opacity: 0.7;
filter: grayscale(100%);
}
button:hover .emoji {
filter: grayscale(80%);
} }
.preferences-modal { .preferences-modal {

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

@ -37,14 +37,17 @@
let observer: IntersectionObserver | null = null; let observer: IntersectionObserver | null = null;
let subscriptionId: string | null = $state(null); let subscriptionId: string | null = $state(null);
let refreshInterval: ReturnType<typeof setInterval> | null = null; let refreshInterval: ReturnType<typeof setInterval> | null = null;
let subscriptionSetup = $state(false); // Track if subscription is already set up
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
await loadFeed(); await loadFeed();
// Set up persistent subscription for new events // Set up persistent subscription for new events (only once)
setupSubscription(); if (!subscriptionSetup) {
// Also set up periodic refresh as fallback (every 30 seconds) setupSubscription();
setupPeriodicRefresh(); setupPeriodicRefresh();
subscriptionSetup = true;
}
}); });
// Cleanup subscription on unmount // Cleanup subscription on unmount
@ -54,6 +57,11 @@
nostrClient.unsubscribe(subscriptionId); nostrClient.unsubscribe(subscriptionId);
subscriptionId = null; subscriptionId = null;
} }
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
subscriptionSetup = false;
}; };
}); });
@ -196,7 +204,7 @@
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
// Load initial feed - use cache for fast initial load, but also query relays // Load initial feed - use cache for fast initial load
const filters = [{ kinds: [1], limit: 20 }]; const filters = [{ kinds: [1], limit: 20 }];
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
filters, filters,
@ -204,23 +212,32 @@
{ {
useCache: true, // Use cache for fast initial display useCache: true, // Use cache for fast initial display
cacheResults: true, cacheResults: true,
onUpdate: handleUpdate, // This will be called when new events arrive from subscription // Don't use onUpdate here - subscriptions handle updates
timeout: 10000 timeout: 10000
} }
); );
// Also immediately query relays to ensure we get fresh data // Also immediately query relays to ensure we get fresh data in background
// This runs in parallel and updates via onUpdate callback // This runs in parallel but doesn't use onUpdate to avoid loops
nostrClient.fetchEvents( nostrClient.fetchEvents(
filters, filters,
relays, relays,
{ {
useCache: false, // Force query relays useCache: false, // Force query relays
cacheResults: true, cacheResults: true,
onUpdate: handleUpdate, // Don't use onUpdate - let subscriptions handle it
timeout: 10000 timeout: 10000
} }
).catch(error => { ).then((newEvents) => {
// Only update if we got new events that aren't already in posts
if (newEvents.length > 0) {
const existingIds = new Set(posts.map(p => p.id));
const trulyNew = newEvents.filter(e => !existingIds.has(e.id));
if (trulyNew.length > 0) {
handleUpdate(trulyNew);
}
}
}).catch(error => {
console.debug('[FeedPage] Background relay query error:', error); console.debug('[FeedPage] Background relay query error:', error);
}); });
@ -314,52 +331,65 @@
} }
} }
// Debounced update handler to prevent rapid re-renders // Debounced update handler to prevent rapid re-renders and loops
function handleUpdate(updated: NostrEvent[]) { function handleUpdate(updated: NostrEvent[]) {
console.log(`[FeedPage] handleUpdate called with ${updated.length} events, current posts: ${posts.length}`); if (!updated || updated.length === 0) return;
// Deduplicate incoming updates before adding to pending // Deduplicate incoming updates before adding to pending
const existingIds = new Set(posts.map(p => p.id)); const existingIds = new Set(posts.map(p => p.id));
const newUpdates = updated.filter(e => !existingIds.has(e.id)); const newUpdates = updated.filter(e => e && e.id && !existingIds.has(e.id));
if (newUpdates.length === 0) { if (newUpdates.length === 0) {
console.debug(`[FeedPage] All ${updated.length} events were duplicates, skipping`); return; // All duplicates, skip silently
return;
} }
// Also deduplicate within pendingUpdates // Also deduplicate within pendingUpdates
const pendingIds = new Set(pendingUpdates.map(e => e.id)); const pendingIds = new Set(pendingUpdates.map(e => e.id));
const trulyNew = newUpdates.filter(e => !pendingIds.has(e.id)); const trulyNew = newUpdates.filter(e => !pendingIds.has(e.id));
if (trulyNew.length === 0) {
return; // Already in pending, skip silently
}
pendingUpdates.push(...trulyNew); pendingUpdates.push(...trulyNew);
if (updateTimeout) { if (updateTimeout) {
clearTimeout(updateTimeout); clearTimeout(updateTimeout);
} }
// Batch updates every 500ms // Batch updates every 500ms to prevent rapid re-renders
updateTimeout = setTimeout(() => { updateTimeout = setTimeout(() => {
if (pendingUpdates.length === 0) return; if (pendingUpdates.length === 0) {
return;
}
// Final deduplication check against current posts // Final deduplication check against current posts (posts may have changed)
const currentIds = new Set(posts.map(p => p.id)); const currentIds = new Set(posts.map(p => p.id));
const newEvents = pendingUpdates.filter(e => !currentIds.has(e.id)); const newEvents = pendingUpdates.filter(e => e && e.id && !currentIds.has(e.id));
if (newEvents.length === 0) {
pendingUpdates = [];
return;
}
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${posts.length}`); console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${posts.length}`);
if (newEvents.length > 0) { // Merge and sort, then deduplicate by ID
// Merge and sort, then deduplicate by ID const merged = [...posts, ...newEvents];
const merged = [...posts, ...newEvents]; // Deduplicate by ID (keep first occurrence)
// Deduplicate by ID (keep first occurrence) const uniqueMap = new Map<string, NostrEvent>();
const uniqueMap = new Map<string, NostrEvent>(); for (const event of merged) {
for (const event of merged) { if (event && event.id && !uniqueMap.has(event.id)) {
if (!uniqueMap.has(event.id)) { uniqueMap.set(event.id, event);
uniqueMap.set(event.id, event);
}
} }
const unique = Array.from(uniqueMap.values()); }
const sorted = unique.sort((a, b) => b.created_at - a.created_at); const unique = Array.from(uniqueMap.values());
console.log(`[FeedPage] Setting posts to ${sorted.length} events (deduplicated from ${merged.length})`); const sorted = unique.sort((a, b) => b.created_at - a.created_at);
// Only update if we actually have new events to prevent loops
if (sorted.length > posts.length || sorted.some((e, i) => e.id !== posts[i]?.id)) {
posts = sorted; posts = sorted;
console.debug(`[FeedPage] Updated posts to ${sorted.length} events`);
} }
pendingUpdates = []; pendingUpdates = [];

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

@ -1,325 +1,127 @@
<script lang="ts"> <script lang="ts">
import { fade, slide } from 'svelte/transition';
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import CommentThread from '../comments/CommentThread.svelte'; import CommentThread from '../comments/CommentThread.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 { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
opEvent: NostrEvent | null; // The event that was clicked opEvent: NostrEvent | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
} }
let { opEvent, isOpen, onClose }: Props = $props(); let { opEvent, isOpen, onClose }: Props = $props();
let drawerElement: HTMLElement | null = $state(null);
let loading = $state(false); let loading = $state(false);
let rootEvent = $state<NostrEvent | null>(null); let subscriptionId: string | null = $state(null);
let rootReactions = $state<NostrEvent[]>([]); // Reactions for the root event let isInitialized = $state(false);
// Load root event and subscribe to updates when drawer opens // Initialize nostr client once
$effect(() => { onMount(async () => {
if (isOpen && opEvent) { if (!isInitialized) {
// Hide main page scrollbar when drawer is open await nostrClient.initialize();
const originalOverflow = document.body.style.overflow; isInitialized = true;
document.body.style.overflow = 'hidden';
loadRootEvent().then(() => {
// Only subscribe after rootEvent is loaded
if (rootEvent) {
subscribeToUpdates();
}
});
// Cleanup on close
return () => {
document.body.style.overflow = originalOverflow;
rootEvent = null;
rootReactions = [];
};
} else {
// Reset when closed and restore scrollbar
document.body.style.overflow = '';
rootEvent = null;
rootReactions = [];
} }
}); });
/** // Handle drawer open/close - only load when opening
* Find the root OP event by traversing up the reply chain $effect(() => {
* Uses a visited set to prevent infinite loops if (isOpen && opEvent && isInitialized) {
* Optimized to use cache-first lookup for speed // Drawer opened - reset loading state
*/ loading = false;
async function findRootEvent(event: NostrEvent, visited: Set<string> = new Set()): Promise<NostrEvent> {
// Prevent infinite loops
if (visited.has(event.id)) {
return event;
}
visited.add(event.id);
// Check for 'root' tag first (NIP-10) - this directly points to the root
const rootTag = event.tags.find((t) => t[0] === 'root');
if (rootTag && rootTag[1]) {
// If root tag points to self, we're already at root
if (rootTag[1] === event.id) {
return event;
}
// Use getEventById which checks cache first, only hits network if not found
const relays = relayManager.getFeedReadRelays();
const rootEvent = await nostrClient.getEventById(rootTag[1], relays);
if (rootEvent) {
return rootEvent;
}
}
// Check if this event has a parent 'e' tag (NIP-10)
// Look for 'e' tag with 'reply' marker, or any 'e' tag that's not self
const eTags = event.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== event.id);
// Prefer 'e' tag with 'reply' marker (NIP-10)
let parentId: string | undefined;
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag) {
parentId = replyTag[1];
} else if (eTags.length > 0) {
// Use first 'e' tag if no explicit reply marker
parentId = eTags[0][1];
}
if (!parentId) {
// No parent - this is the root
return event;
}
// Use getEventById which checks cache first, only hits network if not found
const relays = relayManager.getFeedReadRelays();
const parent = await nostrClient.getEventById(parentId, relays);
if (!parent) {
// Parent not found - treat current event as root
return event;
}
// Recursively find root
return findRootEvent(parent, visited);
}
async function loadRootEvent() {
if (!opEvent) return;
// Always set loading initially to prevent empty panel
loading = true;
try {
// Find the root OP event by traversing up the chain
rootEvent = await findRootEvent(opEvent);
if (!rootEvent) {
// Fallback to opEvent if root not found
rootEvent = opEvent;
}
// Try to load reactions from cache first // Cleanup subscription when drawer closes
const reactionRelays = relayManager.getProfileReadRelays(); return () => {
try { if (subscriptionId) {
const cachedReactions = await nostrClient.fetchEvents( nostrClient.unsubscribe(subscriptionId);
[{ kinds: [7], '#e': [rootEvent.id] }], subscriptionId = null;
reactionRelays,
{ useCache: true, cacheResults: false, timeout: 100 }
);
if (cachedReactions.length > 0) {
rootReactions = cachedReactions;
loading = false; // Show content immediately with cached reactions
// Load fresh reactions in background
loadRootReactions();
return;
} }
} catch (error) { };
// Cache check failed, continue to full load } else if (!isOpen) {
} // Drawer closed - cleanup
if (subscriptionId) {
// No cached reactions - load fresh nostrClient.unsubscribe(subscriptionId);
await loadRootReactions(); subscriptionId = null;
loading = false;
} catch (error) {
console.error('Error loading root event:', error);
// Ensure we have at least the opEvent to show
if (!rootEvent && opEvent) {
rootEvent = opEvent;
} }
loading = false;
} }
} });
async function loadRootReactions() {
if (!rootEvent) return;
try { // Handle keyboard events
const reactionRelays = relayManager.getProfileReadRelays(); function handleKeyDown(e: KeyboardEvent) {
const initialReactions = await nostrClient.fetchEvents( if (e.key === 'Escape' && isOpen) {
[{ kinds: [7], '#e': [rootEvent.id] }], onClose();
reactionRelays,
{ useCache: true, cacheResults: true }
);
rootReactions = initialReactions;
} catch (error) {
console.error('Error loading root reactions:', error);
} }
} }
function subscribeToUpdates() { // Handle backdrop click
if (!rootEvent) return;
const reactionRelays = relayManager.getProfileReadRelays();
const commentRelays = relayManager.getCommentReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
// Subscribe to reactions for the root event
nostrClient.fetchEvents(
[{ kinds: [7], '#e': [rootEvent.id] }],
reactionRelays,
{
useCache: true,
cacheResults: true,
onUpdate: (updated: NostrEvent[]) => {
// Batch updates to prevent flickering
requestAnimationFrame(() => {
// Add new reactions and update existing ones
const existingIds = new Set(rootReactions.map(r => r.id));
const hasNew = updated.some(r => !existingIds.has(r.id));
if (hasNew) {
// Only update if there are actual changes
const updatedMap = new Map(rootReactions.map(r => [r.id, r]));
for (const reaction of updated) {
updatedMap.set(reaction.id, reaction);
}
rootReactions = Array.from(updatedMap.values());
}
});
}
}
).catch(error => {
console.error('Error subscribing to reactions:', error);
});
// Subscribe to zap receipts for the root event
nostrClient.fetchEvents(
[{ kinds: [9735], '#e': [rootEvent.id] }],
zapRelays,
{
useCache: true,
cacheResults: true,
onUpdate: (updated: NostrEvent[]) => {
// Zap receipts are handled by FeedPost's internal subscription
// This subscription ensures we get updates
}
}
).catch(error => {
console.error('Error subscribing to zap receipts:', error);
});
// Subscribe to comments/replies for the thread
// CommentThread will handle its own updates, but we can also subscribe here
nostrClient.fetchEvents(
[
{ kinds: [1111], '#e': [rootEvent.id] },
{ kinds: [1111], '#E': [rootEvent.id] },
{ kinds: [1], '#e': [rootEvent.id] },
{ kinds: [1244], '#e': [rootEvent.id] },
{ kinds: [9735], '#e': [rootEvent.id] }
],
commentRelays,
{
useCache: true,
cacheResults: true,
onUpdate: (updated: NostrEvent[]) => {
// CommentThread will handle these updates via its own subscription
// This ensures we get updates even if CommentThread hasn't loaded yet
}
}
).catch(error => {
console.error('Error subscribing to comments:', error);
});
}
function handleBackdropClick(e: MouseEvent) { function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose();
} }
} }
function handleEscape(e: KeyboardEvent) { // Prevent body scroll when drawer is open
if (e.key === 'Escape') { $effect(() => {
onClose(); if (isOpen) {
} document.body.style.overflow = 'hidden';
} return () => {
document.body.style.overflow = '';
function handleBackdropWheel(e: WheelEvent) { };
// Only prevent scrolling if the event target is the backdrop itself
// Allow scrolling on the drawer content
const target = e.target as HTMLElement;
if (target && target.classList.contains('drawer-backdrop')) {
e.preventDefault();
e.stopPropagation();
}
}
function handleBackdropTouchMove(e: TouchEvent) {
// Only prevent touch scrolling if the event target is the backdrop itself
const target = e.target as HTMLElement;
if (target && target.classList.contains('drawer-backdrop')) {
e.preventDefault();
e.stopPropagation();
} }
} });
</script> </script>
<svelte:window onkeydown={handleEscape} /> {#if isOpen && opEvent}
{#if isOpen}
<div <div
class="drawer-backdrop" class="drawer-backdrop"
onclick={handleBackdropClick} onclick={handleBackdropClick}
onwheel={handleBackdropWheel} onkeydown={handleKeyDown}
ontouchmove={handleBackdropTouchMove}
onkeydown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Close drawer" aria-label="Close thread drawer"
transition:fade={{ duration: 200 }} ></div>
<div
class="thread-drawer drawer-right"
bind:this={drawerElement}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-label="Thread drawer"
tabindex="-1"
> >
<div class="drawer" transition:slide={{ axis: 'x', duration: 300 }}> <div class="drawer-header">
<div class="drawer-header"> <h3 class="drawer-title">Thread</h3>
<h2 class="drawer-title">Thread</h2> <button
<button class="close-button" onclick={onClose} aria-label="Close drawer"> onclick={onClose}
× class="drawer-close"
</button> aria-label="Close thread drawer"
</div> title="Close"
>
×
</button>
</div>
<div class="drawer-content"> <div class="drawer-content">
{#if loading && !rootEvent} {#if loading}
<div class="loading-state">
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
{:else if rootEvent} </div>
<!-- Display the root OP event --> {:else}
<div class="op-section"> <div class="thread-content">
<FeedPost post={rootEvent} reactions={rootReactions} /> <!-- Display the OP event -->
<div class="op-post">
<FeedPost post={opEvent} />
</div> </div>
<!-- Display all replies using CommentThread --> <!-- Display comments/replies -->
<div class="replies-section"> <div class="comments-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} /> <CommentThread threadId={opEvent.id} event={opEvent} />
</div> </div>
{:else} </div>
<p class="text-fog-text-light dark:text-fog-dark-text-light">Unable to load thread.</p> {/if}
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -332,36 +134,42 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: 1000; z-index: 999;
display: flex; animation: fadeIn 0.3s ease-out;
justify-content: flex-end;
overflow: hidden;
overscroll-behavior: contain;
} }
/* Allow scrolling on drawer content */ @keyframes fadeIn {
.drawer-content { from {
touch-action: auto; opacity: 0;
} }
to {
:global(.dark) .drawer-backdrop { opacity: 1;
background: rgba(0, 0, 0, 0.7); }
} }
.drawer { .thread-drawer {
width: var(--content-width, 800px); position: fixed;
max-width: 100vw; top: 0;
height: 100%; right: 0;
bottom: 0;
width: min(600px, 90vw);
max-width: 600px;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); border-left: 2px solid var(--fog-border, #cbd5e1);
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
animation: slideInRight 0.3s ease-out;
transform: translateX(0);
} }
:global(.dark) .drawer { :global(.dark) .thread-drawer {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3); border-left-color: var(--fog-dark-border, #475569);
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5);
} }
.drawer-header { .drawer-header {
@ -370,6 +178,7 @@
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb); border-bottom: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
} }
:global(.dark) .drawer-header { :global(.dark) .drawer-header {
@ -377,56 +186,81 @@
} }
.drawer-title { .drawer-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0; margin: 0;
color: var(--fog-text, #111827); font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
} }
:global(.dark) .drawer-title { :global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
} }
.close-button { .drawer-close {
background: none; background: transparent;
border: none; border: none;
font-size: 2rem; font-size: 1.5rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
color: var(--fog-text-light, #6b7280); color: var(--fog-text-light, #9ca3af);
padding: 0; padding: 0.25rem 0.5rem;
width: 2rem; border-radius: 0.25rem;
height: 2rem; transition: all 0.2s;
display: flex; }
align-items: center;
justify-content: center; .drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
} }
.close-button:hover { :global(.dark) .drawer-close {
color: var(--fog-text, #111827); color: var(--fog-dark-text-light, #6b7280);
} }
:global(.dark) .close-button:hover { :global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
} }
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.drawer-content { .drawer-content {
flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem; overflow-x: hidden;
flex: 1;
padding: 0;
} }
.op-section { .loading-state {
margin-bottom: 2rem; padding: 2rem;
padding-bottom: 1rem; text-align: center;
}
.thread-content {
display: flex;
flex-direction: column;
}
.op-post {
padding: 1rem;
border-bottom: 2px solid var(--fog-border, #e5e7eb); border-bottom: 2px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
} }
:global(.dark) .op-section { :global(.dark) .op-post {
border-bottom-color: var(--fog-dark-border, #374151); border-bottom-color: var(--fog-dark-border, #374151);
} }
.replies-section { .comments-section {
margin-top: 2rem; padding: 1rem;
flex: 1;
min-height: 0;
} }
</style> </style>

270
src/lib/modules/profiles/ProfilePage.svelte

@ -20,7 +20,11 @@
let interactionsWithMe = $state<NostrEvent[]>([]); let interactionsWithMe = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts'); let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts');
let nip05Validations = $state<Map<string, boolean | null>>(new Map()); // null = checking, true = valid, false = invalid let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid
// Cache for NIP-05 validation results (nip05+pubkey -> result)
// This prevents re-validating the same NIP-05 address repeatedly
const nip05ValidationCache = new Map<string, boolean>();
// Get current logged-in user's pubkey // Get current logged-in user's pubkey
let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey()); let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
@ -65,22 +69,33 @@
try { try {
const interactionRelays = relayManager.getFeedResponseReadRelays(); const interactionRelays = relayManager.getFeedResponseReadRelays();
// Fetch current user's posts to find replies
// Fetch current user's posts from cache first (fast)
const currentUserPosts = await nostrClient.fetchEvents( const currentUserPosts = await nostrClient.fetchEvents(
[{ kinds: [1], authors: [currentUserPubkey], limit: 50 }], [{ kinds: [1], authors: [currentUserPubkey], limit: 50 }],
interactionRelays, interactionRelays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache
); );
const currentUserPostIds = new Set(currentUserPosts.map(p => p.id)); const currentUserPostIds = new Set(currentUserPosts.map(p => p.id));
const interactionEvents = await nostrClient.fetchEvents( // Only fetch interactions if we have some posts to check against
[ if (currentUserPostIds.size === 0) {
{ kinds: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds), limit: 20 }, // Replies to current user's posts interactionsWithMe = [];
{ kinds: [1], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } // Mentions of current user return;
], }
interactionRelays,
{ useCache: true, cacheResults: true } // Fetch interactions with timeout to prevent blocking
); const interactionEvents = await Promise.race([
nostrClient.fetchEvents(
[
{ kinds: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries
{ kinds: [1], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 }
],
interactionRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 5000)) // 5s timeout
]);
// Deduplicate and filter to only include actual interactions // Deduplicate and filter to only include actual interactions
const seenIds = new Set<string>(); const seenIds = new Set<string>();
@ -101,7 +116,7 @@
}) })
.sort((a, b) => b.created_at - a.created_at); .sort((a, b) => b.created_at - a.created_at);
} catch (error) { } catch (error) {
console.error('Error loading interactions with me:', error); console.debug('Error loading interactions with me:', error);
interactionsWithMe = []; interactionsWithMe = [];
} }
} }
@ -162,52 +177,127 @@
return null; return null;
} }
/**
* Get well-known.json URL for a NIP-05 address
* Uses URL constructor like jumble for proper URL building
*/
function getNIP05WellKnownUrl(nip05: string): string | null {
const [localPart, domain] = nip05.split('@');
if (!localPart || !domain) {
return null;
}
const url = new URL('/.well-known/nostr.json', `https://${domain}`);
url.searchParams.set('name', localPart);
return url.toString();
}
/** /**
* Validate NIP-05 address against well-known.json * Validate NIP-05 address against well-known.json
* Uses caching like jumble to avoid repeated lookups
*/ */
async function validateNIP05(nip05: string, expectedPubkey: string) { async function validateNIP05(nip05: string, expectedPubkey: string) {
console.log(`[NIP-05] Starting validation for ${nip05} with pubkey ${expectedPubkey.substring(0, 8)}...`);
// Check cache first (like jumble does)
const cacheKey = `${nip05}:${expectedPubkey}`;
if (nip05ValidationCache.has(cacheKey)) {
const cachedResult = nip05ValidationCache.get(cacheKey)!;
console.log(`[NIP-05] Cache hit for ${nip05}: ${cachedResult}`);
nip05Validations[nip05] = cachedResult;
return;
}
// Mark as checking // Mark as checking
nip05Validations.set(nip05, null); console.log(`[NIP-05] Cache miss, fetching for ${nip05}`);
nip05Validations[nip05] = null;
try { try {
// Parse NIP-05: format is "local@domain.com" // Parse NIP-05: format is "local@domain.com"
const [localPart, domain] = nip05.split('@'); const [localPart, domain] = nip05.split('@');
if (!localPart || !domain) { if (!localPart || !domain) {
nip05Validations.set(nip05, false); console.log(`[NIP-05] Invalid format for ${nip05}`);
nip05Validations[nip05] = false;
nip05ValidationCache.set(cacheKey, false);
return; return;
} }
// Fetch well-known JSON // Build URL using URL constructor (like jumble)
// NIP-05 spec: https://[domain]/.well-known/nostr.json?name=[local] const url = getNIP05WellKnownUrl(nip05);
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; if (!url) {
console.log(`[NIP-05] Failed to build URL for ${nip05}`);
const response = await fetch(url, { nip05Validations[nip05] = false;
method: 'GET', nip05ValidationCache.set(cacheKey, false);
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
nip05Validations.set(nip05, false);
return; return;
} }
const data = await response.json(); console.log(`[NIP-05] Fetching ${url}`);
// Check if the response contains the expected pubkey // Add timeout to prevent hanging
// NIP-05 format: { "names": { "local": "hex-pubkey" } } const controller = new AbortController();
if (data.names && data.names[localPart]) { const timeoutId = setTimeout(() => {
const verifiedPubkey = data.names[localPart].toLowerCase(); console.log(`[NIP-05] Timeout reached for ${nip05}`);
const expected = expectedPubkey.toLowerCase(); controller.abort();
nip05Validations.set(nip05, verifiedPubkey === expected); }, 5000); // 5 second timeout
} else {
nip05Validations.set(nip05, false); try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json'
},
signal: controller.signal
});
clearTimeout(timeoutId);
console.log(`[NIP-05] Response status for ${nip05}: ${response.status}`);
if (!response.ok) {
console.log(`[NIP-05] Response not OK for ${nip05}: ${response.status}`);
nip05Validations[nip05] = false;
nip05ValidationCache.set(cacheKey, false);
return;
}
let data;
try {
data = await response.json();
console.log(`[NIP-05] Parsed JSON for ${nip05}:`, data);
} catch (jsonError) {
console.error(`[NIP-05] Failed to parse JSON for ${nip05}:`, jsonError);
nip05Validations[nip05] = false;
nip05ValidationCache.set(cacheKey, false);
return;
}
// Check if the response contains the expected pubkey
// NIP-05 format: { "names": { "local": "hex-pubkey" } }
const verifiedPubkey = data.names?.[localPart];
const isValid = verifiedPubkey && typeof verifiedPubkey === 'string'
? verifiedPubkey.toLowerCase() === expectedPubkey.toLowerCase()
: false;
console.log(`[NIP-05] Validation result for ${nip05}: ${isValid} (verified: ${verifiedPubkey}, expected: ${expectedPubkey.substring(0, 8)}...)`);
nip05Validations[nip05] = isValid;
nip05ValidationCache.set(cacheKey, isValid);
} catch (fetchError) {
clearTimeout(timeoutId);
// Check if it was aborted due to timeout
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.log(`[NIP-05] Timeout/abort for ${nip05}`);
nip05Validations[nip05] = false;
nip05ValidationCache.set(cacheKey, false);
} else {
console.error(`[NIP-05] Fetch error for ${nip05}:`, fetchError);
throw fetchError; // Re-throw other errors
}
} }
} catch (error) { } catch (error) {
console.error('Error validating NIP-05:', nip05, error); console.error(`[NIP-05] Error validating ${nip05}:`, error);
nip05Validations.set(nip05, false); nip05Validations[nip05] = false;
nip05ValidationCache.set(cacheKey, false);
} }
console.log(`[NIP-05] Validation complete for ${nip05}`);
} }
async function loadProfile() { async function loadProfile() {
@ -230,73 +320,71 @@
loading = true; loading = true;
try { try {
console.log('Loading profile for pubkey:', pubkey, '(decoded from:', param + ')'); // Step 1: Load profile and status first (fast from cache) - display immediately
const [profileData, status] = await Promise.all([
fetchProfile(pubkey),
fetchUserStatus(pubkey)
]);
// Load profile
const profileData = await fetchProfile(pubkey);
profile = profileData; profile = profileData;
console.log('Profile loaded:', profileData); userStatus = status;
loading = false; // Show profile immediately, even if posts are still loading
// Validate NIP-05 addresses (async, don't wait) // Validate NIP-05 addresses in background (non-blocking)
if (profileData?.nip05 && profileData.nip05.length > 0) { if (profileData?.nip05 && profileData.nip05.length > 0) {
for (const nip05 of profileData.nip05) { for (const nip05 of profileData.nip05) {
// Validate in background - don't block page load
validateNIP05(nip05, pubkey).catch(err => { validateNIP05(nip05, pubkey).catch(err => {
console.error('NIP-05 validation error:', err); console.error('[NIP-05] Unhandled validation error:', nip05, err);
// Ensure state is set even on unhandled errors
nip05Validations[nip05] = false;
const cacheKey = `${nip05}:${pubkey}`;
nip05ValidationCache.set(cacheKey, false);
}); });
} }
} }
// Load user status // Step 2: Load posts and responses in parallel (non-blocking, update when ready)
const status = await fetchUserStatus(pubkey);
userStatus = status;
// Load kind 1 posts
const profileRelays = relayManager.getProfileReadRelays(); const profileRelays = relayManager.getProfileReadRelays();
const responseRelays = relayManager.getFeedResponseReadRelays();
// Load posts first (needed for response filtering)
const feedEvents = await nostrClient.fetchEvents( const feedEvents = await nostrClient.fetchEvents(
[{ kinds: [1], authors: [pubkey], limit: 20 }], [{ kinds: [1], authors: [pubkey], limit: 20 }],
profileRelays, profileRelays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true, timeout: 5000 }
); );
posts = feedEvents.sort((a, b) => b.created_at - a.created_at); posts = feedEvents.sort((a, b) => b.created_at - a.created_at);
// Load kind 1 responses (replies to this user's posts) // Load responses in parallel with posts (but filter after posts are loaded)
const responseRelays = relayManager.getFeedResponseReadRelays(); const userPostIds = new Set(posts.map(p => p.id));
const responseEvents = await nostrClient.fetchEvents( const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [1], '#p': [pubkey], limit: 20 }], [{ kinds: [1], '#p': [pubkey], limit: 50 }], // Fetch more to account for filtering
responseRelays, responseRelays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true, timeout: 5000 }
); );
// Filter to only include actual replies (have e tag pointing to user's posts)
// AND exclude self-replies (where author is the same as the profile owner) // Filter responses (exclude self-replies, only include replies to user's posts)
const userPostIds = new Set(posts.map(p => p.id));
responses = responseEvents responses = responseEvents
.filter(e => { .filter(e => {
// Exclude self-replies if (e.pubkey === pubkey) return false; // Exclude self-replies
if (e.pubkey === pubkey) {
return false;
}
const eTag = e.tags.find(t => t[0] === 'e'); const eTag = e.tags.find(t => t[0] === 'e');
return eTag && userPostIds.has(eTag[1]); return eTag && userPostIds.has(eTag[1]);
}) })
.sort((a, b) => b.created_at - a.created_at); .sort((a, b) => b.created_at - a.created_at)
.slice(0, 20); // Limit to 20 after filtering
// Load "Interactions with me" if user is logged in and viewing another user's profile // Step 3: Load interactions in background (non-blocking)
if (currentUserPubkey && currentUserPubkey !== pubkey) { if (currentUserPubkey && currentUserPubkey !== pubkey) {
await loadInteractionsWithMe(pubkey, currentUserPubkey); loadInteractionsWithMe(pubkey, currentUserPubkey).catch(err => {
console.debug('Error loading interactions:', err);
});
} else { } else {
interactionsWithMe = []; interactionsWithMe = [];
} }
} catch (error) { } catch (error) {
console.error('Error loading profile:', error); console.error('Error loading profile:', error);
// Set loading to false even on error so UI can show error state
loading = false;
profile = null; // Clear profile on error
} finally {
// Ensure loading is always set to false
if (loading) {
loading = false; loading = false;
} profile = null;
} }
} }
</script> </script>
@ -339,13 +427,25 @@
{#if profile.nip05 && profile.nip05.length > 0} {#if profile.nip05 && profile.nip05.length > 0}
<div class="nip05 mb-2"> <div class="nip05 mb-2">
{#each profile.nip05 as nip05} {#each profile.nip05 as nip05}
{@const isValid = nip05Validations.get(nip05)} {@const isValid = nip05Validations[nip05]}
{@const wellKnownUrl = getNIP05WellKnownUrl(nip05)}
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light mr-2"> <span class="text-sm text-fog-text-light dark:text-fog-dark-text-light mr-2">
{nip05} {nip05}
{#if isValid === true} {#if isValid === true}
<span class="nip05-valid" title="NIP-05 verified"></span> <span class="nip05-valid" title="NIP-05 verified"></span>
{:else if isValid === false} {:else if isValid === false}
<span class="nip05-invalid" title="NIP-05 verification failed"></span> {#if wellKnownUrl}
<button
onclick={() => window.open(wellKnownUrl, '_blank', 'noopener,noreferrer')}
class="nip05-invalid-button"
title="NIP-05 verification failed - Click to view well-known.json"
aria-label="Open well-known.json for verification"
>
</button>
{:else}
<span class="nip05-invalid" title="NIP-05 verification failed"></span>
{/if}
{:else} {:else}
<span class="nip05-checking" title="Verifying NIP-05..."></span> <span class="nip05-checking" title="Verifying NIP-05..."></span>
{/if} {/if}
@ -481,6 +581,28 @@
font-weight: bold; font-weight: bold;
} }
.nip05-invalid-button {
color: #ef4444;
margin-left: 0.25rem;
font-weight: bold;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: inherit;
line-height: inherit;
transition: opacity 0.2s;
}
.nip05-invalid-button:hover {
opacity: 0.7;
text-decoration: underline;
}
.nip05-invalid-button:active {
opacity: 0.5;
}
.nip05-checking { .nip05-checking {
color: #9ca3af; color: #9ca3af;
margin-left: 0.25rem; margin-left: 0.25rem;

20
src/lib/services/nostr/gif-service.ts

@ -89,9 +89,9 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null {
// Try url tag (NIP-94 kind 1063 standard tag) // Try url tag (NIP-94 kind 1063 standard tag)
if (!url) { if (!url) {
const urlTag = event.tags.find(t => t[0] === 'url' && t[1]); const urlTag = event.tags.find(t => t[0] === 'url' && t[1]);
if (urlTag && urlTag[1] && urlTag[1].toLowerCase().includes('.gif')) { if (urlTag && urlTag[1] && urlTag[1].toLowerCase().includes('.gif')) {
url = urlTag[1]; url = urlTag[1];
} }
} }
@ -134,15 +134,15 @@ function parseGifFromEvent(event: NostrEvent): GifMetadata | null {
} }
if (!width || !height) { if (!width || !height) {
const dimTag = event.tags.find(t => t[0] === 'dim' && t[1]); const dimTag = event.tags.find(t => t[0] === 'dim' && t[1]);
if (dimTag && dimTag[1]) { if (dimTag && dimTag[1]) {
const dims = dimTag[1].split('x'); const dims = dimTag[1].split('x');
if (dims.length >= 2) { if (dims.length >= 2) {
width = parseInt(dims[0], 10); width = parseInt(dims[0], 10);
height = parseInt(dims[1], 10); height = parseInt(dims[1], 10);
}
} }
} }
}
const sha256Tag = event.tags.find(t => t[0] === 'x' && t[1]); const sha256Tag = event.tags.find(t => t[0] === 'x' && t[1]);
sha256 = sha256Tag?.[1]; sha256 = sha256Tag?.[1];
@ -228,7 +228,7 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi
useCache: true, // Try cache first useCache: true, // Try cache first
cacheResults: true, cacheResults: true,
timeout: config.relayTimeout timeout: config.relayTimeout
}); });
// If still no events, try querying relays directly // If still no events, try querying relays directly
if (events.length === 0) { if (events.length === 0) {

9
src/lib/services/nostr/nostr-client.ts

@ -169,7 +169,7 @@ class NostrClient {
} }
candidateEvents = idEvents; candidateEvents = idEvents;
} else if (filter.kinds && filter.kinds.length > 0) { } else if (filter.kinds && filter.kinds.length > 0) {
// Query by kind(s) if specified // Query by kind(s) if specified
// If single kind, use index for efficiency // If single kind, use index for efficiency
if (filter.kinds.length === 1) { if (filter.kinds.length === 1) {
candidateEvents = await getEventsByKind(filter.kinds[0], (filter.limit || 100) * 3); candidateEvents = await getEventsByKind(filter.kinds[0], (filter.limit || 100) * 3);
@ -292,9 +292,10 @@ class NostrClient {
const limited = sorted.slice(0, limit); const limited = sorted.slice(0, limit);
const filtered = filterEvents(limited); const filtered = filterEvents(limited);
// Only log cache queries that return results to reduce console noise // Only log cache queries at debug level to reduce console noise
if (filtered.length > 0) { // Only log if we got multiple events or if it's an interesting query
console.log(`[nostr-client] Cache query: ${limited.length} events before filter, ${filtered.length} after filter`); if (filtered.length > 5 || (filtered.length > 0 && limited.length > filtered.length * 2)) {
console.debug(`[nostr-client] Cache query: ${limited.length} events before filter, ${filtered.length} after filter`);
} }
return filtered; return filtered;

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

@ -26,7 +26,8 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
9802: { number: 9802, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, 9802: { number: 9802, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
// Threads and comments // Threads and comments
11: { number: 11, description: 'Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, 11: { number: 11, description: 'Discussion Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
34550: { number: 34550, description: 'Community', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true }, 1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true },
// Media // Media

Loading…
Cancel
Save