4 changed files with 478 additions and 296 deletions
@ -0,0 +1,420 @@
@@ -0,0 +1,420 @@
|
||||
import { nip19 } from "nostr-tools"; |
||||
import { getEventHash, signEvent, prefixNostrAddresses } from "./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 "./nostrUtils"; |
||||
|
||||
export interface RootEventInfo { |
||||
rootId: string; |
||||
rootPubkey: string; |
||||
rootRelay: string; |
||||
rootKind: number; |
||||
rootAddress: string; |
||||
rootIValue: string; |
||||
rootIRelay: string; |
||||
isRootA: boolean; |
||||
isRootI: boolean; |
||||
} |
||||
|
||||
export interface ParentEventInfo { |
||||
parentId: string; |
||||
parentPubkey: string; |
||||
parentRelay: string; |
||||
parentKind: number; |
||||
parentAddress: string; |
||||
} |
||||
|
||||
export interface EventPublishResult { |
||||
success: boolean; |
||||
relay?: string; |
||||
eventId?: string; |
||||
error?: string; |
||||
} |
||||
|
||||
/** |
||||
* Helper function to find a tag by its first element |
||||
*/ |
||||
function findTag(tags: string[][], tagName: string): string[] | undefined { |
||||
return tags?.find((t: string[]) => t[0] === tagName); |
||||
} |
||||
|
||||
/** |
||||
* Helper function to get tag value safely |
||||
*/ |
||||
function getTagValue(tags: string[][], tagName: string, index: number = 1): string { |
||||
const tag = findTag(tags, tagName); |
||||
return tag?.[index] || ''; |
||||
} |
||||
|
||||
/** |
||||
* Helper function to create a tag array |
||||
*/ |
||||
function createTag(name: string, ...values: (string | number)[]): string[] { |
||||
return [name, ...values.map(v => String(v))]; |
||||
} |
||||
|
||||
/** |
||||
* Helper function to add tags to an array |
||||
*/ |
||||
function addTags(tags: string[][], ...newTags: string[][]): void { |
||||
tags.push(...newTags); |
||||
} |
||||
|
||||
/** |
||||
* Extract root event information from parent event tags |
||||
*/ |
||||
export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { |
||||
const rootInfo: RootEventInfo = { |
||||
rootId: parent.id, |
||||
rootPubkey: getPubkeyString(parent.pubkey), |
||||
rootRelay: getRelayString(parent.relay), |
||||
rootKind: parent.kind || 1, |
||||
rootAddress: '', |
||||
rootIValue: '', |
||||
rootIRelay: '', |
||||
isRootA: false, |
||||
isRootI: false, |
||||
}; |
||||
|
||||
if (!parent.tags) return rootInfo; |
||||
|
||||
const rootE = findTag(parent.tags, 'E'); |
||||
const rootA = findTag(parent.tags, 'A'); |
||||
const rootI = findTag(parent.tags, 'I'); |
||||
|
||||
rootInfo.isRootA = !!rootA; |
||||
rootInfo.isRootI = !!rootI; |
||||
|
||||
if (rootE) { |
||||
rootInfo.rootId = rootE[1]; |
||||
rootInfo.rootRelay = getRelayString(rootE[2]); |
||||
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey); |
||||
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind; |
||||
} else if (rootA) { |
||||
rootInfo.rootAddress = rootA[1]; |
||||
rootInfo.rootRelay = getRelayString(rootA[2]); |
||||
rootInfo.rootPubkey = getPubkeyString(getTagValue(parent.tags, 'P') || rootInfo.rootPubkey); |
||||
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind; |
||||
} else if (rootI) { |
||||
rootInfo.rootIValue = rootI[1]; |
||||
rootInfo.rootIRelay = getRelayString(rootI[2]); |
||||
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind; |
||||
} |
||||
|
||||
return rootInfo; |
||||
} |
||||
|
||||
/** |
||||
* Extract parent event information |
||||
*/ |
||||
export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo { |
||||
const dTag = getTagValue(parent.tags || [], 'd'); |
||||
const parentAddress = dTag ? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}` : ''; |
||||
|
||||
return { |
||||
parentId: parent.id, |
||||
parentPubkey: getPubkeyString(parent.pubkey), |
||||
parentRelay: getRelayString(parent.relay), |
||||
parentKind: parent.kind || 1, |
||||
parentAddress, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Build root scope tags for NIP-22 threading |
||||
*/ |
||||
function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo): string[][] { |
||||
const tags: string[][] = []; |
||||
|
||||
if (rootInfo.rootAddress) { |
||||
const tagType = rootInfo.isRootA ? 'A' : rootInfo.isRootI ? 'I' : 'E'; |
||||
addTags(tags, createTag(tagType, rootInfo.rootAddress || rootInfo.rootId, rootInfo.rootRelay)); |
||||
} else if (rootInfo.rootIValue) { |
||||
addTags(tags, createTag('I', rootInfo.rootIValue, rootInfo.rootIRelay)); |
||||
} else { |
||||
addTags(tags, createTag('E', rootInfo.rootId, rootInfo.rootRelay)); |
||||
} |
||||
|
||||
addTags(tags, createTag('K', rootInfo.rootKind)); |
||||
|
||||
if (rootInfo.rootPubkey && !rootInfo.rootIValue) { |
||||
addTags(tags, createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay)); |
||||
} |
||||
|
||||
return tags; |
||||
} |
||||
|
||||
/** |
||||
* Build parent scope tags for NIP-22 threading |
||||
*/ |
||||
function buildParentScopeTags(parent: NDKEvent, parentInfo: ParentEventInfo, rootInfo: RootEventInfo): string[][] { |
||||
const tags: string[][] = []; |
||||
|
||||
if (parentInfo.parentAddress) { |
||||
const tagType = rootInfo.isRootA ? 'a' : rootInfo.isRootI ? 'i' : 'e'; |
||||
addTags(tags, createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay)); |
||||
} |
||||
|
||||
addTags( |
||||
tags, |
||||
createTag('e', parent.id, parentInfo.parentRelay), |
||||
createTag('k', parentInfo.parentKind), |
||||
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||
); |
||||
|
||||
return tags; |
||||
} |
||||
|
||||
/** |
||||
* Build tags for a reply event based on parent and root information |
||||
*/ |
||||
export function buildReplyTags( |
||||
parent: NDKEvent, |
||||
rootInfo: RootEventInfo, |
||||
parentInfo: ParentEventInfo, |
||||
kind: number |
||||
): string[][] { |
||||
const tags: string[][] = []; |
||||
|
||||
const isParentReplaceable = parentInfo.parentKind >= 30000 && parentInfo.parentKind < 40000; |
||||
const isParentComment = parentInfo.parentKind === 1111; |
||||
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id; |
||||
|
||||
if (kind === 1) { |
||||
// Kind 1 replies use simple e/p tags
|
||||
addTags( |
||||
tags, |
||||
createTag('e', parent.id, parentInfo.parentRelay, 'root'), |
||||
createTag('p', parentInfo.parentPubkey) |
||||
); |
||||
|
||||
// Add address for replaceable events
|
||||
if (isParentReplaceable) { |
||||
const dTag = getTagValue(parent.tags || [], 'd'); |
||||
if (dTag) { |
||||
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`; |
||||
addTags(tags, createTag('a', parentAddress, '', 'root')); |
||||
} |
||||
} |
||||
} else { |
||||
// Kind 1111 uses NIP-22 threading format
|
||||
if (isParentReplaceable) { |
||||
const dTag = getTagValue(parent.tags || [], 'd'); |
||||
if (dTag) { |
||||
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`; |
||||
|
||||
if (isReplyToComment) { |
||||
// Root scope (uppercase) - use the original article
|
||||
addTags( |
||||
tags, |
||||
createTag('A', parentAddress, parentInfo.parentRelay), |
||||
createTag('K', rootInfo.rootKind), |
||||
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay) |
||||
); |
||||
// Parent scope (lowercase) - the comment we're replying to
|
||||
addTags( |
||||
tags, |
||||
createTag('e', parent.id, parentInfo.parentRelay), |
||||
createTag('k', parentInfo.parentKind), |
||||
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||
); |
||||
} else { |
||||
// Top-level comment - root and parent are the same
|
||||
addTags( |
||||
tags, |
||||
createTag('A', parentAddress, parentInfo.parentRelay), |
||||
createTag('K', rootInfo.rootKind), |
||||
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay), |
||||
createTag('a', parentAddress, parentInfo.parentRelay), |
||||
createTag('e', parent.id, parentInfo.parentRelay), |
||||
createTag('k', parentInfo.parentKind), |
||||
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||
); |
||||
} |
||||
} else { |
||||
// Fallback to E/e tags if no d-tag found
|
||||
if (isReplyToComment) { |
||||
addTags( |
||||
tags, |
||||
createTag('E', rootInfo.rootId, rootInfo.rootRelay), |
||||
createTag('K', rootInfo.rootKind), |
||||
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay), |
||||
createTag('e', parent.id, parentInfo.parentRelay), |
||||
createTag('k', parentInfo.parentKind), |
||||
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||
); |
||||
} else { |
||||
addTags( |
||||
tags, |
||||
createTag('E', parent.id, rootInfo.rootRelay), |
||||
createTag('K', rootInfo.rootKind), |
||||
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay), |
||||
createTag('e', parent.id, parentInfo.parentRelay), |
||||
createTag('k', parentInfo.parentKind), |
||||
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||
); |
||||
} |
||||
} |
||||
} else { |
||||
// For regular events, use E/e tags
|
||||
if (isReplyToComment) { |
||||
// Reply to a comment - distinguish root from parent
|
||||
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo)); |
||||
addTags( |
||||
tags, |
||||
createTag('e', parent.id, parentInfo.parentRelay), |
||||
createTag('k', parentInfo.parentKind), |
||||
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||
); |
||||
} else { |
||||
// Top-level comment or regular event
|
||||
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo)); |
||||
addTags(tags, ...buildParentScopeTags(parent, parentInfo, rootInfo)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return tags; |
||||
} |
||||
|
||||
/** |
||||
* Create and sign a Nostr event |
||||
*/ |
||||
export async function createSignedEvent( |
||||
content: string, |
||||
pubkey: string, |
||||
kind: number, |
||||
tags: string[][] |
||||
): Promise<{ id: string; sig: string; event: any }> { |
||||
const prefixedContent = prefixNostrAddresses(content); |
||||
|
||||
const eventToSign = { |
||||
kind: Number(kind), |
||||
created_at: Number(Math.floor(Date.now() / 1000)), |
||||
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]), |
||||
content: String(prefixedContent), |
||||
pubkey: pubkey, |
||||
}; |
||||
|
||||
let sig, id; |
||||
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) { |
||||
const signed = await window.nostr.signEvent(eventToSign); |
||||
sig = signed.sig as string; |
||||
id = 'id' in signed ? signed.id as string : getEventHash(eventToSign); |
||||
} else { |
||||
id = getEventHash(eventToSign); |
||||
sig = await signEvent(eventToSign); |
||||
} |
||||
|
||||
return { |
||||
id, |
||||
sig, |
||||
event: { |
||||
...eventToSign, |
||||
id, |
||||
sig, |
||||
} |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Publish event to a single relay |
||||
*/ |
||||
async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> { |
||||
const ws = new WebSocket(relayUrl); |
||||
|
||||
return 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) { |
||||
ws.close(); |
||||
resolve(); |
||||
} else { |
||||
ws.close(); |
||||
reject(new Error(message)); |
||||
} |
||||
} |
||||
}; |
||||
|
||||
ws.onerror = () => { |
||||
clearTimeout(timeout); |
||||
ws.close(); |
||||
reject(new Error("WebSocket error")); |
||||
}; |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Publish event to relays |
||||
*/ |
||||
export async function publishEvent( |
||||
signedEvent: any, |
||||
useOtherRelays = false, |
||||
useFallbackRelays = false, |
||||
userRelayPreference = false |
||||
): Promise<EventPublishResult> { |
||||
// Determine which relays to use
|
||||
let relays = userRelayPreference ? get(userRelays) : standardRelays; |
||||
if (useOtherRelays) { |
||||
relays = userRelayPreference ? standardRelays : get(userRelays); |
||||
} |
||||
if (useFallbackRelays) { |
||||
relays = fallbackRelays; |
||||
} |
||||
|
||||
// Try to publish to relays
|
||||
for (const relayUrl of relays) { |
||||
try { |
||||
await publishToRelay(relayUrl, signedEvent); |
||||
return { |
||||
success: true, |
||||
relay: relayUrl, |
||||
eventId: signedEvent.id |
||||
}; |
||||
} catch (e) { |
||||
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||
} |
||||
} |
||||
|
||||
return { |
||||
success: false, |
||||
error: "Failed to publish to any relays" |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Navigate to the published event |
||||
*/ |
||||
export function navigateToEvent(eventId: string): void { |
||||
const nevent = nip19.neventEncode({ id: eventId }); |
||||
goto(`/events?id=${nevent}`); |
||||
} |
||||
|
||||
// Helper functions to ensure relay and pubkey are always strings
|
||||
function getRelayString(relay: any): string { |
||||
if (!relay) return ''; |
||||
if (typeof relay === 'string') return relay; |
||||
if (typeof relay.url === 'string') return relay.url; |
||||
return ''; |
||||
} |
||||
|
||||
function getPubkeyString(pubkey: any): string { |
||||
if (!pubkey) return ''; |
||||
if (typeof pubkey === 'string') return pubkey; |
||||
if (typeof pubkey.hex === 'function') return pubkey.hex(); |
||||
if (typeof pubkey.pubkey === 'string') return pubkey.pubkey; |
||||
return ''; |
||||
}
|
||||
Loading…
Reference in new issue