From 8419ca46607d3d4329f6031a9274370a6b1d803b Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:46:12 +0200 Subject: [PATCH] fix(events): collapse own-event draft + relay copy into one card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinate-keying the events store (kind:pubkey:d-tag) regressed the own-events merge: a creator's own event showed up twice in the feed. `loadOwnEvents` surfaces the caller's own LNbits events via REST (`ticketedEventToEvent`) so drafts appear before their NIP-52 event is on a relay. That adapter stamps an empty `organizer.pubkey`, `isMine`, and no ticket info. The relay-published copy lands under the real publisher pubkey (`resolve_for_wallet` — NOT the user's Nostr login key) with full ticket counts. Pre-coordinate-keying both collapsed on the bare d-tag so the relay copy replaced the draft; under `kind:pubkey:d-tag` the empty-pubkey draft and the real-pubkey copy are distinct keys, so both render — the empty "..."/no-tickets card next to the real one. Only the logged-in owner sees it, since only own events get the REST merge. upsertEvent now reconciles by d-tag: the published copy supersedes the provisional draft and inherits its `isMine`, so the creator keeps the Yours badge + Hosting filter even though the publisher key differs from their login key. Handles both arrival orderings; a draft with no published copy yet (pending review) still shows alone; genuinely distinct authors sharing a d-tag are untouched. Co-Authored-By: Claude Opus 4.8 --- src/modules/events/stores/events.spec.ts | 65 ++++++++++++++++++++++++ src/modules/events/stores/events.ts | 52 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/modules/events/stores/events.spec.ts b/src/modules/events/stores/events.spec.ts index e6e90e7..39f1d80 100644 --- a/src/modules/events/stores/events.spec.ts +++ b/src/modules/events/stores/events.spec.ts @@ -92,6 +92,71 @@ describe('useEventsStore.upsertEvent', () => { }) }) +describe('useEventsStore.upsertEvent draft↔published reconciliation', () => { + beforeEach(() => setActivePinia(createPinia())) + + // A provisional draft is the caller's own LNbits event surfaced via REST + // before its NIP-52 event is on a relay: empty pubkey, isMine, no ticket + // info. The relay copy lands under the real publisher pubkey (which is + // NOT the user's login key), so the two have different coordinates. + function makeDraft(overrides: Partial = {}): Event { + return makeEvent({ + id: 'my-event', + organizer: { pubkey: '' }, + isMine: true, + lnbitsStatus: 'approved', + createdAt: new Date('2026-06-01T00:00:00Z'), + ...overrides, + }) + } + function makePublished(overrides: Partial = {}): Event { + return makeEvent({ + id: 'my-event', + organizer: { pubkey: 'the-architect' }, // resolve_for_wallet key + createdAt: new Date('2026-06-02T00:00:00Z'), + ...overrides, + }) + } + + it('collapses to one card when the published copy arrives after the draft', () => { + const store = useEventsStore() + store.upsertEvent(makeDraft()) + store.upsertEvent(makePublished()) + + expect(store.events).toHaveLength(1) + const only = store.getEventById('my-event') + expect(only?.organizer.pubkey).toBe('the-architect') + // Ownership carries over even though the publisher key != login key. + expect(only?.isMine).toBe(true) + }) + + it('collapses to one card when the draft arrives after the published copy', () => { + const store = useEventsStore() + store.upsertEvent(makePublished()) + store.upsertEvent(makeDraft()) + + expect(store.events).toHaveLength(1) + const only = store.getEventById('my-event') + expect(only?.organizer.pubkey).toBe('the-architect') + expect(only?.isMine).toBe(true) + }) + + it('keeps the draft alone while no published copy exists (pending review)', () => { + const store = useEventsStore() + store.upsertEvent(makeDraft({ lnbitsStatus: 'proposed' })) + + expect(store.events).toHaveLength(1) + expect(store.getEventById('my-event')?.lnbitsStatus).toBe('proposed') + }) + + it('does not fold across two genuinely different authors (no empty pubkey)', () => { + const store = useEventsStore() + store.upsertEvent(makeEvent({ id: 'concert', organizer: { pubkey: 'alice' } })) + store.upsertEvent(makeEvent({ id: 'concert', organizer: { pubkey: 'bob' } })) + expect(store.events).toHaveLength(2) + }) +}) + describe('useEventsStore lookups & removal', () => { beforeEach(() => setActivePinia(createPinia())) diff --git a/src/modules/events/stores/events.ts b/src/modules/events/stores/events.ts index 7a4d437..faea1fe 100644 --- a/src/modules/events/stores/events.ts +++ b/src/modules/events/stores/events.ts @@ -27,6 +27,19 @@ export function eventCoordinate( return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}` } +/** + * A *provisional draft* is the caller's own LNbits event surfaced via REST + * (`loadOwnEvents` → `ticketedEventToEvent`) before its NIP-52 event is on + * a relay. It has no Nostr author yet, so its coordinate carries an empty + * pubkey. The relay-published copy of the same event lands under the real + * publisher's coordinate (`resolve_for_wallet`, which is NOT the user's + * Nostr login key), so the two never share a coordinate and must be + * reconciled by d-tag — see {@link upsertEvent}. + */ +function isProvisionalDraft(event: Pick): boolean { + return event.organizer.pubkey === '' +} + /** * Pinia store for cached events from Nostr relays. * Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag). @@ -73,8 +86,47 @@ export const useEventsStore = defineStore('events', () => { * version (by `created_at`) replaces an older one *for the same * coordinate only* — a same-d-tag event from a different author lands * under its own coordinate and never clobbers another author's event. + * + * Draft↔published reconciliation: a provisional draft (the caller's own + * REST event, empty-pubkey coordinate) and the relay-published copy of + * the same event have *different* coordinates, so without intervention a + * creator would see their own event twice once it's published. We keep a + * single card per d-tag: the published copy wins (real author, live + * ticket counts) and inherits the draft's `isMine` so the creator keeps + * the Yours badge + Hosting filter — the publisher key differs from their + * Nostr login key, so ownership can't be re-derived by pubkey match. */ function upsertEvent(event: Event) { + const incomingIsDraft = isProvisionalDraft(event) + + // Look for an existing entry for the same d-tag with the OPPOSITE + // draft/published status — the pair that needs reconciling. + let pairKey: string | undefined + let pair: Event | undefined + for (const [k, e] of eventsMap.value) { + if (e.id === event.id && isProvisionalDraft(e) !== incomingIsDraft) { + pairKey = k + pair = e + break + } + } + + if (pair && pairKey) { + if (incomingIsDraft) { + // Published copy already present — fold the draft's ownership into + // it and drop the draft (don't add a second card). + if (event.isMine && !pair.isMine) { + eventsMap.value.set(pairKey, { ...pair, isMine: true }) + lastUpdated.value = new Date() + } + return + } + // Incoming is the published copy superseding a draft — inherit the + // draft's ownership, then remove the draft so only one card remains. + if (pair.isMine) event.isMine = true + eventsMap.value.delete(pairKey) + } + const key = eventCoordinate(event) const existing = eventsMap.value.get(key)