4 changed files with 478 additions and 296 deletions
@ -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