Browse Source

rename /thread route to /event

handle naddrs in /event route
master
Silberengel 1 month ago
parent
commit
15547d942d
  1. 2
      src/lib/components/EventMenu.svelte
  2. 2
      src/lib/components/layout/Header.svelte
  3. 2
      src/lib/modules/feed/FeedPost.svelte
  4. 2
      src/lib/modules/threads/ThreadCard.svelte
  5. 6
      src/routes/+page.svelte
  6. 551
      src/routes/event/[id]/+page.svelte
  7. 79
      src/routes/thread/[id]/+page.svelte

2
src/lib/components/EventMenu.svelte

@ -217,7 +217,7 @@ @@ -217,7 +217,7 @@
async function shareWithAitherboard() {
try {
const url = `${window.location.origin}/thread/${event.id}`;
const url = `${window.location.origin}/event/${event.id}`;
await navigator.clipboard.writeText(url);
copied = 'share';
setTimeout(() => {

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

@ -36,7 +36,7 @@ @@ -36,7 +36,7 @@
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">Aitherboard</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Threads</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Discussions</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Feed</a>
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Write</a>

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

@ -356,7 +356,7 @@ @@ -356,7 +356,7 @@
>
{#if previewMode}
<!-- Preview mode: show only title and first 150 chars -->
<a href="/thread/{post.id}" class="card-link">
<a href="/event/{post.id}" class="card-link">
<div class="card-content">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">

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

@ -200,7 +200,7 @@ @@ -200,7 +200,7 @@
</script>
<article class="thread-card bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 mb-4 shadow-sm dark:shadow-lg">
<a href="/thread/{thread.id}" class="card-link">
<a href="/event/{thread.id}" class="card-link">
<div class="card-content" class:expanded bind:this={contentElement}>
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">

6
src/routes/+page.svelte

@ -13,9 +13,9 @@ @@ -13,9 +13,9 @@
<Header />
<main class="container mx-auto px-4 py-8">
<div class="threads-header mb-4">
<div class="discussions-header mb-4">
<div>
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Threads</h1>
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Discussions</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr. Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="text-fog-accent dark:text-fog-dark-accent hover:text-fog-text dark:hover:text-fog-dark-text underline transition-colors">Silberengel</a>.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
@ -36,7 +36,7 @@ @@ -36,7 +36,7 @@
margin: 0 auto;
}
.threads-header {
.discussions-header {
display: flex;
justify-content: space-between;
align-items: flex-start;

551
src/routes/event/[id]/+page.svelte

@ -1,107 +1,83 @@ @@ -1,107 +1,83 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import MetadataCard from '../../../lib/components/content/MetadataCard.svelte';
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte';
import EventMenu from '../../../lib/components/EventMenu.svelte';
import ThreadView from '../../../lib/modules/threads/ThreadView.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { loadEventIndex, type EventIndexItem } from '../../../lib/services/nostr/event-index-loader.js';
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js';
import { sessionManager } from '../../../lib/services/auth/session-manager.js';
import PublicationStatusModal from '../../../lib/components/modals/PublicationStatusModal.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { getKindInfo, isReplaceableKind, isParameterizedReplaceableKind, KIND } from '../../../lib/types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../../lib/services/client-tag-preference.js';
let event = $state<NostrEvent | null>(null);
let loading = $state(true);
let decodedEventId = $state<string | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let indexItems = $state<EventIndexItem[]>([]);
let loadingIndex = $state(false);
let labeling = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
onMount(async () => {
await nostrClient.initialize();
await loadEvent();
});
$effect(() => {
if ($page.params.id) {
loadEvent();
}
});
async function loadEvent() {
if (!$page.params.id) return;
loading = true;
error = null;
try {
const eventId = decodeEventId($page.params.id);
if (!eventId) {
error = 'Invalid event ID format';
loading = false;
return;
}
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids: [eventId], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) {
error = 'Event not found';
} else {
event = events[0];
// If kind 30040, load event index
if (event.kind === 30040) {
await loadIndex();
}
}
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
} finally {
loading = false;
}
}
function decodeEventId(param: string): string | null {
/**
* Decode route parameter to event hex ID
* Supports: hex event id, note, nevent, naddr
*/
async function decodeEventId(param: string): Promise<string | null> {
if (!param) return null;
// Check if it's already a hex event ID
// Check if it's already a hex event ID (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(param)) {
return param.toLowerCase();
}
// Check if it's a bech32 encoded format
// Check if it's a bech32 encoded format (note, nevent, naddr)
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) {
try {
const decoded = nip19.decode(param);
if (decoded.type === 'note') {
return String(decoded.data);
} else if (decoded.type === 'nevent') {
// nevent contains event id and optional relays
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// For naddr, we need to fetch by kind+pubkey+d
// This is handled separately
return null;
// naddr is for parameterized replaceable events (kind + pubkey + d tag)
// Fetch the event using kind, pubkey, and d tag
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) {
const naddrData = decoded.data as { kind: number; pubkey: string; identifier?: string; relays?: string[] };
const kind = naddrData.kind;
const pubkey = naddrData.pubkey;
const dTag = naddrData.identifier || '';
// Use relays from naddr if provided, otherwise use default relays
const relays = naddrData.relays && naddrData.relays.length > 0
? naddrData.relays
: relayManager.getProfileReadRelays();
// Fetch the event by kind, pubkey, and d tag
const filter: any = {
kinds: [kind],
authors: [pubkey],
limit: 1
};
// Only add #d filter if d tag is present
if (dTag) {
filter['#d'] = [dTag];
}
const filters = [filter];
const events = await nostrClient.fetchEvents(
filters,
relays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
return events[0].id;
} else {
console.warn('Event not found for naddr:', param);
return null;
}
}
}
} catch (err) {
console.error('Error decoding bech32:', err);
} catch (error) {
console.error('Error decoding bech32:', error);
return null;
}
}
@ -109,413 +85,52 @@ @@ -109,413 +85,52 @@
return null;
}
async function loadIndex() {
if (!event || event.kind !== 30040) return;
loadingIndex = true;
try {
indexItems = await loadEventIndex(event);
} catch (err) {
console.error('Error loading event index:', err);
} finally {
loadingIndex = false;
}
}
function getSectionTitle(item: EventIndexItem): string {
const titleTag = item.event.tags.find(t => t[0] === 'title' && t[1]);
if (titleTag) {
return titleTag[1];
}
// Fallback to d-tag in Title Case
const dTag = item.event.tags.find(t => t[0] === 'd' && t[1])?.[1];
if (dTag) {
return dTag.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
}
return `Section ${item.order + 1}`;
}
function openSectionInNewWindow(item: EventIndexItem) {
window.open(`/event/${item.event.id}`, '_blank');
}
async function labelAsBook() {
if (!event) return;
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to label events');
return;
}
async function loadEvent() {
if (!$page.params.id) return;
labeling = true;
publicationResults = null;
loading = true;
error = null;
decodedEventId = null;
try {
const tags: string[][] = [
['L', 'ugc'], // Namespace tag
['l', 'booklist', 'ugc'], // Label tag
];
// Add reference to the event
// For kind 30040 (replaceable), use 'a' tag with kind:pubkey:d-tag
// For other events, use 'e' tag with event ID
if (event.kind === 30040) {
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
if (dTag) {
const aTag = `${event.kind}:${event.pubkey}:${dTag}`;
// Add relay hint if available
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['a', aTag, relayUrl]);
} else {
tags.push(['a', aTag]);
}
} else {
// Fallback to 'e' tag if no d-tag found
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['e', event.id, relayUrl]);
} else {
tags.push(['e', event.id]);
}
}
const eventId = await decodeEventId($page.params.id);
if (eventId) {
decodedEventId = eventId;
} else {
// Regular event - use 'e' tag
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['e', event.id, relayUrl]);
} else {
tags.push(['e', event.id]);
}
}
// Add client tag (NIP-89)
if (shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
}
const eventTemplate = {
kind: KIND.LABEL, // 1985
content: '',
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: session.pubkey,
};
const config = nostrClient.getConfig();
const relays = relayManager.getPublishRelays(config.defaultRelays);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
if (results.success.length > 0) {
// Success - could show a success message
console.log('Event labeled as book successfully');
} else {
console.error('Failed to publish label event');
error = 'Event not found or invalid format';
}
} catch (err) {
console.error('Error labeling event as book:', err);
alert('Failed to label event as book. Please try again.');
console.error('Error loading event:', err);
error = 'Failed to load event';
} finally {
labeling = false;
loading = false;
}
}
const isReplaceable = $derived(event ? isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind) : false);
const hasContent = $derived(event ? !!event.content : false);
const isKind30040 = $derived(event?.kind === 30040);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
onMount(async () => {
await nostrClient.initialize();
await loadEvent();
});
$effect(() => {
if ($page.params.id) {
loadEvent();
}
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading event...</p>
</div>
<p class="text-fog-text dark:text-fog-dark-text">Loading event...</p>
{:else if error}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text">{error}</p>
</div>
{:else if event}
<div class="event-page">
{#if isReplaceable}
<div class="metadata-wrapper">
<MetadataCard event={event} />
{#if isKind30040 && isLoggedIn}
<div class="metadata-actions">
<EventMenu event={event} showContentActions={false} />
<button class="btn-label-book" onclick={labelAsBook} disabled={labeling}>
{labeling ? 'Labeling...' : 'Label as book'}
</button>
</div>
{/if}
</div>
{/if}
{#if isKind30040}
{#if loadingIndex}
<div class="loading-index">
<p class="text-fog-text dark:text-fog-dark-text">Loading publication...</p>
</div>
{:else if indexItems.length > 0}
{#each indexItems as item (item.event.id)}
<div class="section-item">
<div class="section-header">
<h3 class="section-title">{getSectionTitle(item)}</h3>
<div class="section-actions">
<EventMenu event={item.event} showContentActions={false} />
<button class="btn-open-window" onclick={() => openSectionInNewWindow(item)}>
Open in new window
</button>
</div>
</div>
<div class="section-content">
<MarkdownRenderer content={item.event.content} event={item.event} />
</div>
</div>
{/each}
{/if}
{:else if hasContent}
<div class="event-content">
<MarkdownRenderer content={event.content} event={event} />
</div>
{:else}
<div class="event-tags">
<h3 class="tags-title">Tags</h3>
<div class="tags-list">
{#each event.tags as tag}
<div class="tag-item">
<span class="tag-name">{tag[0]}</span>
{#each tag.slice(1) as value}
<span class="tag-value">{value}</span>
{/each}
</div>
{/each}
</div>
</div>
{/if}
<div class="event-details">
<FeedPost post={event} />
</div>
</div>
<p class="text-fog-text dark:text-fog-dark-text">{error}</p>
{:else if decodedEventId}
<ThreadView threadId={decodedEventId} />
{:else if $page.params.id}
<p class="text-fog-text dark:text-fog-dark-text">Invalid event ID format. Supported: hex event ID, note, nevent, or naddr</p>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Event ID required</p>
{/if}
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.loading-state,
.error-state {
padding: 2rem;
text-align: center;
}
.event-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.event-content {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-content {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-tags {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-tags {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.tags-title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .tags-title {
color: var(--fog-dark-text, #f9fafb);
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .tag-item {
background: var(--fog-dark-highlight, #374151);
}
.tag-name {
font-weight: 600;
color: var(--fog-accent, #64748b);
}
:global(.dark) .tag-name {
color: var(--fog-dark-accent, #94a3b8);
}
.tag-value {
color: var(--fog-text, #1f2937);
word-break: break-all;
}
:global(.dark) .tag-value {
color: var(--fog-dark-text, #f9fafb);
}
.event-details {
padding: 1rem;
}
.metadata-wrapper {
position: relative;
}
.metadata-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.btn-label-book {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .btn-label-book {
background: var(--fog-dark-accent, #94a3b8);
}
.btn-label-book:hover {
opacity: 0.9;
}
.loading-index {
padding: 2rem;
text-align: center;
}
.section-item {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .section-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .section-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .section-title {
color: var(--fog-dark-text, #f9fafb);
}
.section-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-open-window {
padding: 0.25rem 0.75rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .btn-open-window {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.btn-open-window:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .btn-open-window:hover {
background: var(--fog-dark-border, #475569);
}
.section-content {
padding: 0.5rem 0;
}
.btn-label-book:hover:not(:disabled) {
opacity: 0.9;
}
.btn-label-book:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />

79
src/routes/thread/[id]/+page.svelte

@ -1,79 +0,0 @@ @@ -1,79 +0,0 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import ThreadView from '../../../lib/modules/threads/ThreadView.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
let decodedEventId = $state<string | null>(null);
/**
* Decode route parameter to event hex ID
* Supports: hex event id, note, nevent, naddr
*/
function decodeEventId(param: string): string | null {
if (!param) return null;
// Check if it's already a hex event ID (64 hex characters)
if (/^[0-9a-f]{64}$/i.test(param)) {
return param.toLowerCase();
}
// Check if it's a bech32 encoded format (note, nevent, naddr)
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) {
try {
const decoded = nip19.decode(param);
if (decoded.type === 'note') {
return String(decoded.data);
} else if (decoded.type === 'nevent') {
// nevent contains event id and optional relays
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// naddr is for parameterized replaceable events (kind + pubkey + d tag)
// We need to fetch the event using these parameters, then get its id
// For now, return a special marker that ThreadView can handle
// naddr format: { kind, pubkey, identifier (d tag), relays? }
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) {
// Store naddr data for fetching - we'll handle this in ThreadView
// For now, return null and ThreadView will need to fetch by kind+pubkey+d
console.warn('naddr requires fetching event by kind+pubkey+d, not yet fully supported');
return null;
}
}
} catch (error) {
console.error('Error decoding bech32:', error);
return null;
}
}
return null;
}
onMount(async () => {
await nostrClient.initialize();
if ($page.params.id) {
decodedEventId = decodeEventId($page.params.id);
}
});
$effect(() => {
if ($page.params.id) {
decodedEventId = decodeEventId($page.params.id);
}
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
{#if decodedEventId}
<ThreadView threadId={decodedEventId} />
{:else if $page.params.id}
<p class="text-fog-text dark:text-fog-dark-text">Invalid event ID format. Supported: hex event ID, note, nevent, or naddr</p>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Event ID required</p>
{/if}
</main>
Loading…
Cancel
Save