From c6ed24703129d5dce0ec23c31397619febe050c8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 00:41:16 +0200 Subject: [PATCH 1/3] feat(events): make favoriting instant (optimistic) + pop animation The heart took ~1s to fill because toggleBookmark awaited the remote LNbits signer + relay publish before updating state. Flip local state optimistically so the heart responds on tap, then sign/publish in the background and roll back (with an error toast) if it fails. Add a brief scale pop on the heart when a favorite is added for tactile feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../events/components/BookmarkButton.vue | 25 +++++++++-- .../events/composables/useBookmarks.ts | 44 +++++++++++++++---- 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/modules/events/components/BookmarkButton.vue b/src/modules/events/components/BookmarkButton.vue index fc01311..132fb48 100644 --- a/src/modules/events/components/BookmarkButton.vue +++ b/src/modules/events/components/BookmarkButton.vue @@ -1,5 +1,5 @@ @@ -43,6 +57,9 @@ function handleToggle() { :class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'" @click.stop="handleToggle" > - + diff --git a/src/modules/events/composables/useBookmarks.ts b/src/modules/events/composables/useBookmarks.ts index 8af2f36..57281a9 100644 --- a/src/modules/events/composables/useBookmarks.ts +++ b/src/modules/events/composables/useBookmarks.ts @@ -88,9 +88,20 @@ export function useBookmarks() { /** * Toggle bookmark for an event. Publishes updated NIP-51 bookmark list. + * + * Updates local state OPTIMISTICALLY so the UI (heart fill) responds + * instantly, then signs + publishes in the background. Signing routes + * through the remote LNbits signer and publishing hits relays, so + * awaiting both before flipping state made the heart lag ~1s. On + * failure the optimistic change is rolled back. Resolves to whether + * the change was persisted. */ - async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) { - if (!isAuthenticated.value || !currentUser.value?.pubkey) return + async function toggleBookmark( + eventKind: number, + pubkey: string, + dTag: string, + ): Promise { + if (!isAuthenticated.value || !currentUser.value?.pubkey) return false const coord = `${eventKind}:${pubkey}:${dTag}` const newCoords = new Set(state.value.bookmarkedCoords) @@ -101,6 +112,17 @@ export function useBookmarks() { newCoords.add(coord) } + // Optimistic flip — preserve the prior state so we can roll back if + // signing or publishing fails. Keep lastEventId/lastCreatedAt until + // the real event is confirmed. + const prevState = state.value + state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId } + ;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt + + function rollback() { + state.value = prevState + } + // Build and publish updated bookmark list const tags: string[][] = Array.from(newCoords).map(c => ['a', c]) @@ -116,19 +138,25 @@ export function useBookmarks() { signedEvent = await signEventViaLnbits(template) } catch (err) { console.error('[useBookmarks] signEventViaLnbits failed:', err) - return + rollback() + return false } const relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB) - if (!relayHub) return + if (!relayHub) { + rollback() + return false + } const result = await relayHub.publishEvent(signedEvent) if (result.success > 0) { - state.value = { - bookmarkedCoords: newCoords, - lastEventId: signedEvent.id, - } + state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id } + ;(state.value as any).lastCreatedAt = template.created_at + return true } + + rollback() + return false } onMounted(() => { From 75ec28b4dc0aa1fc8515efa1bc82503a741ec54b Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 16 Jun 2026 11:50:47 +0200 Subject: [PATCH 2/3] fix(events): refresh owned tickets after purchase (no reload needed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful ticket purchase, the feed/calendar "My tickets" filter and EventCard owned badges didn't update until a full page reload — the shared useOwnedTickets singleton was never refreshed. Its own docs note a successful purchase should call refresh(); wire that in at the payment- confirmed point in useTicketPurchase so every surface reflects the new ticket immediately. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/events/composables/useTicketPurchase.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts index 4d4dc45..5c3df37 100644 --- a/src/modules/events/composables/useTicketPurchase.ts +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -5,12 +5,19 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import type { PaymentService } from '@/core/services/PaymentService' import type { TicketApiService } from '../services/TicketApiService' +import { useOwnedTickets } from './useOwnedTickets' export function useTicketPurchase() { const { isAuthenticated, currentUser } = useAuth() const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService + // Refresh the shared owned-tickets singleton after a purchase so the + // feed/calendar "My tickets" filter and EventCard owned badges update + // without a reload — purchase is exactly the "consumer that mutates + // the ticket set" useOwnedTickets's docs anticipate. + const { refresh: refreshOwnedTickets } = useOwnedTickets() + // Async operations const purchaseOperation = useAsyncOperation() @@ -178,6 +185,12 @@ export function useTicketPurchase() { clearInterval(checkInterval) } + // Ticket row(s) now exist — refresh the shared owned-tickets + // state so the feed/calendar My-tickets filter and owned + // badges reflect the purchase immediately (no reload). Runs in + // parallel with QR generation below. + void refreshOwnedTickets() + // Multi-ticket purchases come back with `ticketIds` (N rows // sharing one invoice). Single-ticket purchases include // `ticketId` only. Render one QR per row so each attendee From 373e52dd796a9ef9754c8a914d7585f875f71115 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 17 Jun 2026 10:13:24 +0200 Subject: [PATCH 3/3] feat(events): show a live like count on the favorite heart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display how many people have favorited (liked) an event next to its heart, updating in real time. A like == the event appearing in someone's NIP-51 bookmark list (kind 10003) — the same action the heart performs. New useEventLikes composable keeps ONE batched subscription over every mounted heart's event coordinate (filtered by #a). It stays open after EOSE, so a like published by anyone is pushed live and the count ticks up for everyone — verified end-to-end against a relay (a like from a fresh key bumped the shown count 2→3 with no reload). The heart also pops on a live increment (gated past the initial historical-load window), and the user's own like/un-like reflects instantly via the optimistic heart state. Caveat: an un-like by another user only reflects on next load — a replaceable list that no longer contains the coord stops matching the #a filter, so the removal isn't pushed. Counts are correct on fresh load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../events/components/BookmarkButton.vue | 57 +++++++-- .../events/composables/useEventLikes.ts | 108 ++++++++++++++++++ 2 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 src/modules/events/composables/useEventLikes.ts diff --git a/src/modules/events/components/BookmarkButton.vue b/src/modules/events/components/BookmarkButton.vue index 132fb48..a99d9c2 100644 --- a/src/modules/events/components/BookmarkButton.vue +++ b/src/modules/events/components/BookmarkButton.vue @@ -1,11 +1,12 @@