fix(events): collapse own-event draft + relay copy into one card #127

Merged
padreug merged 1 commit from fix/own-event-draft-duplicate into dev 2026-06-18 22:51:04 +00:00
2 changed files with 117 additions and 0 deletions
Showing only changes of commit 8419ca4660 - Show all commits

fix(events): collapse own-event draft + relay copy into one card

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 <noreply@anthropic.com>
Padreug 2026-06-19 00:46:12 +02:00

View file

@ -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> = {}): 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> = {}): 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()))

View file

@ -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<Event, 'organizer'>): 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.
*
* Draftpublished 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)