diff --git a/package-lock.json b/package-lock.json index 5906dfab..61607f25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.11.2", + "version": "23.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.11.2", + "version": "23.12.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index cc1cf275..ccc974b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 9b1b24e6..4b70f247 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -71,7 +71,10 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R } 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 if ((evt as any)?.relayStatuses) { diff --git a/src/lib/companion-publish.test.ts b/src/lib/companion-publish.test.ts new file mode 100644 index 00000000..9f27e0d1 --- /dev/null +++ b/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) + }) +}) diff --git a/src/lib/companion-publish.ts b/src/lib/companion-publish.ts new file mode 100644 index 00000000..83703ec6 --- /dev/null +++ b/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 { + 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() + + 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 => { + 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 { + if (!relayUrls.length || !companions.length) return + + for (const companion of companions) { + try { + await client.publishEvent(relayUrls, companion, { + ...extras, + publishBatchLabel: 'companion' + }) + } catch { + /* best-effort */ + } + } +} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 21699e62..9ff8b8dc 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/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] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) }) - const publishResult = await client.publishEvent(relays, event, { + const publishExtras = { favoriteRelayUrls, /** Picker / `specifiedRelayUrls` is the authoritative target list — do not prepend full NIP-65 outbox again. */ 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', { success: publishResult.success, successCount: publishResult.successCount, diff --git a/src/types/index.d.ts b/src/types/index.d.ts index f909d1bb..6b295a5b 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -201,6 +201,13 @@ export type TPublishOptions = { 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) */ 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). */