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. 138
      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 @@ @@ -12,10 +12,12 @@
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"idb": "^8.0.0",
"marked": "^11.1.1",
"nostr-tools": "^2.22.1",
"svelte": "^5.0.0"
"svelte": "^5.0.0",
"unicode-emoji-json": "^0.8.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
@ -1987,6 +1989,12 @@ @@ -1987,6 +1989,12 @@
"dev": true,
"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": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
@ -4135,6 +4143,12 @@ @@ -4135,6 +4143,12 @@
"optional": 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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

4
package.json

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

4
public/healthz.json

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

14
src/app.css

@ -47,6 +47,20 @@ @@ -47,6 +47,20 @@
--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 {
font-size: var(--text-size);
line-height: var(--line-height);

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

@ -34,9 +34,9 @@ @@ -34,9 +34,9 @@
<!-- Navigation -->
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3">
<div class="flex 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>
<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="/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}

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

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
<script lang="ts">
import { getActivityStatus, getActivityMessage } from '../../services/auth/activity-tracker.js';
import { fetchProfile, fetchUserStatus } from '../../services/user-data.js';
import { onMount } from 'svelte';
interface Props {
pubkey: string;
@ -13,9 +12,11 @@ @@ -13,9 +12,11 @@
let status = $state<string | null>(null);
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
let activityMessage = $state<string | null>(null);
let imageError = $state(false);
$effect(() => {
if (pubkey) {
imageError = false; // Reset image error when pubkey changes
loadProfile();
loadStatus();
updateActivityStatus();
@ -50,24 +51,57 @@ @@ -50,24 +51,57 @@
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>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2">
{#if profile?.picture}
<img src={profile.picture} alt={profile.name || pubkey} class="profile-picture w-6 h-6 rounded" />
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2 min-w-0 max-w-full">
{#if profile?.picture && !imageError}
<img
src={profile.picture}
alt={profile.name || pubkey}
class="profile-picture w-6 h-6 rounded flex-shrink-0"
onerror={() => {
imageError = true;
}}
/>
{: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}
<span>{profile?.name || pubkey.slice(0, 16)}...</span>
<span class="truncate min-w-0">{profile?.name || pubkey.slice(0, 16)}...</span>
{#if activityStatus && activityMessage}
<span
class="w-2 h-2 rounded-full"
class="activity-dot w-2 h-2 rounded-full flex-shrink-0"
style="background-color: {getActivityColor()}"
title={activityMessage}
aria-label={activityMessage}
></span>
{/if}
{#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}
</a>
@ -75,9 +109,28 @@ @@ -75,9 +109,28 @@
.profile-badge {
text-decoration: none;
color: inherit;
max-width: 100%;
}
.profile-badge:hover {
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>

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

@ -42,29 +42,32 @@ @@ -42,29 +42,32 @@
const root = document.documentElement;
// Text size
// Text size - set both CSS variable and data attribute
const textSizes = {
small: '14px',
medium: '16px',
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 = {
tight: '1.4',
normal: '1.6',
loose: '1.8'
};
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 = {
narrow: '600px',
medium: '800px',
wide: '1200px'
};
root.style.setProperty('--content-width', contentWidths[contentWidth]);
root.setAttribute('data-content-width', contentWidth);
}
$effect(() => {

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

@ -22,6 +22,7 @@ @@ -22,6 +22,7 @@
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button
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
let drawerOpen = $state(false);
@ -166,20 +167,31 @@ @@ -166,20 +167,31 @@
}, 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
// 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(
feedFilter,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000,
onUpdate: debouncedOnUpdate
}
fetchOptions
);
// Process cached events immediately
// Process cached events
// Load ALL feed events into posts array (including replies and kind 1111)
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1);
@ -195,6 +207,7 @@ @@ -195,6 +207,7 @@
);
if (reset) {
// For initial load, batch all updates at once to prevent scrolling
// Load ALL events into posts array - filtering happens client-side
// Only sort if we have posts to prevent unnecessary re-renders
if (regularPosts.length > 0 || otherFeedEvents.length > 0) {
@ -235,13 +248,21 @@ @@ -235,13 +248,21 @@
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
// 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
// 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
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);
if (displayedEventIds.length > 0) {
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
@ -434,11 +455,24 @@ @@ -434,11 +455,24 @@
}
function isReply(post: NostrEvent): boolean {
// Check if this is a kind 1 event with a reply tag
if (post.kind === 1) {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
return replyTag !== undefined && replyTag[1] !== undefined;
// Check if this event references another event (reply, quote, or replaceable event reference)
// Filter out anything with "e", "a", or "q" tags
// 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;
}
@ -458,12 +492,12 @@ @@ -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) {
filtered = filtered.filter(post => {
// Filter out all kind 1111 events (comments)
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;
// Keep everything else (original posts)
return true;
@ -478,10 +512,21 @@ @@ -478,10 +512,21 @@
let cachedFeedItemsKey = '';
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) {
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;
}
}
@ -540,7 +585,7 @@ @@ -540,7 +585,7 @@
<div class="Feed-feed">
<div class="feed-header mb-4">
<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">
<input
type="checkbox"
@ -581,9 +626,9 @@ @@ -581,9 +626,9 @@
{#each getAllFeedItems() 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) : 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 quotedEvent = quotedId ? posts.find(p => p.id === quotedId) : undefined}
{@const quotedEvent = quotedId ? (posts.find(p => p.id === quotedId) || loadedParentQuotedEvents.get(quotedId)) : undefined}
<div
data-post-id={item.event.id}
class="post-wrapper"
@ -603,21 +648,17 @@ @@ -603,21 +648,17 @@
parentEvent={parentEvent}
quotedEvent={quotedEvent}
onParentLoaded={(event) => {
// Add loaded parent to posts array if not already there
// Don't re-sort - just append to prevent feed jumping
if (!posts.find(p => p.id === event.id)) {
posts = [...posts, event];
// Invalidate cache
cachedFeedItems = null;
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
onQuotedLoaded={(event) => {
// Add loaded quoted event to posts array if not already there
// Don't re-sort - just append to prevent feed jumping
if (!posts.find(p => p.id === event.id)) {
posts = [...posts, event];
// Invalidate cache
cachedFeedItems = null;
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
/>
@ -672,8 +713,15 @@ @@ -672,8 +713,15 @@
.feed-controls {
display: flex;
flex-wrap: wrap;
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 {

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

@ -172,14 +172,14 @@ @@ -172,14 +172,14 @@
/>
{/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} />
<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()}
<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 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}
</div>

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

@ -19,32 +19,120 @@ @@ -19,32 +19,120 @@
let loading = $state(false);
let threadEvents = $state<NostrEvent[]>([]);
let reactions = $state<NostrEvent[]>([]);
let rootEvent = $state<NostrEvent | null>(null); // The actual OP/root event
// Load thread when drawer opens
$effect(() => {
if (isOpen && opEvent) {
// Hide main page scrollbar when drawer is open
document.body.style.overflow = 'hidden';
loadThread();
} else {
// Reset when closed
// Reset when closed and restore scrollbar
document.body.style.overflow = '';
threadEvents = [];
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() {
if (!opEvent) return;
loading = true;
try {
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
// For kind 1111 comments: use #E tag for threads (kind 11), #e tag for other events
const replyFilters = [
{ kinds: [9735], '#e': [eventId] }, // Zap receipts
{ kinds: [1244], '#e': [eventId] }, // Yak backs (voice 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
@ -63,10 +151,10 @@ @@ -63,10 +151,10 @@
reactions = reactionEvents;
// Recursively fetch nested replies
await fetchNestedReplies(allReplies, relays, eventId);
threadEvents = allReplies;
// Recursively fetch nested replies (this updates threadEvents internally)
await fetchNestedReplies(allReplies, relays, eventId, isThread);
// threadEvents is updated by fetchNestedReplies
} catch (error) {
console.error('Error loading thread:', error);
} finally {
@ -74,7 +162,7 @@ @@ -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 iterations = 0;
const maxIterations = 10;
@ -93,11 +181,15 @@ @@ -93,11 +181,15 @@
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
const nestedFilters = [
{ kinds: [9735], '#e': replyIds },
{ kinds: [1244], '#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(
@ -122,9 +214,9 @@ @@ -122,9 +214,9 @@
// Find parent event in thread
const eTag = event.tags.find((t) => t[0] === 'e' && t[1] !== event.id);
if (eTag && eTag[1]) {
// Check if parent is the OP
if (opEvent && eTag[1] === opEvent.id) {
return opEvent;
// Check if parent is the root OP
if (rootEvent && eTag[1] === rootEvent.id) {
return rootEvent;
}
// Check if parent is another reply
return threadEvents.find((e) => e.id === eTag[1]);
@ -163,9 +255,9 @@ @@ -163,9 +255,9 @@
const parentId = eTag?.[1];
if (parentId) {
// Check if parent is OP or another reply
if (opEvent && parentId === opEvent.id) {
// Direct reply to OP
// Check if parent is root OP or another reply
if (rootEvent && parentId === rootEvent.id) {
// Direct reply to root OP
rootItems.push(item);
} else if (eventMap.has(parentId)) {
// Reply to another reply
@ -253,12 +345,12 @@ @@ -253,12 +345,12 @@
<div class="drawer-content">
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p>
{:else if opEvent}
{:else if rootEvent}
<!-- OP with reactions -->
<div class="op-section">
<FeedPost post={opEvent} />
<FeedPost post={rootEvent} />
<div class="reactions-section">
<FeedReactionButtons event={opEvent} />
<FeedReactionButtons event={rootEvent} />
</div>
</div>
@ -272,7 +364,7 @@ @@ -272,7 +364,7 @@
{#if item.type === 'zap'}
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parentEvent || opEvent}
parentEvent={parentEvent || rootEvent}
/>
{:else if item.type === 'yak'}
<!-- Yak back (voice reply) - TODO: create component or use existing -->
@ -282,12 +374,12 @@ @@ -282,12 +374,12 @@
{:else if item.type === 'reply'}
<FeedPost
post={item.event}
parentEvent={parentEvent || opEvent}
parentEvent={parentEvent || rootEvent}
/>
{:else if item.type === 'comment'}
<Comment
comment={item.event}
parentEvent={parentEvent || opEvent}
parentEvent={parentEvent || rootEvent}
/>
{/if}
{/each}
@ -320,8 +412,8 @@ @@ -320,8 +412,8 @@
}
.drawer {
width: 100%;
max-width: 600px;
width: var(--content-width, 800px);
max-width: 100vw;
height: 100%;
background: var(--fog-post, #ffffff);
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);

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

@ -4,6 +4,8 @@ @@ -4,6 +4,8 @@
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
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 {
event: NostrEvent; // Feed event
@ -14,9 +16,33 @@ @@ -14,9 +16,33 @@
let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map());
let userReaction = $state<string | null>(null);
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 () => {
await nostrClient.initialize();
@ -51,7 +77,7 @@ @@ -51,7 +77,7 @@
}
}
function processReactions(reactionEvents: NostrEvent[]) {
async function processReactions(reactionEvents: NostrEvent[]) {
const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>();
const currentUser = sessionManager.getCurrentPubkey();
@ -69,6 +95,10 @@ @@ -69,6 +95,10 @@
}
reactions = reactionMap;
// Resolve custom emojis (NIP-30) to image URLs
const emojiUrls = await resolveCustomEmojis(reactionMap);
customEmojiUrls = emojiUrls;
}
async function toggleReaction(content: string) {
@ -130,57 +160,199 @@ @@ -130,57 +160,199 @@
function getReactionDisplay(content: string): string {
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;
}
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 {
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);
</script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
{#each commonReactions as reaction}
{@const count = getReactionCount(reaction)}
{#if count > 0 || reaction === '+' || showMore}
<!-- Heart button - always visible, opens menu -->
<div class="reaction-wrapper">
<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
onclick={() => toggleReaction(reaction)}
class="reaction-btn {userReaction === reaction ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${reaction}`}
aria-label={reaction === '+' ? 'Like' : `React with ${reaction}`}
onclick={() => toggleReaction(content)}
class="reaction-btn {userReaction === content ? 'active' : ''}"
title={`React with ${content}`}
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>
{/if}
{/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>
<style>
@ -226,7 +398,168 @@ @@ -226,7 +398,168 @@
border-color: var(--fog-dark-accent, #64748b);
}
.more-btn {
opacity: 0.7;
.reaction-wrapper {
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>

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

@ -2,7 +2,8 @@ @@ -2,7 +2,8 @@
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
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';
let threads = $state<NostrEvent[]>([]);
@ -10,6 +11,10 @@ @@ -10,6 +11,10 @@
let sortBy = $state<'newest' | 'active' | 'upvoted'>('newest');
let showOlder = $state(false);
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(() => {
loadThreads();
@ -91,22 +96,11 @@ @@ -91,22 +96,11 @@
? reactions.sort((a, b) => b.created_at - a.created_at)[0].created_at
: 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
const lastActivity = Math.max(
event.created_at,
lastCommentTime,
lastReactionTime,
lastZapTime
lastReactionTime
);
return { event, lastActivity };
@ -215,6 +209,23 @@ @@ -215,6 +209,23 @@
}
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>
<div class="thread-list">
@ -275,13 +286,41 @@ @@ -275,13 +286,41 @@
<!-- Show all threads grouped by topic -->
<h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">General</h2>
{#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 getTopics() as topic}
<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}
<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}
{:else}
@ -290,7 +329,21 @@ @@ -290,7 +329,21 @@
{selectedTopic === undefined ? 'General' : selectedTopic}
</h2>
{#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}
{#if getFilteredThreads().length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found in this topic.</p>
@ -300,10 +353,31 @@ @@ -300,10 +353,31 @@
{/if}
</div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style>
.thread-list {
max-width: var(--content-width);
margin: 0 auto;
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>

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

@ -0,0 +1,158 @@ @@ -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> = { @@ -19,6 +19,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
24: { number: 4, description: 'Public Message', showInFeed: true, 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 },
1063: { number: 1063, description: 'File Metadata (GIFs)', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Replaceable events
30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
@ -75,6 +76,10 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -75,6 +76,10 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
// Payment addresses
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
10895: { number: 10895, description: 'RSS Feed', showInFeed: false, isReplaceable: false }

Loading…
Cancel
Save