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 @@
version: '3.8'
services: services:
aitherboard: aitherboard:
build: build:

4
public/healthz.json

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

43
src/app.css

@ -471,6 +471,49 @@ img.emoji-inline {
filter: none !important; 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) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,

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

@ -7,6 +7,7 @@
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
import ProfileBadge from '../layout/ProfileBadge.svelte'; import ProfileBadge from '../layout/ProfileBadge.svelte';
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import { fetchProfile } from '../../services/user-data.js';
interface Props { interface Props {
eventId: string; // Can be hex, note, nevent, naddr eventId: string; // Can be hex, note, nevent, naddr
@ -166,6 +167,16 @@
return subjectTag?.[1] || null; 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 { function getImageUrl(): string | null {
if (!event) return null; if (!event) return null;
const imageTag = event.tags.find(t => t[0] === 'image'); const imageTag = event.tags.find(t => t[0] === 'image');
@ -337,6 +348,19 @@
line-height: 1.5; 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 { .embedded-event-title {
font-weight: 600; font-weight: 600;
font-size: 1.125rem; font-size: 1.125rem;

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

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

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

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

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

@ -5,17 +5,53 @@
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
showMenu?: boolean; 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) // 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 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 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 author = $derived(event.tags.find(t => t[0] === 'author' && t[1])?.[1]);
const title = $derived( 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 // Fallback to d-tag in Title Case
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
@ -25,7 +61,7 @@
).join(' '); ).join(' ');
} }
return null; return null;
})() })())
); );
const hasMetadata = $derived(image || description || summary || author || title); const hasMetadata = $derived(image || description || summary || author || title);
@ -108,6 +144,7 @@
} }
.metadata-image img { .metadata-image img {
max-width: 600px;
width: 100%; width: 100%;
height: auto; height: auto;
display: block; display: block;

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

@ -17,6 +17,7 @@
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); 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 // Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent); let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -24,20 +25,26 @@
// Sync provided parent event changes and load if needed // Sync provided parent event changes and load if needed
$effect(() => { $effect(() => {
if (providedParentEvent) { 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; 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 no provided parent event and we have an ID, try to load it
if (!loadedParentEvent && parentEventId && !loadingParent) { // Only load if we haven't already tried to load this ID
loadParentEvent(); if (eventIdToLoad && !loadedParentEvent && !loadingParent && lastLoadAttemptId !== eventIdToLoad) {
loadParentEvent(eventIdToLoad);
} }
}); });
async function loadParentEvent() { async function loadParentEvent(eventId: string) {
const eventId = parentEventId || parentEvent?.id; if (!eventId || loadingParent || lastLoadAttemptId === eventId) return;
if (!eventId || loadingParent) return;
lastLoadAttemptId = eventId;
loadingParent = true; loadingParent = true;
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
@ -47,7 +54,8 @@
{ useCache: true, cacheResults: true } { 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]; loadedParentEvent = events[0];
if (onParentLoaded && typeof onParentLoaded === 'function') { if (onParentLoaded && typeof onParentLoaded === 'function') {
onParentLoaded(loadedParentEvent); onParentLoaded(loadedParentEvent);
@ -56,7 +64,10 @@
} catch (error) { } catch (error) {
console.error('Error loading parent event:', error); console.error('Error loading parent event:', error);
} finally { } 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 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3"> <nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto"> <div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm font-mono"> <div class="flex flex-wrap gap-2 sm:gap-4 items-center font-mono" style="font-size: 0.875em;">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">aitherboard</a> <a href="/" class="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="/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> <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} {#if isLoggedIn}
@ -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="/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> <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>
<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} {#if isLoggedIn && currentPubkey}
<UserPreferences /> <UserPreferences />
<ProfileBadge pubkey={currentPubkey} /> <ProfileBadge pubkey={currentPubkey} />
@ -117,9 +117,5 @@
nav a, nav button { nav a, nav button {
font-size: 0.875rem; /* Slightly smaller text on mobile */ font-size: 0.875rem; /* Slightly smaller text on mobile */
} }
nav .text-xl {
font-size: 1.125rem; /* Smaller logo on mobile */
}
} }
</style> </style>

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

@ -15,10 +15,20 @@
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null); let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
let activityMessage = $state<string | null>(null); let activityMessage = $state<string | null>(null);
let imageError = $state(false); let imageError = $state(false);
let loadingProfile = $state(false);
let loadingStatus = $state(false);
let loadingActivity = $state(false);
let lastLoadedPubkey = $state<string | null>(null);
$effect(() => { $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 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 // Load immediately - no debounce
loadProfile(); loadProfile();
// Only load status and activity if not inline // Only load status and activity if not inline
@ -30,19 +40,63 @@
}); });
async function loadProfile() { async function loadProfile() {
const p = await fetchProfile(pubkey); const currentPubkey = pubkey;
if (p) { if (!currentPubkey || loadingProfile || lastLoadedPubkey === currentPubkey) {
profile = p; 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() { 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() { async function updateActivityStatus() {
activityStatus = await getActivityStatus(pubkey); const currentPubkey = pubkey;
activityMessage = await getActivityMessage(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 { function getActivityColor(): string {

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

@ -1,475 +1,22 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; // Simple button component that links to settings page
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();
}
}
</script> </script>
<!-- Settings button to open modal --> <a
<button href="/settings"
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 inline-flex items-center"
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 settings"
aria-label="Open preferences" title="Settings"
title="Preferences"
> >
<span class="emoji emoji-grayscale"></span> <span class="emoji emoji-grayscale"></span>
</button> </a>
<!-- 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}
<style> <style>
.emoji-grayscale { .emoji-grayscale {
filter: grayscale(100%); filter: grayscale(100%);
} }
button:hover .emoji-grayscale { a:hover .emoji-grayscale {
filter: grayscale(80%); 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> </style>

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

@ -89,9 +89,9 @@
<div class="comment-header flex items-center gap-2 mb-2"> <div class="comment-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={comment.pubkey} /> <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()} {#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} {/if}
<div class="ml-auto"> <div class="ml-auto">
<EventMenu event={comment} /> <EventMenu event={comment} />
@ -106,7 +106,8 @@
{#if needsExpansion} {#if needsExpansion}
<button <button
onclick={toggleExpanded} 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'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
@ -122,7 +123,8 @@
{/if} {/if}
<button <button
onclick={handleReply} 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 Reply
</button> </button>

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

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

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

@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import VoteCount from '../../components/content/VoteCount.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 { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -14,9 +18,10 @@
upvotes?: number; // Pre-calculated upvote count from batch fetch upvotes?: number; // Pre-calculated upvote count from batch fetch
downvotes?: number; // Pre-calculated downvote count from batch fetch downvotes?: number; // Pre-calculated downvote count from batch fetch
votesCalculated?: boolean; // Whether vote counts are ready to display 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); let commentCount = $state(0);
@ -190,14 +195,15 @@
</script> </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"> <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"> {#if !fullView}
<div class="card-content" class:expanded bind:this={contentElement}> <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"> <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()} {getTitle()}
</h3> </h3>
<div class="flex items-center gap-2"> <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 --> <!-- Menu hidden in preview - not clickable in card preview -->
</div> </div>
</div> </div>
@ -207,11 +213,52 @@
<ProfileBadge pubkey={thread.pubkey} /> <ProfileBadge pubkey={thread.pubkey} />
</div> </div>
{#if getClientName()} {#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} {/if}
</div> </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} {#if getTopics().length > 0}
<div class="flex gap-2 topic-tags"> <div class="flex gap-2 topic-tags">
@ -221,34 +268,53 @@
</div> </div>
{/if} {/if}
</div> </div>
</a> {/if}
{#if needsExpansion} {#if !fullView && needsExpansion}
<button <button
onclick={toggleExpanded} 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'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<!-- Card footer (stats) - always visible, outside collapsible content --> <!-- 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"> <div class="flex items-center gap-4 flex-wrap">
{#if providedVotesCalculated} {#if fullView}
<VoteCount upvotes={providedUpvotes} downvotes={providedDownvotes} votesCalculated={true} size="xs" /> <DiscussionVoteButtons event={thread} />
{:else} {: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}
{#if loadingStats} {#if !fullView}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span> {#if loadingStats}
{:else} <span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span> {:else}
{#if latestResponseTime} <span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</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}
{#if zapCount > 0} {:else}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span> {#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}
{/if} {/if}
</div> </div>
@ -392,4 +458,15 @@
border-top-color: var(--fog-dark-border, #374151); 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> </style>

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

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

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

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

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

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

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

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import FeedPost from './FeedPost.svelte'; import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte'; import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
@ -16,9 +17,6 @@
let events = $state<NostrEvent[]>([]); let events = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null);
let relayError = $state<string | null>(null); let relayError = $state<string | null>(null);
// Batch-loaded parent and quoted events // Batch-loaded parent and quoted events
@ -29,16 +27,12 @@
let drawerOpen = $state(false); let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null); let drawerEvent = $state<NostrEvent | null>(null);
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
let subscriptionId: string | null = $state(null); let subscriptionId: string | null = $state(null);
let isMounted = $state(true); let isMounted = $state(true);
let loadingParents = $state(false); // Guard to prevent concurrent parent/quoted event loads let loadingParents = $state(false); // Guard to prevent concurrent parent/quoted event loads
let loadingFeed = $state(false); // Guard to prevent concurrent feed loads let loadingFeed = $state(false); // Guard to prevent concurrent feed loads
let pendingSubscriptionEvents = $state<NostrEvent[]>([]); // Batch subscription events let pendingSubscriptionEvents = $state<NostrEvent[]>([]); // Batch subscription events
let subscriptionBatchTimeout: ReturnType<typeof setTimeout> | null = null; 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) { function openDrawer(event: NostrEvent) {
drawerEvent = event; drawerEvent = event;
@ -64,7 +58,6 @@
await loadFeed(); await loadFeed();
if (!isMounted) return; if (!isMounted) return;
setupSubscription(); setupSubscription();
setupObserver();
})(); })();
return () => { return () => {
@ -73,18 +66,10 @@
nostrClient.unsubscribe(subscriptionId); nostrClient.unsubscribe(subscriptionId);
subscriptionId = null; subscriptionId = null;
} }
if (observer) {
observer.disconnect();
observer = null;
}
if (subscriptionBatchTimeout) { if (subscriptionBatchTimeout) {
clearTimeout(subscriptionBatchTimeout); clearTimeout(subscriptionBatchTimeout);
subscriptionBatchTimeout = null; subscriptionBatchTimeout = null;
} }
if (loadMoreTimeout) {
clearTimeout(loadMoreTimeout);
loadMoreTimeout = null;
}
pendingSubscriptionEvents = []; pendingSubscriptionEvents = [];
}; };
}); });
@ -162,7 +147,7 @@
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD); 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( subscriptionId = nostrClient.subscribe(
filters, filters,
@ -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() { async function loadCachedFeed() {
if (!isMounted || singleRelay) return; if (!isMounted || singleRelay) return;
@ -237,7 +200,6 @@
if (sortedEvents.length > 0) { if (sortedEvents.length > 0) {
events = sortedEvents; events = sortedEvents;
oldestTimestamp = Math.min(...events.map(e => e.created_at));
// Load parent/quoted events in background, don't await // Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches // Only load if not already loading to prevent cascading fetches
if (!loadingParents) { if (!loadingParents) {
@ -284,18 +246,18 @@
} }
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD); 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 ? { const fetchOptions = singleRelay ? {
relayFirst: true, relayFirst: true,
useCache: false, useCache: false,
cacheResults: false, cacheResults: false,
timeout: 15000 timeout: config.singleRelayTimeout
} : { } : {
relayFirst: true, relayFirst: true,
useCache: true, useCache: true,
cacheResults: true, cacheResults: true,
timeout: 3000 timeout: config.standardTimeout
}; };
const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions); const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions);
@ -327,7 +289,6 @@
events = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at); events = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at);
if (events.length > 0) { if (events.length > 0) {
oldestTimestamp = Math.min(...events.map(e => e.created_at));
// Load parent/quoted events in background, don't await // Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches // Only load if not already loading to prevent cascading fetches
if (!loadingParents) { if (!loadingParents) {
@ -336,8 +297,6 @@
}); });
} }
} }
hasMore = fetchedEvents.length >= 20;
} catch (error) { } catch (error) {
console.error('Error loading feed:', error); console.error('Error loading feed:', error);
} finally { } finally {
@ -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[]) { async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) {
if (!isMounted || postsToLoad.length === 0 || loadingParents) return; if (!isMounted || postsToLoad.length === 0 || loadingParents) return;
@ -475,12 +343,12 @@
relayFirst: true, relayFirst: true,
useCache: false, useCache: false,
cacheResults: false, cacheResults: false,
timeout: 3000 timeout: config.standardTimeout
} : { } : {
relayFirst: true, relayFirst: true,
useCache: true, useCache: true,
cacheResults: true, cacheResults: true,
timeout: 3000 timeout: config.standardTimeout
} }
); );
@ -554,16 +422,6 @@
{#if drawerOpen && drawerEvent} {#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} /> <ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if} {/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} {/if}
</div> </div>
@ -594,12 +452,6 @@
gap: 1rem; gap: 1rem;
} }
.feed-sentinel {
padding: 2rem;
text-align: center;
min-height: 100px;
}
.relay-info { .relay-info {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 1rem; padding: 1rem;
@ -637,9 +489,21 @@
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
} }
/* Limit all images to 600px wide */ /* Responsive images and media in feed */
:global(.feed-page img) { :global(.feed-page img):not(.profile-picture) {
max-width: 600px; max-width: 600px;
width: 100%;
height: auto; height: auto;
} }
:global(.feed-page video) {
max-width: 600px;
width: 100%;
height: auto;
}
:global(.feed-page audio) {
max-width: 600px;
width: 100%;
}
</style> </style>

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

@ -2,6 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import MetadataCard from '../../components/content/MetadataCard.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte'; import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
@ -49,9 +50,6 @@
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent }: Props = $props(); 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) // Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in // Only check if user is logged in
let bookmarked = $state(false); let bookmarked = $state(false);
@ -72,23 +70,8 @@
} }
}); });
// Derive the effective parent event: prefer provided, fall back to loaded // Use provided parent event directly - ReplyContext handles loading if not provided
let parentEvent = $derived(providedParentEvent || loadedParentEvent); let parentEvent = $derived(providedParentEvent);
// Sync provided parent event changes and load if needed
$effect(() => {
if (providedParentEvent) {
return;
}
if (!loadedParentEvent && isReply()) {
setTimeout(() => {
if (!providedParentEvent && !loadedParentEvent && isReply()) {
loadParentEvent();
}
}, 1000);
}
});
// Lazy load PollCard when post is a poll and component is visible // Lazy load PollCard when post is a poll and component is visible
$effect(() => { $effect(() => {
@ -157,28 +140,8 @@
return quotedTag?.[1] || null; return quotedTag?.[1] || null;
} }
async function loadParentEvent() { // Removed loadParentEvent - ReplyContext now handles parent event loading
const replyEventId = getReplyEventId(); // This prevents conflicts and duplicate fetches
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;
}
}
function getTitle(): string { function getTitle(): string {
@ -325,11 +288,14 @@
/> />
{/if} {/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) --> <!-- 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} {#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD}
{@const title = getTitle()} {@const title = getTitle()}
{#if title && title !== 'Untitled'} {#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} {title}
</h2> </h2>
{/if} {/if}
@ -337,17 +303,17 @@
<div class="post-header flex items-center gap-2 mb-2 flex-wrap"> <div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} /> <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()} {#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}
{#if post.kind === KIND.DISCUSSION_THREAD} {#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])} {@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])}
{#if topics.length === 0} {#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} {:else}
{#each topics.slice(0, 3) as topic} {#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} {/each}
{/if} {/if}
{/if} {/if}
@ -398,7 +364,8 @@
{#if onReply} {#if onReply}
<button <button
onclick={() => onReply(post)} 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 Reply
</button> </button>

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

@ -20,6 +20,11 @@
let subscriptionId: string | null = $state(null); let subscriptionId: string | null = $state(null);
let isInitialized = $state(false); let isInitialized = $state(false);
let hierarchyChain = $state<NostrEvent[]>([]); 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 // Initialize nostr client once
onMount(async () => { onMount(async () => {
@ -30,64 +35,97 @@
}); });
// Build event hierarchy when drawer opens // Build event hierarchy when drawer opens
async function loadHierarchy(abortSignal: AbortSignal) { async function loadHierarchy(abortSignal: AbortSignal, eventId: string) {
if (!opEvent || !isInitialized) return; 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; loading = true;
try { try {
const hierarchy = await buildEventHierarchy(opEvent); const hierarchy = await buildEventHierarchy(opEvent);
// Check if operation was aborted before updating state // Check if operation was aborted or event changed
if (abortSignal.aborted) return; if (abortSignal.aborted || currentLoadEventId !== eventId) return;
const chain = getHierarchyChain(hierarchy); const chain = getHierarchyChain(hierarchy);
// Check again before final state update // Check again before final state update
if (abortSignal.aborted) return; if (abortSignal.aborted || currentLoadEventId !== eventId) return;
hierarchyChain = chain; hierarchyChain = chain;
} catch (error) { } catch (error) {
// Only update state if not aborted // Only update state if not aborted and still loading this event
if (abortSignal.aborted) return; if (abortSignal.aborted || currentLoadEventId !== eventId) return;
console.error('Error building event hierarchy:', error); console.error('Error building event hierarchy:', error);
hierarchyChain = [opEvent]; // Fallback to just the event hierarchyChain = [opEvent]; // Fallback to just the event
} finally { } finally {
// Only update loading state if not aborted // Only update loading state if not aborted and still loading this event
if (!abortSignal.aborted) { if (!abortSignal.aborted && currentLoadEventId === eventId) {
loading = false; loading = false;
} }
} }
} }
// Handle drawer open/close - only load when opening // Handle drawer open/close - only load when opening
// Track event ID separately to prevent reactive loops from object reference changes
$effect(() => { $effect(() => {
if (isOpen && opEvent && isInitialized) { const eventId = opEventId;
// Create abort controller to track effect lifecycle
const abortController = new AbortController(); // Only proceed if event ID actually changed or drawer state changed
if (!isOpen || !eventId || !isInitialized || !opEvent) {
// Drawer opened - load hierarchy with abort signal // Drawer closed or no event - cleanup
loadHierarchy(abortController.signal); if (!isOpen || !eventId) {
currentLoadEventId = null;
// Cleanup subscription when drawer closes lastOpEventId = null;
return () => {
// Abort the async operation
abortController.abort();
if (subscriptionId) { if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId); nostrClient.unsubscribe(subscriptionId);
subscriptionId = null; subscriptionId = null;
} }
}; hierarchyChain = [];
} else if (!isOpen) { loading = false;
// Drawer closed - cleanup }
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) { if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId); nostrClient.unsubscribe(subscriptionId);
subscriptionId = null; subscriptionId = null;
} }
hierarchyChain = []; };
loading = false;
}
}); });
// Handle keyboard events // Handle keyboard events

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

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

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

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

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

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

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

@ -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;
const ZAP_THRESHOLD = 1; 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 { export interface NostrConfig {
defaultRelays: string[]; defaultRelays: string[];
profileRelays: string[]; profileRelays: string[];
@ -41,6 +56,19 @@ export interface NostrConfig {
threadPublishRelays: string[]; threadPublishRelays: string[];
relayTimeout: number; relayTimeout: number;
gifRelays: string[]; 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[] { function parseRelays(envVar: string | undefined, fallback: string[]): string[] {
@ -67,7 +95,20 @@ export function getConfig(): NostrConfig {
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30), threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30),
threadPublishRelays: THREAD_PUBLISH_RELAYS, threadPublishRelays: THREAD_PUBLISH_RELAYS,
relayTimeout: RELAY_TIMEOUT, 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(
} }
} }
// If not found in specific pubkeys, ensure all packs are loaded // If not found in specific pubkeys and searchBroadly is true, check the global cache
if (searchBroadly && !allEmojiPacksLoaded) { // But don't automatically load all packs - that should only happen when emoji picker opens
await loadAllEmojiPacks(); if (searchBroadly) {
} // Only check cache if already loaded - don't trigger background fetch
if (allEmojiPacksLoaded && shortcodeCache.has(cleanShortcode)) {
// Check the global shortcode cache return shortcodeCache.get(cleanShortcode)!;
if (shortcodeCache.has(cleanShortcode)) { }
return shortcodeCache.get(cleanShortcode)!; // If packs aren't loaded yet, return null (don't fetch in background)
return null;
} }
return null; return null;
@ -291,10 +292,8 @@ export async function resolveCustomEmojis(
const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey)); const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey));
await Promise.all(emojiSetPromises); await Promise.all(emojiSetPromises);
// Ensure all emoji packs are loaded for broader search // Don't load all emoji packs here - that should only happen when emoji picker opens
if (!allEmojiPacksLoaded) { // Only check cache if already loaded
await loadAllEmojiPacks();
}
// Build map of shortcode -> URL // Build map of shortcode -> URL
const emojiMap = new Map<string, string>(); const emojiMap = new Map<string, string>();

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

@ -25,6 +25,7 @@ interface FetchOptions {
timeout?: number; timeout?: number;
relayFirst?: boolean; // If true, query relays first with timeout, then fill from cache 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) 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 { class NostrClient {
@ -1257,6 +1258,86 @@ class NostrClient {
this.processingQueue = false; 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 * Create a human-readable description of a filter for logging
*/ */
@ -1288,7 +1369,8 @@ class NostrClient {
relays: string[], relays: string[],
options: FetchOptions = {} options: FetchOptions = {}
): Promise<NostrEvent[]> { ): 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 // Create a key for this fetch to prevent duplicates
const fetchKey = JSON.stringify({ const fetchKey = JSON.stringify({
@ -1339,7 +1421,7 @@ class NostrClient {
if (updatedEntry && !updatedEntry.pending) { if (updatedEntry && !updatedEntry.pending) {
if ((Date.now() - updatedEntry.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) { if ((Date.now() - updatedEntry.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const finalAge = Math.round((Date.now() - updatedEntry.cachedAt) / 1000); 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 []; return [];
} }
break; // No longer pending, but result expired or had data break; // No longer pending, but result expired or had data
@ -1352,7 +1434,7 @@ class NostrClient {
} else if (!emptyCacheEntry.pending && age < this.EMPTY_RESULT_CACHE_TTL) { } else if (!emptyCacheEntry.pending && age < this.EMPTY_RESULT_CACHE_TTL) {
const ageSeconds = Math.round(age / 1000); const ageSeconds = Math.round(age / 1000);
// Use debug level to reduce console noise - this is expected behavior // 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([]); return Promise.resolve([]);
} }
} }
@ -1365,7 +1447,7 @@ class NostrClient {
const recheck = this.emptyResultCache.get(emptyCacheKey); const recheck = this.emptyResultCache.get(emptyCacheKey);
if (recheck && !recheck.pending && (Date.now() - recheck.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) { if (recheck && !recheck.pending && (Date.now() - recheck.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const finalAge = Math.round((Date.now() - recheck.cachedAt) / 1000); 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 []; return [];
} }
} }
@ -1454,7 +1536,7 @@ class NostrClient {
const emptyCacheEntry2 = this.emptyResultCache.get(emptyCacheKey); const emptyCacheEntry2 = this.emptyResultCache.get(emptyCacheKey);
if (emptyCacheEntry2 && !emptyCacheEntry2.pending && (Date.now() - emptyCacheEntry2.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) { if (emptyCacheEntry2 && !emptyCacheEntry2.pending && (Date.now() - emptyCacheEntry2.cachedAt) < this.EMPTY_RESULT_CACHE_TTL) {
const age = Math.round((Date.now() - emptyCacheEntry2.cachedAt) / 1000); 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 // Clear pending flag if it was set
this.emptyResultCache.delete(emptyCacheKey); this.emptyResultCache.delete(emptyCacheKey);
return []; return [];

2
src/routes/discussions/+page.svelte

@ -16,7 +16,7 @@
<div class="discussions-content"> <div class="discussions-content">
<div class="discussions-header mb-4"> <div class="discussions-header mb-4">
<div> <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> <p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p>
</div> </div>
<a href="/write?kind=11" class="write-button" title="Write a new thread"> <a href="/write?kind=11" class="write-button" title="Write a new thread">

2
src/routes/feed/+page.svelte

@ -15,7 +15,7 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="feed-content"> <div class="feed-content">
<div class="feed-header mb-6"> <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="feed-controls">
<div class="search-section"> <div class="search-section">
<SearchBox /> <SearchBox />

2
src/routes/find/+page.svelte

@ -116,7 +116,7 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="find-page"> <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"> <div class="find-sections">
<!-- Find Event Section --> <!-- Find Event Section -->

42
src/routes/login/+page.svelte

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

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

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

138
src/routes/rss/+page.svelte

@ -3,6 +3,7 @@
import { sessionManager } from '../../lib/services/auth/session-manager.js'; import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.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 { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { NostrEvent } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js';
@ -11,14 +12,6 @@
const RSS_FEED_KIND = 10895; 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 currentPubkey = $state<string | null>(null);
let rssEvent = $state<NostrEvent | null>(null); let rssEvent = $state<NostrEvent | null>(null);
@ -26,6 +19,7 @@
let loadingFeeds = $state(false); let loadingFeeds = $state(false);
let rssItems = $state<RSSItem[]>([]); let rssItems = $state<RSSItem[]>([]);
let feedErrors = $state<Map<string, string>>(new Map()); 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(() => { let subscribedFeeds = $derived.by(() => {
if (!rssEvent) return []; if (!rssEvent) return [];
@ -34,6 +28,9 @@
.map(tag => tag[1]); .map(tag => tag[1]);
}); });
// Track the last feeds we loaded to detect changes
let lastLoadedFeeds = $state<string[]>([]);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
const session = sessionManager.getSession(); const session = sessionManager.getSession();
@ -47,8 +44,21 @@
}); });
$effect(() => { $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(); loadRssFeeds();
} else if (subscribedFeeds.length === 0 && feedsLoaded) {
// Reset if feeds were removed
feedsLoaded = false;
rssItems = [];
feedErrors.clear();
lastLoadedFeeds = [];
} }
}); });
@ -65,7 +75,14 @@
); );
if (events.length > 0) { 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) { } catch (error) {
console.error('Error checking RSS event:', error); console.error('Error checking RSS event:', error);
@ -75,30 +92,86 @@
} }
async function loadRssFeeds() { async function loadRssFeeds() {
if (subscribedFeeds.length === 0 || loadingFeeds) return; if (subscribedFeeds.length === 0 || loadingFeeds || feedsLoaded) return;
loadingFeeds = true; loadingFeeds = true;
feedErrors.clear(); feedErrors.clear();
const allItems: RSSItem[] = []; const allItems: RSSItem[] = [];
try { try {
// Fetch all feeds in parallel // First, try to load from cache
const feedPromises = subscribedFeeds.map(async (feedUrl) => { const cachePromises = subscribedFeeds.map(async (feedUrl) => {
try { const cached = await getCachedRSSFeed(feedUrl);
const items = await fetchRssFeed(feedUrl); if (cached && cached.length > 0) {
allItems.push(...items); return { feedUrl, items: cached, fromCache: true };
} catch (error) {
console.error(`Error fetching RSS feed ${feedUrl}:`, error);
feedErrors.set(feedUrl, error instanceof Error ? error.message : 'Failed to fetch feed');
} }
return { feedUrl, items: null, fromCache: false };
}); });
await Promise.all(feedPromises); const cacheResults = await Promise.all(cachePromises);
// 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;
}
// 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);
// Sort by date (newest first) // Merge fresh items with cached items
allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()); 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;
}
rssItems = allItems; feedsLoaded = true;
} catch (error) { } catch (error) {
console.error('Error loading RSS feeds:', error); console.error('Error loading RSS feeds:', error);
} finally { } finally {
@ -107,21 +180,10 @@
} }
async function fetchRssFeed(feedUrl: string): Promise<RSSItem[]> { async function fetchRssFeed(feedUrl: string): Promise<RSSItem[]> {
// Use a CORS proxy if needed, or fetch directly // Always use a CORS proxy to avoid CORS errors
// For now, try direct fetch first // Direct fetch will fail for most RSS feeds due to CORS restrictions
let response: Response; const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`;
try { const response = await fetch(proxyUrl);
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);
}
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);

358
src/routes/settings/+page.svelte

@ -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 @@
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte'; import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { config } from '../../../lib/services/nostr/config.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { NostrEvent } from '../../../lib/types/nostr.js'; import type { NostrEvent } from '../../../lib/types/nostr.js';
@ -13,6 +14,32 @@
let loading = $state(true); let loading = $state(true);
let topicName = $derived($page.params.name); 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 () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
await loadCachedTopicEvents(); await loadCachedTopicEvents();
@ -77,9 +104,9 @@
// Fetch events with matching t-tag (most efficient - uses relay filtering) // Fetch events with matching t-tag (most efficient - uses relay filtering)
const tTagEvents = await nostrClient.fetchEvents( 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, 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) // Also search for hashtags in content (less efficient but catches events without t-tags)
@ -90,9 +117,9 @@
// For content-based hashtag search, we need to fetch more events // For content-based hashtag search, we need to fetch more events
// But we'll limit this to avoid fetching too much // But we'll limit this to avoid fetching too much
const contentEvents = await nostrClient.fetchEvents( const contentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 500 }], [{ kinds: [KIND.SHORT_TEXT_NOTE], limit: config.feedLimit }],
relays, 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 // Filter events that contain the hashtag in content but don't already have a t-tag
@ -136,11 +163,16 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="topic-content"> <div class="topic-content">
<div class="topic-header mb-6"> <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} Topic: #{topicName}
</h1> </h1>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2"> <p class="text-fog-text-light dark:text-fog-dark-text-light mt-2">
{events.length} {events.length === 1 ? 'event' : 'events'} found {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> </p>
</div> </div>
@ -154,12 +186,46 @@
</div> </div>
{:else} {:else}
<div class="events-list"> <div class="events-list">
{#each events as event (event.id)} {#each paginatedEvents as event (event.id)}
<div class="event-item"> <div class="event-item">
<FeedPost post={event} /> <FeedPost post={event} />
</div> </div>
{/each} {/each}
</div> </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} {/if}
</div> </div>
</main> </main>
@ -203,4 +269,42 @@
border-color: var(--fog-dark-border, #374151); border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937); 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> </style>

2
src/routes/write/+page.svelte

@ -66,7 +66,7 @@
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="write-page"> <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} {#if !isLoggedIn}
<div class="login-prompt"> <div class="login-prompt">

Loading…
Cancel
Save