Browse Source

more bug-fixes

master
Silberengel 1 month ago
parent
commit
3f3d9fb6b6
  1. 16
      package-lock.json
  2. 4
      package.json
  3. 4
      public/healthz.json
  4. 14
      src/app.css
  5. 4
      src/lib/components/layout/Header.svelte
  6. 69
      src/lib/components/layout/ProfileBadge.svelte
  7. 11
      src/lib/components/preferences/UserPreferences.svelte
  8. 116
      src/lib/modules/feed/FeedPage.svelte
  9. 8
      src/lib/modules/feed/FeedPost.svelte
  10. 136
      src/lib/modules/feed/ThreadDrawer.svelte
  11. 413
      src/lib/modules/reactions/FeedReactionButtons.svelte
  12. 106
      src/lib/modules/threads/ThreadList.svelte
  13. 158
      src/lib/services/nostr/nip30-emoji.ts
  14. 5
      src/lib/types/kind-lookup.ts

16
package-lock.json generated

@ -12,10 +12,12 @@
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"idb": "^8.0.0", "idb": "^8.0.0",
"marked": "^11.1.1", "marked": "^11.1.1",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
"svelte": "^5.0.0" "svelte": "^5.0.0",
"unicode-emoji-json": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.0", "@sveltejs/adapter-static": "^3.0.0",
@ -1987,6 +1989,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-picker-element": {
"version": "1.28.1",
"resolved": "https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.28.1.tgz",
"integrity": "sha512-8c64IPish2PWoV9oYCo2pvuPHwIv+uK9bO0dfpPyMupDAvaWL9ZvYhWNTAR+2sx7BhfRjciImqP6CIUgNX+DMg==",
"license": "Apache-2.0"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.24.2", "version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
@ -4135,6 +4143,12 @@
"optional": true, "optional": true,
"peer": true "peer": true
}, },
"node_modules/unicode-emoji-json": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/unicode-emoji-json/-/unicode-emoji-json-0.8.0.tgz",
"integrity": "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==",
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

4
package.json

@ -26,10 +26,12 @@
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"idb": "^8.0.0", "idb": "^8.0.0",
"marked": "^11.1.1", "marked": "^11.1.1",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
"svelte": "^5.0.0" "svelte": "^5.0.0",
"unicode-emoji-json": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.0", "@sveltejs/adapter-static": "^3.0.0",

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.0",
"buildTime": "2026-02-03T07:57:56.985Z", "buildTime": "2026-02-03T09:44:26.608Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770105476985 "timestamp": 1770111866608
} }

14
src/app.css

@ -47,6 +47,20 @@
--content-width: 1200px; --content-width: 1200px;
} }
/* Automatically set content width to narrow on mobile screens */
@media (max-width: 768px) {
:root {
--content-width: 600px;
}
/* Override any user preference on mobile */
[data-content-width='narrow'],
[data-content-width='medium'],
[data-content-width='wide'] {
--content-width: 600px;
}
}
body { body {
font-size: var(--text-size); font-size: var(--text-size);
line-height: var(--line-height); line-height: var(--line-height);

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

@ -34,9 +34,9 @@
<!-- 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 items-center justify-between max-w-7xl mx-auto"> <div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a> <a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<div class="flex gap-4 items-center text-sm"> <div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a> <a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a> <a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
{#if isLoggedIn && currentPubkey} {#if isLoggedIn && currentPubkey}

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

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js'; import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js';
import { fetchProfile, fetchUserStatus } from '../../services/user-data.js'; import { fetchProfile, fetchUserStatus } from '../../services/user-data.js';
import { onMount } from 'svelte';
interface Props { interface Props {
pubkey: string; pubkey: string;
@ -13,9 +12,11 @@
let status = $state<string | null>(null); let status = $state<string | null>(null);
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);
$effect(() => { $effect(() => {
if (pubkey) { if (pubkey) {
imageError = false; // Reset image error when pubkey changes
loadProfile(); loadProfile();
loadStatus(); loadStatus();
updateActivityStatus(); updateActivityStatus();
@ -50,24 +51,57 @@
return '#9ca3af'; return '#9ca3af';
} }
} }
// Generate deterministic avatar color from pubkey
let avatarColor = $derived.by(() => {
// Hash the pubkey to get consistent colors
let hash = 0;
for (let i = 0; i < pubkey.length; i++) {
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%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
});
// Get avatar initials from pubkey
let avatarInitials = $derived(pubkey.slice(0, 2).toUpperCase());
</script> </script>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2"> <a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2 min-w-0 max-w-full">
{#if profile?.picture} {#if profile?.picture && !imageError}
<img src={profile.picture} alt={profile.name || pubkey} class="profile-picture w-6 h-6 rounded" /> <img
src={profile.picture}
alt={profile.name || pubkey}
class="profile-picture w-6 h-6 rounded flex-shrink-0"
onerror={() => {
imageError = true;
}}
/>
{:else} {:else}
<div class="w-6 h-6 rounded bg-fog-highlight dark:bg-fog-dark-highlight"></div> <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;"
title={pubkey}
>
{avatarInitials}
</div>
{/if} {/if}
<span>{profile?.name || pubkey.slice(0, 16)}...</span> <span class="truncate min-w-0">{profile?.name || pubkey.slice(0, 16)}...</span>
{#if activityStatus && activityMessage} {#if activityStatus && activityMessage}
<span <span
class="w-2 h-2 rounded-full" class="activity-dot w-2 h-2 rounded-full flex-shrink-0"
style="background-color: {getActivityColor()}" style="background-color: {getActivityColor()}"
title={activityMessage} title={activityMessage}
aria-label={activityMessage}
></span> ></span>
{/if} {/if}
{#if status} {#if status}
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">({status})</span> <span class="status-text text-sm text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap">({status})</span>
{/if} {/if}
</a> </a>
@ -75,9 +109,28 @@
.profile-badge { .profile-badge {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
max-width: 100%;
} }
.profile-badge:hover { .profile-badge:hover {
text-decoration: underline; text-decoration: underline;
} }
.profile-picture {
object-fit: cover;
display: block;
}
.profile-placeholder {
user-select: none;
line-height: 1;
}
.activity-dot {
display: inline-block;
}
.status-text {
display: inline-block;
}
</style> </style>

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

@ -42,29 +42,32 @@
const root = document.documentElement; const root = document.documentElement;
// Text size // Text size - set both CSS variable and data attribute
const textSizes = { const textSizes = {
small: '14px', small: '14px',
medium: '16px', medium: '16px',
large: '18px' large: '18px'
}; };
root.style.setProperty('--base-font-size', textSizes[textSize]); root.style.setProperty('--text-size', textSizes[textSize]);
root.setAttribute('data-text-size', textSize);
// Line spacing // Line spacing - set both CSS variable and data attribute
const lineSpacings = { const lineSpacings = {
tight: '1.4', tight: '1.4',
normal: '1.6', normal: '1.6',
loose: '1.8' loose: '1.8'
}; };
root.style.setProperty('--line-height', lineSpacings[lineSpacing]); root.style.setProperty('--line-height', lineSpacings[lineSpacing]);
root.setAttribute('data-line-spacing', lineSpacing);
// Content width // Content width - set CSS variable and data attribute
const contentWidths = { const contentWidths = {
narrow: '600px', narrow: '600px',
medium: '800px', medium: '800px',
wide: '1200px' wide: '1200px'
}; };
root.style.setProperty('--content-width', contentWidths[contentWidth]); root.style.setProperty('--content-width', contentWidths[contentWidth]);
root.setAttribute('data-content-width', contentWidth);
} }
$effect(() => { $effect(() => {

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

@ -22,6 +22,7 @@
let scrollTimeout: ReturnType<typeof setTimeout> | null = null; let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button
let pendingNewReplaceable = $state<NostrEvent[]>([]); // Store new replaceable events let pendingNewReplaceable = $state<NostrEvent[]>([]); // Store new replaceable events
let loadedParentQuotedEvents = $state<Map<string, NostrEvent>>(new Map()); // Store loaded parent/quoted events separately (doesn't trigger feed re-render)
// Thread drawer state // Thread drawer state
let drawerOpen = $state(false); let drawerOpen = $state(false);
@ -166,20 +167,31 @@
}, 1000); // Debounce to 1 second to reduce update frequency }, 1000); // Debounce to 1 second to reduce update frequency
}; };
// For initial load (reset), don't use onUpdate to prevent incremental updates
// Wait for the full fetch to complete before displaying anything
const fetchOptions = reset
? {
useCache: true,
cacheResults: true,
timeout: 10000
// No onUpdate during initial load to prevent scrolling
}
: {
useCache: true,
cacheResults: true,
timeout: 10000,
onUpdate: debouncedOnUpdate
};
// Get cached events first for immediate display, then refresh in background // Get cached events first for immediate display, then refresh in background
// useCache: true will automatically trigger background refresh with onUpdate // For initial load, wait for full fetch without onUpdate to prevent scrolling
const cachedEvents = await nostrClient.fetchEvents( const cachedEvents = await nostrClient.fetchEvents(
feedFilter, feedFilter,
relays, relays,
{ fetchOptions
useCache: true,
cacheResults: true,
timeout: 10000,
onUpdate: debouncedOnUpdate
}
); );
// Process cached events immediately // Process cached events
// Load ALL feed events into posts array (including replies and kind 1111) // Load ALL feed events into posts array (including replies and kind 1111)
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox // Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1); const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1);
@ -195,6 +207,7 @@
); );
if (reset) { if (reset) {
// For initial load, batch all updates at once to prevent scrolling
// Load ALL events into posts array - filtering happens client-side // Load ALL events into posts array - filtering happens client-side
// Only sort if we have posts to prevent unnecessary re-renders // Only sort if we have posts to prevent unnecessary re-renders
if (regularPosts.length > 0 || otherFeedEvents.length > 0) { if (regularPosts.length > 0 || otherFeedEvents.length > 0) {
@ -235,13 +248,21 @@
allFeedEvents = cachedEvents; allFeedEvents = cachedEvents;
} }
// For initial load, wait a moment to ensure all data is processed before showing feed
// This prevents scrolling issues from incremental updates
if (reset) {
// Small delay to ensure DOM is ready and prevent scroll jumping
await new Promise(resolve => setTimeout(resolve, 100));
}
// Background refresh is handled automatically by fetchEvents with useCache: true // Background refresh is handled automatically by fetchEvents with useCache: true
// No need to wait for it - it updates the UI via onUpdate callback // For initial load, we don't use onUpdate, so background refresh won't cause scrolling
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events // Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout) // One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
// Only fetch if we're not in a loading state to prevent excessive requests // Only fetch if we're not in a loading state to prevent excessive requests
if (!isLoadingFeed && !loading && !loadingMore) { // Don't fetch during initial load to prevent scrolling
if (!isLoadingFeed && !loading && !loadingMore && !reset) {
const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id); const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id);
if (displayedEventIds.length > 0) { if (displayedEventIds.length > 0) {
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events // Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
@ -434,11 +455,24 @@
} }
function isReply(post: NostrEvent): boolean { function isReply(post: NostrEvent): boolean {
// Check if this is a kind 1 event with a reply tag // Check if this event references another event (reply, quote, or replaceable event reference)
if (post.kind === 1) { // Filter out anything with "e", "a", or "q" tags
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
return replyTag !== undefined && replyTag[1] !== undefined; // Check for "e" tag (references another event - reply)
if (post.tags.some((t) => t[0] === 'e' && t[1] !== undefined && t[1] !== post.id)) {
return true;
}
// Check for "a" tag (references a replaceable event)
if (post.tags.some((t) => t[0] === 'a' && t[1] !== undefined)) {
return true;
} }
// Check for "q" tag (quotes another event)
if (post.tags.some((t) => t[0] === 'q' && t[1] !== undefined)) {
return true;
}
return false; return false;
} }
@ -458,12 +492,12 @@
}); });
} }
// Filter for OPs only (original posts, no replies) // Filter for OPs only (original posts, no replies, quotes, or references)
if (showOPsOnly) { if (showOPsOnly) {
filtered = filtered.filter(post => { filtered = filtered.filter(post => {
// Filter out all kind 1111 events (comments) // Filter out all kind 1111 events (comments)
if (post.kind === 1111) return false; if (post.kind === 1111) return false;
// Filter out kind 1 events that are replies // Filter out any event that is a reply, quote, or reference
if (isReply(post)) return false; if (isReply(post)) return false;
// Keep everything else (original posts) // Keep everything else (original posts)
return true; return true;
@ -478,10 +512,21 @@
let cachedFeedItemsKey = ''; let cachedFeedItemsKey = '';
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) { function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements // Don't open drawer if clicking on interactive elements (but allow the wrapper itself)
if (e) { if (e) {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('[role="button"]')) { const wrapper = target.closest('.post-wrapper');
// If the target itself is a button, link, or has role="button" (but not the wrapper)
if (target.tagName === 'BUTTON' || target.tagName === 'A' ||
(target.getAttribute('role') === 'button' && target !== wrapper)) {
return;
}
// Check if clicking inside a button or link (but not the wrapper)
const button = target.closest('button');
const link = target.closest('a');
if ((button && button !== wrapper) || (link && link !== wrapper)) {
return; return;
} }
} }
@ -540,7 +585,7 @@
<div class="Feed-feed"> <div class="Feed-feed">
<div class="feed-header mb-4"> <div class="feed-header mb-4">
<h1 class="text-2xl font-bold mb-4">Feed</h1> <h1 class="text-2xl font-bold mb-4">Feed</h1>
<div class="feed-controls flex items-center gap-4"> <div class="feed-controls flex flex-wrap items-center gap-2 sm:gap-4">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -581,9 +626,9 @@
{#each getAllFeedItems() as item (item.id)} {#each getAllFeedItems() as item (item.id)}
{#if item.type === 'post'} {#if item.type === 'post'}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]} {@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? posts.find(p => p.id === parentId) : undefined} {@const parentEvent = parentId ? (posts.find(p => p.id === parentId) || loadedParentQuotedEvents.get(parentId)) : undefined}
{@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]} {@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]}
{@const quotedEvent = quotedId ? posts.find(p => p.id === quotedId) : undefined} {@const quotedEvent = quotedId ? (posts.find(p => p.id === quotedId) || loadedParentQuotedEvents.get(quotedId)) : undefined}
<div <div
data-post-id={item.event.id} data-post-id={item.event.id}
class="post-wrapper" class="post-wrapper"
@ -603,21 +648,17 @@
parentEvent={parentEvent} parentEvent={parentEvent}
quotedEvent={quotedEvent} quotedEvent={quotedEvent}
onParentLoaded={(event) => { onParentLoaded={(event) => {
// Add loaded parent to posts array if not already there // Store loaded parent/quoted events in separate map to prevent feed re-rendering
// Don't re-sort - just append to prevent feed jumping // NEVER add to main posts array - this causes feed jumping
if (!posts.find(p => p.id === event.id)) { if (!loadedParentQuotedEvents.has(event.id)) {
posts = [...posts, event]; loadedParentQuotedEvents.set(event.id, event);
// Invalidate cache
cachedFeedItems = null;
} }
}} }}
onQuotedLoaded={(event) => { onQuotedLoaded={(event) => {
// Add loaded quoted event to posts array if not already there // Store loaded parent/quoted events in separate map to prevent feed re-rendering
// Don't re-sort - just append to prevent feed jumping // NEVER add to main posts array - this causes feed jumping
if (!posts.find(p => p.id === event.id)) { if (!loadedParentQuotedEvents.has(event.id)) {
posts = [...posts, event]; loadedParentQuotedEvents.set(event.id, event);
// Invalidate cache
cachedFeedItems = null;
} }
}} }}
/> />
@ -672,8 +713,15 @@
.feed-controls { .feed-controls {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 1rem; gap: 0.75rem 1rem; /* row-gap column-gap for better wrapping */
}
@media (max-width: 768px) {
.feed-controls {
gap: 0.5rem 0.75rem; /* Smaller gaps on mobile */
}
} }
.checkbox { .checkbox {

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

@ -172,14 +172,14 @@
/> />
{/if} {/if}
<div class="post-header flex items-center gap-2 mb-2"> <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">{getRelativeTime()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{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-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if} {/if}
{#if isReply()} {#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
{/if} {/if}
</div> </div>

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

@ -19,32 +19,120 @@
let loading = $state(false); let loading = $state(false);
let threadEvents = $state<NostrEvent[]>([]); let threadEvents = $state<NostrEvent[]>([]);
let reactions = $state<NostrEvent[]>([]); let reactions = $state<NostrEvent[]>([]);
let rootEvent = $state<NostrEvent | null>(null); // The actual OP/root event
// Load thread when drawer opens // Load thread when drawer opens
$effect(() => { $effect(() => {
if (isOpen && opEvent) { if (isOpen && opEvent) {
// Hide main page scrollbar when drawer is open
document.body.style.overflow = 'hidden';
loadThread(); loadThread();
} else { } else {
// Reset when closed // Reset when closed and restore scrollbar
document.body.style.overflow = '';
threadEvents = []; threadEvents = [];
reactions = []; reactions = [];
rootEvent = null;
} }
// Cleanup on unmount
return () => {
document.body.style.overflow = '';
};
}); });
/**
* Find the root OP event by traversing up the reply chain
* Uses a visited set to prevent infinite loops
*/
async function findRootEvent(event: NostrEvent, visited: Set<string> = new Set()): Promise<NostrEvent> {
// Prevent infinite loops
if (visited.has(event.id)) {
return event;
}
visited.add(event.id);
// Check for 'root' tag first (NIP-10) - this directly points to the root
const rootTag = event.tags.find((t) => t[0] === 'root');
if (rootTag && rootTag[1]) {
// If root tag points to self, we're already at root
if (rootTag[1] === event.id) {
return event;
}
const relays = relayManager.getFeedReadRelays();
const rootEvents = await nostrClient.fetchEvents(
[{ ids: [rootTag[1]] }], // Don't filter by kind, root could be any kind
relays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);
if (rootEvents.length > 0) {
return rootEvents[0];
}
}
// Check if this event has a parent 'e' tag (NIP-10)
// Look for 'e' tag with 'reply' marker, or any 'e' tag that's not self
const eTags = event.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== event.id);
// Prefer 'e' tag with 'reply' marker (NIP-10)
let parentId: string | undefined;
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag) {
parentId = replyTag[1];
} else if (eTags.length > 0) {
// Use first 'e' tag if no explicit reply marker
parentId = eTags[0][1];
}
if (!parentId) {
// No parent - this is the root
return event;
}
// Fetch parent event
const relays = relayManager.getFeedReadRelays();
const parentEvents = await nostrClient.fetchEvents(
[{ ids: [parentId] }],
relays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);
if (parentEvents.length === 0) {
// Parent not found - treat current event as root
return event;
}
const parent = parentEvents[0];
// Recursively find root
return findRootEvent(parent, visited);
}
async function loadThread() { async function loadThread() {
if (!opEvent) return; if (!opEvent) return;
loading = true; loading = true;
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const eventId = opEvent.id;
// First, find the root OP event
rootEvent = await findRootEvent(opEvent);
const eventId = rootEvent.id;
const isThread = rootEvent.kind === 11;
// Load all replies: zap receipts (9735), yak backs (1244), kind 1 replies, kind 1111 comments // 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
const replyFilters = [ const replyFilters = [
{ kinds: [9735], '#e': [eventId] }, // Zap receipts { kinds: [9735], '#e': [eventId] }, // Zap receipts
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies) { kinds: [1244], '#e': [eventId] }, // Yak backs (voice replies)
{ kinds: [1], '#e': [eventId] }, // Kind 1 replies { kinds: [1], '#e': [eventId] }, // Kind 1 replies
{ kinds: [1111], '#e': [eventId] } // Kind 1111 comments // Kind 1111 comments: use #E for threads, #e for other events
...(isThread
? [{ kinds: [1111], '#E': [eventId], '#K': ['11'] }]
: [{ kinds: [1111], '#e': [eventId] }]
)
]; ];
// Fetch all reply types // Fetch all reply types
@ -63,10 +151,10 @@
reactions = reactionEvents; reactions = reactionEvents;
// Recursively fetch nested replies // Recursively fetch nested replies (this updates threadEvents internally)
await fetchNestedReplies(allReplies, relays, eventId); await fetchNestedReplies(allReplies, relays, eventId, isThread);
threadEvents = allReplies; // threadEvents is updated by fetchNestedReplies
} catch (error) { } catch (error) {
console.error('Error loading thread:', error); console.error('Error loading thread:', error);
} finally { } finally {
@ -74,7 +162,7 @@
} }
} }
async function fetchNestedReplies(initialReplies: NostrEvent[], relays: string[], rootEventId: string) { async function fetchNestedReplies(initialReplies: NostrEvent[], relays: string[], rootEventId: string, isThread: boolean) {
let hasNewReplies = true; let hasNewReplies = true;
let iterations = 0; let iterations = 0;
const maxIterations = 10; const maxIterations = 10;
@ -93,11 +181,15 @@
if (replyIds.length > 0) { if (replyIds.length > 0) {
// Fetch replies to any of our replies // Fetch replies to any of our replies
// For kind 1111 comments: use #E tag for threads, #e tag for other events
const nestedFilters = [ const nestedFilters = [
{ kinds: [9735], '#e': replyIds }, { kinds: [9735], '#e': replyIds },
{ kinds: [1244], '#e': replyIds }, { kinds: [1244], '#e': replyIds },
{ kinds: [1], '#e': replyIds }, { kinds: [1], '#e': replyIds },
{ kinds: [1111], '#e': replyIds } ...(isThread
? [{ kinds: [1111], '#E': replyIds, '#K': ['11'] }]
: [{ kinds: [1111], '#e': replyIds }]
)
]; ];
const nestedReplies = await nostrClient.fetchEvents( const nestedReplies = await nostrClient.fetchEvents(
@ -122,9 +214,9 @@
// Find parent event in thread // Find parent event in thread
const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id); const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) { if (eTag && eTag[1]) {
// Check if parent is the OP // Check if parent is the root OP
if (opEvent && eTag[1] === opEvent.id) { if (rootEvent && eTag[1] === rootEvent.id) {
return opEvent; return rootEvent;
} }
// Check if parent is another reply // Check if parent is another reply
return threadEvents.find((e) => e.id === eTag[1]); return threadEvents.find((e) => e.id === eTag[1]);
@ -163,9 +255,9 @@
const parentId = eTag?.[1]; const parentId = eTag?.[1];
if (parentId) { if (parentId) {
// Check if parent is OP or another reply // Check if parent is root OP or another reply
if (opEvent && parentId === opEvent.id) { if (rootEvent && parentId === rootEvent.id) {
// Direct reply to OP // Direct reply to root OP
rootItems.push(item); rootItems.push(item);
} else if (eventMap.has(parentId)) { } else if (eventMap.has(parentId)) {
// Reply to another reply // Reply to another reply
@ -253,12 +345,12 @@
<div class="drawer-content"> <div class="drawer-content">
{#if loading} {#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
{:else if opEvent} {:else if rootEvent}
<!-- OP with reactions --> <!-- OP with reactions -->
<div class="op-section"> <div class="op-section">
<FeedPost post={opEvent} /> <FeedPost post={rootEvent} />
<div class="reactions-section"> <div class="reactions-section">
<FeedReactionButtons event={opEvent} /> <FeedReactionButtons event={rootEvent} />
</div> </div>
</div> </div>
@ -272,7 +364,7 @@
{#if item.type === 'zap'} {#if item.type === 'zap'}
<ZapReceiptReply <ZapReceiptReply
zapReceipt={item.event} zapReceipt={item.event}
parentEvent={parentEvent || opEvent} parentEvent={parentEvent || rootEvent}
/> />
{:else if item.type === 'yak'} {:else if item.type === 'yak'}
<!-- Yak back (voice reply) - TODO: create component or use existing --> <!-- Yak back (voice reply) - TODO: create component or use existing -->
@ -282,12 +374,12 @@
{:else if item.type === 'reply'} {:else if item.type === 'reply'}
<FeedPost <FeedPost
post={item.event} post={item.event}
parentEvent={parentEvent || opEvent} parentEvent={parentEvent || rootEvent}
/> />
{:else if item.type === 'comment'} {:else if item.type === 'comment'}
<Comment <Comment
comment={item.event} comment={item.event}
parentEvent={parentEvent || opEvent} parentEvent={parentEvent || rootEvent}
/> />
{/if} {/if}
{/each} {/each}
@ -320,8 +412,8 @@
} }
.drawer { .drawer {
width: 100%; width: var(--content-width, 800px);
max-width: 600px; max-width: 100vw;
height: 100%; height: 100%;
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);

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

@ -4,6 +4,8 @@
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import { resolveCustomEmojis } from '../../services/nostr/nip30-emoji.js';
interface Props { interface Props {
event: NostrEvent; // Feed event event: NostrEvent; // Feed event
@ -14,9 +16,33 @@
let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map()); let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map());
let userReaction = $state<string | null>(null); let userReaction = $state<string | null>(null);
let loading = $state(true); let loading = $state(true);
let showMore = $state(false); let showMenu = $state(false);
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
// Derived value for heart count
let heartCount = $derived(getReactionCount('+'));
// Get emoji list from unicode-emoji-json library
// The library provides an array of emoji strings in order
const reactionMenu = $derived.by(() => {
const emojis: string[] = ['+']; // Heart (default) always first
// Add ALL emojis from the library - the menu will scroll
// The data-ordered-emoji.json is already an array of emoji strings
for (let i = 0; i < emojiData.length; i++) {
const emoji = emojiData[i];
if (typeof emoji === 'string' && emoji.trim()) {
emojis.push(emoji);
}
}
return emojis;
});
const commonReactions = ['+', '😂', '😢', '😮', '😡', '👍', '👎']; // Custom emoji reactions (like :turtlehappy_sm:)
// These will be added dynamically from actual reactions received
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
@ -51,7 +77,7 @@
} }
} }
function processReactions(reactionEvents: NostrEvent[]) { async function processReactions(reactionEvents: NostrEvent[]) {
const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>(); const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>();
const currentUser = sessionManager.getCurrentPubkey(); const currentUser = sessionManager.getCurrentPubkey();
@ -69,6 +95,10 @@
} }
reactions = reactionMap; reactions = reactionMap;
// Resolve custom emojis (NIP-30) to image URLs
const emojiUrls = await resolveCustomEmojis(reactionMap);
customEmojiUrls = emojiUrls;
} }
async function toggleReaction(content: string) { async function toggleReaction(content: string) {
@ -130,57 +160,199 @@
function getReactionDisplay(content: string): string { function getReactionDisplay(content: string): string {
if (content === '+') return '❤'; if (content === '+') return '❤';
// Check if this is a custom emoji with a resolved URL
if (content.startsWith(':') && content.endsWith(':')) {
const url = customEmojiUrls.get(content);
if (url) {
// Return a placeholder that will be replaced with img tag in template
return content; // We'll render as img in template
}
}
return content; return content;
} }
function isCustomEmoji(content: string): boolean {
return content.startsWith(':') && content.endsWith(':') && customEmojiUrls.has(content);
}
function getCustomEmojiUrl(content: string): string | null {
return customEmojiUrls.get(content) || null;
}
function getReactionCount(content: string): number { function getReactionCount(content: string): number {
return reactions.get(content)?.pubkeys.size || 0; return reactions.get(content)?.pubkeys.size || 0;
} }
function getAllReactions(): Array<{ content: string; count: number }> {
// Get all reactions that have counts > 0, sorted by count (descending)
const allReactions: Array<{ content: string; count: number }> = [];
for (const [content, data] of reactions.entries()) {
if (data.pubkeys.size > 0) {
allReactions.push({ content, count: data.pubkeys.size });
}
}
// Sort by count descending, then by content
return allReactions.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.content.localeCompare(b.content);
});
}
function getCustomEmojis(): string[] {
// Extract custom emoji reactions (format: :name:)
const customEmojis: string[] = [];
for (const content of reactions.keys()) {
if (content.startsWith(':') && content.endsWith(':') && !reactionMenu.includes(content)) {
customEmojis.push(content);
}
}
return customEmojis.sort();
}
function closeMenuOnOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (menuButton &&
!menuButton.contains(target) &&
!target.closest('.reaction-menu')) {
showMenu = false;
}
}
function handleHeartClick() {
if (showMenu) {
// If menu is open, clicking heart again should just like/unlike
toggleReaction('+');
showMenu = false;
} 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;
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', closeMenuOnOutsideClick);
return () => document.removeEventListener('click', closeMenuOnOutsideClick);
}
});
let includeClientTag = $state(true); let includeClientTag = $state(true);
</script> </script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap"> <div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
{#each commonReactions as reaction} <!-- Heart button - always visible, opens menu -->
{@const count = getReactionCount(reaction)} <div class="reaction-wrapper">
{#if count > 0 || reaction === '+' || showMore} <button
bind:this={menuButton}
onclick={handleHeartClick}
class="reaction-btn heart-btn {userReaction === '+' ? 'active' : ''}"
title="Like or choose reaction"
aria-label="Like or choose reaction"
>
{heartCount > 0 ? heartCount : ''}
</button>
<!-- Reaction menu dropdown -->
{#if showMenu}
<div class="reaction-menu" class:menu-below={menuPosition === 'below'}>
<div class="reaction-menu-grid">
{#each reactionMenu as reaction}
{@const count = getReactionCount(reaction)}
<button
onclick={() => {
toggleReaction(reaction);
showMenu = false;
}}
class="reaction-menu-item {userReaction === reaction ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${getReactionDisplay(reaction)}`}
>
{#if isCustomEmoji(reaction)}
{@const url = getCustomEmojiUrl(reaction)}
{#if url}
<img src={url} alt={reaction} class="custom-emoji-img" />
{:else}
{reaction}
{/if}
{:else}
{getReactionDisplay(reaction)}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
<!-- Custom emojis section -->
{#if getCustomEmojis().length > 0}
<div class="custom-emojis-section">
<div class="custom-emojis-label">Custom</div>
<div class="reaction-menu-grid">
{#each getCustomEmojis() as emoji}
{@const count = getReactionCount(emoji)}
<button
onclick={() => {
toggleReaction(emoji);
showMenu = false;
}}
class="reaction-menu-item {userReaction === emoji ? 'active' : ''}"
title={`React with ${emoji}`}
>
{#if isCustomEmoji(emoji)}
{@const url = getCustomEmojiUrl(emoji)}
{#if url}
<img src={url} alt={emoji} class="custom-emoji-img" />
{:else}
{emoji}
{/if}
{:else}
{emoji}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Display all other reactions that have counts > 0 (except + which is shown as heart) -->
{#each getAllReactions() as { content, count }}
{#if content !== '+'}
<button <button
onclick={() => toggleReaction(reaction)} onclick={() => toggleReaction(content)}
class="reaction-btn {userReaction === reaction ? 'active' : ''}" class="reaction-btn {userReaction === content ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${reaction}`} title={`React with ${content}`}
aria-label={reaction === '+' ? 'Like' : `React with ${reaction}`} aria-label={`React with ${content}`}
> >
{getReactionDisplay(reaction)} {count > 0 ? count : ''} {#if isCustomEmoji(content)}
{@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
{content}
{/if}
{:else}
{content}
{/if}
{count}
</button> </button>
{/if} {/if}
{/each} {/each}
{#if !showMore}
<button
onclick={() => (showMore = true)}
class="reaction-btn more-btn"
title="More reactions"
aria-label="More reactions"
>
</button>
{/if}
<!-- Show other reactions that aren't in common list -->
{#if showMore}
{#each Array.from(reactions.keys()) as reaction}
{#if !commonReactions.includes(reaction)}
<button
onclick={() => toggleReaction(reaction)}
class="reaction-btn {userReaction === reaction ? 'active' : ''}"
title={`React with ${reaction}`}
aria-label={`React with ${reaction}`}
>
{reaction} {getReactionCount(reaction)}
</button>
{/if}
{/each}
{/if}
</div> </div>
<style> <style>
@ -226,7 +398,168 @@
border-color: var(--fog-dark-accent, #64748b); border-color: var(--fog-dark-accent, #64748b);
} }
.more-btn { .reaction-wrapper {
opacity: 0.7; position: relative;
}
.heart-btn {
/* Heart button styling */
}
.reaction-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding: 0.5rem;
z-index: 1000;
min-width: 200px;
max-width: 300px;
max-height: min(60vh, 400px);
overflow-y: scroll;
overflow-x: hidden;
/* Ensure scrollbar is always visible */
scrollbar-width: thin;
scrollbar-color: var(--fog-border, #e5e7eb) var(--fog-post, #ffffff);
}
.reaction-menu.menu-below {
bottom: auto;
top: 100%;
margin-bottom: 0;
margin-top: 0.5rem;
}
.reaction-menu::-webkit-scrollbar {
width: 8px;
}
.reaction-menu::-webkit-scrollbar-track {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
}
.reaction-menu::-webkit-scrollbar-thumb {
background: var(--fog-border, #e5e7eb);
border-radius: 4px;
}
.reaction-menu::-webkit-scrollbar-thumb:hover {
background: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-menu::-webkit-scrollbar-track {
background: var(--fog-dark-post, #1f2937);
}
:global(.dark) .reaction-menu::-webkit-scrollbar-thumb {
background: var(--fog-dark-border, #374151);
}
:global(.dark) .reaction-menu::-webkit-scrollbar-thumb:hover {
background: var(--fog-dark-accent, #64748b);
}
:global(.dark) .reaction-menu {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.reaction-menu-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.25rem;
}
.reaction-menu-item {
position: relative;
padding: 0.5rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
transition: all 0.2s;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 2.5rem;
}
.reaction-menu-item:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-border, #e5e7eb);
}
:global(.dark) .reaction-menu-item:hover {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #374151);
}
.reaction-menu-item.active {
background: var(--fog-accent, #64748b);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-menu-item.active {
background: var(--fog-dark-accent, #64748b);
border-color: var(--fog-dark-accent, #64748b);
}
.reaction-count {
position: absolute;
bottom: 0.125rem;
right: 0.125rem;
font-size: 0.625rem;
font-weight: 600;
background: var(--fog-accent, #64748b);
color: white;
border-radius: 50%;
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
:global(.dark) .reaction-count {
background: var(--fog-dark-accent, #64748b);
}
.custom-emojis-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .custom-emojis-section {
border-top-color: var(--fog-dark-border, #374151);
}
.custom-emojis-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-text-light, #6b7280);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .custom-emojis-label {
color: var(--fog-dark-text-light, #9ca3af);
}
.custom-emoji-img {
width: 1.25rem;
height: 1.25rem;
object-fit: contain;
display: inline-block;
vertical-align: middle;
} }
</style> </style>

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

@ -2,7 +2,8 @@
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';
import ThreadCard from './ThreadCard.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
let threads = $state<NostrEvent[]>([]); let threads = $state<NostrEvent[]>([]);
@ -11,6 +12,10 @@
let showOlder = $state(false); let showOlder = $state(false);
let selectedTopic = $state<string | null | undefined>(null); // null = All, undefined = General, string = specific topic let selectedTopic = $state<string | null | undefined>(null); // null = All, undefined = General, string = specific topic
// Thread drawer state
let drawerOpen = $state(false);
let selectedEvent = $state<NostrEvent | null>(null);
$effect(() => { $effect(() => {
loadThreads(); loadThreads();
}); });
@ -91,22 +96,11 @@
? reactions.sort((a, b) => b.created_at - a.created_at)[0].created_at ? reactions.sort((a, b) => b.created_at - a.created_at)[0].created_at
: 0; : 0;
// Get most recent zap
const zaps = await nostrClient.fetchEvents(
[{ kinds: [9735], '#e': [event.id], limit: 1 }],
zapRelays,
{ useCache: true }
);
const lastZapTime = zaps.length > 0
? zaps.sort((a, b) => b.created_at - a.created_at)[0].created_at
: 0;
// Last activity is the most recent of all activities // Last activity is the most recent of all activities
const lastActivity = Math.max( const lastActivity = Math.max(
event.created_at, event.created_at,
lastCommentTime, lastCommentTime,
lastReactionTime, lastReactionTime
lastZapTime
); );
return { event, lastActivity }; return { event, lastActivity };
@ -215,6 +209,23 @@
} }
return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic)); return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic));
} }
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements
if (e) {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('[role="button"]')) {
return;
}
}
selectedEvent = event;
drawerOpen = true;
}
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
}
</script> </script>
<div class="thread-list"> <div class="thread-list">
@ -275,13 +286,41 @@
<!-- Show all threads grouped by topic --> <!-- Show all threads grouped by topic -->
<h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">General</h2> <h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">General</h2>
{#each getThreadsByTopic(null) as thread} {#each getThreadsByTopic(null) as thread}
<ThreadCard {thread} /> <div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
}
}}
>
<FeedPost post={thread} />
</div>
{/each} {/each}
{#each getTopics() as topic} {#each getTopics() as topic}
<h2 class="text-xl font-bold mb-4 mt-8 text-fog-text dark:text-fog-dark-text">{topic}</h2> <h2 class="text-xl font-bold mb-4 mt-8 text-fog-text dark:text-fog-dark-text">{topic}</h2>
{#each getThreadsByTopic(topic) as thread} {#each getThreadsByTopic(topic) as thread}
<ThreadCard {thread} /> <div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
}
}}
>
<FeedPost post={thread} />
</div>
{/each} {/each}
{/each} {/each}
{:else} {:else}
@ -290,7 +329,21 @@
{selectedTopic === undefined ? 'General' : selectedTopic} {selectedTopic === undefined ? 'General' : selectedTopic}
</h2> </h2>
{#each getFilteredThreads() as thread} {#each getFilteredThreads() as thread}
<ThreadCard {thread} /> <div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
}
}}
>
<FeedPost post={thread} />
</div>
{/each} {/each}
{#if getFilteredThreads().length === 0} {#if getFilteredThreads().length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found in this topic.</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found in this topic.</p>
@ -300,10 +353,31 @@
{/if} {/if}
</div> </div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style> <style>
.thread-list { .thread-list {
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.thread-wrapper {
cursor: pointer;
transition: background 0.2s;
margin-bottom: 1rem;
}
.thread-wrapper:hover {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .thread-wrapper:hover {
background: var(--fog-dark-highlight, #374151);
}
</style> </style>

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

@ -0,0 +1,158 @@
/**
* NIP-30 Custom Emoji Support
*
* NIP-30 defines custom emojis using:
* - Kind 10030: Emoji sets (replaceable, one per user)
* - Kind 30030: Emoji packs (replaceable, parameterized)
*
* Each emoji set/pack maps shortcodes (e.g., "turtlehappy_sm") to image URLs.
*
* Format: :shortcode: in reactions/content
*/
import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js';
import { matchAll } from 'nostr-tools/nip30';
export interface EmojiDefinition {
shortcode: string; // Without colons, e.g., "turtlehappy_sm"
url: string; // Image URL
}
export interface EmojiSet {
pubkey: string;
emojis: Map<string, EmojiDefinition>; // shortcode -> definition
}
// Cache of emoji sets by pubkey
const emojiSetCache = new Map<string, EmojiSet>();
/**
* Parse a kind 10030 emoji set event or kind 30030 emoji pack
*/
export function parseEmojiSet(event: NostrEvent): EmojiSet | null {
if (event.kind !== 10030 && event.kind !== 30030) return null;
const emojis = new Map<string, EmojiDefinition>();
// Parse emoji tags: ["emoji", "shortcode", "url"]
for (const tag of event.tags) {
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
const shortcode = tag[1];
const url = tag[2];
emojis.set(shortcode, { shortcode, url });
}
}
if (emojis.size === 0) return null;
return {
pubkey: event.pubkey,
emojis
};
}
/**
* Fetch emoji set for a pubkey
*/
export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> {
// Check cache first
if (emojiSetCache.has(pubkey)) {
return emojiSetCache.get(pubkey)!;
}
try {
const relays = relayManager.getFeedReadRelays();
// Fetch both emoji sets (10030) and emoji packs (30030)
const events = await nostrClient.fetchEvents(
[{ kinds: [10030, 30030], authors: [pubkey], limit: 10 }], // Get multiple in case of packs
relays,
{ useCache: true, cacheResults: true, timeout: 5000 }
);
if (events.length === 0) return null;
// Get the most recent event (replaceable events)
const event = events.sort((a, b) => b.created_at - a.created_at)[0];
const emojiSet = parseEmojiSet(event);
if (emojiSet) {
emojiSetCache.set(pubkey, emojiSet);
}
return emojiSet;
} catch (error) {
console.error('Error fetching emoji set:', error);
return null;
}
}
/**
* Resolve a shortcode to an image URL
* Tries to find the emoji in emoji sets from the given pubkeys
*/
export async function resolveEmojiShortcode(
shortcode: string,
pubkeys: string[] = []
): Promise<string | null> {
// Remove colons if present
const cleanShortcode = shortcode.replace(/^:|:$/g, '');
// Try each pubkey's emoji set
for (const pubkey of pubkeys) {
const emojiSet = await fetchEmojiSet(pubkey);
if (emojiSet?.emojis.has(cleanShortcode)) {
return emojiSet.emojis.get(cleanShortcode)!.url;
}
}
return null;
}
/**
* Get all unique pubkeys from reactions to fetch their emoji sets
*/
export function extractPubkeysFromReactions(reactions: Map<string, { content: string; pubkeys: Set<string> }>): string[] {
const pubkeys = new Set<string>();
for (const { pubkeys: reactionPubkeys } of reactions.values()) {
for (const pubkey of reactionPubkeys) {
pubkeys.add(pubkey);
}
}
return Array.from(pubkeys);
}
/**
* Resolve all custom emoji shortcodes in reactions to their URLs
*/
export async function resolveCustomEmojis(
reactions: Map<string, { content: string; pubkeys: Set<string> }>
): Promise<Map<string, string>> {
// Extract all pubkeys that have reactions
const pubkeys = extractPubkeysFromReactions(reactions);
// Fetch all emoji sets in parallel
const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey));
await Promise.all(emojiSetPromises);
// Build map of shortcode -> URL
const emojiMap = new Map<string, string>();
for (const [content] of reactions.entries()) {
if (content.startsWith(':') && content.endsWith(':')) {
const shortcode = content.slice(1, -1); // Remove colons
// Try to find in any emoji set
for (const pubkey of pubkeys) {
const emojiSet = emojiSetCache.get(pubkey);
if (emojiSet?.emojis.has(shortcode)) {
emojiMap.set(content, emojiSet.emojis.get(shortcode)!.url);
break;
}
}
}
}
return emojiMap;
}

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

@ -19,6 +19,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
24: { number: 4, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, 24: { number: 4, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
5: { number: 5, description: 'Event Deletion', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, 5: { number: 5, description: 'Event Deletion', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, 7: { number: 7, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
1063: { number: 1063, description: 'File Metadata (GIFs)', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Replaceable events // Replaceable events
30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, 30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
@ -75,6 +76,10 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
// Payment addresses // Payment addresses
10133: { number: 10133, description: 'Payment Addresses', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, 10133: { number: 10133, description: 'Payment Addresses', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Custom emojis (NIP-30)
10030: { number: 10030, description: 'Emoji Set', showInFeed: false, isReplaceable: true, isSecondaryKind: false },
30030: { number: 30030, description: 'Emoji Pack', showInFeed: false, isReplaceable: true, isSecondaryKind: false },
// RSS feeds // RSS feeds
10895: { number: 10895, description: 'RSS Feed', showInFeed: false, isReplaceable: false } 10895: { number: 10895, description: 'RSS Feed', showInFeed: false, isReplaceable: false }

Loading…
Cancel
Save