feat(events): real-time favoriting + live like count + post-purchase refresh #111
4 changed files with 216 additions and 15 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
108
src/modules/events/composables/useEventLikes.ts
Normal file
108
src/modules/events/composables/useEventLikes.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue