feat(events): real-time favoriting + live like count + post-purchase refresh #111

Merged
padreug merged 3 commits from feat/events-realtime into dev 2026-06-17 08:36:47 +00:00
4 changed files with 216 additions and 15 deletions

View file

@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Heart } from 'lucide-vue-next' import { Heart } from 'lucide-vue-next'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useBookmarks } from '../composables/useBookmarks' import { useBookmarks } from '../composables/useBookmarks'
import { useEventLikes } from '../composables/useEventLikes'
import { NIP52_KINDS } from '../types/nip52' import { NIP52_KINDS } from '../types/nip52'
const props = defineProps<{ const props = defineProps<{
@ -15,13 +16,56 @@ const props = defineProps<{
}>() }>()
const router = useRouter() const router = useRouter()
const { isAuthenticated } = useAuth() const { isAuthenticated, user } = useAuth()
const { isBookmarked, toggleBookmark } = useBookmarks() const { isBookmarked, toggleBookmark } = useBookmarks()
const { track, likeCount, setSelf } = useEventLikes()
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT) const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const coord = computed(() => `${eventKind.value}:${props.pubkey}:${props.dTag}`)
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag)) const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
function handleToggle() { // Live count of how many people have favorited (liked) this event.
const count = computed(() => likeCount(coord.value))
// Register this event so its like count is fetched + kept live.
// `ready` gates the live-increment pop so the historical backlog that
// streams in right after mount doesn't make every heart pop on load.
const ready = ref(false)
onMounted(() => {
track(coord.value)
setTimeout(() => (ready.value = true), 1500)
})
// Keep the current user's own contribution in sync with the optimistic
// heart state instant like/un-like for self, and rollback-safe.
watch(
bookmarked,
(now) => {
const pk = user.value?.pubkey
if (pk) setSelf(coord.value, pk, now)
},
{ immediate: true },
)
// Brief scale "pop" for tactile feedback.
const popping = ref(false)
function pop() {
popping.value = true
setTimeout(() => (popping.value = false), 220)
}
// Pop on the user's own favorite (optimistic, fires immediately on tap).
watch(bookmarked, (now, was) => {
if (now && !was) pop()
})
// Pop when the live count ticks up from someone else liking it too
// only once past the initial historical-load settle window.
watch(count, (now, was) => {
if (ready.value && now > was) pop()
})
async function handleToggle() {
if (!isAuthenticated.value) { if (!isAuthenticated.value) {
toast.info('Log in to save favorites', { toast.info('Log in to save favorites', {
action: { action: {
@ -31,18 +75,26 @@ function handleToggle() {
}) })
return return
} }
toggleBookmark(eventKind.value, props.pubkey, props.dTag) const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
if (!ok) {
toast.error("Couldn't save favorite — please try again")
}
} }
</script> </script>
<template> <template>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
class="h-8 w-8" class="h-8 gap-1 px-2"
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'" :class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
:aria-label="bookmarked ? 'Remove favorite' : 'Add favorite'"
@click.stop="handleToggle" @click.stop="handleToggle"
> >
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" /> <Heart
class="w-4 h-4 transition-transform duration-200 ease-out"
:class="[{ 'fill-current': bookmarked }, popping ? 'scale-125' : 'scale-100']"
/>
<span v-if="count > 0" class="text-xs font-medium tabular-nums">{{ count }}</span>
</Button> </Button>
</template> </template>

View file

@ -88,9 +88,20 @@ export function useBookmarks() {
/** /**
* Toggle bookmark for an event. Publishes updated NIP-51 bookmark list. * 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) { async function toggleBookmark(
if (!isAuthenticated.value || !currentUser.value?.pubkey) return eventKind: number,
pubkey: string,
dTag: string,
): Promise<boolean> {
if (!isAuthenticated.value || !currentUser.value?.pubkey) return false
const coord = `${eventKind}:${pubkey}:${dTag}` const coord = `${eventKind}:${pubkey}:${dTag}`
const newCoords = new Set(state.value.bookmarkedCoords) const newCoords = new Set(state.value.bookmarkedCoords)
@ -101,6 +112,17 @@ export function useBookmarks() {
newCoords.add(coord) 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 // Build and publish updated bookmark list
const tags: string[][] = Array.from(newCoords).map(c => ['a', c]) const tags: string[][] = Array.from(newCoords).map(c => ['a', c])
@ -116,19 +138,25 @@ export function useBookmarks() {
signedEvent = await signEventViaLnbits(template) signedEvent = await signEventViaLnbits(template)
} catch (err) { } catch (err) {
console.error('[useBookmarks] signEventViaLnbits failed:', err) console.error('[useBookmarks] signEventViaLnbits failed:', err)
return rollback()
return false
} }
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB) const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) return if (!relayHub) {
rollback()
return false
}
const result = await relayHub.publishEvent(signedEvent) const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) { if (result.success > 0) {
state.value = { state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
bookmarkedCoords: newCoords, ;(state.value as any).lastCreatedAt = template.created_at
lastEventId: signedEvent.id, return true
}
} }
rollback()
return false
} }
onMounted(() => { onMounted(() => {

View file

@ -0,0 +1,108 @@
import { reactive } from 'vue'
import type { Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
/**
* Live "like" counts for events. A like == the event appearing in a
* user's NIP-51 bookmark list (kind 10003) the same action the heart
* performs (and what the Favorites page reads).
*
* One batched subscription covers every event coordinate that a mounted
* heart has registered, filtered by `#a`. It stays open after EOSE, so
* when anyone publishes/updates a bookmark list referencing a tracked
* event the relay pushes it live and the count increments for everyone
* in real time (Alice likes Bob's count ticks up).
*
* Caveats (inherent to counting replaceable bookmark lists via `#a`):
* - An un-like by ANOTHER user only reflects on next load: their new
* list no longer contains the coord, so it no longer matches the
* filter and we never receive the update. Counts are correct on a
* fresh load (the un-liker is simply absent from the results).
* - The current user's own like/un-like updates instantly via setSelf(),
* driven by the optimistic heart state.
*/
const BOOKMARK_KIND = 10003
// coord ("kind:pubkey:dTag") -> set of pubkeys who bookmarked it.
const authorsByCoord = new Map<string, Set<string>>() // plain map, for dedup
const counts = reactive(new Map<string, number>()) // reactive mirror for the UI
const tracked = new Set<string>()
let unsubscribe: (() => void) | null = null
let resubTimer: ReturnType<typeof setTimeout> | null = null
function setCount(coord: string, pubkey: string, present: boolean) {
let set = authorsByCoord.get(coord)
if (!set) {
set = new Set()
authorsByCoord.set(coord, set)
}
const had = set.has(pubkey)
if (present && !had) {
set.add(pubkey)
counts.set(coord, set.size)
} else if (!present && had) {
set.delete(pubkey)
counts.set(coord, set.size)
}
}
function ingest(event: NostrEvent) {
// A bookmark list references many events via 'a' tags; credit the
// author to every coord we're tracking.
for (const tag of event.tags) {
if (tag[0] === 'a' && tracked.has(tag[1])) setCount(tag[1], event.pubkey, true)
}
}
function resubscribe() {
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
if (!relayHub) {
scheduleResubscribe() // relay hub not registered yet — retry shortly
return
}
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
const coords = [...tracked]
if (coords.length === 0) return
unsubscribe = relayHub.subscribe({
id: 'event-likes-aggregate',
filters: [{ kinds: [BOOKMARK_KIND], '#a': coords }],
onEvent: (event: NostrEvent) => ingest(event),
})
}
function scheduleResubscribe() {
if (resubTimer) clearTimeout(resubTimer)
// Debounced so a burst of mounting hearts results in one (re)subscribe.
resubTimer = setTimeout(resubscribe, 250)
}
export function useEventLikes() {
/** Register an event coordinate so its like count is fetched + kept live. */
function track(coord: string) {
if (!coord || tracked.has(coord)) return
tracked.add(coord)
scheduleResubscribe()
}
/** Reactive like count for a coordinate (0 when none/unknown). */
function likeCount(coord: string): number {
return counts.get(coord) ?? 0
}
/**
* Reflect the current user's own like state immediately (optimistic),
* so their count matches the instant heart fill and their un-like
* decrements right away.
*/
function setSelf(coord: string, pubkey: string, liked: boolean) {
if (!coord || !pubkey) return
setCount(coord, pubkey, liked)
}
return { track, likeCount, setSelf }
}

View file

@ -5,12 +5,19 @@ import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
import type { TicketApiService } from '../services/TicketApiService' import type { TicketApiService } from '../services/TicketApiService'
import { useOwnedTickets } from './useOwnedTickets'
export function useTicketPurchase() { export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth() const { isAuthenticated, currentUser } = useAuth()
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
const ticketApi = injectService(SERVICE_TOKENS.TICKET_API) as TicketApiService 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 // Async operations
const purchaseOperation = useAsyncOperation() const purchaseOperation = useAsyncOperation()
@ -178,6 +185,12 @@ export function useTicketPurchase() {
clearInterval(checkInterval) 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 // Multi-ticket purchases come back with `ticketIds` (N rows
// sharing one invoice). Single-ticket purchases include // sharing one invoice). Single-ticket purchases include
// `ticketId` only. Render one QR per row so each attendee // `ticketId` only. Render one QR per row so each attendee