Browse Source

publish notes as packages

imwald
Silberengel 4 weeks ago
parent
commit
17d7c54f0f
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 5
      src/components/NoteStats/RepostButton.tsx
  4. 49
      src/lib/companion-publish.test.ts
  5. 214
      src/lib/companion-publish.ts
  6. 13
      src/providers/NostrProvider/index.tsx
  7. 7
      src/types/index.d.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.11.2", "version": "23.12.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.11.2", "version": "23.12.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.11.2", "version": "23.12.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

5
src/components/NoteStats/RepostButton.tsx

@ -71,7 +71,10 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
} }
const repost = createRepostDraftEvent(event) const repost = createRepostDraftEvent(event)
const evt = await publish(repost, { addClientTag: storage.getAddClientTag() }) const evt = await publish(repost, {
addClientTag: storage.getAddClientTag(),
companionSourceEvent: event
})
// Show publishing feedback // Show publishing feedback
if ((evt as any)?.relayStatuses) { if ((evt as any)?.relayStatuses) {

49
src/lib/companion-publish.test.ts

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { kinds, nip19 } from 'nostr-tools'
import {
COMPANION_PUBLISH_CAP,
collectCompanionRefsInPublishOrder
} from './companion-publish'
const HEX_A = 'a'.repeat(64)
const HEX_B = 'b'.repeat(64)
const HEX_C = 'c'.repeat(64)
const HEX_D = 'd'.repeat(64)
const HEX_PUB = 'e'.repeat(64)
describe('collectCompanionRefsInPublishOrder', () => {
it('orders embedded before q before a before e', () => {
const note1 = nip19.noteEncode(HEX_B)
const ev = {
id: HEX_A,
kind: kinds.ShortTextNote,
content: `see nostr:${note1}`,
tags: [
['q', HEX_B],
['a', `30023:${HEX_PUB}:doc`, HEX_C],
['e', HEX_D]
],
pubkey: HEX_PUB,
created_at: 1,
sig: 's'.repeat(128)
}
const refs = collectCompanionRefsInPublishOrder(ev as never)
const tiers = refs.map((r) => r.tier)
const firstEmbedded = tiers.indexOf('embedded')
const firstQ = tiers.indexOf('q')
const firstA = tiers.indexOf('a')
const firstE = tiers.indexOf('e')
expect(firstEmbedded).toBeGreaterThanOrEqual(0)
expect(firstQ).toBeGreaterThan(firstEmbedded)
expect(firstA).toBeGreaterThan(firstQ)
expect(firstE).toBeGreaterThan(firstA)
})
})
describe('COMPANION_PUBLISH_CAP', () => {
it('is 5', () => {
expect(COMPANION_PUBLISH_CAP).toBe(5)
})
})

214
src/lib/companion-publish.ts

@ -0,0 +1,214 @@
import { EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns'
import { relayHintWssUrlsFromEvent } from '@/lib/event'
import { findTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json'
import client from '@/services/client.service'
import type { TPublishEventExtras } from '@/types'
import { nip19, type Event } from 'nostr-tools'
export const COMPANION_PUBLISH_CAP = 5
type CompanionRef =
| { tier: 'embedded'; hexId: string }
| { tier: 'embedded'; nip19: string }
| { tier: 'embedded'; inline: Event }
| { tier: 'q'; hexId: string }
| { tier: 'q'; coordinate: string }
| { tier: 'a'; hexId: string }
| { tier: 'a'; coordinate: string }
| { tier: 'e'; hexId: string }
function normalizeHex(id: string | undefined): string | undefined {
if (!id) return undefined
const t = id.trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(t) ? t : undefined
}
function parseQOrATagValue(raw: string | undefined): { hexId?: string; coordinate?: string } | undefined {
if (raw == null) return undefined
let s0 = raw.trim()
if (s0.toLowerCase().startsWith('nostr:')) s0 = s0.slice(6).trim()
if (!s0) return undefined
const hex = normalizeHex(s0)
if (hex) return { hexId: hex }
const coordMatch = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(s0)
if (coordMatch) {
return {
coordinate: `${Number(coordMatch[1])}:${coordMatch[2].toLowerCase()}:${coordMatch[3]}`
}
}
if (/^n(?:ote|event|addr)1/i.test(s0)) {
try {
const { type, data } = nip19.decode(s0)
if (type === 'note') return { hexId: normalizeHex(typeof data === 'string' ? data : (data as { id?: string }).id) }
if (type === 'nevent') return { hexId: normalizeHex((data as { id: string }).id) }
if (type === 'naddr') {
const d = data as { kind: number; pubkey: string; identifier: string }
return {
coordinate: `${d.kind}:${d.pubkey.toLowerCase()}:${d.identifier ?? ''}`
}
}
} catch {
/* ignore */
}
}
return undefined
}
function collectEmbeddedRefsFromContent(ev: Event, out: CompanionRef[]): void {
for (const full of ev.content.match(EMBEDDED_EVENT_REGEX) ?? []) {
const colon = full.indexOf(':')
if (colon < 0) continue
const bech32 = full.slice(colon + 1)
try {
const { type, data } = nip19.decode(bech32)
if (type === 'note') {
const hex = normalizeHex(typeof data === 'string' ? data : (data as { id?: string }).id)
if (hex) out.push({ tier: 'embedded', hexId: hex })
} else if (type === 'nevent') {
const hex = normalizeHex((data as { id: string }).id)
if (hex) out.push({ tier: 'embedded', hexId: hex })
} else if (type === 'naddr') {
out.push({ tier: 'embedded', nip19: bech32 })
}
} catch {
/* ignore */
}
}
const trailing = findTrailingStringifiedNostrEvent(ev.content)
if (trailing) {
out.push({ tier: 'embedded', inline: trailing.event })
collectEmbeddedRefsFromContent(trailing.event, out)
}
}
/** Ordered refs: embedded (content + trailing JSON), then all `q`, then `a`, then `e`. */
export function collectCompanionRefsInPublishOrder(event: Event): CompanionRef[] {
const embedded: CompanionRef[] = []
const qRefs: CompanionRef[] = []
const aRefs: CompanionRef[] = []
const eRefs: CompanionRef[] = []
collectEmbeddedRefsFromContent(event, embedded)
for (const tag of event.tags) {
const name = tag[0]
if (name === 'q' || name === 'Q') {
const parsed = parseQOrATagValue(tag[1])
if (parsed?.hexId) qRefs.push({ tier: 'q', hexId: parsed.hexId })
else if (parsed?.coordinate) qRefs.push({ tier: 'q', coordinate: parsed.coordinate })
continue
}
if (name === 'a' || name === 'A') {
const snap = normalizeHex(tag[3])
if (snap) {
aRefs.push({ tier: 'a', hexId: snap })
continue
}
const parsed = parseQOrATagValue(tag[1])
if (parsed?.hexId) aRefs.push({ tier: 'a', hexId: parsed.hexId })
else if (parsed?.coordinate) aRefs.push({ tier: 'a', coordinate: parsed.coordinate })
continue
}
if (name === 'e' || name === 'E') {
const hex = normalizeHex(tag[1])
if (hex) eRefs.push({ tier: 'e', hexId: hex })
}
}
return [...embedded, ...qRefs, ...aRefs, ...eRefs]
}
/**
* Resolve referenced events for companion republish (boost target, quotes, replies with embeds).
* Order preserved: embedded q a e; capped at {@link COMPANION_PUBLISH_CAP}.
*/
export async function resolveCompanionEventsForPublish(
source: Event,
opts?: { excludeIds?: string[] }
): Promise<Event[]> {
const exclude = new Set(
[source.id, ...(opts?.excludeIds ?? [])].map((id) => id.trim().toLowerCase()).filter(Boolean)
)
const relayHints = relayHintWssUrlsFromEvent(source)
const fetchOpts = relayHints.length > 0 ? { relayHints } : undefined
const refs = collectCompanionRefsInPublishOrder(source)
const resolved: Event[] = []
const seen = new Set<string>()
const tryAdd = (ev: Event | undefined) => {
if (!ev) return false
const k = ev.id.toLowerCase()
if (exclude.has(k) || seen.has(k)) return false
seen.add(k)
resolved.push(ev)
return resolved.length >= COMPANION_PUBLISH_CAP
}
const resolveRef = async (ref: CompanionRef): Promise<Event | undefined> => {
if ('inline' in ref) return ref.inline
if ('nip19' in ref) {
try {
return await client.fetchEvent(ref.nip19, fetchOpts)
} catch {
return undefined
}
}
if ('coordinate' in ref) {
try {
return await client.fetchEvent(ref.coordinate, fetchOpts)
} catch {
return undefined
}
}
if ('hexId' in ref) {
try {
return await client.fetchEvent(ref.hexId, fetchOpts)
} catch {
return undefined
}
}
return undefined
}
for (const ref of refs) {
if (resolved.length >= COMPANION_PUBLISH_CAP) break
if ('inline' in ref) {
if (tryAdd(ref.inline)) break
continue
}
if ('hexId' in ref) {
const k = ref.hexId
if (exclude.has(k) || seen.has(k)) continue
}
const ev = await resolveRef(ref)
if (tryAdd(ev)) break
}
return resolved
}
/** Fire-and-forget friendly: publish companions to the same relays; never throws. */
export async function publishCompanionEventsBestEffort(
relayUrls: string[],
companions: readonly Event[],
extras: TPublishEventExtras
): Promise<void> {
if (!relayUrls.length || !companions.length) return
for (const companion of companions) {
try {
await client.publishEvent(relayUrls, companion, {
...extras,
publishBatchLabel: 'companion'
})
} catch {
/* best-effort */
}
}
}

13
src/providers/NostrProvider/index.tsx

@ -1599,11 +1599,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) }) logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) })
logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) }) logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) })
const publishResult = await client.publishEvent(relays, event, { const publishExtras = {
favoriteRelayUrls, favoriteRelayUrls,
/** Picker / `specifiedRelayUrls` is the authoritative target list — do not prepend full NIP-65 outbox again. */ /** Picker / `specifiedRelayUrls` is the authoritative target list — do not prepend full NIP-65 outbox again. */
skipOutboxRetry: (options.specifiedRelayUrls?.length ?? 0) > 0 skipOutboxRetry: (options.specifiedRelayUrls?.length ?? 0) > 0
}) }
const publishResult = await client.publishEvent(relays, event, publishExtras)
if (publishResult.successCount >= 1 && !options.skipCompanionPublish) {
const companionSource = options.companionSourceEvent ?? event
void import('@/lib/companion-publish').then(({ resolveCompanionEventsForPublish, publishCompanionEventsBestEffort }) =>
resolveCompanionEventsForPublish(companionSource, { excludeIds: [event.id] }).then((companions) =>
publishCompanionEventsBestEffort(relays, companions, publishExtras)
)
)
}
logger.debug('[Publish] publishEvent completed', { logger.debug('[Publish] publishEvent completed', {
success: publishResult.success, success: publishResult.success,
successCount: publishResult.successCount, successCount: publishResult.successCount,

7
src/types/index.d.ts vendored

@ -201,6 +201,13 @@ export type TPublishOptions = {
disableFallbacks?: boolean // If true, don't use fallback relays when publishing fails disableFallbacks?: boolean // If true, don't use fallback relays when publishing fails
/** Override global "Add client tag" preference for this publish (default: read from localStorage) */ /** Override global "Add client tag" preference for this publish (default: read from localStorage) */
addClientTag?: boolean addClientTag?: boolean
/**
* Resolve companion republishes from this event (default: the event being published).
* Use when the published event is a wrapper (e.g. boost) but embeds live on the target note.
*/
companionSourceEvent?: Event
/** Skip automatic companion republish of embedded / q / a / e references. */
skipCompanionPublish?: boolean
} }
/** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */ /** Options for {@link ClientService.publishEvent} (second argument bundle in code: favorites + internal retry pass). */

Loading…
Cancel
Save