Browse Source

landing page columns and search bar. Event comment box. bugfixes

master
Silberengel 10 months ago
parent
commit
3c6daa1231
  1. 18
      .vscode/settings.json
  2. 317
      src/lib/components/CommentBox.svelte
  3. 5
      src/lib/components/EventDetails.svelte
  4. 39
      src/lib/components/EventSearch.svelte
  5. 90
      src/lib/components/PublicationFeed.svelte
  6. 2
      src/lib/components/PublicationHeader.svelte
  7. 53
      src/lib/components/RelayActions.svelte
  8. 3
      src/lib/components/util/ArticleNav.svelte
  9. 183
      src/lib/components/util/InlineProfile.svelte
  10. 4
      src/lib/stores/relayStore.ts
  11. 26
      src/lib/utils.ts
  12. 34
      src/lib/utils/nostrUtils.ts
  13. 27
      src/routes/+page.svelte
  14. 23
      src/routes/events/+page.svelte

18
.vscode/settings.json vendored

@ -1,6 +1,14 @@
{ {
"editor.tabSize": 2, "css.validate": false,
"files.associations": { "tailwindCSS.includeLanguages": {
"*.css": "postcss" "svelte": "html",
} "typescript": "javascript",
} "javascript": "javascript"
},
"editor.quickSuggestions": {
"strings": true
},
"files.associations": {
"*.svelte": "svelte"
}
}

317
src/lib/components/CommentBox.svelte

@ -0,0 +1,317 @@
<script lang="ts">
import { Button, Textarea, Alert } from 'flowbite-svelte';
import { parseBasicmarkup } from '$lib/utils/markup/basicMarkupParser';
import { nip19 } from 'nostr-tools';
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils';
import { standardRelays, fallbackRelays } from '$lib/consts';
import { userRelays } from '$lib/stores/relayStore';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils';
import { onMount } from 'svelte';
const props = $props<{
event: NDKEvent;
userPubkey: string;
userRelayPreference: boolean;
}>();
let content = $state('');
let preview = $state('');
let isSubmitting = $state(false);
let success = $state<{ relay: string; eventId: string } | null>(null);
let error = $state<string | null>(null);
let showOtherRelays = $state(false);
let showFallbackRelays = $state(false);
let userProfile = $state<NostrProfile | null>(null);
// Fetch user profile on mount
onMount(async () => {
if (props.userPubkey) {
const npub = nip19.npubEncode(props.userPubkey);
userProfile = await getUserMetadata(npub);
}
});
// Markup buttons
const markupButtons = [
{ label: 'Bold', action: () => insertMarkup('**', '**') },
{ label: 'Italic', action: () => insertMarkup('_', '_') },
{ label: 'Strike', action: () => insertMarkup('~~', '~~') },
{ label: 'Link', action: () => insertMarkup('[', '](url)') },
{ label: 'Image', action: () => insertMarkup('![', '](url)') },
{ label: 'Quote', action: () => insertMarkup('> ', '') },
{ label: 'List', action: () => insertMarkup('- ', '') },
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') },
{ label: 'Hashtag', action: () => insertMarkup('#', '') }
];
function insertMarkup(prefix: string, suffix: string) {
const textarea = document.querySelector('textarea');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
content = content.substring(0, start) + prefix + selectedText + suffix + content.substring(end);
updatePreview();
// Set cursor position after the inserted markup
setTimeout(() => {
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length;
}, 0);
}
async function updatePreview() {
preview = await parseBasicmarkup(content);
}
function clearForm() {
content = '';
preview = '';
error = null;
success = null;
showOtherRelays = false;
showFallbackRelays = false;
}
function removeFormatting() {
content = content
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/!\[(.*?)\]\(.*?\)/g, '$1')
.replace(/^>\s*/gm, '')
.replace(/^[-*]\s*/gm, '')
.replace(/^\d+\.\s*/gm, '')
.replace(/#(\w+)/g, '$1');
updatePreview();
}
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) {
isSubmitting = true;
error = null;
success = null;
try {
if (!props.event.kind) {
throw new Error('Invalid event: missing kind');
}
const kind = props.event.kind === 1 ? 1 : 1111;
const tags: string[][] = [];
if (kind === 1) {
// NIP-10 reply
tags.push(['e', props.event.id, '', 'reply']);
tags.push(['p', props.event.pubkey]);
if (props.event.tags) {
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root');
if (rootTag) {
tags.push(['e', rootTag[1], '', 'root']);
}
// Add all p tags from the parent event
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => {
if (!tags.some((pt: string[]) => pt[1] === t[1])) {
tags.push(['p', t[1]]);
}
});
}
} else {
// NIP-22 comment
tags.push(['E', props.event.id, '', props.event.pubkey]);
tags.push(['K', props.event.kind.toString()]);
tags.push(['P', props.event.pubkey]);
tags.push(['e', props.event.id, '', props.event.pubkey]);
tags.push(['k', props.event.kind.toString()]);
tags.push(['p', props.event.pubkey]);
}
const eventToSign = {
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
pubkey: props.userPubkey
};
const id = getEventHash(eventToSign);
const sig = await signEvent(eventToSign);
const signedEvent = {
...eventToSign,
id,
sig
};
// Determine which relays to use
let relays = props.userRelayPreference ? get(userRelays) : standardRelays;
if (useOtherRelays) {
relays = props.userRelayPreference ? standardRelays : get(userRelays);
}
if (useFallbackRelays) {
relays = fallbackRelays;
}
// Try to publish to relays
let published = false;
for (const relayUrl of relays) {
try {
const ws = new WebSocket(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Timeout'));
}, 5000);
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', signedEvent]));
};
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === 'OK' && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
published = true;
success = { relay: relayUrl, eventId: signedEvent.id };
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message));
}
}
};
ws.onerror = () => {
clearTimeout(timeout);
ws.close();
reject(new Error('WebSocket error'));
};
});
if (published) break;
} catch (e) {
console.error(`Failed to publish to ${relayUrl}:`, e);
}
}
if (!published) {
if (!useOtherRelays && !useFallbackRelays) {
showOtherRelays = true;
error = 'Failed to publish to primary relays. Would you like to try the other relays?';
} else if (useOtherRelays && !useFallbackRelays) {
showFallbackRelays = true;
error = 'Failed to publish to other relays. Would you like to try the fallback relays?';
} else {
error = 'Failed to publish to any relays. Please try again later.';
}
} else {
// Navigate to the event page
const nevent = nip19.neventEncode({ id: signedEvent.id });
goto(`/events?id=${nevent}`);
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
} finally {
isSubmitting = false;
}
}
</script>
<div class="w-full space-y-4">
<div class="flex flex-wrap gap-2">
{#each markupButtons as button}
<Button size="xs" on:click={button.action}>{button.label}</Button>
{/each}
<Button size="xs" color="alternative" on:click={removeFormatting}>Remove Formatting</Button>
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Textarea
bind:value={content}
on:input={updatePreview}
placeholder="Write your comment..."
rows={10}
class="w-full"
/>
</div>
<div class="prose dark:prose-invert max-w-none p-4 border rounded-lg">
{@html preview}
</div>
</div>
{#if error}
<Alert color="red" dismissable>
{error}
{#if showOtherRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}>Try Other Relays</Button>
{/if}
{#if showFallbackRelays}
<Button size="xs" class="mt-2" on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button>
{/if}
</Alert>
{/if}
{#if success}
<Alert color="green" dismissable>
Comment published successfully to {success.relay}!
<a href="/events?id={nip19.neventEncode({ id: success.eventId })}" class="text-primary-600 dark:text-primary-500 hover:underline">
View your comment
</a>
</Alert>
{/if}
<div class="flex justify-end items-center gap-4">
{#if userProfile}
<div class="flex items-center gap-2 text-sm">
{#if userProfile.picture}
<img
src={userProfile.picture}
alt={userProfile.name || 'Profile'}
class="w-8 h-8 rounded-full"
onerror={(e) => {
const img = e.target as HTMLImageElement;
img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`;
}}
/>
{/if}
<span class="text-gray-700 dark:text-gray-300">
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'}
</span>
</div>
{/if}
<Button
on:click={() => handleSubmit()}
disabled={isSubmitting || !content.trim() || !props.userPubkey}
class="w-full md:w-auto"
>
{#if !props.userPubkey}
Not Signed In
{:else if isSubmitting}
Publishing...
{:else}
Post Comment
{/if}
</Button>
</div>
{#if !props.userPubkey}
<Alert color="yellow" class="mt-4">
Please sign in to post comments. Your comments will be signed with your current account.
</Alert>
{/if}
</div>
<style>
/* Add styles for disabled state */
:global(.disabled) {
opacity: 0.6;
cursor: not-allowed;
}
</style>

5
src/lib/components/EventDetails.svelte

@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { getMimeTags, getEventType } from "$lib/utils/mime"; import { getMimeTags } from "$lib/utils/mime";
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { toNpub } from "$lib/utils/nostrUtils"; import { toNpub } from "$lib/utils/nostrUtils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from '$lib/utils/nostrUtils';
import { onMount } from "svelte";
import { getMatchingTags } from '$lib/utils/nostrUtils'; import { getMatchingTags } from '$lib/utils/nostrUtils';
const { event, profile = null } = $props<{ const { event, profile = null } = $props<{

39
src/lib/components/EventSearch.svelte

@ -5,8 +5,6 @@
import { nip19 } from '$lib/utils/nostrUtils'; import { nip19 } from '$lib/utils/nostrUtils';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { NDKEvent } from '$lib/utils/nostrUtils'; import type { NDKEvent } from '$lib/utils/nostrUtils';
import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { standardRelays, fallbackRelays } from '$lib/consts';
import RelayDisplay from './RelayDisplay.svelte'; import RelayDisplay from './RelayDisplay.svelte';
const { loading, error, searchValue, onEventFound, event } = $props<{ const { loading, error, searchValue, onEventFound, event } = $props<{
@ -129,43 +127,6 @@
} }
} }
async function resilientSearch(filterOrId: any) {
const ndk = $ndkInstance;
const allRelays = [
...standardRelays,
...Array.from(ndk.pool?.relays.values() || []).map(r => r.url),
...fallbackRelays
].filter((url, idx, arr) => arr.indexOf(url) === idx);
relayStatuses = Object.fromEntries(allRelays.map(r => [r, 'pending']));
foundEvent = null;
await Promise.all(
allRelays.map(async (relay) => {
try {
const relaySet = NDKRelaySet.fromRelayUrls(allRelays, ndk);
const event = await ndk.fetchEvent(
typeof filterOrId === 'string' ? { ids: [filterOrId] } : filterOrId,
undefined,
relaySet
).withTimeout(2500);
if (event && !foundEvent) {
foundEvent = event;
handleFoundEvent(event);
}
relayStatuses = { ...relayStatuses, [relay]: event ? 'found' : 'notfound' };
} catch {
relayStatuses = { ...relayStatuses, [relay]: 'notfound' };
}
})
);
}
function searchForEvent(value: string) {
searchEvent(false);
}
function handleFoundEvent(event: NDKEvent) { function handleFoundEvent(event: NDKEvent) {
foundEvent = event; foundEvent = event;
onEventFound(event); onEventFound(event);

90
src/lib/components/PublicationFeed.svelte

@ -1,13 +1,13 @@
<script lang='ts'> <script lang='ts'>
import { indexKind } from '$lib/consts'; import { indexKind } from '$lib/consts';
import { ndkInstance } from '$lib/ndk'; import { ndkInstance } from '$lib/ndk';
import { filterValidIndexEvents } from '$lib/utils'; import { filterValidIndexEvents, debounce } from '$lib/utils';
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; import { Button, P, Skeleton, Spinner } from 'flowbite-svelte';
import ArticleHeader from './PublicationHeader.svelte'; import ArticleHeader from './PublicationHeader.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getMatchingTags, NDKRelaySetFromNDK, type NDKEvent, type NDKRelaySet } from '$lib/utils/nostrUtils';
let { relays, fallbackRelays } = $props<{ relays: string[], fallbackRelays: string[] }>(); let { relays, fallbackRelays, searchQuery = '' } = $props<{ relays: string[], fallbackRelays: string[], searchQuery?: string }>();
let eventsInView: NDKEvent[] = $state([]); let eventsInView: NDKEvent[] = $state([]);
let loadingMore: boolean = $state(false); let loadingMore: boolean = $state(false);
@ -19,22 +19,48 @@
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime() eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime()
); );
async function getEvents(before: number | undefined = undefined) { // Debounced search function
const debouncedSearch = debounce(async (query: string) => {
if (query.trim()) {
await getEvents(undefined, query);
} else {
await getEvents();
}
}, 300);
$effect(() => {
debouncedSearch(searchQuery);
});
async function getEvents(before: number | undefined = undefined, search: string = '') {
loading = true; loading = true;
const ndk = $ndkInstance; const ndk = $ndkInstance;
const primaryRelays: string[] = relays; const primaryRelays: string[] = relays;
const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r)); const fallback: string[] = fallbackRelays.filter((r: string) => !primaryRelays.includes(r));
relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending'])); relayStatuses = Object.fromEntries(primaryRelays.map((r: string) => [r, 'pending']));
let allEvents: NDKEvent[] = []; let allEvents: NDKEvent[] = [];
let fetchedCount = 0; // Track number of new events
// Function to filter events based on search query
const filterEventsBySearch = (events: NDKEvent[]) => {
if (!search) return events;
const query = search.toLowerCase();
return events.filter(event => {
const title = getMatchingTags(event, 'title')[0]?.[1]?.toLowerCase() ?? '';
const author = event.pubkey.toLowerCase();
return title.includes(query) || author.includes(query);
});
};
// First, try primary relays // First, try primary relays
await Promise.all( await Promise.all(
primaryRelays.map(async (relay: string) => { primaryRelays.map(async (relay: string) => {
try { try {
const relaySet = NDKRelaySet.fromRelayUrls([relay], ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents( let eventSet = await ndk.fetchEvents(
{ {
kinds: [indexKind], kinds: [indexKind],
limit: 16, limit: 30,
until: before, until: before,
}, },
{ {
@ -45,7 +71,8 @@
relaySet relaySet
).withTimeout(2500); ).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet); eventSet = filterValidIndexEvents(eventSet);
const eventArray = Array.from(eventSet); const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) { if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray); allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' }; relayStatuses = { ...relayStatuses, [relay]: 'found' };
@ -63,7 +90,7 @@
await Promise.all( await Promise.all(
fallback.map(async (relay: string) => { fallback.map(async (relay: string) => {
try { try {
const relaySet = NDKRelaySet.fromRelayUrls([relay], ndk); const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk.fetchEvents( let eventSet = await ndk.fetchEvents(
{ {
kinds: [indexKind], kinds: [indexKind],
@ -78,7 +105,8 @@
relaySet relaySet
).withTimeout(2500); ).withTimeout(2500);
eventSet = filterValidIndexEvents(eventSet); eventSet = filterValidIndexEvents(eventSet);
const eventArray = Array.from(eventSet); const eventArray = filterEventsBySearch(Array.from(eventSet));
fetchedCount += eventArray.length; // Count new events
if (eventArray.length > 0) { if (eventArray.length > 0) {
allEvents = allEvents.concat(eventArray); allEvents = allEvents.concat(eventArray);
relayStatuses = { ...relayStatuses, [relay]: 'found' }; relayStatuses = { ...relayStatuses, [relay]: 'found' };
@ -96,8 +124,15 @@
const uniqueEvents = Array.from(eventMap.values()); const uniqueEvents = Array.from(eventMap.values());
uniqueEvents.sort((a, b) => b.created_at! - a.created_at!); uniqueEvents.sort((a, b) => b.created_at! - a.created_at!);
eventsInView = uniqueEvents; eventsInView = uniqueEvents;
endOfFeed = false; // Could add logic to detect end // Set endOfFeed if fewer than limit events were fetched
if ((before !== undefined && fetchedCount < 30 && fallback.length === 0) ||
(before !== undefined && fetchedCount < 16 && fallback.length > 0)) {
endOfFeed = true;
} else {
endOfFeed = false;
}
loading = false; loading = false;
console.debug('Relay statuses:', relayStatuses);
} }
const getSkeletonIds = (): string[] => { const getSkeletonIds = (): string[] => {
@ -121,26 +156,25 @@
}); });
</script> </script>
<div class='leather flex flex-col space-y-4'> <div class='leather'>
<div class="flex flex-wrap gap-2"> <div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{#each Object.entries(relayStatuses) as [relay, status]} {#if loading && eventsInView.length === 0}
<span class="text-xs font-mono px-2 py-1 rounded border" class:bg-green-100={status==='found'} class:bg-red-100={status==='notfound'} class:bg-yellow-100={status==='pending'}>{relay}: {status}</span> {#each getSkeletonIds() as id}
{/each} <Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}
<ArticleHeader {event} />
{/each}
{:else}
<div class='col-span-full'>
<p class='text-center'>No publications found.</p>
</div>
{/if}
</div> </div>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass='skeleton-leather w-full' size='lg' />
{/each}
{:else if eventsInView.length > 0}
{#each eventsInView as event}
<ArticleHeader {event} />
{/each}
{:else}
<p class='text-center'>No publications found.</p>
{/if}
{#if !loadingMore && !endOfFeed} {#if !loadingMore && !endOfFeed}
<div class='flex justify-center mt-4 mb-8'> <div class='flex justify-center mt-4 mb-8'>
<Button outline class="w-full" onclick={async () => { <Button outline class="w-full max-w-md" onclick={async () => {
await loadMorePublications(); await loadMorePublications();
}}> }}>
Show more publications Show more publications
@ -148,7 +182,7 @@
</div> </div>
{:else if loadingMore} {:else if loadingMore}
<div class='flex justify-center mt-4 mb-8'> <div class='flex justify-center mt-4 mb-8'>
<Button outline disabled class="w-full"> <Button outline disabled class="w-full max-w-md">
<Spinner class='mr-3 text-gray-300' size='4' /> <Spinner class='mr-3 text-gray-300' size='4' />
Loading... Loading...
</Button> </Button>

2
src/lib/components/PublicationHeader.svelte

@ -24,7 +24,7 @@
); );
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);

53
src/lib/components/RelayActions.svelte

@ -6,7 +6,6 @@
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils'; import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils';
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte'; import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte';
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays } from "$lib/consts";
import NDK from '@nostr-dev-kit/ndk';
const { event } = $props<{ const { event } = $props<{
event: NDKEvent; event: NDKEvent;
@ -31,48 +30,6 @@
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/>
</svg>`; </svg>`;
async function searchRelays() {
if (!event) return;
const currentEvent = event; // Store reference to avoid null checks
searchingRelays = true;
foundRelays = [];
try {
const ndk = get(ndkInstance);
if (!ndk) throw new Error('NDK not initialized');
// Get all relays from the pool
const allRelays = Array.from(ndk.pool?.relays.values() || [])
.map(r => r.url)
.concat(standardRelays)
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
// Try to fetch the event from each relay
const results = await Promise.allSettled(
allRelays.map(async (relay) => {
const relaySet = createRelaySetFromUrls([relay], ndk);
const found = await ndk.fetchEvent(
{ ids: [currentEvent.id] },
undefined,
relaySet
).withTimeout(3000);
return found ? relay : null;
})
);
// Collect successful results
foundRelays = results
.filter((r): r is PromiseFulfilledResult<string | null> =>
r.status === 'fulfilled' && r.value !== null
)
.map(r => r.value as string);
} catch (err) {
console.error('Error searching relays:', err);
} finally {
searchingRelays = false;
}
}
async function broadcastEvent() { async function broadcastEvent() {
if (!event || !$ndkInstance?.activeUser) return; if (!event || !$ndkInstance?.activeUser) return;
broadcasting = true; broadcasting = true;
@ -142,16 +99,6 @@
showRelayModal = false; showRelayModal = false;
} }
async function initializeNDK() {
const ndk = new NDK({ explicitRelayUrls: [
'wss://relay.nostr.band',
'wss://another.relay',
'wss://fallback.relay'
] });
await ndk.connect();
ndkInstance.set(ndk);
console.log('Connected relays:', getConnectedRelays());
}
</script> </script>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">

3
src/lib/components/util/ArticleNav.svelte

@ -7,7 +7,6 @@
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
let { let {
rootId,
publicationType, publicationType,
indexEvent indexEvent
} = $props<{ } = $props<{
@ -17,7 +16,7 @@
}>(); }>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]); let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(indexgetMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); let author: string = $derived(indexEvent.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown');
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null); let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null);
let isLeaf: boolean = $derived(indexEvent.kind === 30041); let isLeaf: boolean = $derived(indexEvent.kind === 30041);

183
src/lib/components/util/InlineProfile.svelte

@ -1,183 +0,0 @@
<script lang='ts'>
import { Avatar } from 'flowbite-svelte';
import NDK, { type NDKUserProfile } from "@nostr-dev-kit/ndk";
import { ndkInstance } from '$lib/ndk';
import { userBadge } from '$lib/snippets/UserSnippets.svelte';
// Component configuration types
type AvatarSize = 'sm' | 'md' | 'lg';
// Component props interface
interface $$Props {
pubkey: string; // Required: The Nostr public key of the user
name?: string | null; // Optional: Display name override
showAvatar?: boolean; // Optional: Whether to show the avatar (default: true)
avatarSize?: AvatarSize; // Optional: Size of the avatar (default: 'md')
}
// Destructure and set default props
let {
pubkey,
name = null,
showAvatar = true,
avatarSize = 'md' as AvatarSize
} = $props();
console.log('[InlineProfile] Initialized with props:', {
pubkey,
name,
showAvatar,
avatarSize
});
// Constants
const EXTERNAL_PROFILE_DESTINATION = './events?id=';
// Component state type definition
type ProfileState = {
loading: boolean; // Whether we're currently loading the profile
error: string | null; // Any error that occurred during loading
profile: NDKUserProfile | null; // The user's profile data
npub: string; // The user's npub (bech32 encoded pubkey)
};
// Initialize component state
let state = $state<ProfileState>({
loading: true,
error: null,
profile: null,
npub: ''
});
// Derived values from state
const pfp = $derived(state.profile?.image);
const username = $derived(state.profile?.name);
const isAnonymous = $derived(!state.profile?.name && !name);
// Log derived values reactively when they change
$effect(() => {
console.log('[InlineProfile] Derived values updated:', {
pfp,
username,
isAnonymous,
hasProfile: !!state.profile,
hasNpub: !!state.npub,
profileState: {
loading: state.loading,
error: state.error,
hasProfile: !!state.profile,
npub: state.npub
}
});
});
// Avatar size classes mapping
const avatarClasses: Record<AvatarSize, string> = {
sm: 'h-5 w-5',
md: 'h-7 w-7',
lg: 'h-9 w-9'
};
/**
* Fetches user data from NDK
* @param pubkey - The Nostr public key to fetch data for
*/
async function fetchUserData(pubkey: string) {
console.log('[InlineProfile] fetchUserData called with pubkey:', pubkey);
if (!pubkey) {
console.warn('[InlineProfile] No pubkey provided to fetchUserData');
state.error = 'No pubkey provided';
state.loading = false;
return;
}
try {
console.log('[InlineProfile] Getting NDK instance');
const ndk = $ndkInstance as NDK;
console.log('[InlineProfile] Creating NDK user object');
const user = ndk.getUser({ pubkey });
console.log('[InlineProfile] Getting npub');
state.npub = user.npub;
console.log('[InlineProfile] Got npub:', state.npub);
console.log('[InlineProfile] Fetching user profile');
const userProfile = await user.fetchProfile();
console.log('[InlineProfile] Got user profile:', {
name: userProfile?.name,
displayName: userProfile?.displayName,
nip05: userProfile?.nip05,
hasImage: !!userProfile?.image
});
state.profile = userProfile;
state.loading = false;
} catch (error) {
console.error('[InlineProfile] Error fetching user data:', error);
state.error = error instanceof Error ? error.message : 'Failed to fetch profile';
state.loading = false;
}
}
/**
* Shortens an npub string for display
* @param long - The npub string to shorten
* @returns Shortened npub string
*/
function shortenNpub(long: string | undefined): string {
if (!long) return '';
const shortened = `${long.slice(0, 8)}…${long.slice(-4)}`;
console.log('[InlineProfile] Shortened npub:', { original: long, shortened });
return shortened;
}
// Effect to fetch user data when pubkey changes
$effect(() => {
console.log('[InlineProfile] Effect triggered, pubkey:', pubkey);
if (pubkey) {
fetchUserData(pubkey);
} else {
console.warn('[InlineProfile] No pubkey available for effect');
}
});
</script>
<!-- Component Template -->
{#if state.loading}
<!-- Loading state -->
<span class="animate-pulse" title="Loading profile...">
{name ?? '…'}
</span>
{:else if state.error}
<!-- Error state -->
<span class="text-red-500" title={state.error}>
{name ?? shortenNpub(pubkey)}
</span>
{:else if isAnonymous}
<!-- Anonymous user state -->
{@render userBadge(pubkey, name)}
{:else if state.npub}
<!-- Authenticated user with profile -->
<a
href={EXTERNAL_PROFILE_DESTINATION + state.npub}
title={name ?? username}
class="inline-flex items-center hover:opacity-80 transition-opacity"
>
{#if showAvatar}
<Avatar
rounded
class={`${avatarClasses[avatarSize]} mx-1 cursor-pointer inline bg-transparent`}
src={pfp}
alt={username ?? 'User avatar'}
/>
{/if}
{@render userBadge(pubkey, name)}
</a>
{:else}
<!-- Fallback state -->
<span title="No profile data available">
{name ?? shortenNpub(pubkey)}
</span>
{/if}

4
src/lib/stores/relayStore.ts

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
// Initialize with empty array, will be populated from user preferences
export const userRelays = writable<string[]>([]);

26
src/lib/utils.ts

@ -163,3 +163,29 @@ Array.prototype.findIndexAsync = function<T>(
): Promise<number> { ): Promise<number> {
return findIndexAsync(this, predicate); return findIndexAsync(this, predicate);
}; };
/**
* Creates a debounced function that delays invoking func until after wait milliseconds have elapsed
* since the last time the debounced function was invoked.
* @param func The function to debounce
* @param wait The number of milliseconds to delay
* @returns A debounced version of the function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = undefined;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}

34
src/lib/utils/nostrUtils.ts

@ -6,6 +6,9 @@ import NDK, { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; import type { NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { standardRelays, fallbackRelays } from "$lib/consts"; import { standardRelays, fallbackRelays } from "$lib/consts";
import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk'; import { NDKRelaySet as NDKRelaySetFromNDK } from '@nostr-dev-kit/ndk';
import { sha256 } from '@noble/hashes/sha256';
import { schnorr } from '@noble/curves/secp256k1';
import { bytesToHex } from '@noble/hashes/utils';
const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>' const badgeCheckSvg = '<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2c-.791 0-1.55.314-2.11.874l-.893.893a.985.985 0 0 1-.696.288H7.04A2.984 2.984 0 0 0 4.055 7.04v1.262a.986.986 0 0 1-.288.696l-.893.893a2.984 2.984 0 0 0 0 4.22l.893.893a.985.985 0 0 1 .288.696v1.262a2.984 2.984 0 0 0 2.984 2.984h1.262c.261 0 .512.104.696.288l.893.893a2.984 2.984 0 0 0 4.22 0l.893-.893a.985.985 0 0 1 .696-.288h1.262a2.984 2.984 0 0 0 2.984-2.984V15.7c0-.261.104-.512.288-.696l.893-.893a2.984 2.984 0 0 0 0-4.22l-.893-.893a.985.985 0 0 1-.288-.696V7.04a2.984 2.984 0 0 0-2.984-2.984h-1.262a.985.985 0 0 1-.696-.288l-.893-.893A2.984 2.984 0 0 0 12 2Zm3.683 7.73a1 1 0 1 0-1.414-1.413l-4.253 4.253-1.277-1.277a1 1 0 0 0-1.415 1.414l1.985 1.984a1 1 0 0 0 1.414 0l4.96-4.96Z" clip-rule="evenodd"/></svg>'
@ -391,6 +394,7 @@ export function toNpub(pubkey: string | undefined): string | null {
} }
export type { NDKEvent, NDKRelaySet, NDKUser }; export type { NDKEvent, NDKRelaySet, NDKUser };
export { NDKRelaySetFromNDK };
export { nip19 }; export { nip19 };
export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) { export function createRelaySetFromUrls(relayUrls: string[], ndk: NDK) {
@ -409,4 +413,34 @@ export function createNDKEvent(ndk: NDK, rawEvent: any) {
*/ */
export function getMatchingTags(event: NDKEvent, tagName: string): string[][] { export function getMatchingTags(event: NDKEvent, tagName: string): string[][] {
return event.tags.filter((tag: string[]) => tag[0] === tagName); return event.tags.filter((tag: string[]) => tag[0] === tagName);
}
export function getEventHash(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
}): string {
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
return bytesToHex(sha256(serialized));
}
export async function signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
pubkey: string;
}): Promise<string> {
const id = getEventHash(event);
const sig = await schnorr.sign(id, event.pubkey);
return bytesToHex(sig);
} }

27
src/routes/+page.svelte

@ -1,6 +1,6 @@
<script lang='ts'> <script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts'; import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts';
import { Alert, Button, Dropdown, Radio } from "flowbite-svelte"; import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; import PublicationFeed from '$lib/components/PublicationFeed.svelte';
@ -20,6 +20,8 @@
return ''; return '';
} }
}; };
let searchQuery = $state('');
</script> </script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'> <Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'>
@ -31,13 +33,22 @@
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> <main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn} {#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} /> <PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else} {:else}
<div class='leather w-full flex justify-end'> <div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
<Button> <Button id="feed-toggle-btn" class="min-w-[220px] max-w-xs">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' /> {`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
<ChevronDownOutline class='w-6 h-6' />
</Button> </Button>
<Dropdown class='w-fit p-2 space-y-2 text-sm'> <Input
bind:value={searchQuery}
placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base"
/>
<Dropdown
class='w-fit p-2 space-y-2 text-sm'
triggeredBy="#feed-toggle-btn"
>
<li> <li>
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio> <Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio>
</li> </li>
@ -47,9 +58,9 @@
</Dropdown> </Dropdown>
</div> </div>
{#if $feedType === FeedType.StandardRelays} {#if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} /> <PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{:else if $feedType === FeedType.UserRelays} {:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} /> <PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} />
{/if} {/if}
{/if} {/if}
</main> </main>

23
src/routes/events/+page.svelte

@ -6,9 +6,10 @@
import EventSearch from '$lib/components/EventSearch.svelte'; import EventSearch from '$lib/components/EventSearch.svelte';
import EventDetails from '$lib/components/EventDetails.svelte'; import EventDetails from '$lib/components/EventDetails.svelte';
import RelayActions from '$lib/components/RelayActions.svelte'; import RelayActions from '$lib/components/RelayActions.svelte';
import CommentBox from '$lib/components/CommentBox.svelte';
let loading = false; let loading = $state(false);
let error: string | null = null; let error = $state<string | null>(null);
let searchValue = $state<string | null>(null); let searchValue = $state<string | null>(null);
let event = $state<NDKEvent | null>(null); let event = $state<NDKEvent | null>(null);
let profile = $state<{ let profile = $state<{
@ -21,6 +22,8 @@
lud16?: string; lud16?: string;
nip05?: string; nip05?: string;
} | null>(null); } | null>(null);
let userPubkey = $state<string | null>(null);
let userRelayPreference = $state(false);
function handleEventFound(newEvent: NDKEvent) { function handleEventFound(newEvent: NDKEvent) {
event = newEvent; event = newEvent;
@ -35,11 +38,15 @@
} }
} }
onMount(() => { onMount(async () => {
const id = $page.url.searchParams.get('id'); const id = $page.url.searchParams.get('id');
if (id) { if (id) {
searchValue = id; searchValue = id;
} }
// Get user's pubkey and relay preference from localStorage
userPubkey = localStorage.getItem('userPubkey');
userRelayPreference = localStorage.getItem('useUserRelays') === 'true';
}); });
</script> </script>
@ -57,6 +64,16 @@
{#if event} {#if event}
<EventDetails {event} {profile} /> <EventDetails {event} {profile} />
<RelayActions {event} /> <RelayActions {event} />
{#if userPubkey}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
{/if} {/if}
</main> </main>
</div> </div>

Loading…
Cancel
Save