Browse Source

get rid of magic numbers

master
Silberengel 1 month ago
parent
commit
1907bf1681
  1. 89
      ideas.txt
  2. 4
      public/healthz.json
  3. 20
      src/lib/components/EventMenu.svelte
  4. 2
      src/lib/components/content/EmbeddedEvent.svelte
  5. 2
      src/lib/components/content/QuotedContext.svelte
  6. 2
      src/lib/components/content/ReplyContext.svelte
  7. 17
      src/lib/components/modals/EventJsonModal.svelte
  8. 352
      src/lib/components/modals/RelatedEventsModal.svelte
  9. 8
      src/lib/modules/comments/CommentForm.svelte
  10. 103
      src/lib/modules/comments/CommentThread.svelte
  11. 13
      src/lib/modules/feed/FeedPage.svelte
  12. 18
      src/lib/modules/feed/FeedPost.svelte
  13. 2
      src/lib/modules/feed/Reply.svelte
  14. 2
      src/lib/modules/profiles/PaymentAddresses.svelte
  15. 11
      src/lib/modules/profiles/ProfilePage.svelte
  16. 23
      src/lib/modules/reactions/FeedReactionButtons.svelte
  17. 11
      src/lib/modules/reactions/ReactionButtons.svelte
  18. 8
      src/lib/modules/threads/ThreadCard.svelte
  19. 13
      src/lib/modules/threads/ThreadList.svelte
  20. 4
      src/lib/modules/zaps/ZapButton.svelte
  21. 2
      src/lib/modules/zaps/ZapReceipt.svelte
  22. 2
      src/lib/services/auth/activity-tracker.ts
  23. 3
      src/lib/services/cache/profile-cache.ts
  24. 5
      src/lib/services/nostr/auth-handler.ts
  25. 4
      src/lib/services/nostr/gif-service.ts
  26. 11
      src/lib/services/nostr/nip30-emoji.ts
  27. 11
      src/lib/services/nostr/nostr-client.ts
  28. 13
      src/lib/services/user-data.ts
  29. 131
      src/lib/types/kind-lookup.ts

89
ideas.txt

@ -0,0 +1,89 @@
1. When I open a ThreadDrawer, the "Replying to:..." blurb at the top should render as full event. And, if that replied-to event is also a reply, it's OP should also be rendered as a full event. And so on, up the hierarchy, until we get to an event that isn't a reference or reply to any other (no e-tag or q-tag or a-tag). I want to see the entire discussion tree, so that the event I clicked in the Feed view is displayed in complete context.
2. Fix the Threads list loading so slowly. I should immediately be seeing what is in cache, and then you update the cache and add anything missing, in a second sweep. And make sure updating doesn't cause the page the jump around or create endless loops.
3. Make sure that pinning and bookmarking (from the event "..." menu) actually create/update and publish the list events.
4. Add a delete event menu item to the event "..." menu, that publishes a deletion request to all available relays.
5. Always render a pretty OpenGraph card, for URLs, if they provide one. Unless the URL is in the middle of a list, paragraph, or otherwise part of some larger structure.
6. Make sure that highlights work, according to NIP-84. refer to ../jumble for a working version.
Some example events:
{
"id": "93bea17f71ed9ea7f6832e3be7e617b3387e0700193cfcebaf3ffbc2e6f48a7f",
"pubkey": "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4",
"created_at": 1769023343,
"kind": 9802,
"tags": [
[
"e",
"6f854ade40cf3f24046249e650f55b33add3ee1526c00cc93cc7dfc80b8dc121",
"source"
]
],
"content": "not real wisdom, being a pretense of knowing the unknown",
"sig": "150279e733e16fa85439916f9f5b8108898a35cbf18062638dfc94e7a38f4a2faae8ce918750ef327fc16b7e7ca8739b1e8aff3b9dd238363d08eec423abba83"
}
{
"id": "1cd2017dd33a2efddffb9814c1993cf62e6d8a8e2e90af40973b6d4d1ea509f0",
"pubkey": "a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be",
"created_at": 1769288219,
"kind": 9802,
"tags": [
[
"p",
"a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"
],
[
"a",
"30023:a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be:comparing-community-specs"
],
[
"context",
"A single publication can be targeted to up to 12 communities via one Targeted Publication event. The creator's intended audience is explicit and transparent — anyone can see which communities a piece of content was meant for. This can serve as an organic disovery route for related Communities + lowers the bar for bootstrapping new ones."
],
[
"alt",
"This highlight was made by https://primal.net web client"
]
],
"content": " The creator's intended audience is explicit and transparent — anyone can see which communities a piece of content was meant for. This can serve as an organic disovery route for related Communities + lowers the bar for bootstrapping new ones.",
"sig": "b490a12fbc1ab0063c6ddb3ae091212a4fcf76fdf9581d5f0291f24a9443b45d9f11d70e8035ea9c61b95ad47952c46ceeffa6dbb0fa5351bc51aad2e3d54add"
}
In the first highlight event, there is simply the content field, which should be rendered as a quote, with a link to the original source (event or URL) below it. If the URL provides OpenGraph data, display it and add the hyperlink to it. For events: display a card with "A note from: <user badge rendered>" and then the "title", "image", and "summary" tags, if available. Make the card a clickable hyperlink to the event's /event page.
7. Make #hashtags and t-tag topic buttons clickable. Clicking on one should launch a /topics/nameOfHashtag page, that reveals an event list of everything on the relays that includes that topic as a hashtag or a t-tag.
8. Display a metadata card, at the top of the page, when rendering any replaceable event in /event . Render tags like "image", "description", "summary", "author", "title", etc.
9. Add an Asciidoctor library to the packages. Use that for rendering kinds 30818 and 30041. All other kinds use Markdown.
10. If a /event page is opened for a 30040 event, make sure that you analyze and then lazy-load the entire event-index hierarchy (see ../nips-silberengel/NKBIP-01.adoc) into the cache and then into the view. The index can use a-tags or e-tags, or a mix of both. Handle both types of tags and make sure to render the events in the original order. Retry any missing events, after the first loading pass, but don't loop infinitely.
11. Display a metadata card, at the top, for the OP 30040. Only display metadata for nested events, if they differ from the OP.
12. Please note that kind 30040 events typically contain 30041s, but they can actually contain any type of event the creator wants. Make sure to render each one according to its kind (markdown or asciidoc).
13. Both the metadata card and the section events should have their "title" displayed (if none is provided, render the d-tag without hyphens and in Title Case) and have a "..." menu. The section events should have a new menu item: "Open in a new window" that opens the section as a /event in the browser. The index OP should have a new menu item: "Label this as a book" that creates a "general" 1985 label with "booklist".
14. If an event opened in /event has been highlighted, render the highlight on the displayed text. (for 30040s, this needs to run after the publication has finished loading, or it won't find the text). Hovering over the highlight should display the user-badge of the person who created the highlight, with a button "View the highlight". Clicking the button should make the highlight open to the right, in a thread panel.
15. There should be a /replaceable/d-tag-placed-here url path that searches for all replaceable events that have that d-tag and lists them in a list. Clicking one should display it in thread-panel on the right.
16. Add a main menu item, to the right of Feeds: Write
it should open to a page offering two choicees: find an existing event to edit, create a new event
Clicking find should then demand they enter an event id (hex id, nevent, naddr, note) and click "Find".
The event should be searched for in cache and then the relays, (return the newest version found) and the json rendered, below a hyperlink to the related /event page.
They should be able to click an "Edit" button, and then the event is displayed as a form, where they can add/edit/delete tags and change the content. Don't render id, kind, pubkey, sig, created_at as those are to be generated when they click "Publish". Publish to cache and to the standard write-relays. Publishing should reveal the standard success/failure message for the relays. If none were successful, allow them to attempt to republish from cache. If successful, wait 5 seconds and then, open the event in the /event page.
Clicking create should ask them to enter a kind they would like to write: 1, 11, 9802, 1222, 20, 21, 22, 30023, 30818, 30817, 30041, 30040 (metadata-only, no sections added, they can do that manually in the edit function, add that as a help-text), 1068
17. If the user is looking at their own profile page, display a menu item "Adjust profile events" that opens a left-side panel that allows them select one of the following events to create/update: 0, 3, 30315, 10133, 10002, 10432, 10001, 10003, 10895, 10015, 10030, 30030, 10000, 30008. Selecting one should open an appropriate form and preload it with any event found in cache or on the relays. Publish to cache and to the standard write-relays. Publishing should reveal the standard success/failure message for the relays. If none were successful, allow them to attempt to republish from cache. If successful, wait 5 seconds and then, open the event in the /event page.
18. Make sure the /event page can handle metadata-only (no "content") events gracefully, displaying their tag-lists.

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-04T04:42:40.028Z", "buildTime": "2026-02-04T07:23:20.145Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770180160028 "timestamp": 1770189800145
} }

20
src/lib/components/EventMenu.svelte

@ -14,6 +14,9 @@
toggleHighlight toggleHighlight
} from '../services/user-actions.js'; } from '../services/user-actions.js';
import { eventMenuStore } from '../services/event-menu-store.js'; import { eventMenuStore } from '../services/event-menu-store.js';
import { sessionManager } from '../services/auth/session-manager.js';
import RelatedEventsModal from './modals/RelatedEventsModal.svelte';
import { KIND } from '../types/kind-lookup.js';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -24,6 +27,7 @@
let menuOpen = $state(false); let menuOpen = $state(false);
let jsonModalOpen = $state(false); let jsonModalOpen = $state(false);
let relatedEventsModalOpen = $state(false);
let publicationModalOpen = $state(false); let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null); let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let broadcasting = $state(false); let broadcasting = $state(false);
@ -36,7 +40,10 @@
let menuId = $derived(event.id); let menuId = $derived(event.id);
// Check if this is a note with content (kind 1 or kind 11) // Check if this is a note with content (kind 1 or kind 11)
let isContentNote = $derived(event.kind === 1 || event.kind === 11); let isContentNote = $derived(event.kind === KIND.SHORT_TEXT_NOTE || event.kind === KIND.DISCUSSION_THREAD);
// Check if user is logged in
let isLoggedIn = $derived(sessionManager.isLoggedIn());
// Track pin/bookmark/highlight state // Track pin/bookmark/highlight state
let pinnedState = $state(false); let pinnedState = $state(false);
@ -172,6 +179,11 @@
closeMenu(); closeMenu();
} }
function viewRelatedEvents() {
relatedEventsModalOpen = true;
closeMenu();
}
async function broadcastEvent() { async function broadcastEvent() {
broadcasting = true; broadcasting = true;
closeMenu(); closeMenu();
@ -260,6 +272,11 @@
<button class="menu-item" onclick={viewJson}> <button class="menu-item" onclick={viewJson}>
View JSON View JSON
</button> </button>
{#if isLoggedIn}
<button class="menu-item" onclick={viewRelatedEvents}>
View your related events
</button>
{/if}
<button class="menu-item" onclick={broadcastEvent} disabled={broadcasting}> <button class="menu-item" onclick={broadcastEvent} disabled={broadcasting}>
{broadcasting ? 'Broadcasting...' : 'Broadcast event'} {broadcasting ? 'Broadcasting...' : 'Broadcast event'}
</button> </button>
@ -296,6 +313,7 @@
</div> </div>
<EventJsonModal bind:open={jsonModalOpen} event={event} /> <EventJsonModal bind:open={jsonModalOpen} event={event} />
<RelatedEventsModal bind:open={relatedEventsModalOpen} event={event} />
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> <PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<style> <style>

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

@ -117,7 +117,7 @@
function getTitle(): string { function getTitle(): string {
if (!event) return ''; if (!event) return '';
if (event.kind === 11) { if (event.kind === KIND.DISCUSSION_THREAD) {
const titleTag = event.tags.find(t => t[0] === 'title'); const titleTag = event.tags.find(t => t[0] === 'title');
return titleTag?.[1] || ''; return titleTag?.[1] || '';
} }

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

@ -41,7 +41,7 @@
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [eventId] }], [{ kinds: [KIND.SHORT_TEXT_NOTE], ids: [eventId] }],
relays, relays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );

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

@ -41,7 +41,7 @@
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [eventId] }], [{ kinds: [KIND.SHORT_TEXT_NOTE], ids: [eventId] }],
relays, relays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );

17
src/lib/components/modals/EventJsonModal.svelte

@ -38,13 +38,24 @@
{#if open && event} {#if open && event}
<div <div
class="modal-overlay" class="modal-overlay"
onclick={close} onclick={(e) => {
onkeydown={(e) => e.key === 'Escape' && close()} // Only close if clicking directly on the overlay, not on modal content
if (e.target === e.currentTarget) {
close();
}
}}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
close();
}
}}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Event JSON modal"
tabindex="-1" tabindex="-1"
> >
<div class="modal-content" onclick={(e) => e.stopPropagation()}> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2>Event JSON</h2> <h2>Event JSON</h2>
<button onclick={close} class="close-button">×</button> <button onclick={close} class="close-button">×</button>

352
src/lib/components/modals/RelatedEventsModal.svelte

@ -0,0 +1,352 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { onMount } from 'svelte';
interface Props {
open?: boolean;
event?: NostrEvent | null;
}
let { open = $bindable(false), event = $bindable(null) }: Props = $props();
let relatedEvents = $state<NostrEvent[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let jsonText = $derived(JSON.stringify(relatedEvents, null, 2));
let copied = $state(false);
// Get current user's pubkey
const currentPubkey = $derived(sessionManager.getCurrentPubkey());
// Get replaceable event address (kind:pubkey:d-tag) if event is replaceable
function getReplaceableAddress(event: NostrEvent): string | null {
// Replaceable events have a 'd' tag
const dTag = event.tags.find(t => t[0] === 'd' || t[0] === 'D');
if (dTag && dTag[1]) {
return `${event.kind}:${event.pubkey}:${dTag[1]}`;
}
return null;
}
async function loadRelatedEvents() {
if (!event || !currentPubkey) {
error = 'No event or user not logged in';
return;
}
loading = true;
error = null;
relatedEvents = [];
try {
const relays = relayManager.getProfileReadRelays();
const filters: any[] = [];
// Query for events with #e tag (event reference)
filters.push({
authors: [currentPubkey],
'#e': [event.id],
limit: 100
});
// Query for events with #q tag (quoted event)
filters.push({
authors: [currentPubkey],
'#q': [event.id],
limit: 100
});
// Query for events with #a tag (replaceable event address) - only if event is replaceable
const replaceableAddress = getReplaceableAddress(event);
if (replaceableAddress) {
filters.push({
authors: [currentPubkey],
'#a': [replaceableAddress],
limit: 100
});
}
// Fetch all related events
const allEvents = await nostrClient.fetchEvents(
filters,
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
// Deduplicate by event ID
const uniqueEvents = Array.from(
new Map(allEvents.map(e => [e.id, e])).values()
);
// Sort by created_at descending
relatedEvents = uniqueEvents.sort((a, b) => b.created_at - a.created_at);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load related events';
console.error('Error loading related events:', err);
} finally {
loading = false;
}
}
function close() {
open = false;
}
async function copyJson() {
if (!jsonText) return;
try {
await navigator.clipboard.writeText(jsonText);
copied = true;
setTimeout(() => {
copied = false;
}, 2000);
} catch (error) {
console.error('Failed to copy JSON:', error);
}
}
function selectAll() {
const textarea = document.querySelector('.json-textarea') as HTMLTextAreaElement;
if (textarea) {
textarea.select();
}
}
// Load related events when modal opens
$effect(() => {
if (open && event && currentPubkey) {
loadRelatedEvents();
} else if (!open) {
// Reset state when closing
relatedEvents = [];
error = null;
}
});
</script>
{#if open && event}
<div
class="modal-overlay"
onclick={(e) => {
// Only close if clicking directly on the overlay, not on modal content
if (e.target === e.currentTarget) {
close();
}
}}
onkeydown={(e) => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
close();
}
}}
role="dialog"
aria-modal="true"
aria-label="Related events modal"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2>Your Related Events</h2>
<button onclick={close} class="close-button">×</button>
</div>
<div class="modal-body">
{#if loading}
<div class="loading-state">
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading related events...</p>
</div>
{:else if error}
<div class="error-state">
<p class="text-red-600 dark:text-red-400">Error: {error}</p>
</div>
{:else if relatedEvents.length === 0}
<div class="empty-state">
<p class="text-fog-text-light dark:text-fog-dark-text-light">No related events found.</p>
<p class="text-fog-text-light dark:text-fog-dark-text-light text-sm mt-2">
This shows events you've signed that reference this event via 'e', 'q', or 'a' tags.
</p>
</div>
{:else}
<div class="events-info">
<p class="text-fog-text-light dark:text-fog-dark-text-light text-sm mb-2">
Found {relatedEvents.length} related event{relatedEvents.length !== 1 ? 's' : ''}:
</p>
</div>
<textarea
class="json-textarea"
readonly
value={jsonText}
onclick={selectAll}
></textarea>
{/if}
</div>
<div class="modal-footer">
{#if relatedEvents.length > 0}
<button onclick={copyJson} class="copy-button">
{copied ? 'Copied!' : 'Copy'}
</button>
{/if}
<button onclick={close}>Close</button>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 8px;
max-width: 900px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .modal-header h2 {
color: var(--fog-dark-text, #f1f5f9);
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #f1f5f9);
}
.modal-body {
padding: 1rem;
flex: 1;
overflow: auto;
}
.loading-state,
.empty-state,
.error-state {
padding: 2rem;
text-align: center;
}
.events-info {
margin-bottom: 0.5rem;
}
.json-textarea {
width: 100%;
min-height: 400px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 4px;
background: var(--fog-bg, #ffffff);
color: var(--fog-text, #1f2937);
resize: vertical;
}
:global(.dark) .json-textarea {
background: var(--fog-dark-bg, #0f172a);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f1f5f9);
}
.modal-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
:global(.dark) .modal-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.modal-footer button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.875rem;
}
.copy-button {
background: var(--fog-accent, #64748b);
color: white;
}
.copy-button:hover {
background: var(--fog-accent-dark, #475569);
}
.modal-footer button:not(.copy-button) {
background: var(--fog-border, #e5e7eb);
color: var(--fog-text, #1f2937);
}
.modal-footer button:not(.copy-button):hover {
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .modal-footer button:not(.copy-button) {
background: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .modal-footer button:not(.copy-button):hover {
background: var(--fog-dark-highlight, #374151);
}
</style>

8
src/lib/modules/comments/CommentForm.svelte

@ -44,17 +44,17 @@
// If replying to a parent event, check its kind // If replying to a parent event, check its kind
if (parentEvent) { if (parentEvent) {
// If parent is kind 1, reply with kind 1 // If parent is kind 1, reply with kind 1
if (parentEvent.kind === 1) return 1; if (parentEvent.kind === KIND.SHORT_TEXT_NOTE) return KIND.SHORT_TEXT_NOTE;
// Everything else gets kind 1111 // Everything else gets kind 1111
return 1111; return KIND.COMMENT;
} }
// If replying to root, check root kind // If replying to root, check root kind
if (rootEvent) { if (rootEvent) {
// If root is kind 1, reply with kind 1 // If root is kind 1, reply with kind 1
if (rootEvent.kind === 1) return 1; if (rootEvent.kind === KIND.SHORT_TEXT_NOTE) return KIND.SHORT_TEXT_NOTE;
// Everything else gets kind 1111 // Everything else gets kind 1111
return 1111; return KIND.COMMENT;
} }
// Default to kind 1111 if we can't determine // Default to kind 1111 if we can't determine

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

@ -7,6 +7,7 @@
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 type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props { interface Props {
threadId: string; // The event ID of the root event threadId: string; // The event ID of the root event
@ -25,7 +26,7 @@
let nestedSubscriptionActive = $state(false); // Track if nested subscription is active let nestedSubscriptionActive = $state(false); // Track if nested subscription is active
let isProcessingUpdate = $state(false); // Prevent recursive update processing let isProcessingUpdate = $state(false); // Prevent recursive update processing
const isKind1 = $derived(event?.kind === 1); const isKind1 = $derived(event?.kind === KIND.SHORT_TEXT_NOTE);
const rootKind = $derived(event?.kind || null); const rootKind = $derived(event?.kind || null);
onMount(async () => { onMount(async () => {
@ -76,7 +77,7 @@
*/ */
function getParentEventId(replyEvent: NostrEvent): string | null { function getParentEventId(replyEvent: NostrEvent): string | null {
// For kind 1111, check both uppercase and lowercase E and A tags // For kind 1111, check both uppercase and lowercase E and A tags
if (replyEvent.kind === 1111) { if (replyEvent.kind === KIND.COMMENT) {
// Check uppercase E tag first (NIP-22 standard for root) // Check uppercase E tag first (NIP-22 standard for root)
const eTag = replyEvent.tags.find((t) => t[0] === 'E'); const eTag = replyEvent.tags.find((t) => t[0] === 'E');
if (eTag && eTag[1]) { if (eTag && eTag[1]) {
@ -112,7 +113,7 @@
} }
// For kind 1, 1244, 9735: check e tag // For kind 1, 1244, 9735: check e tag
if (replyEvent.kind === 1 || replyEvent.kind === 1244 || replyEvent.kind === 9735) { if (replyEvent.kind === KIND.SHORT_TEXT_NOTE || replyEvent.kind === KIND.VOICE_REPLY || replyEvent.kind === KIND.ZAP_RECEIPT) {
// For kind 1, check all e tags (NIP-10) // For kind 1, check all e tags (NIP-10)
const eTags = replyEvent.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== replyEvent.id); const eTags = replyEvent.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== replyEvent.id);
// Prefer e tag with 'reply' marker, otherwise use first e tag // Prefer e tag with 'reply' marker, otherwise use first e tag
@ -130,7 +131,7 @@
* For other kinds: checks e tag * For other kinds: checks e tag
*/ */
function referencesRoot(replyEvent: NostrEvent): boolean { function referencesRoot(replyEvent: NostrEvent): boolean {
if (replyEvent.kind === 1111) { if (replyEvent.kind === KIND.COMMENT) {
// Check uppercase E tag (NIP-22 standard for root) // Check uppercase E tag (NIP-22 standard for root)
const eTag = replyEvent.tags.find((t) => t[0] === 'E'); const eTag = replyEvent.tags.find((t) => t[0] === 'E');
if (eTag && eTag[1] === threadId) return true; if (eTag && eTag[1] === threadId) return true;
@ -201,16 +202,16 @@
} }
// Add the reply to the appropriate map // Add the reply to the appropriate map
if (reply.kind === 1111) { if (reply.kind === KIND.COMMENT) {
commentsMap.set(reply.id, reply); commentsMap.set(reply.id, reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 1) { } else if (reply.kind === KIND.SHORT_TEXT_NOTE) {
kind1RepliesMap.set(reply.id, reply); kind1RepliesMap.set(reply.id, reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 1244) { } else if (reply.kind === KIND.VOICE_REPLY) {
yakBacksMap.set(reply.id, reply); yakBacksMap.set(reply.id, reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 9735) { } else if (reply.kind === KIND.ZAP_RECEIPT) {
zapReceiptsMap.set(reply.id, reply); zapReceiptsMap.set(reply.id, reply);
hasNewReplies = true; hasNewReplies = true;
} }
@ -236,14 +237,14 @@
} }
const allRelays = relayManager.getProfileReadRelays(); const allRelays = relayManager.getProfileReadRelays();
const replyFilters: any[] = [ const replyFilters: any[] = [
{ kinds: [1111], '#e': [threadId] }, { kinds: [KIND.COMMENT], '#e': [threadId] },
{ kinds: [1111], '#E': [threadId] }, { kinds: [KIND.COMMENT], '#E': [threadId] },
{ kinds: [1111], '#a': [threadId] }, { kinds: [KIND.COMMENT], '#a': [threadId] },
{ kinds: [1111], '#A': [threadId] }, { kinds: [KIND.COMMENT], '#A': [threadId] },
{ kinds: [1], '#e': [threadId] }, { kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId] },
{ kinds: [1244], '#e': [threadId] }, { kinds: [KIND.VOICE_REPLY], '#e': [threadId] },
{ kinds: [9735], '#e': [threadId] } { kinds: [KIND.ZAP_RECEIPT], '#e': [threadId] }
]; ];
// fetchEvents with useCache:true returns cached data immediately if available, // fetchEvents with useCache:true returns cached data immediately if available,
@ -276,10 +277,10 @@
const rootReplies = allReplies.filter(reply => referencesRoot(reply)); const rootReplies = allReplies.filter(reply => referencesRoot(reply));
// Separate by type // Separate by type
comments = rootReplies.filter(e => e.kind === 1111); comments = rootReplies.filter(e => e.kind === KIND.COMMENT);
kind1Replies = rootReplies.filter(e => e.kind === 1); kind1Replies = rootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
yakBacks = rootReplies.filter(e => e.kind === 1244); yakBacks = rootReplies.filter(e => e.kind === KIND.VOICE_REPLY);
zapReceipts = rootReplies.filter(e => e.kind === 9735); zapReceipts = rootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT);
loading = false; // Hide loading now that we have data (cached or fresh) loading = false; // Hide loading now that we have data (cached or fresh)
@ -320,11 +321,11 @@
// Use a single subscription that covers all reply IDs // Use a single subscription that covers all reply IDs
const nestedFilters: any[] = [ const nestedFilters: any[] = [
{ kinds: [1111], '#e': Array.from(allReplyIds) }, { kinds: [KIND.COMMENT], '#e': Array.from(allReplyIds) },
{ kinds: [1111], '#E': Array.from(allReplyIds) }, { kinds: [KIND.COMMENT], '#E': Array.from(allReplyIds) },
{ kinds: [1], '#e': Array.from(allReplyIds) }, { kinds: [KIND.SHORT_TEXT_NOTE], '#e': Array.from(allReplyIds) },
{ kinds: [1244], '#e': Array.from(allReplyIds) }, { kinds: [KIND.VOICE_REPLY], '#e': Array.from(allReplyIds) },
{ kinds: [9735], '#e': Array.from(allReplyIds) } { kinds: [KIND.ZAP_RECEIPT], '#e': Array.from(allReplyIds) }
]; ];
nostrClient.fetchEvents( nostrClient.fetchEvents(
@ -361,16 +362,16 @@
if (allReplyIds.size > 0) { if (allReplyIds.size > 0) {
const nestedFilters: any[] = [ const nestedFilters: any[] = [
// Fetch nested kind 1111 comments - check both e/E and a/A tags // Fetch nested kind 1111 comments - check both e/E and a/A tags
{ kinds: [1111], '#e': Array.from(allReplyIds) }, { kinds: [KIND.COMMENT], '#e': Array.from(allReplyIds) },
{ kinds: [1111], '#E': Array.from(allReplyIds) }, { kinds: [KIND.COMMENT], '#E': Array.from(allReplyIds) },
{ kinds: [1111], '#a': Array.from(allReplyIds) }, { kinds: [KIND.COMMENT], '#a': Array.from(allReplyIds) },
{ kinds: [1111], '#A': Array.from(allReplyIds) }, { kinds: [KIND.COMMENT], '#A': Array.from(allReplyIds) },
// Fetch nested kind 1 replies // Fetch nested kind 1 replies
{ kinds: [1], '#e': Array.from(allReplyIds) }, { kinds: [KIND.SHORT_TEXT_NOTE], '#e': Array.from(allReplyIds) },
// Fetch nested yak backs // Fetch nested yak backs
{ kinds: [1244], '#e': Array.from(allReplyIds) }, { kinds: [KIND.VOICE_REPLY], '#e': Array.from(allReplyIds) },
// Fetch nested zap receipts // Fetch nested zap receipts
{ kinds: [9735], '#e': Array.from(allReplyIds) } { kinds: [KIND.ZAP_RECEIPT], '#e': Array.from(allReplyIds) }
]; ];
const nestedReplies = await nostrClient.fetchEvents( const nestedReplies = await nostrClient.fetchEvents(
@ -381,16 +382,16 @@
// Add new replies by type // Add new replies by type
for (const reply of nestedReplies) { for (const reply of nestedReplies) {
if (reply.kind === 1111 && !comments.some(c => c.id === reply.id)) { if (reply.kind === KIND.COMMENT && !comments.some(c => c.id === reply.id)) {
comments.push(reply); comments.push(reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 1 && !kind1Replies.some(r => r.id === reply.id)) { } else if (reply.kind === KIND.SHORT_TEXT_NOTE && !kind1Replies.some(r => r.id === reply.id)) {
kind1Replies.push(reply); kind1Replies.push(reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 1244 && !yakBacks.some(y => y.id === reply.id)) { } else if (reply.kind === KIND.VOICE_REPLY && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply); yakBacks.push(reply);
hasNewReplies = true; hasNewReplies = true;
} else if (reply.kind === 9735 && !zapReceipts.some(z => z.id === reply.id)) { } else if (reply.kind === KIND.ZAP_RECEIPT && !zapReceipts.some(z => z.id === reply.id)) {
zapReceipts.push(reply); zapReceipts.push(reply);
hasNewReplies = true; hasNewReplies = true;
} }
@ -536,21 +537,21 @@
// Always fetch kind 1111 comments - check both e and E tags, and a and A tags // Always fetch kind 1111 comments - check both e and E tags, and a and A tags
replyFilters.push( replyFilters.push(
{ kinds: [1111], '#e': [threadId] }, // Lowercase e tag { kinds: [KIND.COMMENT], '#e': [threadId] }, // Lowercase e tag
{ kinds: [1111], '#E': [threadId] }, // Uppercase E tag (NIP-22) { kinds: [KIND.COMMENT], '#E': [threadId] }, // Uppercase E tag (NIP-22)
{ kinds: [1111], '#a': [threadId] }, // Lowercase a tag (some clients use wrong tags) { kinds: [KIND.COMMENT], '#a': [threadId] }, // Lowercase a tag (some clients use wrong tags)
{ kinds: [1111], '#A': [threadId] } // Uppercase A tag (NIP-22 for addressable events) { kinds: [KIND.COMMENT], '#A': [threadId] } // Uppercase A tag (NIP-22 for addressable events)
); );
// For kind 1 events, fetch kind 1 replies // For kind 1 events, fetch kind 1 replies
// Also fetch kind 1 replies for any event (some apps use kind 1 for everything) // Also fetch kind 1 replies for any event (some apps use kind 1 for everything)
replyFilters.push({ kinds: [1], '#e': [threadId] }); replyFilters.push({ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId] });
// Fetch yak backs (kind 1244) - voice replies // Fetch yak backs (kind 1244) - voice replies
replyFilters.push({ kinds: [1244], '#e': [threadId] }); replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId] });
// Fetch zap receipts (kind 9735) // Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [9735], '#e': [threadId] }); replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId] });
// Don't use cache when reloading after publishing - we want fresh data // Don't use cache when reloading after publishing - we want fresh data
const allReplies = await nostrClient.fetchEvents( const allReplies = await nostrClient.fetchEvents(
@ -563,10 +564,10 @@
const rootReplies = allReplies.filter(reply => referencesRoot(reply)); const rootReplies = allReplies.filter(reply => referencesRoot(reply));
// Separate by type // Separate by type
comments = rootReplies.filter(e => e.kind === 1111); comments = rootReplies.filter(e => e.kind === KIND.COMMENT);
kind1Replies = rootReplies.filter(e => e.kind === 1); kind1Replies = rootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
yakBacks = rootReplies.filter(e => e.kind === 1244); yakBacks = rootReplies.filter(e => e.kind === KIND.VOICE_REPLY);
zapReceipts = rootReplies.filter(e => e.kind === 9735); zapReceipts = rootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT);
// Recursively fetch all nested replies (non-blocking - let it run in background) // Recursively fetch all nested replies (non-blocking - let it run in background)
fetchNestedReplies().then(() => { fetchNestedReplies().then(() => {
@ -592,15 +593,15 @@
function getAllowedReplyKind(targetEvent: NostrEvent | null): number { function getAllowedReplyKind(targetEvent: NostrEvent | null): number {
if (!targetEvent) { if (!targetEvent) {
// If replying to root, check root kind // If replying to root, check root kind
if (isKind1) return 1; if (isKind1) return KIND.SHORT_TEXT_NOTE;
return 1111; return KIND.COMMENT;
} }
// If target is kind 1, allow kind 1 reply // If target is kind 1, allow kind 1 reply
if (targetEvent.kind === 1) return 1; if (targetEvent.kind === KIND.SHORT_TEXT_NOTE) return KIND.SHORT_TEXT_NOTE;
// Everything else gets kind 1111 // Everything else gets kind 1111
return 1111; return KIND.COMMENT;
} }
// Calculate total comment count (includes all reply types) // Calculate total comment count (includes all reply types)

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

@ -5,6 +5,7 @@
import ThreadDrawer from './ThreadDrawer.svelte'; import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
let posts = $state<NostrEvent[]>([]); let posts = $state<NostrEvent[]>([]);
let loading = $state(true); let loading = $state(true);
@ -108,7 +109,7 @@
} }
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const filters = [{ kinds: [1], limit: 20 }]; const filters = [{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 }];
// Subscribe to new kind 1 events // Subscribe to new kind 1 events
subscriptionId = nostrClient.subscribe( subscriptionId = nostrClient.subscribe(
@ -146,7 +147,7 @@
: Math.floor(Date.now() / 1000) - 60; // Last minute if no posts : Math.floor(Date.now() / 1000) - 60; // Last minute if no posts
const filters = [{ const filters = [{
kinds: [1], kinds: [KIND.SHORT_TEXT_NOTE],
limit: 50, limit: 50,
since: newestTimestamp + 1 // Only get events newer than what we have since: newestTimestamp + 1 // Only get events newer than what we have
}]; }];
@ -205,7 +206,7 @@
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
// Load initial feed - use cache for fast initial load // Load initial feed - use cache for fast initial load
const filters = [{ kinds: [1], limit: 20 }]; const filters = [{ kinds: [KIND.SHORT_TEXT_NOTE], limit: 20 }];
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
filters, filters,
relays, relays,
@ -275,7 +276,7 @@
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const filters = [{ const filters = [{
kinds: [1], kinds: [KIND.SHORT_TEXT_NOTE],
limit: 20, limit: 20,
until: oldestTimestamp || undefined until: oldestTimestamp || undefined
}]; }];
@ -407,8 +408,8 @@
// Batch fetch all reactions for all posts in one query // Batch fetch all reactions for all posts in one query
const allReactions = await nostrClient.fetchEvents( const allReactions = await nostrClient.fetchEvents(
[ [
{ kinds: [7], '#e': eventIds, limit: 1000 }, { kinds: [KIND.REACTION], '#e': eventIds, limit: 1000 },
{ kinds: [7], '#E': eventIds, limit: 1000 } { kinds: [KIND.REACTION], '#E': eventIds, limit: 1000 }
], ],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, timeout: 10000 } { useCache: true, cacheResults: true, timeout: 10000 }

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

@ -10,7 +10,7 @@
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 type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
interface Props { interface Props {
@ -37,7 +37,7 @@
// Calculate votes as derived values to avoid infinite loops // Calculate votes as derived values to avoid infinite loops
// Deduplicate by pubkey - each user should only count once per vote type // Deduplicate by pubkey - each user should only count once per vote type
let upvotes = $derived.by(() => { let upvotes = $derived.by(() => {
if (post.kind !== 11) return 0; if (post.kind !== KIND.DISCUSSION_THREAD) return 0;
const reactionEvents = reactions; const reactionEvents = reactions;
if (!reactionEvents || !Array.isArray(reactionEvents)) return 0; if (!reactionEvents || !Array.isArray(reactionEvents)) return 0;
@ -73,7 +73,7 @@
}); });
let downvotes = $derived.by(() => { let downvotes = $derived.by(() => {
if (post.kind !== 11) return 0; if (post.kind !== KIND.DISCUSSION_THREAD) return 0;
const reactionEvents = reactions; const reactionEvents = reactions;
if (!reactionEvents || !Array.isArray(reactionEvents)) return 0; if (!reactionEvents || !Array.isArray(reactionEvents)) return 0;
@ -120,7 +120,7 @@
const zapRelays = relayManager.getZapReceiptReadRelays(); const zapRelays = relayManager.getZapReceiptReadRelays();
const filters = [{ const filters = [{
kinds: [9735], kinds: [KIND.ZAP_RECEIPT],
'#e': [post.id] '#e': [post.id]
}]; }];
@ -238,7 +238,7 @@
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [1], ids: [replyEventId] }], [{ kinds: [KIND.SHORT_TEXT_NOTE], ids: [replyEventId] }],
relays, relays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );
@ -372,7 +372,7 @@
{#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">via {getClientName()}</span>
{/if} {/if}
{#if post.kind === 11} {#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = getTopics()} {@const topics = getTopics()}
{#if topics.length === 0} {#if topics.length === 0}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span> <span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span>
@ -388,7 +388,7 @@
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text"> <div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if post.kind === 11 && (upvotes > 0 || downvotes > 0)} {#if post.kind === KIND.DISCUSSION_THREAD && (upvotes > 0 || downvotes > 0)}
<span class="vote-counts text-fog-text-light dark:text-fog-dark-text-light"> <span class="vote-counts text-fog-text-light dark:text-fog-dark-text-light">
{#if upvotes > 0} {#if upvotes > 0}
<span class="upvotes"> {upvotes}</span> <span class="upvotes"> {upvotes}</span>
@ -431,7 +431,7 @@
{#if getClientName()} {#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">via {getClientName()}</span>
{/if} {/if}
{#if post.kind === 11} {#if post.kind === KIND.DISCUSSION_THREAD}
{@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])} {@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])}
{#if topics.length === 0} {#if topics.length === 0}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span> <span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span>
@ -463,7 +463,7 @@
<!-- Post actions (reactions, etc.) - always visible, outside collapsible content --> <!-- Post actions (reactions, etc.) - always visible, outside collapsible content -->
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === 11} {#if post.kind === KIND.DISCUSSION_THREAD}
<!-- Show vote counts for threads --> <!-- Show vote counts for threads -->
{#if upvotes > 0 || downvotes > 0} {#if upvotes > 0 || downvotes > 0}
<span class="vote-counts text-xs text-fog-text-light dark:text-fog-dark-text-light"> <span class="vote-counts text-xs text-fog-text-light dark:text-fog-dark-text-light">

2
src/lib/modules/feed/Reply.svelte

@ -92,7 +92,7 @@
<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">via {getClientName()}</span>
{/if} {/if}
<div class="ml-auto"> <div class="ml-auto">
<EventMenu event={reply} showContentActions={reply.kind === 1} /> <EventMenu event={reply} showContentActions={reply.kind === KIND.SHORT_TEXT_NOTE} />
</div> </div>
</div> </div>

2
src/lib/modules/profiles/PaymentAddresses.svelte

@ -27,7 +27,7 @@
// Fetch kind 10133 (payment targets) // Fetch kind 10133 (payment targets)
const paymentEvents = await nostrClient.fetchEvents( const paymentEvents = await nostrClient.fetchEvents(
[{ kinds: [10133], authors: [pubkey], limit: 1 }], [{ kinds: [KIND.PAYMENT_ADDRESSES], authors: [pubkey], limit: 1 }],
[...config.defaultRelays, ...config.profileRelays], [...config.defaultRelays, ...config.profileRelays],
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );

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

@ -12,6 +12,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
let profile = $state<ProfileData | null>(null); let profile = $state<ProfileData | null>(null);
let userStatus = $state<string | null>(null); let userStatus = $state<string | null>(null);
@ -72,7 +73,7 @@
// Fetch current user's posts from cache first (fast) // Fetch current user's posts from cache first (fast)
const currentUserPosts = await nostrClient.fetchEvents( const currentUserPosts = await nostrClient.fetchEvents(
[{ kinds: [1], authors: [currentUserPubkey], limit: 50 }], [{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: 50 }],
interactionRelays, interactionRelays,
{ useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache { useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache
); );
@ -88,8 +89,8 @@
const interactionEvents = await Promise.race([ const interactionEvents = await Promise.race([
nostrClient.fetchEvents( nostrClient.fetchEvents(
[ [
{ kinds: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries { kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries
{ kinds: [1], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } { kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 }
], ],
interactionRelays, interactionRelays,
{ useCache: true, cacheResults: true, timeout: 5000 } { useCache: true, cacheResults: true, timeout: 5000 }
@ -349,7 +350,7 @@
// Load posts first (needed for response filtering) // Load posts first (needed for response filtering)
const feedEvents = await nostrClient.fetchEvents( const feedEvents = await nostrClient.fetchEvents(
[{ kinds: [1], authors: [pubkey], limit: 20 }], [{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [pubkey], limit: 20 }],
profileRelays, profileRelays,
{ useCache: true, cacheResults: true, timeout: 5000 } { useCache: true, cacheResults: true, timeout: 5000 }
); );
@ -358,7 +359,7 @@
// Load responses in parallel with posts (but filter after posts are loaded) // Load responses in parallel with posts (but filter after posts are loaded)
const userPostIds = new Set(posts.map(p => p.id)); const userPostIds = new Set(posts.map(p => p.id));
const responseEvents = await nostrClient.fetchEvents( const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [1], '#p': [pubkey], limit: 50 }], // Fetch more to account for filtering [{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 50 }], // Fetch more to account for filtering
responseRelays, responseRelays,
{ useCache: true, cacheResults: true, timeout: 5000 } { useCache: true, cacheResults: true, timeout: 5000 }
); );

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

@ -5,6 +5,7 @@
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 type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { resolveCustomEmojis, fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js'; import { resolveCustomEmojis, fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import EmojiPicker from '../../components/content/EmojiPicker.svelte'; import EmojiPicker from '../../components/content/EmojiPicker.svelte';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
@ -110,12 +111,12 @@
allReactionsMap.clear(); allReactionsMap.clear();
const reactionsWithLowerE = await nostrClient.fetchEvents( const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id] }], [{ kinds: [KIND.REACTION], '#e': [event.id] }],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate } { useCache: true, cacheResults: true, onUpdate: handleReactionUpdate }
); );
const reactionsWithUpperE = await nostrClient.fetchEvents( const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [7], '#E': [event.id] }], [{ kinds: [KIND.REACTION], '#E': [event.id] }],
reactionRelays, reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate } { useCache: true, cacheResults: true, onUpdate: handleReactionUpdate }
); );
@ -191,7 +192,7 @@
// Fetch deletion events (kind 5) to filter out deleted reactions // Fetch deletion events (kind 5) to filter out deleted reactions
const reactionRelays = relayManager.getProfileReadRelays(); const reactionRelays = relayManager.getProfileReadRelays();
const deletionEvents = await nostrClient.fetchEvents( const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [5], authors: Array.from(new Set(reactions.map(r => r.pubkey))) }], [{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(reactions.map(r => r.pubkey))) }],
reactionRelays, reactionRelays,
{ useCache: true } { useCache: true }
); );
@ -258,7 +259,7 @@
// For kind 11 events (or kind 1111 replies to kind 11), normalize reactions: only + and - allowed // For kind 11 events (or kind 1111 replies to kind 11), normalize reactions: only + and - allowed
// Backward compatibility: ⬆/↑ = +, ⬇/↓ = - // Backward compatibility: ⬆/↑ = +, ⬇/↓ = -
if (event.kind === 11 || forceUpvoteDownvote) { if (event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) {
if (content === '⬆' || content === '↑') { if (content === '⬆' || content === '↑') {
content = '+'; content = '+';
} else if (content === '⬇' || content === '↓') { } else if (content === '⬇' || content === '↓') {
@ -379,7 +380,7 @@
} }
// For kind 11 events (or kind 1111 replies to kind 11), only allow + and - (upvote/downvote) // For kind 11 events (or kind 1111 replies to kind 11), only allow + and - (upvote/downvote)
if ((event.kind === 11 || forceUpvoteDownvote) && content !== '+' && content !== '-') { if ((event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) && content !== '+' && content !== '-') {
return; return;
} }
@ -389,7 +390,7 @@
if (userReactionEventId) { if (userReactionEventId) {
try { try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = { const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 5, kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!, pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['e', userReactionEventId]], tags: [['e', userReactionEventId]],
@ -437,12 +438,12 @@
} }
// For kind 11 (or kind 1111 replies to kind 11): if user has the opposite vote, delete it first // For kind 11 (or kind 1111 replies to kind 11): if user has the opposite vote, delete it first
if ((event.kind === 11 || forceUpvoteDownvote) && userReaction && userReaction !== content) { if ((event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) && userReaction && userReaction !== content) {
// Delete the existing vote first // Delete the existing vote first
if (userReactionEventId) { if (userReactionEventId) {
try { try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = { const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 5, kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!, pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [['e', userReactionEventId]], tags: [['e', userReactionEventId]],
@ -485,7 +486,7 @@
} }
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = { const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 7, kind: KIND.REACTION,
pubkey: sessionManager.getCurrentPubkey()!, pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,
@ -635,7 +636,7 @@
</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">
{#if event.kind === 11 || forceUpvoteDownvote} {#if event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote}
<!-- Kind 11 (Thread) or Kind 1111 (Reply to Thread): Only upvote and downvote buttons --> <!-- Kind 11 (Thread) or Kind 1111 (Reply to Thread): Only upvote and downvote buttons -->
<button <button
onclick={() => toggleReaction('+')} onclick={() => toggleReaction('+')}
@ -676,7 +677,7 @@
/> />
</div> </div>
{#if event.kind !== 11} {#if event.kind !== KIND.DISCUSSION_THREAD}
{#each getAllReactions() as { content, count }} {#each getAllReactions() as { content, count }}
<span <span
class="reaction-display {userReaction === content ? 'active' : ''}" class="reaction-display {userReaction === content ? 'active' : ''}"

11
src/lib/modules/reactions/ReactionButtons.svelte

@ -4,6 +4,7 @@
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 { KIND } from '../../types/kind-lookup.js';
interface Props { interface Props {
event: NostrEvent; // The event to react to (kind 11 or 1111) event: NostrEvent; // The event to react to (kind 11 or 1111)
@ -28,7 +29,7 @@
// Fetch reactions (kind 7) for this event // Fetch reactions (kind 7) for this event
const filters = [ const filters = [
{ {
kinds: [7], kinds: [KIND.REACTION],
'#e': [event.id] '#e': [event.id]
} }
]; ];
@ -59,7 +60,7 @@
// Normalize reactions for kind 11/1111: only + and - allowed // Normalize reactions for kind 11/1111: only + and - allowed
// Backward compatibility: ⬆ = +, ⬇ = - // Backward compatibility: ⬆ = +, ⬇ = -
let normalizedContent = content; let normalizedContent = content;
if (event.kind === 11 || event.kind === 1111) { if (event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT) {
if (content === '⬆' || content === '↑') { if (content === '⬆' || content === '↑') {
normalizedContent = '+'; normalizedContent = '+';
} else if (content === '⬇' || content === '↓') { } else if (content === '⬇' || content === '↓') {
@ -90,7 +91,7 @@
} }
// For kind 11/1111, only allow + and - // For kind 11/1111, only allow + and -
if ((event.kind === 11 || event.kind === 1111) && content !== '+' && content !== '-') { if ((event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT) && content !== '+' && content !== '-') {
return; return;
} }
@ -151,7 +152,7 @@
function getReactionDisplay(content: string): string { function getReactionDisplay(content: string): string {
if (content === '+') { if (content === '+') {
return event.kind === 1 ? '❤' : '↑'; return event.kind === KIND.SHORT_TEXT_NOTE ? '❤' : '↑';
} }
if (content === '-') { if (content === '-') {
return '↓'; return '↓';
@ -167,7 +168,7 @@
</script> </script>
<div class="reaction-buttons flex gap-2 items-center"> <div class="reaction-buttons flex gap-2 items-center">
{#if event.kind === 11 || event.kind === 1111} {#if event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT}
<!-- Thread/Comment reactions: Only + and - --> <!-- Thread/Comment reactions: Only + and - -->
<button <button
onclick={() => toggleReaction('+')} onclick={() => toggleReaction('+')}

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

@ -5,7 +5,7 @@
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 type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
interface Props { interface Props {
@ -74,7 +74,7 @@
// Load reactions (kind 7) // Load reactions (kind 7)
const reactionRelays = relayManager.getThreadReadRelays(); const reactionRelays = relayManager.getThreadReadRelays();
const reactionEvents = await nostrClient.fetchEvents( const reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [thread.id] }], [{ kinds: [KIND.REACTION], '#e': [thread.id] }],
reactionRelays, reactionRelays,
{ useCache: true } { useCache: true }
); );
@ -92,7 +92,7 @@
// Load comments (kind 1111) // Load comments (kind 1111)
const commentRelays = relayManager.getCommentReadRelays(); const commentRelays = relayManager.getCommentReadRelays();
const commentEvents = await nostrClient.fetchEvents( const commentEvents = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': [thread.id], '#K': ['11'] }], [{ kinds: [KIND.COMMENT], '#E': [thread.id], '#K': ['11'] }],
commentRelays, commentRelays,
{ useCache: true } { useCache: true }
); );
@ -101,7 +101,7 @@
// Load zap receipts (kind 9735) // Load zap receipts (kind 9735)
const zapRelays = relayManager.getZapReceiptReadRelays(); const zapRelays = relayManager.getZapReceiptReadRelays();
const zapReceipts = await nostrClient.fetchEvents( const zapReceipts = await nostrClient.fetchEvents(
[{ kinds: [9735], '#e': [thread.id] }], [{ kinds: [KIND.ZAP_RECEIPT], '#e': [thread.id] }],
zapRelays, zapRelays,
{ useCache: true } { useCache: true }
); );

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

@ -5,6 +5,7 @@
import ThreadDrawer from '../feed/ThreadDrawer.svelte'; import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
// Data maps - all data loaded upfront // Data maps - all data loaded upfront
let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
@ -49,7 +50,7 @@
// Fetch all threads // Fetch all threads
const threadEvents = await nostrClient.fetchEvents( const threadEvents = await nostrClient.fetchEvents(
[{ kinds: [11], since, limit: 50 }], [{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
threadRelays, threadRelays,
{ {
useCache: true, useCache: true,
@ -76,7 +77,7 @@
if (threadIds.length > 0) { if (threadIds.length > 0) {
// Fetch all comments in parallel // Fetch all comments in parallel
const allComments = await nostrClient.fetchEvents( const allComments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': threadIds, '#K': ['11'] }], [{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'] }],
commentRelays, commentRelays,
{ useCache: true } { useCache: true }
); );
@ -95,7 +96,7 @@
// Fetch deletion events for current reactions // Fetch deletion events for current reactions
const deletionEvents = await nostrClient.fetchEvents( const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [5], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }], [{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }],
reactionRelays, reactionRelays,
{ useCache: true } { useCache: true }
); );
@ -173,7 +174,7 @@
}; };
const reactionsWithLowerE = await nostrClient.fetchEvents( const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': threadIds }], [{ kinds: [KIND.REACTION], '#e': threadIds }],
reactionRelays, reactionRelays,
{ {
useCache: true, useCache: true,
@ -185,7 +186,7 @@
let reactionsWithUpperE: NostrEvent[] = []; let reactionsWithUpperE: NostrEvent[] = [];
try { try {
reactionsWithUpperE = await nostrClient.fetchEvents( reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [7], '#E': threadIds }], [{ kinds: [KIND.REACTION], '#E': threadIds }],
reactionRelays, reactionRelays,
{ {
useCache: true, useCache: true,
@ -225,7 +226,7 @@
// Fetch all zap receipts in parallel // Fetch all zap receipts in parallel
const allZapReceipts = await nostrClient.fetchEvents( const allZapReceipts = await nostrClient.fetchEvents(
[{ kinds: [9735], '#e': threadIds }], [{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds }],
zapRelays, zapRelays,
{ useCache: true } { useCache: true }
); );

4
src/lib/modules/zaps/ZapButton.svelte

@ -28,7 +28,7 @@
// Fetch profile to get lud16 or lnurl // Fetch profile to get lud16 or lnurl
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
const profileEvents = await nostrClient.fetchEvents( const profileEvents = await nostrClient.fetchEvents(
[{ kinds: [0], authors: [targetPubkey], limit: 1 }], [{ kinds: [KIND.METADATA], authors: [targetPubkey], limit: 1 }],
[...config.defaultRelays, ...config.profileRelays], [...config.defaultRelays, ...config.profileRelays],
{ useCache: true } { useCache: true }
); );
@ -50,7 +50,7 @@
['p', targetPubkey] ['p', targetPubkey]
]; ];
if (event.kind !== 0) { if (event.kind !== KIND.METADATA) {
// Zap to an event, not just a profile // Zap to an event, not just a profile
tags.push(['e', event.id]); tags.push(['e', event.id]);
tags.push(['k', event.kind.toString()]); tags.push(['k', event.kind.toString()]);

2
src/lib/modules/zaps/ZapReceipt.svelte

@ -35,7 +35,7 @@
// Fetch zap receipts (kind 9735) for this event // Fetch zap receipts (kind 9735) for this event
const filters: any[] = [ const filters: any[] = [
{ {
kinds: [9735], kinds: [KIND.ZAP_RECEIPT],
'#e': [eventId] '#e': [eventId]
} }
]; ];

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

@ -13,7 +13,7 @@ import type { NostrEvent } from '../../types/nostr.js';
export async function getLastActivity(pubkey: string): Promise<number | undefined> { export async function getLastActivity(pubkey: string): Promise<number | undefined> {
// Query for recent events from this pubkey (cache only) // Query for recent events from this pubkey (cache only)
const filters = [ const filters = [
{ authors: [pubkey], kinds: [0, 1, 7, 11, 1111], limit: 1 } { authors: [pubkey], kinds: [KIND.METADATA, KIND.SHORT_TEXT_NOTE, KIND.REACTION, KIND.DISCUSSION_THREAD, KIND.COMMENT], limit: 1 }
]; ];
const events = await nostrClient.getByFilters(filters); const events = await nostrClient.getByFilters(filters);

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

@ -4,6 +4,7 @@
import { getDB } from './indexeddb-store.js'; import { getDB } from './indexeddb-store.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
export interface CachedProfile { export interface CachedProfile {
pubkey: string; pubkey: string;
@ -15,7 +16,7 @@ export interface CachedProfile {
* Store a profile in cache * Store a profile in cache
*/ */
export async function cacheProfile(event: NostrEvent): Promise<void> { export async function cacheProfile(event: NostrEvent): Promise<void> {
if (event.kind !== 0) throw new Error('Not a profile event'); if (event.kind !== KIND.METADATA) throw new Error('Not a profile event');
try { try {
const db = await getDB(); const db = await getDB();
const cached: CachedProfile = { const cached: CachedProfile = {

5
src/lib/services/nostr/auth-handler.ts

@ -14,6 +14,7 @@ import { fetchRelayLists } from '../user-data.js';
import { nostrClient } from './nostr-client.js'; import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js'; import { relayManager } from './relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
// Mute list and blocked relays management // Mute list and blocked relays management
const muteList: Set<string> = new Set(); const muteList: Set<string> = new Set();
@ -94,7 +95,7 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
// Fetch mute list (kind 10000) // Fetch mute list (kind 10000)
const muteEvents = await nostrClient.fetchEvents( const muteEvents = await nostrClient.fetchEvents(
[{ kinds: [10000], authors: [pubkey], limit: 1 }], [{ kinds: [KIND.MUTE_LIST], authors: [pubkey], limit: 1 }],
relayManager.getProfileReadRelays(), relayManager.getProfileReadRelays(),
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );
@ -110,7 +111,7 @@ async function loadUserPreferences(pubkey: string): Promise<void> {
// Fetch blocked relays (kind 10006) // Fetch blocked relays (kind 10006)
const blockedRelayEvents = await nostrClient.fetchEvents( const blockedRelayEvents = await nostrClient.fetchEvents(
[{ kinds: [10006], authors: [pubkey], limit: 1 }], [{ kinds: [KIND.BLOCKED_RELAYS], authors: [pubkey], limit: 1 }],
relayManager.getProfileReadRelays(), relayManager.getProfileReadRelays(),
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );

4
src/lib/services/nostr/gif-service.ts

@ -6,7 +6,7 @@
import { nostrClient } from './nostr-client.js'; import { nostrClient } from './nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND_LOOKUP, getKindInfo } from '../../types/kind-lookup.js'; import { KIND_LOOKUP, getKindInfo, KIND } from '../../types/kind-lookup.js';
import { config } from './config.js'; import { config } from './config.js';
export interface GifMetadata { export interface GifMetadata {
@ -182,7 +182,7 @@ export async function fetchGifs(searchQuery?: string, limit: number = 50): Promi
// This ensures we can still find GIFs even if GIF-specific relays are down // This ensures we can still find GIFs even if GIF-specific relays are down
// Only fetch kind 1063 (NIP-94 file metadata) events - kind 1 floods the fetch // Only fetch kind 1063 (NIP-94 file metadata) events - kind 1 floods the fetch
const fileMetadataKind = KIND_LOOKUP[1063].number; // NIP-94 File Metadata const fileMetadataKind = KIND.FILE_METADATA; // NIP-94 File Metadata
// Fetch a larger number of events to build a good cache // Fetch a larger number of events to build a good cache
// Use a higher limit to ensure we cache enough events for consistent results // Use a higher limit to ensure we cache enough events for consistent results

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

@ -13,6 +13,7 @@
import { nostrClient } from './nostr-client.js'; import { nostrClient } from './nostr-client.js';
import { relayManager } from './relay-manager.js'; import { relayManager } from './relay-manager.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { matchAll } from 'nostr-tools/nip30'; import { matchAll } from 'nostr-tools/nip30';
export interface EmojiDefinition { export interface EmojiDefinition {
@ -48,7 +49,7 @@ let loadingEmojiPacks = false;
* Parse a kind 10030 emoji set event or kind 30030 emoji pack * Parse a kind 10030 emoji set event or kind 30030 emoji pack
*/ */
export function parseEmojiSet(event: NostrEvent): EmojiSet | null { export function parseEmojiSet(event: NostrEvent): EmojiSet | null {
if (event.kind !== 10030 && event.kind !== 30030) return null; if (event.kind !== KIND.EMOJI_SET && event.kind !== KIND.EMOJI_PACK) return null;
const emojis = new Map<string, EmojiDefinition>(); const emojis = new Map<string, EmojiDefinition>();
@ -84,7 +85,7 @@ export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> {
// Fetch both emoji sets (10030) and emoji packs (30030) // Fetch both emoji sets (10030) and emoji packs (30030)
// Get more events to capture all packs (30030 can have multiple with different d tags) // Get more events to capture all packs (30030 can have multiple with different d tags)
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [10030, 30030], authors: [pubkey], limit: 50 }], // Get more to capture all packs [{ kinds: [KIND.EMOJI_SET, KIND.EMOJI_PACK], authors: [pubkey], limit: 50 }], // Get more to capture all packs
relays, relays,
{ useCache: true, cacheResults: true, timeout: 5000 } { useCache: true, cacheResults: true, timeout: 5000 }
); );
@ -92,8 +93,8 @@ export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> {
if (events.length === 0) return null; if (events.length === 0) return null;
// Separate emoji sets (10030) and emoji packs (30030) // Separate emoji sets (10030) and emoji packs (30030)
const emojiSetEvents = events.filter(e => e.kind === 10030); const emojiSetEvents = events.filter(e => e.kind === KIND.EMOJI_SET);
const emojiPackEvents = events.filter(e => e.kind === 30030); const emojiPackEvents = events.filter(e => e.kind === KIND.EMOJI_PACK);
const allEmojis = new Map<string, EmojiDefinition>(); const allEmojis = new Map<string, EmojiDefinition>();
@ -160,7 +161,7 @@ export async function loadAllEmojiPacks(): Promise<void> {
// Fetch all emoji sets (10030) and emoji packs (30030) // Fetch all emoji sets (10030) and emoji packs (30030)
// Use a high limit to get all available packs - increase limit to get more // Use a high limit to get all available packs - increase limit to get more
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [10030, 30030], limit: 1000 }], // Increased limit to get more emoji packs/sets [{ kinds: [KIND.EMOJI_SET, KIND.EMOJI_PACK], limit: 1000 }], // Increased limit to get more emoji packs/sets
relays, relays,
{ useCache: true, cacheResults: true, timeout: 15000 } { useCache: true, cacheResults: true, timeout: 15000 }
); );

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

@ -10,6 +10,7 @@ import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey }
import { getDB } from '../cache/indexeddb-store.js'; import { getDB } from '../cache/indexeddb-store.js';
import { filterEvents, shouldHideEvent } from '../event-filter.js'; import { filterEvents, shouldHideEvent } from '../event-filter.js';
import { sessionManager } from '../auth/session-manager.js'; import { sessionManager } from '../auth/session-manager.js';
import { KIND } from '../../types/kind-lookup.js';
export interface PublishOptions { export interface PublishOptions {
relays?: string[]; relays?: string[];
@ -213,7 +214,7 @@ class NostrClient {
} }
private shouldFilterZapReceipt(event: NostrEvent): boolean { private shouldFilterZapReceipt(event: NostrEvent): boolean {
if (event.kind !== 9735) return false; if (event.kind !== KIND.ZAP_RECEIPT) return false;
const amountTag = event.tags.find((t) => t[0] === 'amount'); const amountTag = event.tags.find((t) => t[0] === 'amount');
if (!amountTag || !amountTag[1]) return true; if (!amountTag || !amountTag[1]) return true;
const amount = parseInt(amountTag[1], 10); const amount = parseInt(amountTag[1], 10);
@ -352,6 +353,14 @@ class NostrClient {
const dTags = event.tags.filter(t => (t[0] === 'd' || t[0] === 'D') && t[1]).map(t => t[1]); const dTags = event.tags.filter(t => (t[0] === 'd' || t[0] === 'D') && t[1]).map(t => t[1]);
if (dTags.length === 0 || !filter['#d'].some(d => dTags.includes(d))) continue; if (dTags.length === 0 || !filter['#d'].some(d => dTags.includes(d))) continue;
} }
if (filter['#q'] && filter['#q'].length > 0) {
const qTags = event.tags.filter(t => (t[0] === 'q' || t[0] === 'Q') && t[1]).map(t => t[1]);
if (qTags.length === 0 || !filter['#q'].some(q => qTags.includes(q))) continue;
}
if (filter['#Q'] && filter['#Q'].length > 0) {
const qTags = event.tags.filter(t => (t[0] === 'q' || t[0] === 'Q') && t[1]).map(t => t[1]);
if (qTags.length === 0 || !filter['#Q'].some(q => qTags.includes(q))) continue;
}
// Use matchFilter for final validation // Use matchFilter for final validation
if (matchFilter(filter, event)) { if (matchFilter(filter, event)) {

13
src/lib/services/user-data.ts

@ -8,6 +8,7 @@ import { relayManager } from './nostr/relay-manager.js';
import { cacheProfile, getProfile, getProfiles } from './cache/profile-cache.js'; import { cacheProfile, getProfile, getProfiles } from './cache/profile-cache.js';
import { config } from './nostr/config.js'; import { config } from './nostr/config.js';
import type { NostrEvent } from '../types/nostr.js'; import type { NostrEvent } from '../types/nostr.js';
import { KIND } from '../types/kind-lookup.js';
// Re-export profile types and functions // Re-export profile types and functions
export interface ProfileData { export interface ProfileData {
@ -84,7 +85,7 @@ export async function fetchProfile(
]; ];
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [0], authors: [pubkey], limit: 1 }], [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relayList, relayList,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );
@ -122,7 +123,7 @@ export async function fetchProfiles(
]; ];
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [0], authors: missing, limit: 1 }], [{ kinds: [KIND.METADATA], authors: missing, limit: 1 }],
relayList, relayList,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );
@ -139,7 +140,7 @@ export async function fetchProfiles(
* Parse user status from kind 30315 event * Parse user status from kind 30315 event
*/ */
export function parseUserStatus(event: NostrEvent): string | null { export function parseUserStatus(event: NostrEvent): string | null {
if (event.kind !== 30315) return null; if (event.kind !== KIND.USER_STATUS) return null;
// Check for d tag with value "general" // Check for d tag with value "general"
const dTag = event.tags.find((t) => t[0] === 'd' && t[1] === 'general'); const dTag = event.tags.find((t) => t[0] === 'd' && t[1] === 'general');
@ -163,7 +164,7 @@ export async function fetchUserStatus(
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[ [
{ {
kinds: [30315], kinds: [KIND.USER_STATUS],
authors: [pubkey], authors: [pubkey],
'#d': ['general'], '#d': ['general'],
limit: 1 limit: 1
@ -236,8 +237,8 @@ export async function fetchRelayLists(
// Fetch both kind 10002 and 10432 // Fetch both kind 10002 and 10432
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[ [
{ kinds: [10002], authors: [pubkey], limit: 1 }, { kinds: [KIND.RELAY_LIST], authors: [pubkey], limit: 1 },
{ kinds: [10432], authors: [pubkey], limit: 1 } { kinds: [KIND.LOCAL_RELAYS], authors: [pubkey], limit: 1 }
], ],
relayList, relayList,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }

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

@ -11,77 +11,96 @@ export interface KindInfo {
isSecondaryKind?: boolean; // Whether this is a secondary kind (used to display the main kind) isSecondaryKind?: boolean; // Whether this is a secondary kind (used to display the main kind)
} }
// Kind number constants
export const KIND = {
METADATA: 0,
SHORT_TEXT_NOTE: 1,
CONTACTS: 3,
EVENT_DELETION: 5,
REACTION: 7,
DISCUSSION_THREAD: 11,
COMMENT: 1111,
VOICE_NOTE: 1222,
VOICE_REPLY: 1244,
PICTURE_NOTE: 20,
VIDEO_NOTE: 21,
SHORT_VIDEO_NOTE: 22,
LONG_FORM_NOTE: 30023,
HIGHLIGHTED_ARTICLE: 9802,
FILE_METADATA: 1063,
POLL: 1068,
POLL_RESPONSE: 1018,
USER_STATUS: 30315,
PAYMENT_ADDRESSES: 10133,
LABEL: 1985,
REPORT: 1984,
ZAP_RECEIPT: 9735,
RELAY_LIST: 10002,
BLOCKED_RELAYS: 10006,
FAVORITE_RELAYS: 10012,
LOCAL_RELAYS: 10432,
PIN_LIST: 10001,
BOOKMARKS: 10003,
RSS_FEED: 10895,
INTEREST_LIST: 10015,
EMOJI_SET: 10030,
EMOJI_PACK: 30030,
MUTE_LIST: 10000,
BADGES: 30008,
} as const;
export const KIND_LOOKUP: Record<number, KindInfo> = { export const KIND_LOOKUP: Record<number, KindInfo> = {
// Core kinds // Core kinds
0: { number: 0, description: 'Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.SHORT_TEXT_NOTE]: { number: KIND.SHORT_TEXT_NOTE, description: 'Short Text Note', showInFeed: true, isReplaceable: false },
1: { number: 1, description: 'Short Text Note', showInFeed: true, isReplaceable: false }, [KIND.CONTACTS]: { number: KIND.CONTACTS, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
3: { number: 3, description: 'Contacts', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.EVENT_DELETION]: { number: KIND.EVENT_DELETION, description: 'Event Deletion', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
24: { number: 4, description: 'Public Message', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.REACTION]: { number: KIND.REACTION, description: 'Reaction', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
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 },
// Articles // Articles
30023: { number: 30023, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false }, [KIND.LONG_FORM_NOTE]: { number: KIND.LONG_FORM_NOTE, description: 'Long-form Note', showInFeed: true, isReplaceable: true, isSecondaryKind: false },
9802: { number: 9802, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
// Threads and comments // Threads and comments
11: { number: 11, description: 'Discussion Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
34550: { number: 34550, description: 'Community', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, [KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true },
1111: { number: 1111, description: 'Comment', showInFeed: true, isReplaceable: false, isSecondaryKind: true },
// Media // Media
20: { number: 20, description: 'Picture Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.PICTURE_NOTE]: { number: KIND.PICTURE_NOTE, description: 'Picture Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
21: { number: 21, description: 'Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.VIDEO_NOTE]: { number: KIND.VIDEO_NOTE, description: 'Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
22: { number: 22, description: 'Short Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.SHORT_VIDEO_NOTE]: { number: KIND.SHORT_VIDEO_NOTE, description: 'Short Video Note', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
1222: { number: 23, description: 'Voice Note (Yak)', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.VOICE_NOTE]: { number: KIND.VOICE_NOTE, description: 'Voice Note (Yak)', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
1244: { number: 24, description: 'Voice Reply (Yak Back)', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, [KIND.VOICE_REPLY]: { number: KIND.VOICE_REPLY, description: 'Voice Reply (Yak Back)', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.FILE_METADATA]: { number: KIND.FILE_METADATA, description: 'File Metadata (GIFs)', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Polls // Polls
1068: { number: 1068, description: 'Poll', showInFeed: true, isReplaceable: false, isSecondaryKind: false }, [KIND.POLL]: { number: KIND.POLL, description: 'Poll', showInFeed: true, isReplaceable: false, isSecondaryKind: false },
1018: { number: 1018, description: 'Poll Response', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, [KIND.POLL_RESPONSE]: { number: KIND.POLL_RESPONSE, description: 'Poll Response', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
// Labels
1985: { number: 1985, description: 'Label', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// User status // User events
30315: { number: 30315, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, [KIND.METADATA]: { number: KIND.METADATA, description: 'Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.USER_STATUS]: { number: KIND.USER_STATUS, description: 'User Status', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
[KIND.PAYMENT_ADDRESSES]: { number: KIND.PAYMENT_ADDRESSES, description: 'Payment Addresses', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.LABEL]: { number: KIND.LABEL, description: 'Label', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Zaps // Zaps
9735: { number: 9735, description: 'Zap Receipt', showInFeed: false, isReplaceable: false, isSecondaryKind: true }, [KIND.ZAP_RECEIPT]: { number: KIND.ZAP_RECEIPT, description: 'Zap Receipt', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
// Relay lists // Relay lists
10002: { number: 10002, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.RELAY_LIST]: { number: KIND.RELAY_LIST, description: 'Relay List Metadata', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.BLOCKED_RELAYS]: { number: KIND.BLOCKED_RELAYS, description: 'Blocked Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Blocked relays [KIND.FAVORITE_RELAYS]: { number: KIND.FAVORITE_RELAYS, description: 'Favorite Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
10006: { number: 10006, description: 'Blocked Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.LOCAL_RELAYS]: { number: KIND.LOCAL_RELAYS, description: 'Local Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Favorite relays // Personal lists
10012: { number: 10012, description: 'Favorite Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.PIN_LIST]: { number: KIND.PIN_LIST, description: 'Pin List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.BOOKMARKS]: { number: KIND.BOOKMARKS, description: 'Bookmarks', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
// Interest lists [KIND.RSS_FEED]: { number: KIND.RSS_FEED, description: 'RSS Feed', showInFeed: false, isReplaceable: false },
10015: { number: 10015, description: 'Interest List', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.INTEREST_LIST]: { number: KIND.INTEREST_LIST, description: 'Interest List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.EMOJI_SET]: { number: KIND.EMOJI_SET, description: 'Emoji Set', showInFeed: false, isReplaceable: true, isSecondaryKind: false },
// Local relays [KIND.EMOJI_PACK]: { number: KIND.EMOJI_PACK, description: 'Emoji Pack', showInFeed: false, isReplaceable: true, isSecondaryKind: false },
10432: { number: 10432, description: 'Local Relays', showInFeed: false, isReplaceable: false, isSecondaryKind: false }, [KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
[KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Mute lists
10000: { number: 10000, description: 'Mute List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
// Pin lists
10001: { number: 10001, description: 'Pin List', showInFeed: false, isReplaceable: false, isSecondaryKind: false },
10009: { number: 10009, description: 'Bookmarks', showInFeed: false, isReplaceable: false, isSecondaryKind: true },
// 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