15 changed files with 1094 additions and 167 deletions
@ -0,0 +1,121 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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