15 changed files with 1094 additions and 167 deletions
@ -0,0 +1,121 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||||
|
import Kind1ReactionButtons from '../reactions/Kind1ReactionButtons.svelte'; |
||||||
|
import ZapButton from '../zaps/ZapButton.svelte'; |
||||||
|
import ZapReceipt from '../zaps/ZapReceipt.svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
reply: NostrEvent; |
||||||
|
parentEvent?: NostrEvent; // The event this is replying to |
||||||
|
onReply?: (post: NostrEvent) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { reply, parentEvent, onReply }: Props = $props(); |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - reply.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
const minutes = Math.floor(diff / 60); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
if (minutes > 0) return `${minutes}m ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
|
||||||
|
function getClientName(): string | null { |
||||||
|
const clientTag = reply.tags.find((t) => t[0] === 'client'); |
||||||
|
return clientTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function getParentPreview(): string { |
||||||
|
if (parentEvent) { |
||||||
|
return parentEvent.content.slice(0, 100) + (parentEvent.content.length > 100 ? '...' : ''); |
||||||
|
} |
||||||
|
// Try to extract from reply tags |
||||||
|
const eTag = reply.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
||||||
|
if (eTag) { |
||||||
|
return 'Replying to...'; |
||||||
|
} |
||||||
|
return ''; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<article class="kind1-reply"> |
||||||
|
{#if parentEvent} |
||||||
|
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light"> |
||||||
|
<span class="font-semibold">Replying to:</span> {getParentPreview()} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="reply-header flex items-center gap-2 mb-2"> |
||||||
|
<ProfileBadge pubkey={reply.pubkey} /> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
||||||
|
{#if getClientName()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
||||||
|
{/if} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">↳ Reply</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="reply-content mb-2"> |
||||||
|
<MarkdownRenderer content={reply.content} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="reply-actions flex items-center gap-4"> |
||||||
|
<Kind1ReactionButtons event={reply} /> |
||||||
|
<ZapButton event={reply} /> |
||||||
|
<ZapReceipt eventId={reply.id} pubkey={reply.pubkey} /> |
||||||
|
{#if onReply} |
||||||
|
<button |
||||||
|
onclick={() => onReply(reply)} |
||||||
|
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||||
|
> |
||||||
|
Reply |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
|
||||||
|
<style> |
||||||
|
.kind1-reply { |
||||||
|
padding: 1rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
border-left: 3px solid var(--fog-accent, #3b82f6); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind1-reply { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
border-left-color: var(--fog-dark-accent, #60a5fa); |
||||||
|
} |
||||||
|
|
||||||
|
.reply-content { |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.reply-actions { |
||||||
|
padding-top: 0.5rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .reply-actions { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.reply-context { |
||||||
|
cursor: pointer; |
||||||
|
transition: opacity 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.reply-context:hover { |
||||||
|
opacity: 0.8; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,141 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
parentEvent: NostrEvent; // The kind 1 event to reply to |
||||||
|
onPublished?: () => void; |
||||||
|
onCancel?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { parentEvent, onPublished, onCancel }: Props = $props(); |
||||||
|
|
||||||
|
let content = $state(''); |
||||||
|
let publishing = $state(false); |
||||||
|
let includeClientTag = $state(true); |
||||||
|
|
||||||
|
async function publish() { |
||||||
|
if (!sessionManager.isLoggedIn()) { |
||||||
|
alert('Please log in to reply'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!content.trim()) { |
||||||
|
alert('Reply cannot be empty'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
publishing = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
// Add NIP-10 threading tags for reply |
||||||
|
const rootTag = parentEvent.tags.find((t) => t[0] === 'root'); |
||||||
|
const rootId = rootTag?.[1] || parentEvent.id; |
||||||
|
|
||||||
|
tags.push(['e', parentEvent.id, '', 'reply']); |
||||||
|
tags.push(['p', parentEvent.pubkey]); |
||||||
|
tags.push(['root', rootId]); |
||||||
|
|
||||||
|
if (includeClientTag) { |
||||||
|
tags.push(['client', 'Aitherboard']); |
||||||
|
} |
||||||
|
|
||||||
|
const event: Omit<NostrEvent, 'id' | 'sig'> = { |
||||||
|
kind: 1, |
||||||
|
pubkey: sessionManager.getCurrentPubkey()!, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags, |
||||||
|
content: content.trim() |
||||||
|
}; |
||||||
|
|
||||||
|
// Get target inbox if replying |
||||||
|
let targetInbox: string[] | undefined; |
||||||
|
try { |
||||||
|
const { fetchRelayLists } = await import('../../services/auth/relay-list-fetcher.js'); |
||||||
|
const { inbox } = await fetchRelayLists(parentEvent.pubkey); |
||||||
|
targetInbox = inbox; |
||||||
|
} catch { |
||||||
|
// Ignore errors, just use default relays |
||||||
|
} |
||||||
|
|
||||||
|
const relays = relayManager.getKind1PublishRelays(targetInbox); |
||||||
|
const result = await signAndPublish(event, relays); |
||||||
|
|
||||||
|
if (result.success.length > 0) { |
||||||
|
content = ''; |
||||||
|
onPublished?.(); |
||||||
|
} else { |
||||||
|
alert('Failed to publish reply'); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error publishing reply:', error); |
||||||
|
alert('Error publishing reply'); |
||||||
|
} finally { |
||||||
|
publishing = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="reply-to-kind1-form"> |
||||||
|
<div class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-sm"> |
||||||
|
<span class="font-semibold">Replying to:</span> {parentEvent.content.slice(0, 100)}... |
||||||
|
</div> |
||||||
|
|
||||||
|
<textarea |
||||||
|
bind:value={content} |
||||||
|
placeholder="Write a reply..." |
||||||
|
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text" |
||||||
|
rows="6" |
||||||
|
disabled={publishing} |
||||||
|
></textarea> |
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-2"> |
||||||
|
<label class="flex items-center gap-2 text-sm text-fog-text dark:text-fog-dark-text"> |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
bind:checked={includeClientTag} |
||||||
|
class="rounded" |
||||||
|
/> |
||||||
|
Include client tag |
||||||
|
</label> |
||||||
|
|
||||||
|
<div class="flex gap-2"> |
||||||
|
{#if onCancel} |
||||||
|
<button |
||||||
|
onclick={onCancel} |
||||||
|
class="px-4 py-2 text-sm border border-fog-border dark:border-fog-dark-border rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" |
||||||
|
disabled={publishing} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
<button |
||||||
|
onclick={publish} |
||||||
|
class="px-4 py-2 text-sm bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 disabled:opacity-50" |
||||||
|
disabled={publishing || !content.trim()} |
||||||
|
> |
||||||
|
{publishing ? 'Publishing...' : 'Reply'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.reply-to-kind1-form { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
textarea { |
||||||
|
resize: vertical; |
||||||
|
min-height: 120px; |
||||||
|
} |
||||||
|
|
||||||
|
textarea:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #3b82f6); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
zapReceipt: NostrEvent; // Kind 9735 zap receipt |
||||||
|
parentEvent?: NostrEvent; // The event this zap receipt is for |
||||||
|
onReply?: (receipt: NostrEvent) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { zapReceipt, parentEvent, onReply }: Props = $props(); |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - zapReceipt.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
const minutes = Math.floor(diff / 60); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
if (minutes > 0) return `${minutes}m ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
|
||||||
|
function getAmount(): number { |
||||||
|
const amountTag = zapReceipt.tags.find((t) => t[0] === 'amount'); |
||||||
|
if (amountTag && amountTag[1]) { |
||||||
|
const amount = parseInt(amountTag[1], 10); |
||||||
|
return isNaN(amount) ? 0 : amount; |
||||||
|
} |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
function getZappedPubkey(): string | null { |
||||||
|
const pTag = zapReceipt.tags.find((t) => t[0] === 'p'); |
||||||
|
return pTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function getZappedEventId(): string | null { |
||||||
|
const eTag = zapReceipt.tags.find((t) => t[0] === 'e'); |
||||||
|
return eTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function getZapperPubkey(): string { |
||||||
|
return zapReceipt.pubkey; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<article class="zap-receipt-reply"> |
||||||
|
<div class="zap-header flex items-center gap-2 mb-2"> |
||||||
|
<ProfileBadge pubkey={getZapperPubkey()} /> |
||||||
|
<span class="text-lg">⚡</span> |
||||||
|
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span> |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
||||||
|
{#if getZappedPubkey()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light"> |
||||||
|
to <ProfileBadge pubkey={getZappedPubkey()} /> |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if zapReceipt.content} |
||||||
|
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text"> |
||||||
|
{zapReceipt.content} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="zap-actions flex items-center gap-4"> |
||||||
|
{#if onReply} |
||||||
|
<button |
||||||
|
onclick={() => onReply(zapReceipt)} |
||||||
|
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" |
||||||
|
> |
||||||
|
Reply |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
|
||||||
|
<style> |
||||||
|
.zap-receipt-reply { |
||||||
|
padding: 1rem; |
||||||
|
margin-bottom: 1rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */ |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .zap-receipt-reply { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
border-left-color: #fbbf24; |
||||||
|
} |
||||||
|
|
||||||
|
.zap-content { |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.zap-actions { |
||||||
|
padding-top: 0.5rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .zap-actions { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,146 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||||
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||||
|
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; |
||||||
|
import CommentThread from '../comments/CommentThread.svelte'; |
||||||
|
import ReactionButtons from '../reactions/ReactionButtons.svelte'; |
||||||
|
import ZapButton from '../zaps/ZapButton.svelte'; |
||||||
|
import ZapReceipt from '../zaps/ZapReceipt.svelte'; |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
threadId: string; |
||||||
|
} |
||||||
|
|
||||||
|
let { threadId }: Props = $props(); |
||||||
|
|
||||||
|
let thread = $state<NostrEvent | null>(null); |
||||||
|
let loading = $state(true); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await nostrClient.initialize(); |
||||||
|
loadThread(); |
||||||
|
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (threadId) { |
||||||
|
loadThread(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function loadThread() { |
||||||
|
loading = true; |
||||||
|
try { |
||||||
|
const relays = relayManager.getThreadReadRelays(); |
||||||
|
const event = await nostrClient.getEventById(threadId, relays); |
||||||
|
thread = event; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading thread:', error); |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getTitle(): string { |
||||||
|
if (!thread) return ''; |
||||||
|
const titleTag = thread.tags.find((t) => t[0] === 'title'); |
||||||
|
return titleTag?.[1] || 'Untitled'; |
||||||
|
} |
||||||
|
|
||||||
|
function getTopics(): string[] { |
||||||
|
if (!thread) return []; |
||||||
|
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3); |
||||||
|
} |
||||||
|
|
||||||
|
function getClientName(): string | null { |
||||||
|
if (!thread) return null; |
||||||
|
const clientTag = thread.tags.find((t) => t[0] === 'client'); |
||||||
|
return clientTag?.[1] || null; |
||||||
|
} |
||||||
|
|
||||||
|
function getRelativeTime(): string { |
||||||
|
if (!thread) return ''; |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const diff = now - thread.created_at; |
||||||
|
const hours = Math.floor(diff / 3600); |
||||||
|
const days = Math.floor(diff / 86400); |
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`; |
||||||
|
if (hours > 0) return `${hours}h ago`; |
||||||
|
return 'just now'; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p> |
||||||
|
{:else if thread} |
||||||
|
<article class="thread-view"> |
||||||
|
<div class="thread-header mb-4"> |
||||||
|
<h1 class="text-2xl font-bold mb-2">{getTitle()}</h1> |
||||||
|
<div class="flex items-center gap-2 mb-2"> |
||||||
|
<ProfileBadge pubkey={thread.pubkey} /> |
||||||
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
||||||
|
{#if getClientName()} |
||||||
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{#if getTopics().length > 0} |
||||||
|
<div class="flex gap-2 mb-2"> |
||||||
|
{#each getTopics() as topic} |
||||||
|
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="thread-content mb-4"> |
||||||
|
<MediaAttachments event={thread} /> |
||||||
|
<MarkdownRenderer content={thread.content} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="thread-actions flex items-center gap-4 mb-6"> |
||||||
|
<ReactionButtons event={thread} /> |
||||||
|
<ZapButton event={thread} /> |
||||||
|
<ZapReceipt eventId={thread.id} pubkey={thread.pubkey} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="comments-section"> |
||||||
|
<CommentThread threadId={thread.id} /> |
||||||
|
</div> |
||||||
|
</article> |
||||||
|
{:else} |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.thread-view { |
||||||
|
max-width: var(--content-width); |
||||||
|
margin: 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
.thread-content { |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.thread-actions { |
||||||
|
padding-top: 1rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .thread-actions { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.comments-section { |
||||||
|
margin-top: 2rem; |
||||||
|
padding-top: 2rem; |
||||||
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .comments-section { |
||||||
|
border-top-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,76 @@ |
|||||||
|
/** |
||||||
|
* NIP-21 URI parser |
||||||
|
* Parses nostr: URIs and extracts bech32 entities |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface ParsedNIP21 { |
||||||
|
type: 'npub' | 'note' | 'nevent' | 'naddr' | 'nprofile'; |
||||||
|
data: string; // The bech32 string without nostr: prefix
|
||||||
|
entity?: any; // Decoded entity data
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a NIP-21 URI (nostr:...) |
||||||
|
*/ |
||||||
|
export function parseNIP21(uri: string): ParsedNIP21 | null { |
||||||
|
// Check if it's a nostr: URI
|
||||||
|
if (!uri.startsWith('nostr:')) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const bech32 = uri.slice(6); // Remove 'nostr:' prefix
|
||||||
|
|
||||||
|
// Validate bech32 format
|
||||||
|
if (!/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(bech32)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract type
|
||||||
|
const typeMatch = bech32.match(/^(npub|note|nevent|naddr|nprofile)/); |
||||||
|
if (!typeMatch) return null; |
||||||
|
|
||||||
|
const type = typeMatch[1] as ParsedNIP21['type']; |
||||||
|
|
||||||
|
// Try to decode (optional, for validation)
|
||||||
|
let entity: any = null; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(bech32); |
||||||
|
entity = decoded; |
||||||
|
} catch { |
||||||
|
// If decoding fails, we can still use the bech32 string
|
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
type, |
||||||
|
data: bech32, |
||||||
|
entity |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find all NIP-21 URIs in text |
||||||
|
*/ |
||||||
|
export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> { |
||||||
|
const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = []; |
||||||
|
|
||||||
|
// Match nostr: URIs (case-insensitive)
|
||||||
|
const regex = /nostr:(npub|note|nevent|naddr|nprofile)1[a-z0-9]+/gi; |
||||||
|
let match; |
||||||
|
|
||||||
|
while ((match = regex.exec(text)) !== null) { |
||||||
|
const uri = match[0]; |
||||||
|
const parsed = parseNIP21(uri); |
||||||
|
if (parsed) { |
||||||
|
links.push({ |
||||||
|
uri, |
||||||
|
start: match.index, |
||||||
|
end: match.index + uri.length, |
||||||
|
parsed |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return links; |
||||||
|
} |
||||||
@ -0,0 +1,226 @@ |
|||||||
|
/** |
||||||
|
* Relay manager for user relay preferences |
||||||
|
* Handles inbox/outbox relays and blocked relays |
||||||
|
*/ |
||||||
|
|
||||||
|
import { fetchRelayLists } from '../auth/relay-list-fetcher.js'; |
||||||
|
import { getBlockedRelays } from '../nostr/auth-handler.js'; |
||||||
|
import { config } from './config.js'; |
||||||
|
import { sessionManager } from '../auth/session-manager.js'; |
||||||
|
|
||||||
|
class RelayManager { |
||||||
|
private userInbox: string[] = []; |
||||||
|
private userOutbox: string[] = []; |
||||||
|
private blockedRelays: Set<string> = new Set(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Load user relay preferences |
||||||
|
*/ |
||||||
|
async loadUserPreferences(pubkey: string): Promise<void> { |
||||||
|
// Fetch relay lists
|
||||||
|
const { inbox, outbox } = await fetchRelayLists(pubkey); |
||||||
|
this.userInbox = inbox; |
||||||
|
this.userOutbox = outbox; |
||||||
|
|
||||||
|
// Get blocked relays
|
||||||
|
this.blockedRelays = getBlockedRelays(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear user preferences (on logout) |
||||||
|
*/ |
||||||
|
clearUserPreferences(): void { |
||||||
|
this.userInbox = []; |
||||||
|
this.userOutbox = []; |
||||||
|
this.blockedRelays.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Filter out blocked relays |
||||||
|
*/ |
||||||
|
private filterBlocked(relays: string[]): string[] { |
||||||
|
if (this.blockedRelays.size === 0) return relays; |
||||||
|
return relays.filter((r) => !this.blockedRelays.has(r)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Normalize and deduplicate relay URLs |
||||||
|
*/ |
||||||
|
private normalizeRelays(relays: string[]): string[] { |
||||||
|
// Normalize URLs (remove trailing slashes, etc.)
|
||||||
|
const normalized = relays.map((r) => { |
||||||
|
let url = r.trim(); |
||||||
|
if (url.endsWith('/')) { |
||||||
|
url = url.slice(0, -1); |
||||||
|
} |
||||||
|
return url; |
||||||
|
}); |
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
return [...new Set(normalized)]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading operations |
||||||
|
*/ |
||||||
|
getReadRelays(baseRelays: string[], includeUserInbox = true): string[] { |
||||||
|
let relays = [...baseRelays]; |
||||||
|
|
||||||
|
// Add user inbox if logged in
|
||||||
|
if (includeUserInbox && sessionManager.isLoggedIn() && this.userInbox.length > 0) { |
||||||
|
relays = [...relays, ...this.userInbox]; |
||||||
|
} |
||||||
|
|
||||||
|
// Normalize and deduplicate
|
||||||
|
relays = this.normalizeRelays(relays); |
||||||
|
|
||||||
|
// Filter blocked relays
|
||||||
|
return this.filterBlocked(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for publishing operations |
||||||
|
*/ |
||||||
|
getPublishRelays(baseRelays: string[], includeUserOutbox = true): string[] { |
||||||
|
let relays = [...baseRelays]; |
||||||
|
|
||||||
|
// Add user outbox if logged in
|
||||||
|
if (includeUserOutbox && sessionManager.isLoggedIn() && this.userOutbox.length > 0) { |
||||||
|
relays = [...relays, ...this.userOutbox]; |
||||||
|
} |
||||||
|
|
||||||
|
// Normalize and deduplicate
|
||||||
|
relays = this.normalizeRelays(relays); |
||||||
|
|
||||||
|
// Filter blocked relays
|
||||||
|
return this.filterBlocked(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading threads (kind 11) |
||||||
|
*/ |
||||||
|
getThreadReadRelays(): string[] { |
||||||
|
return this.getReadRelays(config.defaultRelays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading comments (kind 1111) |
||||||
|
*/ |
||||||
|
getCommentReadRelays(): string[] { |
||||||
|
return this.getReadRelays(config.defaultRelays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading kind 1 feed |
||||||
|
*/ |
||||||
|
getKind1ReadRelays(): string[] { |
||||||
|
return this.getReadRelays(config.defaultRelays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading kind 1 responses |
||||||
|
*/ |
||||||
|
getKind1ResponseReadRelays(): string[] { |
||||||
|
return this.getReadRelays([ |
||||||
|
...config.defaultRelays, |
||||||
|
'wss://aggr.nostr.land' |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading zap receipts (kind 9735) |
||||||
|
*/ |
||||||
|
getZapReceiptReadRelays(): string[] { |
||||||
|
return this.getReadRelays(config.defaultRelays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading profiles (kind 0) |
||||||
|
*/ |
||||||
|
getProfileReadRelays(): string[] { |
||||||
|
return this.getReadRelays([ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading payment targets (kind 10133) |
||||||
|
*/ |
||||||
|
getPaymentTargetReadRelays(): string[] { |
||||||
|
return this.getReadRelays([ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for reading user status (kind 30315) |
||||||
|
*/ |
||||||
|
getUserStatusReadRelays(): string[] { |
||||||
|
return this.getReadRelays([ |
||||||
|
...config.defaultRelays, |
||||||
|
...config.profileRelays |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for publishing threads (kind 11) |
||||||
|
*/ |
||||||
|
getThreadPublishRelays(): string[] { |
||||||
|
return this.getPublishRelays([ |
||||||
|
...config.defaultRelays, |
||||||
|
'wss://thecitadel.nostr1.com' |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for publishing comments (kind 1111) |
||||||
|
* If replying, include target's inbox |
||||||
|
*/ |
||||||
|
getCommentPublishRelays(targetInbox?: string[]): string[] { |
||||||
|
let relays = this.getPublishRelays(config.defaultRelays); |
||||||
|
|
||||||
|
// If replying, add target's inbox
|
||||||
|
if (targetInbox && targetInbox.length > 0) { |
||||||
|
relays = [...relays, ...targetInbox]; |
||||||
|
relays = this.normalizeRelays(relays); |
||||||
|
relays = this.filterBlocked(relays); |
||||||
|
} |
||||||
|
|
||||||
|
return relays; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for publishing kind 1 posts |
||||||
|
* If replying, include target's inbox |
||||||
|
*/ |
||||||
|
getKind1PublishRelays(targetInbox?: string[]): string[] { |
||||||
|
let relays = this.getPublishRelays(config.defaultRelays); |
||||||
|
|
||||||
|
// If replying, add target's inbox
|
||||||
|
if (targetInbox && targetInbox.length > 0) { |
||||||
|
relays = [...relays, ...targetInbox]; |
||||||
|
relays = this.normalizeRelays(relays); |
||||||
|
relays = this.filterBlocked(relays); |
||||||
|
} |
||||||
|
|
||||||
|
return relays; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get relays for publishing reactions (kind 7) |
||||||
|
*/ |
||||||
|
getReactionPublishRelays(): string[] { |
||||||
|
return this.getPublishRelays(config.defaultRelays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update blocked relays (called when user preferences change) |
||||||
|
*/ |
||||||
|
updateBlockedRelays(blocked: Set<string>): void { |
||||||
|
this.blockedRelays = blocked; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const relayManager = new RelayManager(); |
||||||
Loading…
Reference in new issue