Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
58ca3701c6
  1. 2
      docker-compose.yml
  2. 4
      public/healthz.json
  3. 43
      src/app.css
  4. 24
      src/lib/components/content/EmbeddedEvent.svelte
  5. 34
      src/lib/components/content/MarkdownRenderer.svelte
  6. 15
      src/lib/components/content/MediaAttachments.svelte
  7. 45
      src/lib/components/content/MetadataCard.svelte
  8. 27
      src/lib/components/content/ReplyContext.svelte
  9. 10
      src/lib/components/layout/Header.svelte
  10. 68
      src/lib/components/layout/ProfileBadge.svelte
  11. 469
      src/lib/components/preferences/UserPreferences.svelte
  12. 10
      src/lib/modules/comments/Comment.svelte
  13. 65
      src/lib/modules/comments/CommentThread.svelte
  14. 121
      src/lib/modules/discussions/DiscussionCard.svelte
  15. 27
      src/lib/modules/discussions/DiscussionList.svelte
  16. 6
      src/lib/modules/discussions/DiscussionView.svelte
  17. 13
      src/lib/modules/discussions/DiscussionVoteButtons.svelte
  18. 178
      src/lib/modules/feed/FeedPage.svelte
  19. 63
      src/lib/modules/feed/FeedPost.svelte
  20. 90
      src/lib/modules/feed/ThreadDrawer.svelte
  21. 90
      src/lib/modules/profiles/ProfilePage.svelte
  22. 22
      src/lib/modules/reactions/FeedReactionButtons.svelte
  23. 18
      src/lib/services/cache/indexeddb-store.ts
  24. 132
      src/lib/services/cache/rss-cache.ts
  25. 43
      src/lib/services/nostr/config.ts
  26. 23
      src/lib/services/nostr/nip30-emoji.ts
  27. 92
      src/lib/services/nostr/nostr-client.ts
  28. 2
      src/routes/discussions/+page.svelte
  29. 2
      src/routes/feed/+page.svelte
  30. 2
      src/routes/find/+page.svelte
  31. 42
      src/routes/login/+page.svelte
  32. 23
      src/routes/repos/[naddr]/+page.svelte
  33. 138
      src/routes/rss/+page.svelte
  34. 358
      src/routes/settings/+page.svelte
  35. 116
      src/routes/topics/[name]/+page.svelte
  36. 2
      src/routes/write/+page.svelte

2
docker-compose.yml

@ -1,5 +1,3 @@ @@ -1,5 +1,3 @@
version: '3.8'
services:
aitherboard:
build:

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.1",
"buildTime": "2026-02-05T11:28:23.225Z",
"buildTime": "2026-02-05T18:24:55.668Z",
"gitCommit": "unknown",
"timestamp": 1770290903225
"timestamp": 1770315895668
}

43
src/app.css

@ -471,6 +471,49 @@ img.emoji-inline { @@ -471,6 +471,49 @@ img.emoji-inline {
filter: none !important;
}
/* Responsive images and media - max 600px, scale down on smaller screens */
img:not(.profile-picture):not([alt*="profile" i]):not([alt*="avatar" i]):not([src*="avatar" i]):not([src*="profile" i]),
video,
audio {
max-width: 600px;
width: 100%;
height: auto;
}
/* Ensure media in markdown content is responsive */
.markdown-content img,
.markdown-content video,
.markdown-content audio,
.post-content img,
.post-content video,
.post-content audio {
max-width: 600px;
width: 100%;
height: auto;
display: block;
}
/* Media gallery items should also be responsive */
.media-gallery img,
.media-gallery video {
max-width: 100%;
width: 100%;
height: auto;
}
/* Cover images should be responsive */
.cover-image img {
max-width: 600px;
width: 100%;
height: auto;
}
/* Audio players should be full width but constrained */
audio {
max-width: 600px;
width: 100%;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,

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

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
import { stripMarkdown } from '../../services/text-utils.js';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import { KIND } from '../../types/kind-lookup.js';
import { fetchProfile } from '../../services/user-data.js';
interface Props {
eventId: string; // Can be hex, note, nevent, naddr
@ -166,6 +167,16 @@ @@ -166,6 +167,16 @@
return subjectTag?.[1] || null;
}
// Normalize URL for comparison (remove query params, fragments, trailing slashes)
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '');
} catch {
return url;
}
}
function getImageUrl(): string | null {
if (!event) return null;
const imageTag = event.tags.find(t => t[0] === 'image');
@ -336,6 +347,19 @@ @@ -336,6 +347,19 @@
vertical-align: middle;
line-height: 1.5;
}
/* Ensure profile pictures in embedded events stay small */
.embedded-event-header :global(.profile-badge img.profile-picture),
.embedded-event-header :global(.profile-badge .profile-picture) {
width: 1.5rem !important; /* 24px - w-6 */
height: 1.5rem !important; /* 24px - h-6 */
max-width: 1.5rem !important;
max-height: 1.5rem !important;
min-width: 1.5rem !important;
min-height: 1.5rem !important;
object-fit: cover;
flex-shrink: 0;
}
.embedded-event-title {
font-weight: 600;

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

@ -145,11 +145,11 @@ @@ -145,11 +145,11 @@
const escapedUrl = escapeHtml(url);
if (type === 'image') {
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" />` + result.substring(endIndex);
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" style="max-width: 600px; width: 100%; height: auto;" />` + result.substring(endIndex);
} else if (type === 'video') {
result = result.substring(0, index) + `<video src="${escapedUrl}" controls preload="none" style="max-width: 100%; max-height: 500px;"></video>` + result.substring(endIndex);
result = result.substring(0, index) + `<video src="${escapedUrl}" controls preload="none" style="max-width: 600px; width: 100%; height: auto; max-height: 500px;"></video>` + result.substring(endIndex);
} else if (type === 'audio') {
result = result.substring(0, index) + `<audio src="${escapedUrl}" controls preload="none" style="width: 100%;"></audio>` + result.substring(endIndex);
result = result.substring(0, index) + `<audio src="${escapedUrl}" controls preload="none" style="max-width: 600px; width: 100%;"></audio>` + result.substring(endIndex);
}
}
@ -183,16 +183,12 @@ @@ -183,16 +183,12 @@
}
}
// Resolve emojis - first try specific pubkeys, then search broadly
// Resolve emojis - only try specific pubkeys (don't search broadly to avoid background fetching)
// Broad search should only happen when emoji picker is opened
const resolvedUrls = new Map<string, string>();
for (const { shortcode, fullMatch } of matches) {
// First try specific pubkeys (event author, p tags)
let url = await resolveEmojiShortcode(shortcode, Array.from(pubkeysToCheck), false);
// If not found, search broadly across all emoji packs
if (!url) {
url = await resolveEmojiShortcode(shortcode, [], true);
}
// Only try specific pubkeys (event author, p tags) - don't search broadly
const url = await resolveEmojiShortcode(shortcode, Array.from(pubkeysToCheck), false);
if (url) {
resolvedUrls.set(fullMatch, url);
@ -621,7 +617,7 @@ @@ -621,7 +617,7 @@
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
const escapedAlt = escapeHtml(alt);
return `<img src="${escapedUrl}" alt="${escapedAlt}" loading="lazy" />`;
return `<img src="${escapedUrl}" alt="${escapedAlt}" loading="lazy" style="max-width: 600px; width: 100%; height: auto;" />`;
}
// If not a valid URL, remove the markdown syntax to prevent 404s
return alt || '';
@ -633,7 +629,7 @@ @@ -633,7 +629,7 @@
// If it's a valid image URL, convert to a proper img tag
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
return `<img src="${escapedUrl}" alt="" loading="lazy" />`;
return `<img src="${escapedUrl}" alt="" loading="lazy" style="max-width: 600px; width: 100%; height: auto;" />`;
}
// Otherwise, remove to prevent 404s
return '';
@ -904,8 +900,9 @@ @@ -904,8 +900,9 @@
margin-top: 1em;
}
:global(.markdown-content img) {
max-width: 100%;
:global(.markdown-content img):not(.emoji-inline) {
max-width: 600px;
width: 100%;
height: auto;
border-radius: 0.25rem;
margin: 0.5rem 0;
@ -923,21 +920,26 @@ @@ -923,21 +920,26 @@
width: 1.6em;
height: 1.6em;
object-fit: contain;
max-width: none; /* Emojis should keep their fixed size */
/* Emojis should be in full color, no grayscale filter */
}
:global(.markdown-content video) {
max-width: 100%;
max-width: 600px;
width: 100%;
height: auto;
border-radius: 0.25rem;
margin: 0.5rem 0;
display: block;
/* Content videos should be prominent - no grayscale filters */
filter: none !important;
}
:global(.markdown-content audio) {
max-width: 600px;
width: 100%;
margin: 0.5rem 0;
display: block;
/* Content audio should be prominent - no grayscale filters */
filter: none !important;
}

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

@ -385,6 +385,8 @@ @@ -385,6 +385,8 @@
.cover-image img {
border: 1px solid var(--fog-border, #e5e7eb);
max-width: 600px;
width: 100%;
height: auto;
}
:global(.dark) .cover-image img {
@ -398,6 +400,12 @@ @@ -398,6 +400,12 @@
margin-top: 1rem;
}
@media (max-width: 640px) {
.media-gallery {
grid-template-columns: 1fr;
}
}
.media-item {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
@ -414,7 +422,14 @@ @@ -414,7 +422,14 @@
.media-item img,
.media-item video {
max-width: 600px;
width: 100%;
height: auto;
display: block;
}
.media-item audio {
max-width: 600px;
width: 100%;
}
.file-item {

45
src/lib/components/content/MetadataCard.svelte

@ -5,17 +5,53 @@ @@ -5,17 +5,53 @@
interface Props {
event: NostrEvent;
showMenu?: boolean;
hideTitle?: boolean; // If true, don't show title (already displayed elsewhere)
hideImageIfInMedia?: boolean; // If true, check if image is already in MediaAttachments
}
let { event, showMenu = true }: Props = $props();
let { event, showMenu = true, hideTitle = false, hideImageIfInMedia = true }: Props = $props();
// Normalize URL for comparison (same logic as MediaAttachments)
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
// Remove query params and fragments for comparison
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '');
} catch {
return url;
}
}
// Check if image URL is already in imeta tags
function isImageInImeta(imageUrl: string): boolean {
if (!imageUrl) return false;
const normalizedImageUrl = normalizeUrl(imageUrl);
for (const tag of event.tags) {
if (tag[0] === 'imeta') {
for (let i = 1; i < tag.length; i++) {
const item = tag[i];
if (item.startsWith('url ')) {
const imetaUrl = item.substring(4).trim();
if (normalizeUrl(imetaUrl) === normalizedImageUrl) {
return true;
}
}
}
}
}
return false;
}
// Extract metadata tags (using $derived for reactivity)
const image = $derived(event.tags.find(t => t[0] === 'image' && t[1])?.[1]);
const rawImage = $derived(event.tags.find(t => t[0] === 'image' && t[1])?.[1]);
const image = $derived(rawImage && (!hideImageIfInMedia || !isImageInImeta(rawImage)) ? rawImage : null);
const description = $derived(event.tags.find(t => t[0] === 'description' && t[1])?.[1]);
const summary = $derived(event.tags.find(t => t[0] === 'summary' && t[1])?.[1]);
const author = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]);
const title = $derived(
event.tags.find(t => t[0] === 'title' && t[1])?.[1] ||
hideTitle ? null :
(event.tags.find(t => t[0] === 'title' && t[1])?.[1] ||
(() => {
// Fallback to d-tag in Title Case
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
@ -25,7 +61,7 @@ @@ -25,7 +61,7 @@
).join(' ');
}
return null;
})()
})())
);
const hasMetadata = $derived(image || description || summary || author || title);
@ -108,6 +144,7 @@ @@ -108,6 +144,7 @@
}
.metadata-image img {
max-width: 600px;
width: 100%;
height: auto;
display: block;

27
src/lib/components/content/ReplyContext.svelte

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
let lastLoadAttemptId = $state<string | null>(null); // Track which event ID we tried to load
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -24,20 +25,26 @@ @@ -24,20 +25,26 @@
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
// If provided parent event is available, use it
// If provided parent event is available, use it and clear loaded state
loadedParentEvent = null;
lastLoadAttemptId = null;
return;
}
// Determine which event ID we need to load
const eventIdToLoad = parentEventId || parentEvent?.id;
// If no provided parent event and we have an ID, try to load it
if (!loadedParentEvent && parentEventId && !loadingParent) {
loadParentEvent();
// Only load if we haven't already tried to load this ID
if (eventIdToLoad && !loadedParentEvent && !loadingParent && lastLoadAttemptId !== eventIdToLoad) {
loadParentEvent(eventIdToLoad);
}
});
async function loadParentEvent() {
const eventId = parentEventId || parentEvent?.id;
if (!eventId || loadingParent) return;
async function loadParentEvent(eventId: string) {
if (!eventId || loadingParent || lastLoadAttemptId === eventId) return;
lastLoadAttemptId = eventId;
loadingParent = true;
try {
const relays = relayManager.getFeedReadRelays();
@ -47,7 +54,8 @@ @@ -47,7 +54,8 @@
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
// Only update if this is still the event we're trying to load
if (lastLoadAttemptId === eventId && events.length > 0) {
loadedParentEvent = events[0];
if (onParentLoaded && typeof onParentLoaded === 'function') {
onParentLoaded(loadedParentEvent);
@ -56,7 +64,10 @@ @@ -56,7 +64,10 @@
} catch (error) {
console.error('Error loading parent event:', error);
} finally {
loadingParent = false;
// Only clear loading state if this is still the current load
if (lastLoadAttemptId === eventId) {
loadingParent = false;
}
}
}

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

@ -49,8 +49,8 @@ @@ -49,8 +49,8 @@
<!-- 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">
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm font-mono">
<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 font-mono" style="font-size: 0.875em;">
<a href="/" class="font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors" style="font-size: 1.25em;">aitherboard</a>
<a href="/discussions" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Discussions</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>
{#if isLoggedIn}
@ -65,7 +65,7 @@ @@ -65,7 +65,7 @@
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Repos</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Cache</a>
</div>
<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" style="font-size: 0.875em;">
{#if isLoggedIn && currentPubkey}
<UserPreferences />
<ProfileBadge pubkey={currentPubkey} />
@ -117,9 +117,5 @@ @@ -117,9 +117,5 @@
nav a, nav button {
font-size: 0.875rem; /* Slightly smaller text on mobile */
}
nav .text-xl {
font-size: 1.125rem; /* Smaller logo on mobile */
}
}
</style>

68
src/lib/components/layout/ProfileBadge.svelte

@ -15,10 +15,20 @@ @@ -15,10 +15,20 @@
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
let activityMessage = $state<string | null>(null);
let imageError = $state(false);
let loadingProfile = $state(false);
let loadingStatus = $state(false);
let loadingActivity = $state(false);
let lastLoadedPubkey = $state<string | null>(null);
$effect(() => {
if (pubkey) {
// Only load if pubkey changed and we haven't loaded it yet
if (pubkey && pubkey !== lastLoadedPubkey) {
imageError = false; // Reset image error when pubkey changes
// Reset state for new pubkey
profile = null;
status = null;
activityStatus = null;
activityMessage = null;
// Load immediately - no debounce
loadProfile();
// Only load status and activity if not inline
@ -30,19 +40,63 @@ @@ -30,19 +40,63 @@
});
async function loadProfile() {
const p = await fetchProfile(pubkey);
if (p) {
profile = p;
const currentPubkey = pubkey;
if (!currentPubkey || loadingProfile || lastLoadedPubkey === currentPubkey) {
return; // Already loading or already loaded this pubkey
}
loadingProfile = true;
try {
const p = await fetchProfile(currentPubkey);
// Only update if pubkey hasn't changed during load
if (pubkey === currentPubkey) {
if (p) {
profile = p;
}
lastLoadedPubkey = currentPubkey;
}
} finally {
// Only clear loading if this is still the current pubkey
if (pubkey === currentPubkey) {
loadingProfile = false;
}
}
}
async function loadStatus() {
status = await fetchUserStatus(pubkey);
const currentPubkey = pubkey;
if (!currentPubkey || loadingStatus || lastLoadedPubkey !== currentPubkey) return;
loadingStatus = true;
try {
const s = await fetchUserStatus(currentPubkey);
// Only update if pubkey hasn't changed during load
if (pubkey === currentPubkey && lastLoadedPubkey === currentPubkey) {
status = s;
}
} finally {
if (pubkey === currentPubkey) {
loadingStatus = false;
}
}
}
async function updateActivityStatus() {
activityStatus = await getActivityStatus(pubkey);
activityMessage = await getActivityMessage(pubkey);
const currentPubkey = pubkey;
if (!currentPubkey || loadingActivity || lastLoadedPubkey !== currentPubkey) return;
loadingActivity = true;
try {
const actStatus = await getActivityStatus(currentPubkey);
const actMessage = await getActivityMessage(currentPubkey);
// Only update if pubkey hasn't changed during load
if (pubkey === currentPubkey && lastLoadedPubkey === currentPubkey) {
activityStatus = actStatus;
activityMessage = actMessage;
}
} finally {
if (pubkey === currentPubkey) {
loadingActivity = false;
}
}
}
function getActivityColor(): string {

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

@ -1,475 +1,22 @@ @@ -1,475 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose';
type ContentWidth = 'narrow' | 'medium' | 'wide';
let textSize = $state<TextSize>('medium');
let lineSpacing = $state<LineSpacing>('normal');
let contentWidth = $state<ContentWidth>('medium');
let isDark = $state(false);
onMount(() => {
// Load preferences from localStorage
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
// Check theme preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
// Apply preferences
applyPreferences();
});
function applyPreferences() {
// Apply text size
document.documentElement.setAttribute('data-text-size', textSize);
localStorage.setItem('textSize', textSize);
// Apply line spacing
document.documentElement.setAttribute('data-line-spacing', lineSpacing);
localStorage.setItem('lineSpacing', lineSpacing);
// Apply content width
document.documentElement.setAttribute('data-content-width', contentWidth);
localStorage.setItem('contentWidth', contentWidth);
// Apply theme
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
function handleTextSizeChange(size: TextSize) {
textSize = size;
applyPreferences();
}
function handleLineSpacingChange(spacing: LineSpacing) {
lineSpacing = spacing;
applyPreferences();
}
function handleContentWidthChange(width: ContentWidth) {
contentWidth = width;
applyPreferences();
}
function handleThemeToggle() {
isDark = !isDark;
applyPreferences();
}
function close() {
open = false;
}
function handleOverlayClick(e: MouseEvent) {
// Only close if clicking the overlay itself, not the modal content
if (e.target === e.currentTarget) {
close();
}
}
function handleOverlayKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
close();
}
}
// Simple button component that links to settings page
</script>
<!-- Settings button to open modal -->
<button
onclick={() => open = true}
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"
aria-label="Open preferences"
title="Preferences"
<a
href="/settings"
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 inline-flex items-center"
aria-label="Open settings"
title="Settings"
>
<span class="emoji emoji-grayscale"></span>
</button>
<!-- Modal -->
{#if open}
<div
class="modal-overlay"
onclick={handleOverlayClick}
onkeydown={handleOverlayKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="preferences-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="preferences-title">Preferences</h2>
<button onclick={close} class="close-button" aria-label="Close preferences">×</button>
</div>
<div class="modal-body">
<!-- Theme Toggle -->
<div class="preference-section">
<div class="preference-label">
<span>Theme</span>
</div>
<div class="preference-controls">
<button
onclick={handleThemeToggle}
class="toggle-button"
class:active={isDark}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span class="emoji">{isDark ? '☀' : '🌙'}</span>
<span>{isDark ? 'Light' : 'Dark'}</span>
</button>
</div>
</div>
<!-- Text Size -->
<div class="preference-section">
<div class="preference-label">
<span>Text Size</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleTextSizeChange('small')}
class="option-button"
class:active={textSize === 'small'}
aria-label="Small text size"
>
Small
</button>
<button
onclick={() => handleTextSizeChange('medium')}
class="option-button"
class:active={textSize === 'medium'}
aria-label="Medium text size"
>
Medium
</button>
<button
onclick={() => handleTextSizeChange('large')}
class="option-button"
class:active={textSize === 'large'}
aria-label="Large text size"
>
Large
</button>
</div>
</div>
<!-- Line Spacing -->
<div class="preference-section">
<div class="preference-label">
<span>Line Spacing</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleLineSpacingChange('tight')}
class="option-button"
class:active={lineSpacing === 'tight'}
aria-label="Tight line spacing"
>
Tight
</button>
<button
onclick={() => handleLineSpacingChange('normal')}
class="option-button"
class:active={lineSpacing === 'normal'}
aria-label="Normal line spacing"
>
Normal
</button>
<button
onclick={() => handleLineSpacingChange('loose')}
class="option-button"
class:active={lineSpacing === 'loose'}
aria-label="Loose line spacing"
>
Loose
</button>
</div>
</div>
<!-- Content Width -->
<div class="preference-section">
<div class="preference-label">
<span>Content Width</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleContentWidthChange('narrow')}
class="option-button"
class:active={contentWidth === 'narrow'}
aria-label="Narrow content width"
>
Narrow
</button>
<button
onclick={() => handleContentWidthChange('medium')}
class="option-button"
class:active={contentWidth === 'medium'}
aria-label="Medium content width"
>
Medium
</button>
<button
onclick={() => handleContentWidthChange('wide')}
class="option-button"
class:active={contentWidth === 'wide'}
aria-label="Wide content width"
>
Wide
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button onclick={close}>Close</button>
</div>
</div>
</div>
{/if}
</a>
<style>
.emoji-grayscale {
filter: grayscale(100%);
}
button:hover .emoji-grayscale {
a:hover .emoji-grayscale {
filter: grayscale(80%);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: #1e293b;
border-color: #475569;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #cbd5e1;
}
:global(.dark) .modal-header {
border-bottom-color: #475569;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
:global(.dark) .modal-header h2 {
color: #f1f5f9;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: color 0.2s;
}
.close-button:hover {
color: #1e293b;
}
:global(.dark) .close-button {
color: #94a3b8;
}
:global(.dark) .close-button:hover {
color: #f1f5f9;
}
.modal-body {
padding: 1.5rem;
}
.preference-section {
margin-bottom: 1.5rem;
}
.preference-section:last-child {
margin-bottom: 0;
}
.preference-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #1e293b;
}
:global(.dark) .preference-label {
color: #f1f5f9;
}
.preference-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.toggle-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #1e293b;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.toggle-button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.toggle-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
:global(.dark) .toggle-button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
:global(.dark) .toggle-button:hover {
background: #475569;
border-color: #64748b;
}
:global(.dark) .toggle-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.option-button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #ffffff;
color: #1e293b;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.option-button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.option-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
:global(.dark) .option-button {
background: #334155;
border-color: #475569;
color: #f1f5f9;
}
:global(.dark) .option-button:hover {
background: #475569;
border-color: #64748b;
}
:global(.dark) .option-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.modal-footer {
padding: 1rem;
border-top: 1px solid #cbd5e1;
text-align: right;
}
:global(.dark) .modal-footer {
border-top-color: #475569;
}
.modal-footer button {
padding: 0.5rem 1rem;
background: #94a3b8;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.modal-footer button:hover {
background: #64748b;
}
</style>

10
src/lib/modules/comments/Comment.svelte

@ -89,9 +89,9 @@ @@ -89,9 +89,9 @@
<div class="comment-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={comment.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<EventMenu event={comment} />
@ -106,7 +106,8 @@ @@ -106,7 +106,8 @@
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
class="show-more-button text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
style="font-size: 0.875em;"
>
{expanded ? 'Show less' : 'Show more'}
</button>
@ -122,7 +123,8 @@ @@ -122,7 +123,8 @@
{/if}
<button
onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.75em;"
>
Reply
</button>

65
src/lib/modules/comments/CommentThread.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import FeedPost from '../feed/FeedPost.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
@ -287,13 +288,13 @@ @@ -287,13 +288,13 @@
const allRelays = relayManager.getProfileReadRelays();
const replyFilters: any[] = [
{ kinds: [KIND.COMMENT], '#e': [threadId], limit: 100 },
{ kinds: [KIND.COMMENT], '#E': [threadId], limit: 100 },
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: 100 },
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: 100 },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: 100 },
{ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: 100 },
{ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: 100 }
{ kinds: [KIND.COMMENT], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: config.feedLimit },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: config.feedLimit }
];
// fetchEvents with useCache:true returns cached data immediately if available,
@ -305,7 +306,7 @@ @@ -305,7 +306,7 @@
const fetchPromise1 = nostrClient.fetchEvents(
replyFilters,
allRelays,
{ useCache: true, cacheResults: false, timeout: 50 }
{ useCache: true, cacheResults: false, timeout: config.shortTimeout }
);
activeFetchPromises.add(fetchPromise1);
const quickCacheCheck = await fetchPromise1;
@ -326,7 +327,7 @@ @@ -326,7 +327,7 @@
{
useCache: true,
cacheResults: true,
timeout: 10000,
timeout: config.longTimeout,
onUpdate: handleReplyUpdate,
priority: 'high'
}
@ -410,11 +411,11 @@ @@ -410,11 +411,11 @@
// Use a single subscription that covers all reply IDs
const nestedFilters: any[] = [
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 100 },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 100 }
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: config.feedLimit }
];
if (!isMounted) return; // Don't subscribe if unmounted
@ -466,22 +467,22 @@ @@ -466,22 +467,22 @@
if (limitedReplyIds.length > 0) {
const nestedFilters: any[] = [
// Fetch nested kind 1111 comments - check both e/E and a/A tags
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: config.feedLimit },
// Fetch nested kind 1 replies
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: config.feedLimit },
// Fetch nested yak backs
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit },
// Fetch nested zap receipts
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 100 }
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: config.feedLimit }
];
const fetchPromise = nostrClient.fetchEvents(
nestedFilters,
allRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
activeFetchPromises.add(fetchPromise);
const nestedReplies = await fetchPromise;
@ -647,28 +648,28 @@ @@ -647,28 +648,28 @@
// Always fetch kind 1111 comments - check both e and E tags, and a and A tags
replyFilters.push(
{ kinds: [KIND.COMMENT], '#e': [threadId], limit: 100 }, // Lowercase e tag
{ kinds: [KIND.COMMENT], '#E': [threadId], limit: 100 }, // Uppercase E tag (NIP-22)
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: 100 }, // Lowercase a tag (some clients use wrong tags)
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: 100 } // Uppercase A tag (NIP-22 for addressable events)
{ kinds: [KIND.COMMENT], '#e': [threadId], limit: config.feedLimit }, // Lowercase e tag
{ kinds: [KIND.COMMENT], '#E': [threadId], limit: config.feedLimit }, // Uppercase E tag (NIP-22)
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: config.feedLimit }, // Lowercase a tag (some clients use wrong tags)
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: config.feedLimit } // Uppercase A tag (NIP-22 for addressable events)
);
// For kind 1 events, fetch kind 1 replies
// Also fetch kind 1 replies for any event (some apps use kind 1 for everything)
replyFilters.push({ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: 100 });
replyFilters.push({ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: config.feedLimit });
// Fetch yak backs (kind 1244) - voice replies
replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: 100 });
replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit });
// Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: 100 });
replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: config.feedLimit });
// Don't use cache when reloading after publishing - we want fresh data
// Use high priority to ensure comments load before background fetches
const allReplies = await nostrClient.fetchEvents(
replyFilters,
allRelays,
{ useCache: false, cacheResults: true, timeout: 10000, priority: 'high' }
{ useCache: false, cacheResults: true, timeout: config.longTimeout, priority: 'high' }
);
// Filter to only replies that reference the root
@ -722,10 +723,10 @@ @@ -722,10 +723,10 @@
</script>
<div class="comment-thread">
<h2 class="text-xl font-bold mb-4">
<h2 class="font-bold mb-4" style="font-size: 1.25em;">
Comments
{#if !loading && totalCommentCount > 0}
<span class="text-base font-normal text-fog-text-light dark:text-fog-dark-text-light ml-2">
<span class="font-normal text-fog-text-light dark:text-fog-dark-text-light ml-2">
({totalCommentCount})
</span>
{/if}

121
src/lib/modules/discussions/DiscussionCard.svelte

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import VoteCount from '../../components/content/VoteCount.svelte';
import DiscussionVoteButtons from './DiscussionVoteButtons.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MetadataCard from '../../components/content/MetadataCard.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -14,9 +18,10 @@ @@ -14,9 +18,10 @@
upvotes?: number; // Pre-calculated upvote count from batch fetch
downvotes?: number; // Pre-calculated downvote count from batch fetch
votesCalculated?: boolean; // Whether vote counts are ready to display
fullView?: boolean; // If true, show full markdown content instead of preview
}
let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false }: Props = $props();
let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false, fullView = false }: Props = $props();
let commentCount = $state(0);
@ -190,14 +195,15 @@ @@ -190,14 +195,15 @@
</script>
<article class="thread-card bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 mb-4 shadow-sm dark:shadow-lg">
<a href="/event/{thread.id}" class="card-link">
<div class="card-content" class:expanded bind:this={contentElement}>
{#if !fullView}
<a href="/event/{thread.id}" class="card-link">
<div class="card-content" class:expanded={expanded} bind:this={contentElement}>
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold text-fog-text dark:text-fog-dark-text">
<h3 class="font-semibold text-fog-text dark:text-fog-dark-text">
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">{getRelativeTime()}</span>
<!-- Menu hidden in preview - not clickable in card preview -->
</div>
</div>
@ -207,11 +213,52 @@ @@ -207,11 +213,52 @@
<ProfileBadge pubkey={thread.pubkey} />
</div>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
</div>
<p class="text-sm mb-2 text-fog-text dark:text-fog-dark-text">{getPreview()}</p>
<!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
<p class="mb-2 text-fog-text dark:text-fog-dark-text">{getPreview()}</p>
{#if getTopics().length > 0}
<div class="flex gap-2 topic-tags">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
</div>
</a>
{:else}
<div class="card-content" class:expanded={true} bind:this={contentElement}>
<div class="flex justify-between items-start mb-2">
<h3 class="font-semibold text-fog-text dark:text-fog-dark-text">
{getTitle()}
</h3>
<div class="flex items-center gap-2">
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">{getRelativeTime()}</span>
<!-- Menu hidden in preview - not clickable in card preview -->
</div>
</div>
<div class="mb-2 flex items-center gap-2">
<div class="interactive-element">
<ProfileBadge pubkey={thread.pubkey} />
</div>
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
</div>
<!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={thread} hideTitle={true} hideImageIfInMedia={true} />
<div class="post-content mb-2">
<MediaAttachments event={thread} />
<MarkdownRenderer content={thread.content} event={thread} />
</div>
{#if getTopics().length > 0}
<div class="flex gap-2 topic-tags">
@ -221,34 +268,53 @@ @@ -221,34 +268,53 @@
</div>
{/if}
</div>
</a>
{/if}
{#if needsExpansion}
{#if !fullView && needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
class="show-more-button text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
style="font-size: 0.875em;"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<!-- Card footer (stats) - always visible, outside collapsible content -->
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text thread-stats">
<div class="flex items-center justify-between text-fog-text dark:text-fog-dark-text thread-stats" style="font-size: 0.75em;">
<div class="flex items-center gap-4 flex-wrap">
{#if providedVotesCalculated}
<VoteCount upvotes={providedUpvotes} downvotes={providedDownvotes} votesCalculated={true} size="xs" />
{#if fullView}
<DiscussionVoteButtons event={thread} />
{:else}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading votes...</span>
<VoteCount
upvotes={providedUpvotes}
downvotes={providedDownvotes}
votesCalculated={providedVotesCalculated}
size="xs"
/>
{/if}
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{#if !fullView}
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{/if}
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{:else}
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{/if}
{/if}
{/if}
</div>
@ -392,4 +458,15 @@ @@ -392,4 +458,15 @@
border-top-color: var(--fog-dark-border, #374151);
}
.post-content {
line-height: 1.6;
}
.post-content :global(img) {
max-width: 600px;
height: auto;
border-radius: 0.5rem;
margin: 0.5rem 0;
}
</style>

27
src/lib/modules/discussions/DiscussionList.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import DiscussionCard from './DiscussionCard.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -144,13 +145,13 @@ @@ -144,13 +145,13 @@
// Query relays first with 3-second timeout, then fill from cache if needed
const fetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: config.feedLimit }],
threadRelays,
{
relayFirst: true, // Query relays first with timeout
useCache: true, // Fill from cache if relay query returns nothing
cacheResults: true, // Cache the results
timeout: 3000, // 3-second timeout
timeout: config.standardTimeout,
onUpdate: async (updatedEvents) => {
if (!isMounted) return; // Don't update if unmounted
// Update incrementally as events arrive
@ -209,9 +210,9 @@ @@ -209,9 +210,9 @@
// Fetch deletion events for specific reaction IDs only
const reactionIds = allReactions.map(r => r.id);
const deletionFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], '#e': reactionIds, limit: 100 }],
[{ kinds: [KIND.EVENT_DELETION], '#e': reactionIds, limit: config.feedLimit }],
reactionRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 }
{ relayFirst: true, useCache: true, cacheResults: true, timeout: config.standardTimeout }
);
activeFetchPromises.add(deletionFetchPromise);
const deletionEvents = await deletionFetchPromise;
@ -265,13 +266,13 @@ @@ -265,13 +266,13 @@
// Fetch reactions with lowercase e
const reactionsFetchPromise1 = nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': threadIds, limit: 100 }],
[{ kinds: [KIND.REACTION], '#e': threadIds, limit: config.feedLimit }],
reactionRelays,
{
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: 3000,
timeout: config.standardTimeout,
onUpdate: handleReactionUpdate
}
);
@ -285,13 +286,13 @@ @@ -285,13 +286,13 @@
let reactionsWithUpperE: NostrEvent[] = [];
try {
const reactionsFetchPromise2 = nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': threadIds, limit: 100 }],
[{ kinds: [KIND.REACTION], '#E': threadIds, limit: config.feedLimit }],
reactionRelays,
{
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: 3000,
timeout: config.standardTimeout,
onUpdate: handleReactionUpdate
}
);
@ -323,9 +324,9 @@ @@ -323,9 +324,9 @@
// Fetch zap receipts (for sorting)
if (!isMounted) return;
const zapFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: 100 }],
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: config.feedLimit }],
zapRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 }
{ relayFirst: true, useCache: true, cacheResults: true, timeout: config.standardTimeout }
);
activeFetchPromises.add(zapFetchPromise);
const allZapReceipts = await zapFetchPromise;
@ -349,9 +350,9 @@ @@ -349,9 +350,9 @@
// Batch-load comment counts for all threads
if (!isMounted) return;
const commentsFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'], limit: 100 }],
[{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'], limit: config.feedLimit }],
commentRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000, priority: 'low' }
{ relayFirst: true, useCache: true, cacheResults: true, timeout: config.standardTimeout, priority: 'low' }
);
activeFetchPromises.add(commentsFetchPromise);
const allComments = await commentsFetchPromise;
@ -588,7 +589,7 @@ @@ -588,7 +589,7 @@
<!-- Filter by topic buttons -->
<div class="mb-6">
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text mr-2">Filter by topic:</span>
<span class="font-semibold text-fog-text dark:text-fog-dark-text mr-2" style="font-size: 0.875em;">Filter by topic:</span>
<button
onclick={() => (selectedTopic = null)}
class="px-3 py-1 rounded border transition-colors {selectedTopic === null

6
src/lib/modules/discussions/DiscussionView.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import FeedPost from '../feed/FeedPost.svelte';
import DiscussionCard from './DiscussionCard.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
@ -106,9 +106,9 @@ @@ -106,9 +106,9 @@
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
{:else if rootEvent}
<article class="thread-view">
<!-- Display the root OP event (kind 11) -->
<!-- Display the root OP event (kind 11) using DiscussionCard -->
<div class="op-section">
<FeedPost post={rootEvent} />
<DiscussionCard thread={rootEvent} fullView={true} />
</div>
<!-- Display all replies (kind 1111 comments) using CommentThread -->

13
src/lib/modules/discussions/DiscussionVoteButtons.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
@ -198,14 +199,14 @@ @@ -198,14 +199,14 @@
// Use low priority for reactions - they're background data, comments should load first
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: 100 }],
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' }
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: 100 }],
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' }
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' }
);
// Combine and deduplicate by reaction ID
@ -257,9 +258,9 @@ @@ -257,9 +258,9 @@
// This is much more efficient than fetching all deletion events from all users
// Use low priority for deletion events - background data
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: 100 }],
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, timeout: 5000, priority: 'low' }
{ useCache: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Build a set of deleted reaction event IDs (more efficient - just a Set)

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

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
@ -16,9 +17,6 @@ @@ -16,9 +17,6 @@
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null);
let relayError = $state<string | null>(null);
// Batch-loaded parent and quoted events
@ -29,16 +27,12 @@ @@ -29,16 +27,12 @@
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
let subscriptionId: string | null = $state(null);
let isMounted = $state(true);
let loadingParents = $state(false); // Guard to prevent concurrent parent/quoted event loads
let loadingFeed = $state(false); // Guard to prevent concurrent feed loads
let pendingSubscriptionEvents = $state<NostrEvent[]>([]); // Batch subscription events
let subscriptionBatchTimeout: ReturnType<typeof setTimeout> | null = null;
let loadMoreTimeout: ReturnType<typeof setTimeout> | null = null; // Debounce loadMore calls
let lastLoadMoreTime = $state<number>(0); // Track last loadMore call time
function openDrawer(event: NostrEvent) {
drawerEvent = event;
@ -64,7 +58,6 @@ @@ -64,7 +58,6 @@
await loadFeed();
if (!isMounted) return;
setupSubscription();
setupObserver();
})();
return () => {
@ -73,18 +66,10 @@ @@ -73,18 +66,10 @@
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
if (observer) {
observer.disconnect();
observer = null;
}
if (subscriptionBatchTimeout) {
clearTimeout(subscriptionBatchTimeout);
subscriptionBatchTimeout = null;
}
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
loadMoreTimeout = null;
}
pendingSubscriptionEvents = [];
};
});
@ -162,7 +147,7 @@ @@ -162,7 +147,7 @@
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 }));
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: config.feedLimit }));
subscriptionId = nostrClient.subscribe(
filters,
@ -187,28 +172,6 @@ @@ -187,28 +172,6 @@
);
}
function setupObserver() {
if (!sentinelElement || loading || observer) return;
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
// Debounce rapid scroll events - clear any pending loadMore call
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
}
// Wait 300ms before actually loading more to batch rapid scroll events
loadMoreTimeout = setTimeout(() => {
if (hasMore && !loadingMore && isMounted) {
loadMore();
}
loadMoreTimeout = null;
}, 300);
}
}, { threshold: 0.1, rootMargin: '100px' }); // Add rootMargin to trigger slightly earlier
observer.observe(sentinelElement);
}
async function loadCachedFeed() {
if (!isMounted || singleRelay) return;
@ -237,7 +200,6 @@ @@ -237,7 +200,6 @@
if (sortedEvents.length > 0) {
events = sortedEvents;
oldestTimestamp = Math.min(...events.map(e => e.created_at));
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
@ -284,18 +246,18 @@ @@ -284,18 +246,18 @@
}
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 }));
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: config.feedLimit }));
const fetchOptions = singleRelay ? {
relayFirst: true,
useCache: false,
cacheResults: false,
timeout: 15000
timeout: config.singleRelayTimeout
} : {
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: 3000
timeout: config.standardTimeout
};
const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions);
@ -327,7 +289,6 @@ @@ -327,7 +289,6 @@
events = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at);
if (events.length > 0) {
oldestTimestamp = Math.min(...events.map(e => e.created_at));
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
@ -336,8 +297,6 @@ @@ -336,8 +297,6 @@
});
}
}
hasMore = fetchedEvents.length >= 20;
} catch (error) {
console.error('Error loading feed:', error);
} finally {
@ -346,97 +305,6 @@ @@ -346,97 +305,6 @@
}
}
async function loadMore() {
// Double-check guards to prevent concurrent calls
if (!isMounted || loadingMore || !hasMore || loadingFeed) {
return;
}
// Prevent rapid successive calls - minimum 1 second between loads
const now = Date.now();
if (now - lastLoadMoreTime < 1000) {
return;
}
lastLoadMoreTime = now;
loadingMore = true;
try {
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(kind => ({
kinds: [kind],
limit: 20,
until: oldestTimestamp || undefined
}));
const fetchOptions = singleRelay ? {
relayFirst: true,
useCache: false,
cacheResults: false,
timeout: 3000
} : {
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: 3000
};
const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions);
if (!isMounted) return;
if (fetchedEvents.length === 0) {
hasMore = false;
return;
}
const filteredEvents = fetchedEvents.filter((e: NostrEvent) =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
// Use Map-based deduplication to ensure no duplicates
const existingIds = new Set(events.map(e => e.id));
const eventsMap = new Map<string, NostrEvent>();
// Add all existing events first
for (const event of events) {
eventsMap.set(event.id, event);
}
// Track which events are actually new
const newEvents: NostrEvent[] = [];
// Add new events (will only add if not already present)
for (const event of filteredEvents) {
if (!eventsMap.has(event.id)) {
eventsMap.set(event.id, event);
newEvents.push(event);
}
}
// Only update if we have new events
if (newEvents.length > 0) {
// Convert to array and sort
events = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at);
const allTimestamps = events.map(e => e.created_at);
oldestTimestamp = Math.min(...allTimestamps);
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
loadParentAndQuotedEvents(newEvents).catch(err => {
console.error('Error loading parent/quoted events for new events:', err);
});
}
}
hasMore = fetchedEvents.length >= 20;
} catch (error) {
console.error('Error loading more:', error);
} finally {
loadingMore = false;
}
}
async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) {
if (!isMounted || postsToLoad.length === 0 || loadingParents) return;
@ -475,12 +343,12 @@ @@ -475,12 +343,12 @@
relayFirst: true,
useCache: false,
cacheResults: false,
timeout: 3000
timeout: config.standardTimeout
} : {
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: 3000
timeout: config.standardTimeout
}
);
@ -554,16 +422,6 @@ @@ -554,16 +422,6 @@
{#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if}
<div id="feed-sentinel" class="feed-sentinel" bind:this={sentinelElement}>
{#if loadingMore}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p>
{:else if hasMore}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Scroll for more</p>
{:else}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No more posts</p>
{/if}
</div>
{/if}
</div>
@ -594,12 +452,6 @@ @@ -594,12 +452,6 @@
gap: 1rem;
}
.feed-sentinel {
padding: 2rem;
text-align: center;
min-height: 100px;
}
.relay-info {
margin-bottom: 1.5rem;
padding: 1rem;
@ -637,9 +489,21 @@ @@ -637,9 +489,21 @@
border-color: var(--fog-dark-border, #374151);
}
/* Limit all images to 600px wide */
:global(.feed-page img) {
/* Responsive images and media in feed */
:global(.feed-page img):not(.profile-picture) {
max-width: 600px;
width: 100%;
height: auto;
}
:global(.feed-page video) {
max-width: 600px;
width: 100%;
height: auto;
}
:global(.feed-page audio) {
max-width: 600px;
width: 100%;
}
</style>

63
src/lib/modules/feed/FeedPost.svelte

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MetadataCard from '../../components/content/MetadataCard.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
@ -49,9 +50,6 @@ @@ -49,9 +50,6 @@
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
// Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in
let bookmarked = $state(false);
@ -72,23 +70,8 @@ @@ -72,23 +70,8 @@
}
});
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
return;
}
if (!loadedParentEvent && isReply()) {
setTimeout(() => {
if (!providedParentEvent && !loadedParentEvent && isReply()) {
loadParentEvent();
}
}, 1000);
}
});
// Use provided parent event directly - ReplyContext handles loading if not provided
let parentEvent = $derived(providedParentEvent);
// Lazy load PollCard when post is a poll and component is visible
$effect(() => {
@ -157,28 +140,8 @@ @@ -157,28 +140,8 @@
return quotedTag?.[1] || null;
}
async function loadParentEvent() {
const replyEventId = getReplyEventId();
if (!replyEventId || loadingParent) return;
loadingParent = true;
try {
const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], ids: [replyEventId] }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
loadedParentEvent = events[0];
}
} catch (error) {
console.error('Error loading parent event:', error);
} finally {
loadingParent = false;
}
}
// Removed loadParentEvent - ReplyContext now handles parent event loading
// This prevents conflicts and duplicate fetches
function getTitle(): string {
@ -325,11 +288,14 @@ @@ -325,11 +288,14 @@
/>
{/if}
<!-- Display metadata (title, author, summary, description, image) -->
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} />
<!-- Display title prominently for kind 30040 (book index), 30041 (chapter sections), and kind 11 (discussion threads) -->
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD}
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
@ -337,17 +303,17 @@ @@ -337,17 +303,17 @@
<div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
{#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])}
{#if topics.length === 0}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span>
<span class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">General</span>
{:else}
{#each topics.slice(0, 3) as topic}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">{topic}</span>
<span class="topic-badge px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">{topic}</span>
{/each}
{/if}
{/if}
@ -398,7 +364,8 @@ @@ -398,7 +364,8 @@
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
Reply
</button>

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

@ -20,6 +20,11 @@ @@ -20,6 +20,11 @@
let subscriptionId: string | null = $state(null);
let isInitialized = $state(false);
let hierarchyChain = $state<NostrEvent[]>([]);
let currentLoadEventId = $state<string | null>(null); // Track which event is currently being loaded
let lastOpEventId = $state<string | null>(null); // Track the last event ID to prevent reactive loops
// Derive event ID separately to avoid reactive loops from object reference changes
let opEventId = $derived(opEvent?.id || null);
// Initialize nostr client once
onMount(async () => {
@ -30,64 +35,97 @@ @@ -30,64 +35,97 @@
});
// Build event hierarchy when drawer opens
async function loadHierarchy(abortSignal: AbortSignal) {
async function loadHierarchy(abortSignal: AbortSignal, eventId: string) {
if (!opEvent || !isInitialized) return;
// If we're already loading this event, don't start another load
if (currentLoadEventId === eventId && loading) {
return;
}
currentLoadEventId = eventId;
loading = true;
try {
const hierarchy = await buildEventHierarchy(opEvent);
// Check if operation was aborted before updating state
if (abortSignal.aborted) return;
// Check if operation was aborted or event changed
if (abortSignal.aborted || currentLoadEventId !== eventId) return;
const chain = getHierarchyChain(hierarchy);
// Check again before final state update
if (abortSignal.aborted) return;
if (abortSignal.aborted || currentLoadEventId !== eventId) return;
hierarchyChain = chain;
} catch (error) {
// Only update state if not aborted
if (abortSignal.aborted) return;
// Only update state if not aborted and still loading this event
if (abortSignal.aborted || currentLoadEventId !== eventId) return;
console.error('Error building event hierarchy:', error);
hierarchyChain = [opEvent]; // Fallback to just the event
} finally {
// Only update loading state if not aborted
if (!abortSignal.aborted) {
// Only update loading state if not aborted and still loading this event
if (!abortSignal.aborted && currentLoadEventId === eventId) {
loading = false;
}
}
}
// Handle drawer open/close - only load when opening
// Track event ID separately to prevent reactive loops from object reference changes
$effect(() => {
if (isOpen && opEvent && isInitialized) {
// Create abort controller to track effect lifecycle
const abortController = new AbortController();
// Drawer opened - load hierarchy with abort signal
loadHierarchy(abortController.signal);
// Cleanup subscription when drawer closes
return () => {
// Abort the async operation
abortController.abort();
const eventId = opEventId;
// Only proceed if event ID actually changed or drawer state changed
if (!isOpen || !eventId || !isInitialized || !opEvent) {
// Drawer closed or no event - cleanup
if (!isOpen || !eventId) {
currentLoadEventId = null;
lastOpEventId = null;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
};
} else if (!isOpen) {
// Drawer closed - cleanup
hierarchyChain = [];
loading = false;
}
return;
}
// Only load if event ID changed (not just object reference)
// Don't check hierarchyChain.length here - it causes reactive loops
if (lastOpEventId === eventId) {
return; // Already loaded or loading this event
}
lastOpEventId = eventId;
// Create abort controller to track effect lifecycle
const abortController = new AbortController();
// Cancel any previous load for a different event
if (currentLoadEventId && currentLoadEventId !== eventId) {
currentLoadEventId = null;
}
// Drawer opened - load hierarchy with abort signal
loadHierarchy(abortController.signal, eventId);
// Cleanup subscription when drawer closes or event changes
return () => {
// Abort the async operation
abortController.abort();
// Clear current load if this was the active one
if (currentLoadEventId === eventId) {
currentLoadEventId = null;
}
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
hierarchyChain = [];
loading = false;
}
};
});
// Handle keyboard events

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

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
@ -64,13 +65,20 @@ @@ -64,13 +65,20 @@
// Cleanup tracking
let isMounted = $state(true);
let activeFetchPromises = $state<Set<Promise<any>>>(new Set());
let currentLoadPubkey = $state<string | null>(null); // Track which pubkey is currently being loaded
let loadAbortController = $state<AbortController | null>(null); // Abort controller for current load
// Cleanup on unmount
$effect(() => {
return () => {
isMounted = false;
activeFetchPromises.clear();
if (loadAbortController) {
loadAbortController.abort();
loadAbortController = null;
}
loading = false;
currentLoadPubkey = null;
};
});
@ -134,9 +142,9 @@ @@ -134,9 +142,9 @@
const profileRelays = relayManager.getProfileReadRelays();
const fetchPromise = nostrClient.fetchEvents(
[{ ids: Array.from(pinnedIds), limit: 100 }],
[{ ids: Array.from(pinnedIds), limit: config.feedLimit }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
activeFetchPromises.add(fetchPromise);
const pinnedEvents = await fetchPromise;
@ -163,9 +171,9 @@ @@ -163,9 +171,9 @@
// Fetch current user's posts from cache first (fast)
const fetchPromise1 = nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: 50 }],
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: config.mediumBatchLimit }],
interactionRelays,
{ useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache
{ useCache: true, cacheResults: true, timeout: config.shortTimeout } // Short timeout for cache
);
activeFetchPromises.add(fetchPromise1);
const currentUserPosts = await fetchPromise1;
@ -184,11 +192,11 @@ @@ -184,11 +192,11 @@
// Fetch interactions with timeout to prevent blocking
const fetchPromise2 = nostrClient.fetchEvents(
[
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 }
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, config.smallBatchLimit), limit: config.smallBatchLimit }, // Limit IDs to avoid huge queries
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: config.smallBatchLimit }
],
interactionRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
activeFetchPromises.add(fetchPromise2);
const interactionEvents = await Promise.race([
@ -409,6 +417,7 @@ @@ -409,6 +417,7 @@
console.warn('No pubkey parameter provided to ProfilePage');
loading = false;
profile = null;
currentLoadPubkey = null;
return;
}
@ -418,9 +427,26 @@ @@ -418,9 +427,26 @@
console.warn('Invalid pubkey format:', param);
loading = false;
profile = null;
currentLoadPubkey = null;
return;
}
// Cancel any previous load for a different pubkey
if (loadAbortController && currentLoadPubkey !== pubkey) {
loadAbortController.abort();
loadAbortController = null;
}
// If we're already loading this pubkey, don't start another load
if (currentLoadPubkey === pubkey && loading) {
return;
}
// Create new abort controller for this load
const abortController = new AbortController();
loadAbortController = abortController;
currentLoadPubkey = pubkey;
loading = true;
try {
// Step 1: Load profile and status first (fast from cache) - display immediately
@ -432,7 +458,10 @@ @@ -432,7 +458,10 @@
activeFetchPromises.delete(profilePromise);
activeFetchPromises.delete(statusPromise);
if (!isMounted) return;
// Check if this load was aborted or if pubkey changed
if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
profile = profileData;
userStatus = status;
@ -455,14 +484,25 @@ @@ -455,14 +484,25 @@
const profileRelays = relayManager.getProfileReadRelays();
const responseRelays = relayManager.getFeedResponseReadRelays();
// Check again before loading posts
if (abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
// Load posts first (needed for response filtering)
// Fetch all feed kinds (not just kind 1)
const feedKinds = getFeedKinds();
const feedEvents = await nostrClient.fetchEvents(
[{ kinds: feedKinds, authors: [pubkey], limit: 50 }],
[{ kinds: feedKinds, authors: [pubkey], limit: config.feedLimit }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
// Check again after posts load
if (abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
posts = feedEvents.sort((a, b) => b.created_at - a.created_at);
// Reset visible counts when new data loads
@ -473,11 +513,16 @@ @@ -473,11 +513,16 @@
// Load responses in parallel with posts (but filter after posts are loaded)
const userPostIds = new Set(posts.map(p => p.id));
const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 50 }], // Fetch more to account for filtering
[{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: config.feedLimit }], // Fetch more to account for filtering
responseRelays,
{ useCache: true, cacheResults: true, timeout: 5000 }
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
// Check again after responses load
if (abortController.signal.aborted || currentLoadPubkey !== pubkey) {
return;
}
// Filter responses (exclude self-replies, only include replies to user's posts)
responses = responseEvents
.filter(e => {
@ -504,9 +549,18 @@ @@ -504,9 +549,18 @@
pins = [];
}
} catch (error) {
console.error('Error loading profile:', error);
loading = false;
profile = null;
// Only update state if this load wasn't aborted
if (!abortController.signal.aborted && currentLoadPubkey === pubkey) {
console.error('Error loading profile:', error);
loading = false;
profile = null;
}
} finally {
// Clear load tracking if this was the current load
if (currentLoadPubkey === pubkey) {
loadAbortController = null;
currentLoadPubkey = null;
}
}
}
</script>
@ -523,12 +577,12 @@ @@ -523,12 +577,12 @@
class="profile-picture w-24 h-24 rounded-full mb-4"
/>
{/if}
<h1 class="text-3xl font-bold mb-2">{profile.name || 'Anonymous'}</h1>
<h1 class="font-bold mb-2" style="font-size: 2em;">{profile.name || 'Anonymous'}</h1>
{#if profile.about}
<p class="text-fog-text dark:text-fog-dark-text mb-2">{profile.about}</p>
{/if}
{#if userStatus}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light italic mb-2">
<p class="text-fog-text-light dark:text-fog-dark-text-light italic mb-2" style="font-size: 0.875em;">
{userStatus}
</p>
{/if}
@ -551,7 +605,7 @@ @@ -551,7 +605,7 @@
{#each profile.nip05 as 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-fog-text-light dark:text-fog-dark-text-light mr-2" style="font-size: 0.875em;">
{nip05}
{#if isValid === true}
<span class="nip05-valid" title="NIP-05 verified"></span>

22
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
@ -142,14 +143,14 @@ @@ -142,14 +143,14 @@
// Use low priority for reactions - they're background data, comments should load first
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: 100 }],
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' }
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: 100 }],
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' }
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' }
);
// Combine and deduplicate by reaction ID
@ -189,9 +190,9 @@ @@ -189,9 +190,9 @@
// This is much more efficient than fetching all deletion events from all users
// Use low priority for deletion events - background data
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: 100 }],
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, timeout: 5000, priority: 'low' }
{ useCache: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Build a set of deleted reaction event IDs (more efficient - just a Set)
@ -301,13 +302,8 @@ @@ -301,13 +302,8 @@
}
}
// If not found in specific pubkeys, search broadly
if (!found) {
const url = await resolveEmojiShortcode(shortcode, [], true);
if (url) {
emojiUrls.set(content, url);
}
}
// Don't search broadly here - it triggers background fetching
// Broad search should only happen when emoji picker is opened
}
}

18
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 6; // Version 5: Added preferences and drafts stores. Version 6: Removed opengraph store
const DB_VERSION = 7; // Version 6: Removed opengraph store. Version 7: Added RSS cache store
export interface DatabaseSchema {
events: {
@ -33,6 +33,11 @@ export interface DatabaseSchema { @@ -33,6 +33,11 @@ export interface DatabaseSchema {
key: string; // draft id (e.g., 'write', 'comment_<eventId>')
value: unknown;
};
rss: {
key: string; // feed URL
value: unknown;
indexes: { cached_at: number };
};
}
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
@ -83,6 +88,12 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -83,6 +88,12 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (!db.objectStoreNames.contains('drafts')) {
db.createObjectStore('drafts', { keyPath: 'id' });
}
// RSS cache store
if (!db.objectStoreNames.contains('rss')) {
const rssStore = db.createObjectStore('rss', { keyPath: 'feedUrl' });
rssStore.createIndex('cached_at', 'cached_at', { unique: false });
}
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');
@ -102,7 +113,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -102,7 +113,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
!dbInstance.objectStoreNames.contains('keys') ||
!dbInstance.objectStoreNames.contains('search') ||
!dbInstance.objectStoreNames.contains('preferences') ||
!dbInstance.objectStoreNames.contains('drafts')) {
!dbInstance.objectStoreNames.contains('drafts') ||
!dbInstance.objectStoreNames.contains('rss')) {
// Database is corrupted - close and delete it, then recreate
console.warn('Database missing required stores, recreating...');
dbInstance.close();
@ -134,6 +146,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> { @@ -134,6 +146,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
db.createObjectStore('search', { keyPath: 'id' });
db.createObjectStore('preferences', { keyPath: 'key' });
db.createObjectStore('drafts', { keyPath: 'id' });
const rssStore = db.createObjectStore('rss', { keyPath: 'feedUrl' });
rssStore.createIndex('cached_at', 'cached_at', { unique: false });
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');

132
src/lib/services/cache/rss-cache.ts vendored

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
/**
* RSS feed caching with IndexedDB
*/
import { getDB } from './indexeddb-store.js';
export interface RSSItem {
title: string;
link: string;
description: string;
pubDate: Date;
feedUrl: string;
feedTitle?: string;
}
export interface CachedRSSFeed {
feedUrl: string;
items: RSSItem[];
cached_at: number;
}
const CACHE_DURATION = 15 * 60 * 1000; // 15 minutes
/**
* Store RSS feed items in cache
*/
export async function cacheRSSFeed(feedUrl: string, items: RSSItem[]): Promise<void> {
try {
const db = await getDB();
const cached: CachedRSSFeed = {
feedUrl,
items: items.map(item => ({
...item,
pubDate: item.pubDate // Store as Date, will be serialized
})),
cached_at: Date.now()
};
await db.put('rss', cached);
} catch (error) {
console.debug('Error caching RSS feed:', error);
// Don't throw - caching failures shouldn't break the app
}
}
/**
* Get RSS feed items from cache
*/
export async function getCachedRSSFeed(feedUrl: string): Promise<RSSItem[] | null> {
try {
const db = await getDB();
const cached = await db.get('rss', feedUrl) as CachedRSSFeed | undefined;
if (!cached) {
return null;
}
// Check if cache is still valid
const age = Date.now() - cached.cached_at;
if (age > CACHE_DURATION) {
// Cache expired, delete it
await db.delete('rss', feedUrl);
return null;
}
// Restore Date objects from serialized format
const items: RSSItem[] = cached.items.map((item: RSSItem) => ({
...item,
pubDate: new Date(item.pubDate)
}));
return items;
} catch (error) {
console.debug('Error getting cached RSS feed:', error);
return null;
}
}
/**
* Get all cached RSS feeds
*/
export async function getAllCachedRSSFeeds(): Promise<Map<string, RSSItem[]>> {
try {
const db = await getDB();
const allCached = await db.getAll('rss') as CachedRSSFeed[];
const result = new Map<string, RSSItem[]>();
const now = Date.now();
for (const cached of allCached) {
// Check if cache is still valid
const age = now - cached.cached_at;
if (age <= CACHE_DURATION) {
// Restore Date objects
const items: RSSItem[] = cached.items.map((item: RSSItem) => ({
...item,
pubDate: new Date(item.pubDate)
}));
result.set(cached.feedUrl, items);
} else {
// Cache expired, delete it
await db.delete('rss', cached.feedUrl);
}
}
return result;
} catch (error) {
console.debug('Error getting all cached RSS feeds:', error);
return new Map();
}
}
/**
* Clear expired RSS feed caches
*/
export async function clearExpiredRSSCaches(): Promise<void> {
try {
const db = await getDB();
const index = db.transaction('rss', 'readwrite').store.index('cached_at');
const now = Date.now();
const maxAge = now - CACHE_DURATION;
// Get all entries older than cache duration
const range = IDBKeyRange.upperBound(maxAge);
const expired = await index.getAll(range) as CachedRSSFeed[];
for (const cached of expired) {
await db.delete('rss', cached.feedUrl);
}
} catch (error) {
console.debug('Error clearing expired RSS caches:', error);
// Don't throw - cleanup failures shouldn't break the app
}
}

43
src/lib/services/nostr/config.ts

@ -33,6 +33,21 @@ const RELAY_TIMEOUT = 10000; @@ -33,6 +33,21 @@ const RELAY_TIMEOUT = 10000;
const ZAP_THRESHOLD = 1;
// Fetch limits
const FEED_LIMIT = 100; // Standard limit for feed events per kind
const SINGLE_EVENT_LIMIT = 1; // For fetching a single event
const SMALL_BATCH_LIMIT = 20; // For small batches (interactions, etc.)
const MEDIUM_BATCH_LIMIT = 50; // For medium batches
const LARGE_BATCH_LIMIT = 200; // For large batches (comments, etc.)
const VERY_LARGE_BATCH_LIMIT = 500; // For very large batches
// Fetch timeouts (in milliseconds)
const SHORT_TIMEOUT = 2000; // Short timeout for cache-heavy operations
const STANDARD_TIMEOUT = 3000; // Standard timeout for most operations
const MEDIUM_TIMEOUT = 5000; // Medium timeout for profile/standard operations
const LONG_TIMEOUT = 10000; // Long timeout for important operations
const SINGLE_RELAY_TIMEOUT = 15000; // Timeout for single relay operations
export interface NostrConfig {
defaultRelays: string[];
profileRelays: string[];
@ -41,6 +56,19 @@ export interface NostrConfig { @@ -41,6 +56,19 @@ export interface NostrConfig {
threadPublishRelays: string[];
relayTimeout: number;
gifRelays: string[];
// Fetch limits
feedLimit: number;
singleEventLimit: number;
smallBatchLimit: number;
mediumBatchLimit: number;
largeBatchLimit: number;
veryLargeBatchLimit: number;
// Fetch timeouts
shortTimeout: number;
standardTimeout: number;
mediumTimeout: number;
longTimeout: number;
singleRelayTimeout: number;
}
function parseRelays(envVar: string | undefined, fallback: string[]): string[] {
@ -67,7 +95,20 @@ export function getConfig(): NostrConfig { @@ -67,7 +95,20 @@ export function getConfig(): NostrConfig {
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30),
threadPublishRelays: THREAD_PUBLISH_RELAYS,
relayTimeout: RELAY_TIMEOUT,
gifRelays: GIF_RELAYS
gifRelays: GIF_RELAYS,
// Fetch limits
feedLimit: parseIntEnv(import.meta.env.VITE_FEED_LIMIT, FEED_LIMIT, 1),
singleEventLimit: SINGLE_EVENT_LIMIT,
smallBatchLimit: SMALL_BATCH_LIMIT,
mediumBatchLimit: MEDIUM_BATCH_LIMIT,
largeBatchLimit: LARGE_BATCH_LIMIT,
veryLargeBatchLimit: VERY_LARGE_BATCH_LIMIT,
// Fetch timeouts
shortTimeout: SHORT_TIMEOUT,
standardTimeout: STANDARD_TIMEOUT,
mediumTimeout: MEDIUM_TIMEOUT,
longTimeout: LONG_TIMEOUT,
singleRelayTimeout: SINGLE_RELAY_TIMEOUT
};
}

23
src/lib/services/nostr/nip30-emoji.ts

@ -252,14 +252,15 @@ export async function resolveEmojiShortcode( @@ -252,14 +252,15 @@ export async function resolveEmojiShortcode(
}
}
// If not found in specific pubkeys, ensure all packs are loaded
if (searchBroadly && !allEmojiPacksLoaded) {
await loadAllEmojiPacks();
}
// Check the global shortcode cache
if (shortcodeCache.has(cleanShortcode)) {
return shortcodeCache.get(cleanShortcode)!;
// If not found in specific pubkeys and searchBroadly is true, check the global cache
// But don't automatically load all packs - that should only happen when emoji picker opens
if (searchBroadly) {
// Only check cache if already loaded - don't trigger background fetch
if (allEmojiPacksLoaded && shortcodeCache.has(cleanShortcode)) {
return shortcodeCache.get(cleanShortcode)!;
}
// If packs aren't loaded yet, return null (don't fetch in background)
return null;
}
return null;
@ -291,10 +292,8 @@ export async function resolveCustomEmojis( @@ -291,10 +292,8 @@ export async function resolveCustomEmojis(
const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey));
await Promise.all(emojiSetPromises);
// Ensure all emoji packs are loaded for broader search
if (!allEmojiPacksLoaded) {
await loadAllEmojiPacks();
}
// Don't load all emoji packs here - that should only happen when emoji picker opens
// Only check cache if already loaded
// Build map of shortcode -> URL
const emojiMap = new Map<string, string>();

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

@ -25,6 +25,7 @@ interface FetchOptions { @@ -25,6 +25,7 @@ interface FetchOptions {
timeout?: number;
relayFirst?: boolean; // If true, query relays first with timeout, then fill from cache
priority?: 'high' | 'medium' | 'low'; // Priority level: high for critical UI (comments), low for background (reactions, profiles)
caller?: string; // Optional caller identifier for logging (e.g., "topics/[name]/+page.svelte")
}
class NostrClient {
@ -1257,6 +1258,86 @@ class NostrClient { @@ -1257,6 +1258,86 @@ class NostrClient {
this.processingQueue = false;
}
/**
* Extract caller information from stack trace for logging
* Returns a short identifier like "topics/[name]/+page.svelte" or "FeedPage.svelte"
*/
private getCallerInfo(): string {
try {
const stack = new Error().stack;
if (!stack) return 'unknown';
// Skip the first 3 lines: Error, getCallerInfo, fetchEvents
const lines = stack.split('\n').slice(3);
for (const line of lines) {
// Look for file paths in the stack trace
// Match various patterns:
// - at functionName (file:///path/to/file.svelte:123:45)
// - at functionName (/path/to/file.ts:123:45)
// - @file:///path/to/file.svelte:123:45
// - file:///path/to/file.svelte:123:45
let filePath: string | null = null;
// Try different patterns
const patterns = [
/(?:\(|@)(file:\/\/\/[^:)]+\.(?:svelte|ts|js))/i, // file:///path/to/file.svelte
/(?:\(|@)(\/[^:)]+\.(?:svelte|ts|js))/i, // /path/to/file.svelte
/(?:\(|@)([^:)]+src\/[^:)]+\.(?:svelte|ts|js))/i, // ...src/path/to/file.svelte
/([^:)]+src\/[^:)]+\.(?:svelte|ts|js))/i, // ...src/path/to/file.svelte (no parens)
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
filePath = match[1];
break;
}
}
if (filePath) {
// Normalize file:/// paths
if (filePath.startsWith('file:///')) {
filePath = filePath.replace(/^file:\/\/\//, '/');
}
// Extract just the relevant part of the path
const parts = filePath.split('/');
const srcIndex = parts.indexOf('src');
if (srcIndex !== -1) {
// Get everything after 'src'
const relevantParts = parts.slice(srcIndex + 1);
// Prefer showing route/page names
if (relevantParts[0] === 'routes') {
// Show up to the page file
const pageIndex = relevantParts.findIndex(p => p.startsWith('+page') || p.startsWith('+layout'));
if (pageIndex !== -1) {
return relevantParts.slice(0, pageIndex + 1).join('/');
}
return relevantParts.slice(0, 3).join('/'); // routes/topics/[name]/+page.svelte
}
// For lib files, show component/service name
if (relevantParts[0] === 'lib') {
const fileName = relevantParts[relevantParts.length - 1];
// Show parent directory if it's a component
if (relevantParts.includes('components') || relevantParts.includes('modules')) {
const componentIndex = relevantParts.findIndex(p => p === 'components' || p === 'modules');
if (componentIndex !== -1) {
return relevantParts.slice(componentIndex + 1).join('/');
}
}
return fileName;
}
return relevantParts.join('/');
}
}
}
} catch (e) {
// Ignore errors in stack trace parsing
}
return 'unknown';
}
/**
* Create a human-readable description of a filter for logging
*/
@ -1288,7 +1369,8 @@ class NostrClient { @@ -1288,7 +1369,8 @@ class NostrClient {
relays: string[],
options: FetchOptions = {}
): Promise<NostrEvent[]> {
const { useCache = true, cacheResults = true, onUpdate, timeout = 10000, relayFirst = false, priority = 'medium' } = options;
const { useCache = true, cacheResults = true, onUpdate, timeout = 10000, relayFirst = false, priority = 'medium', caller: providedCaller } = options;
const caller = providedCaller || this.getCallerInfo();
// Create a key for this fetch to prevent duplicates
const fetchKey = JSON.stringify({
@ -1339,7 +1421,7 @@ class NostrClient { @@ -1339,7 +1421,7 @@ class NostrClient {
if (updatedEntry && !updatedEntry.pending) {
if ((Date.now() - updatedEntry.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const finalAge = Math.round((Date.now() - updatedEntry.cachedAt) / 1000);
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for pending)`);
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for pending) [caller: ${caller}]`);
return [];
}
break; // No longer pending, but result expired or had data
@ -1352,7 +1434,7 @@ class NostrClient { @@ -1352,7 +1434,7 @@ class NostrClient {
} else if (!emptyCacheEntry.pending && age < this.EMPTY_RESULT_CACHE_TTL) {
const ageSeconds = Math.round(age / 1000);
// Use debug level to reduce console noise - this is expected behavior
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${ageSeconds}s ago`);
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${ageSeconds}s ago [caller: ${caller}]`);
return Promise.resolve([]);
}
}
@ -1365,7 +1447,7 @@ class NostrClient { @@ -1365,7 +1447,7 @@ class NostrClient {
const recheck = this.emptyResultCache.get(emptyCacheKey);
if (recheck && !recheck.pending && (Date.now() - recheck.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const finalAge = Math.round((Date.now() - recheck.cachedAt) / 1000);
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for concurrent)`);
console.debug(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${finalAge}s ago (waited for concurrent) [caller: ${caller}]`);
return [];
}
}
@ -1454,7 +1536,7 @@ class NostrClient { @@ -1454,7 +1536,7 @@ class NostrClient {
const emptyCacheEntry2 = this.emptyResultCache.get(emptyCacheKey);
if (emptyCacheEntry2 && !emptyCacheEntry2.pending && (Date.now() - emptyCacheEntry2.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const age = Math.round((Date.now() - emptyCacheEntry2.cachedAt) / 1000);
console.log(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${age}s ago (checked during fetch)`);
console.log(`[nostr-client] Skipping fetch [${filterDesc}] from [${relayDesc}] - empty result cached ${age}s ago (checked during fetch) [caller: ${caller}]`);
// Clear pending flag if it was set
this.emptyResultCache.delete(emptyCacheKey);
return [];

2
src/routes/discussions/+page.svelte

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
<div class="discussions-content">
<div class="discussions-header mb-4">
<div>
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Discussions</h1>
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Discussions</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">

2
src/routes/feed/+page.svelte

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
<main class="container mx-auto px-4 py-8">
<div class="feed-content">
<div class="feed-header mb-6">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text font-mono">/Feed</h1>
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Feed</h1>
<div class="feed-controls">
<div class="search-section">
<SearchBox />

2
src/routes/find/+page.svelte

@ -116,7 +116,7 @@ @@ -116,7 +116,7 @@
<main class="container mx-auto px-4 py-8">
<div class="find-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Find</h1>
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Find</h1>
<div class="find-sections">
<!-- Find Event Section -->

42
src/routes/login/+page.svelte

@ -285,7 +285,7 @@ @@ -285,7 +285,7 @@
</script>
<main class="container mx-auto px-4 py-8 max-w-md">
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Login</h1>
<h1 class="font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">Login</h1>
{#if error}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 px-4 py-3 rounded mb-4">
@ -330,7 +330,7 @@ @@ -330,7 +330,7 @@
<div class="space-y-4">
{#if activeTab === 'nip07'}
<div class="space-y-4">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
<p class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">
Login using a Nostr browser extension (Alby, nos2x, etc.)
</p>
<button
@ -346,7 +346,7 @@ @@ -346,7 +346,7 @@
{#if storedNsecKeys.length > 0 && !showNewNsecForm}
<!-- Show stored keys list -->
<div class="space-y-3">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
<p class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">
Select a stored key or add a new one:
</p>
@ -357,8 +357,8 @@ @@ -357,8 +357,8 @@
onclick={() => { selectedNsecKey = key.pubkey; nsecPassword = ''; }}
class="w-full text-left px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors {selectedNsecKey === key.pubkey ? 'ring-2 ring-fog-accent dark:ring-fog-dark-accent' : ''}"
>
<div class="font-mono text-sm break-all">{formatNpub(key.pubkey)}</div>
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
<div class="font-mono break-all" style="font-size: 0.875em;">{formatNpub(key.pubkey)}</div>
<div class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">
Created {new Date(key.created_at).toLocaleDateString()}
</div>
</button>
@ -367,7 +367,7 @@ @@ -367,7 +367,7 @@
{#if selectedNsecKey}
<div class="space-y-2">
<label for="stored-nsec-password" class="block text-sm font-medium text-fog-text dark:text-fog-dark-text">
<label for="stored-nsec-password" class="block font-medium text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Password
</label>
<input
@ -401,16 +401,17 @@ @@ -401,16 +401,17 @@
{#if storedNsecKeys.length > 0}
<button
onclick={() => { showNewNsecForm = false; selectedNsecKey = null; }}
class="text-sm text-fog-accent dark:text-fog-dark-accent hover:underline"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
← Back to stored keys
</button>
{/if}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
<p class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">
Enter your nsec key. It will be encrypted and stored securely in your browser.
</p>
<div>
<label for="nsec-input" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
<label for="nsec-input" class="block font-medium mb-1 text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Nsec Key
</label>
<input
@ -423,7 +424,7 @@ @@ -423,7 +424,7 @@
/>
</div>
<div>
<label for="nsec-password" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
<label for="nsec-password" class="block font-medium mb-1 text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Encryption Password
</label>
<input
@ -436,7 +437,7 @@ @@ -436,7 +437,7 @@
/>
</div>
<div>
<label for="nsec-password-confirm" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
<label for="nsec-password-confirm" class="block font-medium mb-1 text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Confirm Password
</label>
<input
@ -447,7 +448,7 @@ @@ -447,7 +448,7 @@
class="w-full px-3 py-2 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 rounded"
disabled={loading}
/>
<p class="text-xs text-fog-text-light dark:text-fog-dark-text-light mt-1">
<p style="font-size: 0.75em;" class="text-fog-text-light dark:text-fog-dark-text-light mt-1">
This password encrypts your key. You'll need it each time you sign events.
</p>
</div>
@ -465,7 +466,7 @@ @@ -465,7 +466,7 @@
<div class="space-y-4">
{#if storedAnonymousKeys.length > 0 && !showNewAnonymousForm}
<div class="space-y-3">
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
<p class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">
Select a stored anonymous key or generate a new one:
</p>
@ -476,8 +477,8 @@ @@ -476,8 +477,8 @@
onclick={() => { selectedAnonymousKey = key.pubkey; anonymousPassword = ''; }}
class="w-full text-left px-3 py-2 border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors {selectedAnonymousKey === key.pubkey ? 'ring-2 ring-fog-accent dark:ring-fog-dark-accent' : ''}"
>
<div class="font-mono text-sm break-all">{formatNpub(key.pubkey)}</div>
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
<div class="font-mono break-all" style="font-size: 0.875em;">{formatNpub(key.pubkey)}</div>
<div class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.75em;">
Created {new Date(key.created_at).toLocaleDateString()}
</div>
</button>
@ -486,7 +487,7 @@ @@ -486,7 +487,7 @@
{#if selectedAnonymousKey}
<div class="space-y-2">
<label for="stored-anonymous-password" class="block text-sm font-medium text-fog-text dark:text-fog-dark-text">
<label for="stored-anonymous-password" class="block font-medium text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Password
</label>
<input
@ -520,16 +521,17 @@ @@ -520,16 +521,17 @@
{#if storedAnonymousKeys.length > 0}
<button
onclick={() => { showNewAnonymousForm = false; selectedAnonymousKey = null; }}
class="text-sm text-fog-accent dark:text-fog-dark-accent hover:underline"
class="text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
← Back to stored keys
</button>
{/if}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
<p class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">
Generate a new anonymous key. It will be encrypted and stored securely in your browser.
</p>
<div>
<label for="anonymous-password" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
<label for="anonymous-password" class="block font-medium mb-1 text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Encryption Password
</label>
<input
@ -542,7 +544,7 @@ @@ -542,7 +544,7 @@
/>
</div>
<div>
<label for="anonymous-password-confirm" class="block text-sm font-medium mb-1 text-fog-text dark:text-fog-dark-text">
<label for="anonymous-password-confirm" class="block font-medium mb-1 text-fog-text dark:text-fog-dark-text" style="font-size: 0.875em;">
Confirm Password
</label>
<input

23
src/routes/repos/[naddr]/+page.svelte

@ -26,6 +26,7 @@ @@ -26,6 +26,7 @@
let gitRepo = $state<GitRepoInfo | null>(null);
let loading = $state(true);
let loadingGitRepo = $state(false);
let gitRepoFetchAttempted = $state(false); // Track if we've already attempted to fetch (even if failed)
let activeTab = $state<'metadata' | 'about' | 'repository' | 'issues' | 'documentation'>('metadata');
let issues = $state<NostrEvent[]>([]);
let issueComments = $state<Map<string, NostrEvent[]>>(new Map());
@ -45,8 +46,15 @@ @@ -45,8 +46,15 @@
// Don't call loadRepo here - let $effect handle it
});
// Track the last naddr we loaded to prevent duplicate loads
let lastLoadedNaddr = $state<string | null>(null);
$effect(() => {
if (naddr && !loadingRepo) {
if (naddr && !loadingRepo && naddr !== lastLoadedNaddr) {
// Reset git repo state when naddr changes
gitRepo = null;
gitRepoFetchAttempted = false;
lastLoadedNaddr = naddr;
loadCachedRepo();
loadRepo();
}
@ -54,15 +62,16 @@ @@ -54,15 +62,16 @@
// Load git repo when repository tab is clicked
$effect(() => {
if (activeTab === 'repository' && repoEvent && !gitRepo && !loadingGitRepo) {
if (activeTab === 'repository' && repoEvent && !gitRepo && !loadingGitRepo && !gitRepoFetchAttempted) {
loadGitRepo();
}
});
async function loadGitRepo() {
if (!repoEvent || loadingGitRepo || gitRepo) return;
if (!repoEvent || loadingGitRepo || gitRepo || gitRepoFetchAttempted) return;
loadingGitRepo = true;
gitRepoFetchAttempted = true; // Mark as attempted immediately to prevent re-triggering
try {
const gitUrls = extractGitUrls(repoEvent);
console.log('Git URLs found:', gitUrls);
@ -205,8 +214,12 @@ @@ -205,8 +214,12 @@
console.log('Fetched events:', events.length);
if (events.length > 0) {
repoEvent = events[0];
console.log('Repo event loaded:', repoEvent.id);
const newRepoEvent = events[0];
// Only update if it's actually different (prevents unnecessary re-renders)
if (!repoEvent || repoEvent.id !== newRepoEvent.id) {
repoEvent = newRepoEvent;
console.log('Repo event loaded:', repoEvent.id);
}
// Don't fetch git repo here - wait until user clicks on repository tab
// This prevents rate limiting from GitHub/GitLab/Gitea

138
src/routes/rss/+page.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { cacheRSSFeed, getCachedRSSFeed, type RSSItem } from '../../lib/services/cache/rss-cache.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../lib/types/nostr.js';
@ -11,14 +12,6 @@ @@ -11,14 +12,6 @@
const RSS_FEED_KIND = 10895;
interface RSSItem {
title: string;
link: string;
description: string;
pubDate: Date;
feedUrl: string;
feedTitle?: string;
}
let currentPubkey = $state<string | null>(null);
let rssEvent = $state<NostrEvent | null>(null);
@ -26,6 +19,7 @@ @@ -26,6 +19,7 @@
let loadingFeeds = $state(false);
let rssItems = $state<RSSItem[]>([]);
let feedErrors = $state<Map<string, string>>(new Map());
let feedsLoaded = $state(false); // Track if feeds have been loaded to prevent re-triggering
let subscribedFeeds = $derived.by(() => {
if (!rssEvent) return [];
@ -34,6 +28,9 @@ @@ -34,6 +28,9 @@
.map(tag => tag[1]);
});
// Track the last feeds we loaded to detect changes
let lastLoadedFeeds = $state<string[]>([]);
onMount(async () => {
await nostrClient.initialize();
const session = sessionManager.getSession();
@ -47,8 +44,21 @@ @@ -47,8 +44,21 @@
});
$effect(() => {
if (subscribedFeeds.length > 0 && rssEvent) {
// Only load if:
// 1. We have feeds to load
// 2. We have an rssEvent
// 3. We're not currently loading
// 4. The feeds have changed (different URLs) or haven't been loaded yet
const feedsChanged = JSON.stringify(subscribedFeeds) !== JSON.stringify(lastLoadedFeeds);
if (subscribedFeeds.length > 0 && rssEvent && !loadingFeeds && (feedsChanged || !feedsLoaded)) {
lastLoadedFeeds = [...subscribedFeeds];
loadRssFeeds();
} else if (subscribedFeeds.length === 0 && feedsLoaded) {
// Reset if feeds were removed
feedsLoaded = false;
rssItems = [];
feedErrors.clear();
lastLoadedFeeds = [];
}
});
@ -65,7 +75,14 @@ @@ -65,7 +75,14 @@
);
if (events.length > 0) {
rssEvent = events[0];
const newRssEvent = events[0];
// Only update if it's actually different (prevents unnecessary re-renders)
if (!rssEvent || rssEvent.id !== newRssEvent.id) {
rssEvent = newRssEvent;
// Reset feeds loaded state when event changes
feedsLoaded = false;
lastLoadedFeeds = [];
}
}
} catch (error) {
console.error('Error checking RSS event:', error);
@ -75,30 +92,86 @@ @@ -75,30 +92,86 @@
}
async function loadRssFeeds() {
if (subscribedFeeds.length === 0 || loadingFeeds) return;
if (subscribedFeeds.length === 0 || loadingFeeds || feedsLoaded) return;
loadingFeeds = true;
feedErrors.clear();
const allItems: RSSItem[] = [];
try {
// Fetch all feeds in parallel
const feedPromises = subscribedFeeds.map(async (feedUrl) => {
try {
const items = await fetchRssFeed(feedUrl);
allItems.push(...items);
} catch (error) {
console.error(`Error fetching RSS feed ${feedUrl}:`, error);
feedErrors.set(feedUrl, error instanceof Error ? error.message : 'Failed to fetch feed');
// First, try to load from cache
const cachePromises = subscribedFeeds.map(async (feedUrl) => {
const cached = await getCachedRSSFeed(feedUrl);
if (cached && cached.length > 0) {
return { feedUrl, items: cached, fromCache: true };
}
return { feedUrl, items: null, fromCache: false };
});
await Promise.all(feedPromises);
const cacheResults = await Promise.all(cachePromises);
// Sort by date (newest first)
allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
// Add cached items immediately
for (const result of cacheResults) {
if (result.items) {
allItems.push(...result.items);
}
}
// If we have cached items, show them immediately
if (allItems.length > 0) {
allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
rssItems = allItems;
feedsLoaded = true;
}
rssItems = allItems;
// Then fetch fresh data in background for feeds that need updating
const feedsToFetch = cacheResults
.filter(result => !result.fromCache || !result.items)
.map(result => result.feedUrl);
if (feedsToFetch.length > 0) {
const fetchPromises = feedsToFetch.map(async (feedUrl) => {
try {
const items = await fetchRssFeed(feedUrl);
// Cache the fetched items
await cacheRSSFeed(feedUrl, items);
return { feedUrl, items };
} catch (error) {
// Only log non-CORS errors to avoid console spam
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch feed';
if (!errorMessage.includes('CORS') && !errorMessage.includes('Cross-Origin')) {
console.error(`Error fetching RSS feed ${feedUrl}:`, error);
}
feedErrors.set(feedUrl, errorMessage);
return { feedUrl, items: [] };
}
});
const fetchResults = await Promise.all(fetchPromises);
// Merge fresh items with cached items
const freshItems: RSSItem[] = [];
for (const result of fetchResults) {
freshItems.push(...result.items);
}
// Combine with existing items and deduplicate by link
const itemMap = new Map<string, RSSItem>();
for (const item of allItems) {
itemMap.set(item.link, item);
}
for (const item of freshItems) {
itemMap.set(item.link, item);
}
// Sort by date (newest first)
const combinedItems = Array.from(itemMap.values());
combinedItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
rssItems = combinedItems;
}
feedsLoaded = true;
} catch (error) {
console.error('Error loading RSS feeds:', error);
} finally {
@ -107,21 +180,10 @@ @@ -107,21 +180,10 @@
}
async function fetchRssFeed(feedUrl: string): Promise<RSSItem[]> {
// Use a CORS proxy if needed, or fetch directly
// For now, try direct fetch first
let response: Response;
try {
response = await fetch(feedUrl, {
mode: 'cors',
headers: {
'Accept': 'application/rss+xml, application/xml, text/xml, */*'
}
});
} catch (error) {
// If CORS fails, try using a proxy
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`;
response = await fetch(proxyUrl);
}
// Always use a CORS proxy to avoid CORS errors
// Direct fetch will fail for most RSS feeds due to CORS restrictions
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);

358
src/routes/settings/+page.svelte

@ -0,0 +1,358 @@ @@ -0,0 +1,358 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte';
import { hasExpiringEventsEnabled } from '../../lib/services/event-expiration.js';
import { shouldIncludeClientTag, setIncludeClientTag } from '../../lib/services/client-tag-preference.js';
type TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose';
type ContentWidth = 'narrow' | 'medium' | 'wide';
let textSize = $state<TextSize>('medium');
let lineSpacing = $state<LineSpacing>('normal');
let contentWidth = $state<ContentWidth>('medium');
let isDark = $state(false);
let expiringEvents = $state(false);
let includeClientTag = $state(true);
onMount(() => {
// Load preferences from localStorage
const storedTextSize = localStorage.getItem('textSize') as TextSize | null;
const storedLineSpacing = localStorage.getItem('lineSpacing') as LineSpacing | null;
const storedContentWidth = localStorage.getItem('contentWidth') as ContentWidth | null;
const storedTheme = localStorage.getItem('theme');
textSize = storedTextSize || 'medium';
lineSpacing = storedLineSpacing || 'normal';
contentWidth = storedContentWidth || 'medium';
// Check theme preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark = storedTheme === 'dark' || (!storedTheme && prefersDark);
// Load expiring events preference
expiringEvents = hasExpiringEventsEnabled();
// Load client tag preference
includeClientTag = shouldIncludeClientTag();
// Apply preferences
applyPreferences();
});
function applyPreferences() {
// Apply text size
document.documentElement.setAttribute('data-text-size', textSize);
localStorage.setItem('textSize', textSize);
// Apply line spacing
document.documentElement.setAttribute('data-line-spacing', lineSpacing);
localStorage.setItem('lineSpacing', lineSpacing);
// Apply content width
document.documentElement.setAttribute('data-content-width', contentWidth);
localStorage.setItem('contentWidth', contentWidth);
// Apply theme
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
function handleTextSizeChange(size: TextSize) {
textSize = size;
applyPreferences();
}
function handleLineSpacingChange(spacing: LineSpacing) {
lineSpacing = spacing;
applyPreferences();
}
function handleContentWidthChange(width: ContentWidth) {
contentWidth = width;
applyPreferences();
}
function handleThemeToggle() {
isDark = !isDark;
applyPreferences();
}
function handleExpiringEventsToggle() {
expiringEvents = !expiringEvents;
localStorage.setItem('aitherboard_expiringEvents', expiringEvents ? 'true' : 'false');
}
function handleClientTagToggle() {
includeClientTag = !includeClientTag;
setIncludeClientTag(includeClientTag);
}
</script>
<Header />
<main class="container mx-auto px-4 py-8 max-w-2xl">
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">Settings</h1>
<div class="space-y-6">
<!-- Theme Toggle -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Theme</span>
</div>
<div class="preference-controls">
<button
onclick={handleThemeToggle}
class="toggle-button"
class:active={isDark}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
<span class="emoji">{isDark ? '☀' : '🌙'}</span>
<span>{isDark ? 'Light' : 'Dark'}</span>
</button>
</div>
</div>
<!-- Text Size -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Text Size</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleTextSizeChange('small')}
class="option-button"
class:active={textSize === 'small'}
aria-label="Small text size"
>
Small
</button>
<button
onclick={() => handleTextSizeChange('medium')}
class="option-button"
class:active={textSize === 'medium'}
aria-label="Medium text size"
>
Medium
</button>
<button
onclick={() => handleTextSizeChange('large')}
class="option-button"
class:active={textSize === 'large'}
aria-label="Large text size"
>
Large
</button>
</div>
</div>
<!-- Line Spacing -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Line Spacing</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleLineSpacingChange('tight')}
class="option-button"
class:active={lineSpacing === 'tight'}
aria-label="Tight line spacing"
>
Tight
</button>
<button
onclick={() => handleLineSpacingChange('normal')}
class="option-button"
class:active={lineSpacing === 'normal'}
aria-label="Normal line spacing"
>
Normal
</button>
<button
onclick={() => handleLineSpacingChange('loose')}
class="option-button"
class:active={lineSpacing === 'loose'}
aria-label="Loose line spacing"
>
Loose
</button>
</div>
</div>
<!-- Content Width -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Content Width</span>
</div>
<div class="preference-controls">
<button
onclick={() => handleContentWidthChange('narrow')}
class="option-button"
class:active={contentWidth === 'narrow'}
aria-label="Narrow content width"
>
Narrow
</button>
<button
onclick={() => handleContentWidthChange('medium')}
class="option-button"
class:active={contentWidth === 'medium'}
aria-label="Medium content width"
>
Medium
</button>
<button
onclick={() => handleContentWidthChange('wide')}
class="option-button"
class:active={contentWidth === 'wide'}
aria-label="Wide content width"
>
Wide
</button>
</div>
</div>
<!-- Expiring Events -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Expiring Events</span>
</div>
<div class="preference-controls">
<button
onclick={handleExpiringEventsToggle}
class="toggle-button"
class:active={expiringEvents}
aria-label={expiringEvents ? 'Disable expiring events' : 'Enable expiring events'}
>
<span>{expiringEvents ? '✓' : ''}</span>
<span>{expiringEvents ? 'Enabled' : 'Disabled'}</span>
</button>
</div>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2" style="font-size: 0.875em;">
When enabled, events will automatically include an expiration tag (6 months from creation).
</p>
</div>
<!-- Include Client Tag -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">Include Client Tag</span>
</div>
<div class="preference-controls">
<button
onclick={handleClientTagToggle}
class="toggle-button"
class:active={includeClientTag}
aria-label={includeClientTag ? 'Disable client tag' : 'Enable client tag'}
>
<span>{includeClientTag ? '✓' : ''}</span>
<span>{includeClientTag ? 'Enabled' : 'Disabled'}</span>
</button>
</div>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2" style="font-size: 0.875em;">
When enabled, published events will include a client tag (NIP-89) identifying aitherboard as the source.
</p>
</div>
</div>
</main>
<style>
.preference-section {
margin-bottom: 0;
}
.preference-label {
display: block;
margin-bottom: 0;
}
.preference-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.toggle-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
}
.toggle-button:hover {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
.toggle-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
:global(.dark) .toggle-button {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .toggle-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
:global(.dark) .toggle-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.option-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
}
.option-button:hover {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
.option-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
:global(.dark) .option-button {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .option-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
:global(.dark) .option-button.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
</style>

116
src/routes/topics/[name]/+page.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { config } from '../../../lib/services/nostr/config.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js';
@ -12,6 +13,32 @@ @@ -12,6 +13,32 @@
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let topicName = $derived($page.params.name);
// Pagination: 2 pages of 100 events each (100 per filter from relays, cache can supplement)
const EVENTS_PER_PAGE = 100;
const MAX_PAGES = 2;
let currentPage = $state(1);
// Computed: paginated events for current page
let paginatedEvents = $derived.by(() => {
const start = (currentPage - 1) * EVENTS_PER_PAGE;
const end = start + EVENTS_PER_PAGE;
return events.slice(start, end);
});
// Computed: total pages available
let totalPages = $derived.by(() => {
const maxEvents = MAX_PAGES * EVENTS_PER_PAGE;
const totalEvents = Math.min(events.length, maxEvents);
return Math.ceil(totalEvents / EVENTS_PER_PAGE);
});
// Reset to page 1 when topic changes
$effect(() => {
if ($page.params.name) {
currentPage = 1;
}
});
onMount(async () => {
await nostrClient.initialize();
@ -77,9 +104,9 @@ @@ -77,9 +104,9 @@
// Fetch events with matching t-tag (most efficient - uses relay filtering)
const tTagEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: 500 }],
[{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: config.feedLimit }],
relays,
{ useCache: true, cacheResults: true }
{ useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (t-tag)` }
);
// Also search for hashtags in content (less efficient but catches events without t-tags)
@ -90,9 +117,9 @@ @@ -90,9 +117,9 @@
// For content-based hashtag search, we need to fetch more events
// But we'll limit this to avoid fetching too much
const contentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 500 }],
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: config.feedLimit }],
relays,
{ useCache: true, cacheResults: true }
{ useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (content)` }
);
// Filter events that contain the hashtag in content but don't already have a t-tag
@ -136,11 +163,16 @@ @@ -136,11 +163,16 @@
<main class="container mx-auto px-4 py-8">
<div class="topic-content">
<div class="topic-header mb-6">
<h1 class="text-2xl font-bold text-fog-text dark:text-fog-dark-text">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
Topic: #{topicName}
</h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
{events.length} {events.length === 1 ? 'event' : 'events'} found
{#if totalPages > 1}
<span class="text-fog-text-light dark:text-fog-dark-text-light">
(Page {currentPage} of {totalPages})
</span>
{/if}
</p>
</div>
@ -154,12 +186,46 @@ @@ -154,12 +186,46 @@
</div>
{:else}
<div class="events-list">
{#each events as event (event.id)}
{#each paginatedEvents as event (event.id)}
<div class="event-item">
<FeedPost post={event} />
</div>
{/each}
</div>
{#if totalPages > 1}
<div class="pagination-controls mt-6 flex justify-center items-center gap-4">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) {
currentPage--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
>
← Previous
</button>
<span class="pagination-info text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
<button
class="pagination-button"
disabled={currentPage >= totalPages}
onclick={() => {
if (currentPage < totalPages) {
currentPage++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}}
>
Next →
</button>
</div>
{/if}
{/if}
</div>
</main>
@ -203,4 +269,42 @@ @@ -203,4 +269,42 @@
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.pagination-controls {
padding: 1rem;
}
.pagination-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
background: var(--fog-border, #e5e7eb);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .pagination-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .pagination-button:hover:not(:disabled) {
background: var(--fog-dark-border, #374151);
}
.pagination-info {
font-size: 0.875rem;
}
</style>

2
src/routes/write/+page.svelte

@ -66,7 +66,7 @@ @@ -66,7 +66,7 @@
<main class="container mx-auto px-4 py-8">
<div class="write-page">
<h1 class="text-2xl font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono">/Write</h1>
<h1 class="font-bold mb-6 text-fog-text dark:text-fog-dark-text font-mono" style="font-size: 1.5em;">/Write</h1>
{#if !isLoggedIn}
<div class="login-prompt">

Loading…
Cancel
Save