Browse Source

more bug-fixes

master
Silberengel 1 month ago
parent
commit
5e889a7e5f
  1. 2
      docker-compose.yml
  2. 4
      public/healthz.json
  3. 12
      src/app.css
  4. 5
      src/lib/components/content/QuotedContext.svelte
  5. 5
      src/lib/components/content/ReplyContext.svelte
  6. 51
      src/lib/components/layout/ProfileBadge.svelte
  7. 195
      src/lib/modules/feed/FeedPage.svelte
  8. 149
      src/lib/modules/feed/FeedPost.svelte
  9. 65
      src/lib/modules/feed/ThreadDrawer.svelte
  10. 77
      src/lib/modules/profiles/ProfilePage.svelte
  11. 265
      src/lib/modules/reactions/FeedReactionButtons.svelte
  12. 3
      src/lib/modules/threads/ThreadCard.svelte
  13. 188
      src/lib/modules/threads/ThreadList.svelte
  14. 4
      src/lib/services/auth/activity-tracker.ts
  15. 111
      src/lib/services/cache/event-cache.ts
  16. 120
      src/lib/services/cache/indexeddb-store.ts
  17. 53
      src/lib/services/cache/profile-cache.ts
  18. 3
      src/lib/services/nostr/config.ts
  19. 97
      src/lib/services/nostr/nostr-client.ts
  20. 39
      src/lib/services/text-utils.ts

2
docker-compose.yml

@ -5,7 +5,7 @@ services: @@ -5,7 +5,7 @@ services:
build:
context: .
args:
VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host,wss://orly-relay.imwald.eu"
VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.sovbit.host,wss://orly-relay.imwald.eu"
VITE_ZAP_THRESHOLD: "1"
VITE_THREAD_TIMEOUT_DAYS: "30"
VITE_PWA_ENABLED: "true"

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2026-02-03T09:44:26.608Z",
"buildTime": "2026-02-03T11:48:59.137Z",
"gitCommit": "unknown",
"timestamp": 1770111866608
"timestamp": 1770119339137
}

12
src/app.css

@ -92,25 +92,27 @@ body { @@ -92,25 +92,27 @@ body {
background-color: #1e293b;
}
/* Anon aesthetic: Grayscale with blue tinge for profile pics and emojis */
/* Anon aesthetic: Pure gray to slightly bluish tints for profile pics */
/* Profile pictures - all instances */
.profile-picture,
img.profile-picture,
.profile-badge img,
.profile-picture,
img[alt*="profile" i],
img[alt*="avatar" i],
img[src*="avatar" i],
img[src*="profile" i] {
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
filter: grayscale(100%) sepia(10%) hue-rotate(200deg) saturate(30%) !important;
transition: filter 0.3s ease;
}
.dark .profile-picture,
.dark img.profile-picture,
.dark .profile-badge img,
.dark .profile-picture,
.dark img[alt*="profile" i],
.dark img[alt*="avatar" i],
.dark img[src*="avatar" i],
.dark img[src*="profile" i] {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
filter: grayscale(100%) sepia(12%) hue-rotate(200deg) saturate(35%) !important;
}
/* Emoji images - grayscale like profile pics */

5
src/lib/components/content/QuotedContext.svelte

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { stripMarkdown } from '../../services/text-utils.js';
interface Props {
quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId
@ -63,8 +64,8 @@ @@ -63,8 +64,8 @@
if (!quotedEvent) {
return loadingQuoted ? 'Loading...' : 'Quoted event not found';
}
// Create preview from quoted event (first 100 chars, plaintext)
const plaintext = quotedEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim();
// Create preview from quoted event (first 100 chars, plaintext with markdown stripped)
const plaintext = stripMarkdown(quotedEvent.content);
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
}

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

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { stripMarkdown } from '../../services/text-utils.js';
interface Props {
parentEvent?: NostrEvent; // Optional - if not provided, will load by parentEventId
@ -63,8 +64,8 @@ @@ -63,8 +64,8 @@
if (!parentEvent) {
return loadingParent ? 'Loading...' : 'Parent event not found';
}
// Create preview from parent (first 100 chars, plaintext)
const plaintext = parentEvent.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ').trim();
// Create preview from parent (first 100 chars, plaintext with markdown stripped)
const plaintext = stripMarkdown(parentEvent.content);
return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : '');
}

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

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js';
import { fetchProfile, fetchUserStatus } from '../../services/user-data.js';
import { nip19 } from 'nostr-tools';
interface Props {
pubkey: string;
@ -14,12 +15,30 @@ @@ -14,12 +15,30 @@
let activityMessage = $state<string | null>(null);
let imageError = $state(false);
// Debounce requests to allow batching from parent components
let loadTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (pubkey) {
imageError = false; // Reset image error when pubkey changes
loadProfile();
loadStatus();
updateActivityStatus();
// Clear any pending timeout
if (loadTimeout) {
clearTimeout(loadTimeout);
}
// Debounce requests by 200ms to allow parent components to batch fetch
loadTimeout = setTimeout(() => {
loadProfile();
loadStatus();
updateActivityStatus();
}, 200);
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout);
}
};
}
});
@ -52,7 +71,7 @@ @@ -52,7 +71,7 @@
}
}
// Generate deterministic avatar color from pubkey
// Generate deterministic muted gray-blue avatar color from pubkey
let avatarColor = $derived.by(() => {
// Hash the pubkey to get consistent colors
let hash = 0;
@ -60,16 +79,28 @@ @@ -60,16 +79,28 @@
hash = pubkey.charCodeAt(i) + ((hash << 5) - hash);
}
// Generate colors from hash
const hue = Math.abs(hash) % 360;
const saturation = 60 + (Math.abs(hash >> 8) % 20); // 60-80%
const lightness = 50 + (Math.abs(hash >> 16) % 15); // 50-65%
// Generate muted gray-blue tones: hue around 200-220 (blue range), low saturation, medium lightness
const hue = 200 + (Math.abs(hash) % 20); // 200-220 (blue range)
const saturation = 15 + (Math.abs(hash >> 8) % 10); // 15-25% (very low saturation for muted look)
const lightness = 45 + (Math.abs(hash >> 16) % 15); // 45-60% (medium lightness)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
});
// Get avatar initials from pubkey
let avatarInitials = $derived(pubkey.slice(0, 2).toUpperCase());
// Get shortened npub for anonymous users (when no profile name)
let shortenedNpub = $derived.by(() => {
try {
const npub = nip19.npubEncode(pubkey);
// Return first 8 characters of npub (e.g., "npub1abc...")
return npub.slice(0, 8) + '...';
} catch {
// Fallback to hex if encoding fails
return pubkey.slice(0, 8) + '...';
}
});
</script>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2 min-w-0 max-w-full">
@ -85,13 +116,13 @@ @@ -85,13 +116,13 @@
{:else}
<div
class="profile-placeholder w-6 h-6 rounded flex-shrink-0 flex items-center justify-center text-xs font-semibold"
style="background: {avatarColor}; color: white;"
style="background: {avatarColor}; color: white; filter: grayscale(100%) sepia(10%) hue-rotate(200deg) saturate(30%);"
title={pubkey}
>
{avatarInitials}
</div>
{/if}
<span class="truncate min-w-0">{profile?.name || pubkey.slice(0, 16)}...</span>
<span class="truncate min-w-0">{profile?.name || shortenedNpub}</span>
{#if activityStatus && activityMessage}
<span
class="activity-dot w-2 h-2 rounded-full flex-shrink-0"

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

@ -114,6 +114,7 @@ @@ -114,6 +114,7 @@
// Don't update the feed automatically - only store new posts for manual update
// This prevents feed jumping and allows user to control when to refresh
// Works the same whether drawer is open or closed - events go to pending arrays
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1);
const updatedReplaceable = updated.filter((e: NostrEvent) =>
@ -128,7 +129,7 @@ @@ -128,7 +129,7 @@
// NEVER update the feed automatically from onUpdate callback
// This prevents feed jumping - user must click button to see updates
// Only store new posts in pending arrays
// Only store new posts in pending arrays (works for both drawer open and closed)
if (!isReset) {
// Store new posts in pending arrays instead of updating feed automatically
const existingIds = new Set([...posts, ...pendingNewPosts].map(p => p.id));
@ -142,15 +143,17 @@ @@ -142,15 +143,17 @@
if (trulyNewPosts.length > 0) {
pendingNewPosts = [...pendingNewPosts, ...trulyNewPosts];
// Update counter for new posts
if (lastPostId) {
const newCount = trulyNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount += newCount;
// Update counter for new posts (only if drawer is closed, to avoid confusion)
if (!drawerOpen) {
if (lastPostId) {
const newCount = trulyNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount += newCount;
}
} else {
// If no lastPostId, count all new posts
newPostsCount += trulyNewPosts.length;
}
} else {
// If no lastPostId, count all new posts
newPostsCount += trulyNewPosts.length;
}
}
}
@ -212,36 +215,31 @@ @@ -212,36 +215,31 @@
// Only sort if we have posts to prevent unnecessary re-renders
if (regularPosts.length > 0 || otherFeedEvents.length > 0) {
posts = sortPosts([...regularPosts, ...otherFeedEvents]);
// Invalidate cache
cachedFeedItems = null;
}
replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null;
// Invalidate cache
cachedFeedItems = null;
} else {
// For infinite scroll (loading more), add new posts directly to feed
// This is a user-initiated action, so update immediately
// Don't re-sort existing posts - just append new ones to prevent jumping
const existingIds = new Set(posts.map(p => p.id));
const allNewEvents = [...regularPosts, ...otherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Sort only the new posts, then append to existing (preserve existing order)
const sortedNewPosts = sortPosts(newPosts);
posts = [...posts, ...sortedNewPosts];
// Invalidate cache
cachedFeedItems = null;
}
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
// Append new replaceable events without re-sorting existing ones
replaceableEvents = [...replaceableEvents, ...newReplaceable.sort((a, b) => b.created_at - a.created_at)];
// Invalidate cache
cachedFeedItems = null;
// BUT: Don't update if drawer is open - freeze the feed
if (!drawerOpen) {
// Don't re-sort existing posts - just append new ones to prevent jumping
const existingIds = new Set(posts.map(p => p.id));
const allNewEvents = [...regularPosts, ...otherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Sort only the new posts, then append to existing (preserve existing order)
const sortedNewPosts = sortPosts(newPosts);
posts = [...posts, ...sortedNewPosts];
}
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
// Append new replaceable events without re-sorting existing ones
replaceableEvents = [...replaceableEvents, ...newReplaceable.sort((a, b) => b.created_at - a.created_at)];
}
}
}
@ -286,33 +284,61 @@ @@ -286,33 +284,61 @@
});
}
// Phase 3: Fetch kind 0 profiles for npubs in feed
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
const uniquePubkeys = new Set<string>();
for (const event of [...posts, ...replaceableEvents]) {
uniquePubkeys.add(event.pubkey);
}
if (uniquePubkeys.size > 0) {
const profileFilter = [{
kinds: [0],
authors: Array.from(uniquePubkeys).slice(0, 100) // Limit to avoid huge requests
}];
// Phase 3: Fetch kind 0 profiles, statuses, and activity data for npubs in feed
// Batch fetch all at once to prevent individual requests from ProfileBadge components
// This runs on both initial load and background refresh
const uniquePubkeys = new Set<string>();
for (const event of [...posts, ...replaceableEvents]) {
uniquePubkeys.add(event.pubkey);
}
// Fetch in background, update cache, view will update automatically via cache
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
nostrClient.fetchEvents(
profileFilter,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
}
).catch(err => {
console.error('Error fetching profiles:', err);
});
}
if (uniquePubkeys.size > 0) {
const pubkeysArray = Array.from(uniquePubkeys).slice(0, 100); // Limit to avoid huge requests
// Batch fetch profiles (kind 0)
const profileFilter = [{
kinds: [0],
authors: pubkeysArray
}];
// Batch fetch user statuses (kind 30315)
const statusFilter = [{
kinds: [30315],
authors: pubkeysArray,
'#d': ['general'],
limit: 1
} as any];
// Batch fetch activity data (recent events from each pubkey)
const activityFilter = [{
authors: pubkeysArray,
kinds: [0, 1, 7, 11, 1111],
limit: 1
}];
// Fetch all in parallel, update cache, view will update automatically via cache
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
// Run in background to not block initial render
Promise.all([
nostrClient.fetchEvents(profileFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
}),
nostrClient.fetchEvents(statusFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
}),
nostrClient.fetchEvents(activityFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
})
]).catch(err => {
console.error('Error fetching profiles/statuses/activity:', err);
});
}
// Fetch missing parent events (batch, one request per relay)
const allEventIds = new Set(posts.map(e => e.id));
@ -428,24 +454,21 @@ @@ -428,24 +454,21 @@
return result;
}
function handleShowNewPosts() {
// Don't update feed while drawer is open - freeze in place
if (drawerOpen) return;
// Merge pending new posts into the feed
if (pendingNewPosts.length > 0) {
// Sort the merged array to maintain proper order
const merged = [...pendingNewPosts, ...posts];
posts = sortPosts(merged);
pendingNewPosts = [];
// Invalidate cache
cachedFeedItems = null;
}
if (pendingNewReplaceable.length > 0) {
replaceableEvents = [...pendingNewReplaceable, ...replaceableEvents].sort((a, b) => b.created_at - a.created_at);
pendingNewReplaceable = [];
// Invalidate cache
cachedFeedItems = null;
}
// Scroll to top and reset new posts count
@ -507,10 +530,6 @@ @@ -507,10 +530,6 @@
return filtered;
}
// Cache the sorted items to prevent re-sorting on every render
let cachedFeedItems: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> | null = null;
let cachedFeedItemsKey = '';
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements (but allow the wrapper itself)
if (e) {
@ -537,17 +556,21 @@ @@ -537,17 +556,21 @@
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
}
function getAllFeedItems(): Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> {
// Create a key based on posts/replaceableEvents length and filter state
const currentKey = `${posts.length}_${replaceableEvents.length}_${showOPsOnly}_${showResponsesToMe}`;
// Return cached result if nothing changed
if (cachedFeedItems && cachedFeedItemsKey === currentKey) {
return cachedFeedItems;
// Events that arrived while drawer was open are already in pendingNewPosts/pendingNewReplaceable
// Update the counter now that drawer is closed so user sees the notification
if (pendingNewPosts.length > 0 && lastPostId) {
const newCount = pendingNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount = newCount;
}
} else if (pendingNewPosts.length > 0) {
newPostsCount = pendingNewPosts.length;
}
}
// Use $derived to make this reactive and prevent infinite loops
let allFeedItems = $derived.by(() => {
const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = [];
// Add filtered posts
@ -571,15 +594,9 @@ @@ -571,15 +594,9 @@
});
}
// Sort by created_at, newest first (only when cache is invalid)
const sorted = items.sort((a, b) => b.created_at - a.created_at);
// Cache the result
cachedFeedItems = sorted;
cachedFeedItemsKey = currentKey;
return sorted;
}
// Sort by created_at, newest first
return items.sort((a, b) => b.created_at - a.created_at);
});
</script>
<div class="Feed-feed">
@ -623,7 +640,7 @@ @@ -623,7 +640,7 @@
</div>
{/if}
<div class="posts-list">
{#each getAllFeedItems() as item (item.id)}
{#each allFeedItems as item (item.id)}
{#if item.type === 'post'}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? (posts.find(p => p.id === parentId) || loadedParentQuotedEvents.get(parentId)) : undefined}
@ -673,7 +690,7 @@ @@ -673,7 +690,7 @@
{#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/if}
{#if !hasMore && getAllFeedItems().length > 0}
{#if !hasMore && allFeedItems.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if}
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0}

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

@ -9,6 +9,7 @@ @@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js';
interface Props {
post: NostrEvent;
@ -17,9 +18,10 @@ @@ -17,9 +18,10 @@
onReply?: (post: NostrEvent) => void;
onParentLoaded?: (event: NostrEvent) => void; // Callback when parent is loaded
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
previewMode?: boolean; // If true, show only title and first 150 chars of content
}
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded }: Props = $props();
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, previewMode = false }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false);
@ -150,69 +152,106 @@ @@ -150,69 +152,106 @@
expanded = !expanded;
}
</script>
function getTitle(): string {
const titleTag = post.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply()}
<ReplyContext
parentEvent={parentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
targetId={parentEvent ? `event-${parentEvent.id}` : undefined}
onParentLoaded={onParentLoaded}
/>
{/if}
function getPreviewContent(): string {
// First 150 chars, plaintext (no markdown/images)
const plaintext = stripMarkdown(post.content);
return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : '');
}
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onQuotedLoaded={onQuotedLoaded}
/>
{/if}
</script>
<div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if}
<article class="Feed-post" data-post-id={post.id} id="event-{post.id}" data-event-id={post.id}>
{#if previewMode}
<!-- Preview mode: show only title and first 150 chars -->
<div class="card-content">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
<a href="/thread/{post.id}">{getTitle()}</a>
</h3>
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
</div>
<div class="mb-2 flex items-center gap-2">
<ProfileBadge pubkey={post.pubkey} />
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
</div>
<p class="text-sm mb-2">{getPreviewContent()}</p>
<div class="flex items-center justify-end text-xs text-fog-text dark:text-fog-dark-text">
<a href="/thread/{post.id}" class="text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</div>
</div>
{:else}
<!-- Full mode: show complete content -->
<div class="card-content" class:expanded bind:this={contentElement}>
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
<ReplyContext
parentEvent={parentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
targetId={parentEvent ? `event-${parentEvent.id}` : undefined}
onParentLoaded={onParentLoaded}
/>
{/if}
</div>
<div class="post-content mb-2">
<MarkdownRenderer content={post.content} />
</div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{#if hasQuotedEvent()}
<QuotedContext
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onQuotedLoaded={onQuotedLoaded}
/>
{/if}
<div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if}
{#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
{/if}
</div>
<div class="post-content mb-2">
<MarkdownRenderer content={post.content} />
</div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
<FeedReactionButtons event={post} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</article>
<style>

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

@ -122,19 +122,29 @@ @@ -122,19 +122,29 @@
const eventId = rootEvent.id;
const isThread = rootEvent.kind === 11;
console.log('Loading thread:', { eventId, isThread, rootEventKind: rootEvent.kind });
// Load all replies: zap receipts (9735), yak backs (1244), kind 1 replies, kind 1111 comments
// For kind 1111 comments: use #E tag for threads (kind 11), #e tag for other events
// Note: Some relays may be case-sensitive, so we query with both uppercase and lowercase
const replyFilters = [
{ kinds: [9735], '#e': [eventId] }, // Zap receipts
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies)
{ kinds: [1], '#e': [eventId] }, // Kind 1 replies
// Kind 1111 comments: use #E for threads, #e for other events
// Query with both uppercase (NIP-22) and lowercase (fallback) tag names
...(isThread
? [{ kinds: [1111], '#E': [eventId], '#K': ['11'] }]
? [
{ kinds: [1111], '#E': [eventId], '#K': ['11'] }, // NIP-22 standard (uppercase)
{ kinds: [1111], '#e': [eventId] } // Fallback (lowercase) - some clients might use this
]
: [{ kinds: [1111], '#e': [eventId] }]
)
];
console.log('Loading thread:', { eventId, isThread, rootEventKind: rootEvent.kind });
console.log('Reply filters:', JSON.stringify(replyFilters, null, 2));
// Fetch all reply types
const allReplies = await nostrClient.fetchEvents(
replyFilters,
@ -142,6 +152,27 @@ @@ -142,6 +152,27 @@
{ useCache: true, cacheResults: true }
);
// Filter comments to ensure they match the thread (for threads, check #E tag and #K tag)
const filteredReplies = allReplies.filter(reply => {
if (reply.kind === 1111 && isThread) {
// For comments on threads, verify they have the correct tags
const eTag = reply.tags.find(t => t[0] === 'E' || t[0] === 'e');
const kTag = reply.tags.find(t => t[0] === 'K' || t[0] === 'k');
const matchesE = eTag && (eTag[1] === eventId);
const matchesK = kTag && (kTag[1] === '11');
// Accept if it matches with uppercase E tag, or if it matches with lowercase e tag (fallback)
return matchesE && (matchesK || !kTag); // If K tag exists, it must match; if not, just check E
}
return true; // Keep all other reply types
});
console.log('Fetched replies:', allReplies.length, 'Filtered:', filteredReplies.length);
console.log('Reply details:', filteredReplies.map(r => ({
kind: r.kind,
id: r.id.slice(0, 8),
tags: r.tags.filter(t => ['E', 'e', 'K', 'k'].includes(t[0])).map(t => [t[0], t[1]])
})));
// Load reactions (kind 7) for the OP
const reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [eventId] }],
@ -152,7 +183,9 @@ @@ -152,7 +183,9 @@
reactions = reactionEvents;
// Recursively fetch nested replies (this updates threadEvents internally)
await fetchNestedReplies(allReplies, relays, eventId, isThread);
await fetchNestedReplies(filteredReplies, relays, eventId, isThread);
console.log('Final threadEvents:', threadEvents.length);
// threadEvents is updated by fetchNestedReplies
} catch (error) {
@ -182,12 +215,16 @@ @@ -182,12 +215,16 @@
if (replyIds.length > 0) {
// Fetch replies to any of our replies
// For kind 1111 comments: use #E tag for threads, #e tag for other events
// Query with both uppercase (NIP-22) and lowercase (fallback) tag names
const nestedFilters = [
{ kinds: [9735], '#e': replyIds },
{ kinds: [1244], '#e': replyIds },
{ kinds: [1], '#e': replyIds },
...(isThread
? [{ kinds: [1111], '#E': replyIds, '#K': ['11'] }]
? [
{ kinds: [1111], '#E': replyIds, '#K': ['11'] }, // NIP-22 standard (uppercase)
{ kinds: [1111], '#e': replyIds } // Fallback (lowercase)
]
: [{ kinds: [1111], '#e': replyIds }]
)
];
@ -198,7 +235,27 @@ @@ -198,7 +235,27 @@
{ useCache: true, cacheResults: true }
);
for (const reply of nestedReplies) {
// Filter nested comments to ensure they match correctly
const filteredNested = nestedReplies.filter(reply => {
if (reply.kind === 1111 && isThread) {
// For comments on threads, verify they have the correct tags
const eTag = reply.tags.find(t => (t[0] === 'E' || t[0] === 'e') && t[1]);
const kTag = reply.tags.find(t => (t[0] === 'K' || t[0] === 'k') && t[1]);
// Check if this reply references the root thread or one of our reply IDs
const referencedId = eTag?.[1];
const matchesReply = referencedId && (referencedId === rootEventId || replyIds.includes(referencedId));
const matchesK = !kTag || kTag[1] === '11'; // K tag should be '11' or not present
return matchesReply && matchesK;
}
// For non-thread comments or other reply types, check if they reference our replies
if (reply.kind !== 1111) {
const eTag = reply.tags.find(t => t[0] === 'e' && t[1]);
return eTag && replyIds.includes(eTag[1]);
}
return true; // Keep other comment types
});
for (const reply of filteredNested) {
if (!allReplies.has(reply.id)) {
allReplies.set(reply.id, reply);
hasNewReplies = true;

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

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
let profile = $state<ProfileData | null>(null);
@ -19,26 +20,86 @@ @@ -19,26 +20,86 @@
onMount(async () => {
await nostrClient.initialize();
// Load profile after initialization
if ($page.params.pubkey) {
loadProfile();
} else {
// No pubkey provided - show error state
loading = false;
profile = null;
}
});
// React to route param changes
let lastParam = $state<string | null>(null);
$effect(() => {
if ($page.params.pubkey && !loading) {
const param = $page.params.pubkey;
// Only reload if parameter actually changed
if (param && param !== lastParam) {
lastParam = param;
loadProfile();
}
});
/**
* Decode route parameter to hex pubkey
* Supports: hex pubkey, npub, or nprofile
*/
function decodePubkey(param: string): string | null {
if (!param) return null;
// Check if it's already a hex pubkey (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(param)) {
return param.toLowerCase();
}
// Check if it's a bech32 encoded format (npub or nprofile)
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(param)) {
try {
const decoded = nip19.decode(param);
if (decoded.type === 'npub') {
return String(decoded.data);
} else if (decoded.type === 'nprofile') {
// nprofile contains pubkey and optional relays
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey);
}
}
} catch (error) {
console.error('Error decoding bech32:', error);
return null;
}
}
return null;
}
async function loadProfile() {
const param = $page.params.pubkey;
if (!param) {
console.warn('No pubkey parameter provided to ProfilePage');
loading = false;
profile = null;
return;
}
// Decode the parameter to hex pubkey
const pubkey = decodePubkey(param);
if (!pubkey) {
console.warn('Invalid pubkey format:', param);
loading = false;
profile = null;
return;
}
loading = true;
try {
const pubkey = $page.params.pubkey;
if (!pubkey) return;
console.log('Loading profile for pubkey:', pubkey, '(decoded from:', param + ')');
// Load profile
const profileData = await fetchProfile(pubkey);
profile = profileData;
console.log('Profile loaded:', profileData);
// Load user status
const status = await fetchUserStatus(pubkey);
@ -70,8 +131,14 @@ @@ -70,8 +131,14 @@
.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading profile:', error);
} finally {
// Set loading to false even on error so UI can show error state
loading = false;
profile = null; // Clear profile on error
} finally {
// Ensure loading is always set to false
if (loading) {
loading = false;
}
}
}
</script>
@ -120,7 +187,7 @@ @@ -120,7 +187,7 @@
{/each}
</div>
{/if}
<PaymentAddresses pubkey={$page.params.pubkey} />
<PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} />
</div>
<div class="profile-posts">

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

@ -20,6 +20,8 @@ @@ -20,6 +20,8 @@
let menuButton: HTMLButtonElement | null = $state(null);
let menuPosition = $state<'above' | 'below'>('above'); // Track menu position
let customEmojiUrls = $state<Map<string, string>>(new Map()); // Map of :shortcode: -> image URL
let emojiSearchQuery = $state('');
let isMobile = $state(false);
// Derived value for heart count
let heartCount = $derived(getReactionCount('+'));
@ -40,14 +42,49 @@ @@ -40,14 +42,49 @@
return emojis;
});
// Filter emojis based on search query
const filteredReactionMenu = $derived.by(() => {
if (!emojiSearchQuery.trim()) {
return reactionMenu;
}
const query = emojiSearchQuery.toLowerCase().trim();
return reactionMenu.filter(emoji => {
// Search by emoji character itself
if (emoji.toLowerCase().includes(query)) {
return true;
}
// For custom emojis, search by shortcode
if (emoji.startsWith(':') && emoji.endsWith(':')) {
return emoji.toLowerCase().includes(query);
}
// Try to match emoji unicode name (basic approach)
// This is a simple search - could be enhanced with proper emoji name data
return false;
});
});
// Custom emoji reactions (like :turtlehappy_sm:)
// These will be added dynamically from actual reactions received
onMount(async () => {
await nostrClient.initialize();
loadReactions();
onMount(() => {
nostrClient.initialize().then(() => {
loadReactions();
});
// Check if mobile on mount and resize
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
});
function checkMobile() {
isMobile = window.innerWidth < 768; // Match Tailwind's md breakpoint
}
async function loadReactions() {
loading = true;
@ -210,6 +247,20 @@ @@ -210,6 +247,20 @@
}
return customEmojis.sort();
}
// Filter custom emojis based on search query
const filteredCustomEmojis = $derived.by(() => {
const customEmojis = getCustomEmojis();
if (!emojiSearchQuery.trim()) {
return customEmojis;
}
const query = emojiSearchQuery.toLowerCase().trim();
return customEmojis.filter(emoji => {
// Search by shortcode (e.g., :turtlehappy_sm:)
return emoji.toLowerCase().includes(query);
});
});
function closeMenuOnOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement;
@ -217,6 +268,7 @@ @@ -217,6 +268,7 @@
!menuButton.contains(target) &&
!target.closest('.reaction-menu')) {
showMenu = false;
emojiSearchQuery = ''; // Clear search when closing
}
}
@ -225,19 +277,55 @@ @@ -225,19 +277,55 @@
// If menu is open, clicking heart again should just like/unlike
toggleReaction('+');
showMenu = false;
emojiSearchQuery = ''; // Clear search when closing
} else {
// Check if there's enough space above the button
if (menuButton) {
const rect = menuButton.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
// Position below if there's more space below or if space above is less than 300px
menuPosition = spaceBelow > spaceAbove || spaceAbove < 300 ? 'below' : 'above';
// On mobile, always use bottom drawer
if (isMobile) {
menuPosition = 'below';
} else {
// Check if there's enough space above the button
if (menuButton) {
const rect = menuButton.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
// Position below if there's more space below or if space above is less than 300px
menuPosition = spaceBelow > spaceAbove || spaceAbove < 300 ? 'below' : 'above';
}
}
// If menu is closed, open it
showMenu = true;
emojiSearchQuery = ''; // Clear search when opening
// Focus search input after menu opens (using setTimeout to ensure DOM is ready)
if (!isMobile) {
setTimeout(() => {
const searchInput = document.querySelector('.emoji-search-input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, 0);
}
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
emojiSearchQuery = target.value;
}
// Action to focus input when menu opens
function focusOnMount(node: HTMLInputElement, shouldFocus: boolean) {
if (shouldFocus) {
setTimeout(() => node.focus(), 0);
}
return {
update(newShouldFocus: boolean) {
if (newShouldFocus) {
setTimeout(() => node.focus(), 0);
}
}
};
}
$effect(() => {
if (showMenu) {
@ -264,9 +352,41 @@ @@ -264,9 +352,41 @@
<!-- Reaction menu dropdown -->
{#if showMenu}
<div class="reaction-menu" class:menu-below={menuPosition === 'below'}>
{#if isMobile}
<!-- Mobile backdrop -->
<div
class="mobile-drawer-backdrop"
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showMenu = false;
emojiSearchQuery = '';
}
}}
role="button"
tabindex="0"
aria-label="Close emoji menu"
></div>
{/if}
<div class="reaction-menu" class:menu-below={menuPosition === 'below'} class:mobile-drawer={isMobile}>
<!-- Search box - always visible at top -->
<div class="emoji-search-container">
<input
type="text"
placeholder="Search emojis..."
value={emojiSearchQuery}
oninput={handleSearchInput}
class="emoji-search-input"
aria-label="Search emojis"
use:focusOnMount={!isMobile}
/>
</div>
<!-- Scrollable content area -->
<div class="reaction-menu-content">
<div class="reaction-menu-grid">
{#each reactionMenu as reaction}
{#each filteredReactionMenu as reaction}
{@const count = getReactionCount(reaction)}
<button
onclick={() => {
@ -294,11 +414,11 @@ @@ -294,11 +414,11 @@
</div>
<!-- Custom emojis section -->
{#if getCustomEmojis().length > 0}
{#if filteredCustomEmojis.length > 0}
<div class="custom-emojis-section">
<div class="custom-emojis-label">Custom</div>
<div class="reaction-menu-grid">
{#each getCustomEmojis() as emoji}
{#each filteredCustomEmojis as emoji}
{@const count = getReactionCount(emoji)}
<button
onclick={() => {
@ -326,6 +446,7 @@ @@ -326,6 +446,7 @@
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
@ -431,12 +552,70 @@ @@ -431,12 +552,70 @@
min-width: 200px;
max-width: 300px;
max-height: min(60vh, 400px);
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
overflow: hidden;
/* Ensure scrollbar is always visible */
scrollbar-width: thin;
scrollbar-color: var(--fog-border, #e5e7eb) var(--fog-post, #ffffff);
}
.reaction-menu-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
}
/* Mobile drawer styles */
.reaction-menu.mobile-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
margin: 0;
border-radius: 1rem 1rem 0 0;
max-width: 100%;
max-height: 70vh;
min-width: auto;
width: 100%;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* Backdrop for mobile drawer */
.mobile-drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.reaction-menu.mobile-drawer {
z-index: 1000;
}
.reaction-menu.menu-below {
bottom: auto;
@ -573,4 +752,58 @@ @@ -573,4 +752,58 @@
display: inline-block;
vertical-align: middle;
}
.emoji-search-container {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
padding-top: 0;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block;
width: 100%;
}
:global(.dark) .emoji-search-container {
border-bottom-color: var(--fog-dark-border, #374151);
}
.emoji-search-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
display: block;
box-sizing: border-box;
}
.emoji-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1);
}
:global(.dark) .emoji-search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .emoji-search-input:focus {
border-color: var(--fog-dark-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.2);
}
/* Adjust grid for mobile */
@media (max-width: 768px) {
.reaction-menu-grid {
grid-template-columns: repeat(8, 1fr);
}
.reaction-menu-item {
min-height: 2.25rem;
font-size: 1.125rem;
}
}
</style>

3
src/lib/modules/threads/ThreadCard.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js';
interface Props {
thread: NostrEvent;
@ -162,7 +163,7 @@ @@ -162,7 +163,7 @@
function getPreview(): string {
// First 250 chars, plaintext (no markdown/images)
const plaintext = thread.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ');
const plaintext = stripMarkdown(thread.content);
return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : '');
}

188
src/lib/modules/threads/ThreadList.svelte

@ -20,6 +20,25 @@ @@ -20,6 +20,25 @@
loadThreads();
});
// Re-sort threads when sortBy changes (but not when threads changes)
let lastSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null);
$effect(() => {
// Only re-sort if sortBy actually changed
if (sortBy !== lastSortBy && threads.length > 0 && !loading) {
lastSortBy = sortBy;
if (sortBy === 'newest') {
threads = sortThreadsSync(threads);
} else {
// For async sorts, trigger the sort without blocking
// Pass sortBy explicitly to ensure we use the current value
sortThreads(threads, sortBy).then(sorted => {
threads = sorted;
});
}
}
});
async function loadThreads() {
loading = true;
try {
@ -39,17 +58,23 @@ @@ -39,17 +58,23 @@
cacheResults: true,
onUpdate: async (updatedEvents) => {
// Update threads when fresh data arrives from relays
threads = await sortThreads(updatedEvents);
// Pass sortBy explicitly to ensure we use the current value
threads = await sortThreads(updatedEvents, sortBy);
}
}
);
// Set initial cached data immediately
if (sortBy === 'newest') {
// Set initial cached data immediately using current sortBy value
// Capture sortBy at this point to ensure we use the correct value
const currentSort = sortBy;
if (currentSort === 'newest') {
threads = sortThreadsSync(events);
} else {
threads = await sortThreads(events);
// Pass sortBy explicitly to ensure we use the current value
threads = await sortThreads(events, currentSort);
}
// Update lastSortBy to match current sort so effect doesn't re-trigger
lastSortBy = currentSort;
} catch (error) {
console.error('Error loading threads:', error);
threads = []; // Set empty array on error to prevent undefined issues
@ -63,69 +88,114 @@ @@ -63,69 +88,114 @@
return [...events].sort((a, b) => b.created_at - a.created_at);
}
async function sortThreads(events: NostrEvent[]): Promise<NostrEvent[]> {
switch (sortBy) {
async function sortThreads(events: NostrEvent[], sortType: 'newest' | 'active' | 'upvoted' = sortBy): Promise<NostrEvent[]> {
switch (sortType) {
case 'newest':
return sortThreadsSync(events);
case 'active':
// Sort by most recent activity (comments, reactions, or zaps)
// Thread bumping: active threads rise to top
const activeSorted = await Promise.all(
events.map(async (event) => {
const commentRelays = relayManager.getCommentReadRelays();
const reactionRelays = relayManager.getThreadReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
// Get most recent comment
const comments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': [event.id], '#K': ['11'], limit: 1 }],
commentRelays,
{ useCache: true }
);
const lastCommentTime = comments.length > 0
? comments.sort((a, b) => b.created_at - a.created_at)[0].created_at
: 0;
// Get most recent reaction
const reactions = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id], limit: 1 }],
reactionRelays,
{ useCache: true }
);
const lastReactionTime = reactions.length > 0
? reactions.sort((a, b) => b.created_at - a.created_at)[0].created_at
: 0;
// Last activity is the most recent of all activities
const lastActivity = Math.max(
event.created_at,
lastCommentTime,
lastReactionTime
);
return { event, lastActivity };
})
// Batch fetch all comments and reactions at once to avoid concurrent request issues
const threadIds = events.map(e => e.id);
const commentRelays = relayManager.getCommentReadRelays();
const reactionRelays = relayManager.getThreadReadRelays();
// Batch fetch all comments for all threads
const allComments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': threadIds, '#K': ['11'] }],
commentRelays,
{ useCache: true }
);
// Batch fetch all reactions for all threads
const allReactions = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': threadIds }],
reactionRelays,
{ useCache: true }
);
// Group comments and reactions by thread ID
const commentsByThread = new Map<string, NostrEvent[]>();
const reactionsByThread = new Map<string, NostrEvent[]>();
for (const comment of allComments) {
const threadId = comment.tags.find(t => t[0] === 'E' || t[0] === 'e')?.[1];
if (threadId) {
if (!commentsByThread.has(threadId)) {
commentsByThread.set(threadId, []);
}
commentsByThread.get(threadId)!.push(comment);
}
}
for (const reaction of allReactions) {
const threadId = reaction.tags.find(t => t[0] === 'e')?.[1];
if (threadId) {
if (!reactionsByThread.has(threadId)) {
reactionsByThread.set(threadId, []);
}
reactionsByThread.get(threadId)!.push(reaction);
}
}
// Calculate last activity for each thread
const activeSorted = events.map((event) => {
const comments = commentsByThread.get(event.id) || [];
const reactions = reactionsByThread.get(event.id) || [];
const lastCommentTime = comments.length > 0
? Math.max(...comments.map(c => c.created_at))
: 0;
const lastReactionTime = reactions.length > 0
? Math.max(...reactions.map(r => r.created_at))
: 0;
const lastActivity = Math.max(
event.created_at,
lastCommentTime,
lastReactionTime
);
return { event, lastActivity };
});
return activeSorted
.sort((a, b) => b.lastActivity - a.lastActivity)
.map(({ event }) => event);
case 'upvoted':
// Sort by upvote count
const upvotedSorted = await Promise.all(
events.map(async (event) => {
const config = nostrClient.getConfig();
const reactionRelays = relayManager.getThreadReadRelays();
const reactions = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id] }],
reactionRelays,
{ useCache: true }
);
const upvoteCount = reactions.filter(
(r) => r.content.trim() === '+' || r.content.trim() === '⬆' || r.content.trim() === '↑'
).length;
return { event, upvotes: upvoteCount };
})
// Batch fetch all reactions at once to avoid concurrent request issues
const allThreadIds = events.map(e => e.id);
const reactionRelaysForUpvotes = relayManager.getThreadReadRelays();
const allReactionsForUpvotes = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': allThreadIds }],
reactionRelaysForUpvotes,
{ useCache: true }
);
// Group reactions by thread ID
const reactionsByThreadForUpvotes = new Map<string, NostrEvent[]>();
for (const reaction of allReactionsForUpvotes) {
const threadId = reaction.tags.find(t => t[0] === 'e')?.[1];
if (threadId) {
if (!reactionsByThreadForUpvotes.has(threadId)) {
reactionsByThreadForUpvotes.set(threadId, []);
}
reactionsByThreadForUpvotes.get(threadId)!.push(reaction);
}
}
// Calculate upvote count for each thread
const upvotedSorted = events.map((event) => {
const reactions = reactionsByThreadForUpvotes.get(event.id) || [];
const upvoteCount = reactions.filter(
(r) => r.content.trim() === '+' || r.content.trim() === '⬆' || r.content.trim() === '↑'
).length;
return { event, upvotes: upvoteCount };
});
return upvotedSorted
.sort((a, b) => b.upvotes - a.upvotes)
.map(({ event }) => event);
@ -245,7 +315,7 @@ @@ -245,7 +315,7 @@
/>
Show older threads
</label>
<select bind:value={sortBy} onchange={async () => { threads = await sortThreads(threads); }} class="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 px-2 py-1 rounded">
<select bind:value={sortBy} class="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 px-2 py-1 rounded">
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
@ -299,7 +369,7 @@ @@ -299,7 +369,7 @@
}
}}
>
<FeedPost post={thread} />
<FeedPost post={thread} previewMode={true} />
</div>
{/each}
@ -319,7 +389,7 @@ @@ -319,7 +389,7 @@
}
}}
>
<FeedPost post={thread} />
<FeedPost post={thread} previewMode={true} />
</div>
{/each}
{/each}
@ -342,7 +412,7 @@ @@ -342,7 +412,7 @@
}
}}
>
<FeedPost post={thread} />
<FeedPost post={thread} previewMode={true} />
</div>
{/each}
{#if getFilteredThreads().length === 0}

4
src/lib/services/auth/activity-tracker.ts

@ -7,9 +7,11 @@ import type { NostrEvent } from '../../types/nostr.js'; @@ -7,9 +7,11 @@ import type { NostrEvent } from '../../types/nostr.js';
/**
* Get last activity timestamp for a pubkey
* Only checks cache - does not make relay requests
* Parent components should batch fetch activity data upfront
*/
export async function getLastActivity(pubkey: string): Promise<number | undefined> {
// Query for recent events from this pubkey
// Query for recent events from this pubkey (cache only)
const filters = [
{ authors: [pubkey], kinds: [0, 1, 7, 11, 1111], limit: 1 }
];

111
src/lib/services/cache/event-cache.ts vendored

@ -13,78 +13,103 @@ export interface CachedEvent extends NostrEvent { @@ -13,78 +13,103 @@ export interface CachedEvent extends NostrEvent {
* Store an event in cache
*/
export async function cacheEvent(event: NostrEvent): Promise<void> {
const db = await getDB();
const cached: CachedEvent = {
...event,
cached_at: Date.now()
};
await db.put('events', cached);
try {
const db = await getDB();
const cached: CachedEvent = {
...event,
cached_at: Date.now()
};
await db.put('events', cached);
} catch (error) {
console.debug('Error caching event:', error);
// Don't throw - caching failures shouldn't break the app
}
}
/**
* Store multiple events in cache
*/
export async function cacheEvents(events: NostrEvent[]): Promise<void> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
for (const event of events) {
const cached: CachedEvent = {
...event,
cached_at: Date.now()
};
await tx.store.put(cached);
try {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
for (const event of events) {
const cached: CachedEvent = {
...event,
cached_at: Date.now()
};
await tx.store.put(cached);
}
await tx.done;
} catch (error) {
console.debug('Error caching events:', error);
// Don't throw - caching failures shouldn't break the app
}
await tx.done;
}
/**
* Get event by ID from cache
*/
export async function getEvent(id: string): Promise<CachedEvent | undefined> {
const db = await getDB();
return db.get('events', id);
try {
const db = await getDB();
return await db.get('events', id);
} catch (error) {
console.debug('Error getting event from cache:', error);
return undefined;
}
}
/**
* Get events by kind
*/
export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('kind');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(kind)) {
if (limit && count >= limit) break;
events.push(cursor.value);
count++;
}
try {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('kind');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(kind)) {
if (limit && count >= limit) break;
events.push(cursor.value);
count++;
}
await tx.done;
await tx.done;
return events.sort((a, b) => b.created_at - a.created_at);
return events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.debug('Error getting events by kind from cache:', error);
return [];
}
}
/**
* Get events by pubkey
*/
export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('pubkey');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(pubkey)) {
if (limit && count >= limit) break;
events.push(cursor.value);
count++;
}
try {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('pubkey');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(pubkey)) {
if (limit && count >= limit) break;
events.push(cursor.value);
count++;
}
await tx.done;
await tx.done;
return events.sort((a, b) => b.created_at - a.created_at);
return events.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.debug('Error getting events by pubkey from cache:', error);
return [];
}
}
/**

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

@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 1;
const DB_VERSION = 2; // Incremented to force upgrade when stores are missing
export interface DatabaseSchema {
events: {
@ -35,34 +35,98 @@ let dbInstance: IDBPDatabase<DatabaseSchema> | null = null; @@ -35,34 +35,98 @@ let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (dbInstance) return dbInstance;
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Events store
if (!db.objectStoreNames.contains('events')) {
const eventStore = db.createObjectStore('events', { keyPath: 'id' });
eventStore.createIndex('kind', 'kind', { unique: false });
eventStore.createIndex('pubkey', 'pubkey', { unique: false });
eventStore.createIndex('created_at', 'created_at', { unique: false });
}
try {
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Events store
if (!db.objectStoreNames.contains('events')) {
const eventStore = db.createObjectStore('events', { keyPath: 'id' });
eventStore.createIndex('kind', 'kind', { unique: false });
eventStore.createIndex('pubkey', 'pubkey', { unique: false });
eventStore.createIndex('created_at', 'created_at', { unique: false });
}
// Profiles store
if (!db.objectStoreNames.contains('profiles')) {
db.createObjectStore('profiles', { keyPath: 'pubkey' });
}
// Profiles store
if (!db.objectStoreNames.contains('profiles')) {
db.createObjectStore('profiles', { keyPath: 'pubkey' });
}
// Keys store
if (!db.objectStoreNames.contains('keys')) {
db.createObjectStore('keys', { keyPath: 'id' });
}
// Keys store
if (!db.objectStoreNames.contains('keys')) {
db.createObjectStore('keys', { keyPath: 'id' });
}
// Search index store
if (!db.objectStoreNames.contains('search')) {
db.createObjectStore('search', { keyPath: 'id' });
// Search index store
if (!db.objectStoreNames.contains('search')) {
db.createObjectStore('search', { keyPath: 'id' });
}
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');
},
blocking() {
// Close connection if another tab wants to upgrade
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}
});
// Verify all stores exist after opening - if not, database is corrupted
if (!dbInstance.objectStoreNames.contains('events') ||
!dbInstance.objectStoreNames.contains('profiles') ||
!dbInstance.objectStoreNames.contains('keys') ||
!dbInstance.objectStoreNames.contains('search')) {
// Database is corrupted - close and delete it, then recreate
console.warn('Database missing required stores, recreating...');
dbInstance.close();
dbInstance = null;
// Delete the corrupted database
const deleteReq = indexedDB.deleteDatabase(DB_NAME);
await new Promise<void>((resolve, reject) => {
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject(deleteReq.error);
deleteReq.onblocked = () => {
console.warn('Database deletion blocked - another tab may have it open');
resolve(); // Continue anyway
};
});
// Wait a bit for deletion to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Recreate database
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, {
upgrade(db) {
const eventStore = db.createObjectStore('events', { keyPath: 'id' });
eventStore.createIndex('kind', 'kind', { unique: false });
eventStore.createIndex('pubkey', 'pubkey', { unique: false });
eventStore.createIndex('created_at', 'created_at', { unique: false });
db.createObjectStore('profiles', { keyPath: 'pubkey' });
db.createObjectStore('keys', { keyPath: 'id' });
db.createObjectStore('search', { keyPath: 'id' });
},
blocked() {
console.warn('IndexedDB is blocked - another tab may have it open');
},
blocking() {
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}
});
}
});
return dbInstance;
return dbInstance;
} catch (error) {
console.error('Failed to open IndexedDB:', error);
// Reset instance so we can retry
dbInstance = null;
throw error;
}
}
/**
@ -74,3 +138,13 @@ export async function closeDB(): Promise<void> { @@ -74,3 +138,13 @@ export async function closeDB(): Promise<void> {
dbInstance = null;
}
}
/**
* Reset database instance (useful for recovery from errors)
*/
export function resetDB(): void {
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}

53
src/lib/services/cache/profile-cache.ts vendored

@ -16,38 +16,53 @@ export interface CachedProfile { @@ -16,38 +16,53 @@ export interface CachedProfile {
*/
export async function cacheProfile(event: NostrEvent): Promise<void> {
if (event.kind !== 0) throw new Error('Not a profile event');
const db = await getDB();
const cached: CachedProfile = {
pubkey: event.pubkey,
event,
cached_at: Date.now()
};
await db.put('profiles', cached);
try {
const db = await getDB();
const cached: CachedProfile = {
pubkey: event.pubkey,
event,
cached_at: Date.now()
};
await db.put('profiles', cached);
} catch (error) {
console.debug('Error caching profile:', error);
// Don't throw - caching failures shouldn't break the app
}
}
/**
* Get profile by pubkey from cache
*/
export async function getProfile(pubkey: string): Promise<CachedProfile | undefined> {
const db = await getDB();
return db.get('profiles', pubkey);
try {
const db = await getDB();
return await db.get('profiles', pubkey);
} catch (error) {
console.debug('Error getting profile from cache:', error);
return undefined;
}
}
/**
* Get multiple profiles
*/
export async function getProfiles(pubkeys: string[]): Promise<Map<string, CachedProfile>> {
const db = await getDB();
const profiles = new Map<string, CachedProfile>();
const tx = db.transaction('profiles', 'readonly');
try {
const db = await getDB();
const profiles = new Map<string, CachedProfile>();
const tx = db.transaction('profiles', 'readonly');
for (const pubkey of pubkeys) {
const profile = await tx.store.get(pubkey);
if (profile) {
profiles.set(pubkey, profile);
for (const pubkey of pubkeys) {
const profile = await tx.store.get(pubkey);
if (profile) {
profiles.set(pubkey, profile);
}
}
}
await tx.done;
return profiles;
await tx.done;
return profiles;
} catch (error) {
console.debug('Error getting profiles from cache:', error);
return new Map();
}
}

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

@ -7,7 +7,6 @@ const DEFAULT_RELAYS = [ @@ -7,7 +7,6 @@ const DEFAULT_RELAYS = [
'wss://theforest.nostr1.com',
'wss://nostr21.com',
'wss://nostr.land',
'wss://nostr.wine',
'wss://nostr.sovbit.host',
'wss://orly-relay.imwald.eu'
];
@ -24,6 +23,8 @@ const THREAD_PUBLISH_RELAYS = [ @@ -24,6 +23,8 @@ const THREAD_PUBLISH_RELAYS = [
const RELAY_TIMEOUT = 10000;
const ZAP_THRESHOLD = 1;
export interface NostrConfig {
defaultRelays: string[];
profileRelays: string[];

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

@ -116,10 +116,31 @@ class NostrClient { @@ -116,10 +116,31 @@ class NostrClient {
return true;
}
/**
* Check if a zap receipt should be filtered (below threshold)
*/
private shouldFilterZapReceipt(event: NostrEvent): boolean {
if (event.kind !== 9735) return false; // Not a zap receipt
const amountTag = event.tags.find((t) => t[0] === 'amount');
if (!amountTag || !amountTag[1]) return true; // Filter if no amount tag
const amount = parseInt(amountTag[1], 10);
if (isNaN(amount)) return true; // Filter if invalid amount
// Filter if amount is below threshold
return amount < config.zapThreshold;
}
/**
* Add event to cache
*/
private addToCache(event: NostrEvent): void {
// Filter out low-value zap receipts before caching
if (this.shouldFilterZapReceipt(event)) {
return; // Don't cache spam zap receipts
}
// Cache to IndexedDB
cacheEvent(event).catch((error) => {
console.error('Error caching event:', error);
@ -130,34 +151,45 @@ class NostrClient { @@ -130,34 +151,45 @@ class NostrClient {
* Get events from cache that match filters
*/
private async getCachedEvents(filters: Filter[]): Promise<NostrEvent[]> {
const results: NostrEvent[] = [];
const seen = new Set<string>();
// Query IndexedDB for each filter
for (const filter of filters) {
if (filter.kinds && filter.kinds.length === 1) {
const events = await getEventsByKind(filter.kinds[0], filter.limit || 50);
for (const event of events) {
if (seen.has(event.id)) continue;
if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
try {
const results: NostrEvent[] = [];
const seen = new Set<string>();
// Query IndexedDB for each filter
for (const filter of filters) {
try {
if (filter.kinds && filter.kinds.length === 1) {
const events = await getEventsByKind(filter.kinds[0], filter.limit || 50);
for (const event of events) {
if (seen.has(event.id)) continue;
if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
}
}
}
}
}
if (filter.authors && filter.authors.length === 1) {
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50);
for (const event of events) {
if (seen.has(event.id)) continue;
if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
if (filter.authors && filter.authors.length === 1) {
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50);
for (const event of events) {
if (seen.has(event.id)) continue;
if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
}
}
}
} catch (error) {
// If cache access fails for a specific filter, log and continue
console.debug('Error accessing cache for filter:', error);
}
}
}
return filterEvents(results);
return filterEvents(results);
} catch (error) {
// If cache is completely unavailable, return empty array
console.debug('Cache unavailable, returning empty results:', error);
return [];
}
}
/**
@ -291,6 +323,8 @@ class NostrClient { @@ -291,6 +323,8 @@ class NostrClient {
try {
// Check if relay is still in pool before processing
if (!client.relays.has(url)) return;
// Filter out low-value zap receipts
if (client.shouldFilterZapReceipt(event)) return;
// Add to cache
client.addToCache(event);
// Call callback
@ -469,6 +503,8 @@ class NostrClient { @@ -469,6 +503,8 @@ class NostrClient {
onevent(event: NostrEvent) {
if (!client.relays.has(relayUrl)) return;
if (shouldHideEvent(event)) return;
// Filter out low-value zap receipts before adding to results
if (client.shouldFilterZapReceipt(event)) return;
events.set(event.id, event);
client.addToCache(event);
},
@ -506,19 +542,22 @@ class NostrClient { @@ -506,19 +542,22 @@ class NostrClient {
const eventArray = Array.from(events.values());
const filtered = filterEvents(eventArray);
// Cache results in background
if (options.cacheResults && filtered.length > 0) {
cacheEvents(filtered).catch((error) => {
// Filter out low-value zap receipts before caching
const zapFiltered = filtered.filter(event => !this.shouldFilterZapReceipt(event));
// Cache results in background (only non-spam zap receipts)
if (options.cacheResults && zapFiltered.length > 0) {
cacheEvents(zapFiltered).catch((error) => {
console.error('Error caching events:', error);
});
}
// Call onUpdate callback
// Call onUpdate callback (with zap-filtered results)
if (options.onUpdate) {
options.onUpdate(filtered);
options.onUpdate(zapFiltered);
}
return filtered;
return zapFiltered;
}

39
src/lib/services/text-utils.ts

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
/**
* Text utility functions
*/
/**
* Strip markdown formatting from text, returning plain text
*/
export function stripMarkdown(text: string): string {
// Remove code blocks (```code```)
text = text.replace(/```[\s\S]*?```/g, '');
// Remove inline code (`code`)
text = text.replace(/`[^`]*`/g, '');
// Remove images (![alt](url))
text = text.replace(/!\[([^\]]*)\]\([^\)]*\)/g, '');
// Remove links ([text](url)) - keep the link text
text = text.replace(/\[([^\]]*)\]\([^\)]*\)/g, '$1');
// Remove headers (# ## ###)
text = text.replace(/^#{1,6}\s+/gm, '');
// Remove bold (**text** or __text__)
text = text.replace(/\*\*([^*]+)\*\*/g, '$1');
text = text.replace(/__([^_]+)__/g, '$1');
// Remove italic (*text* or _text_)
text = text.replace(/\*([^*]+)\*/g, '$1');
text = text.replace(/_([^_]+)_/g, '$1');
// Remove strikethrough (~~text~~)
text = text.replace(/~~([^~]+)~~/g, '$1');
// Remove blockquotes (> text)
text = text.replace(/^>\s+/gm, '');
// Remove list markers (- * + or 1. 2. etc)
text = text.replace(/^[\s]*[-*+]\s+/gm, '');
text = text.replace(/^\d+\.\s+/gm, '');
// Remove horizontal rules (--- or ***)
text = text.replace(/^[-*]{3,}$/gm, '');
// Replace newlines with spaces
text = text.replace(/\n/g, ' ');
// Remove extra whitespace
text = text.replace(/\s+/g, ' ').trim();
return text;
}
Loading…
Cancel
Save