Compare commits
3 commits
b6d1626951
...
373e52dd79
| Author | SHA1 | Date | |
|---|---|---|---|
| 373e52dd79 | |||
| 75ec28b4dc | |||
| c6ed247031 |
4 changed files with 216 additions and 15 deletions
|
|
@ -1,11 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Heart } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useBookmarks } from '../composables/useBookmarks'
|
||||
import { useEventLikes } from '../composables/useEventLikes'
|
||||
import { NIP52_KINDS } from '../types/nip52'
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -15,13 +16,56 @@ const props = defineProps<{
|
|||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isAuthenticated, user } = useAuth()
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks()
|
||||
const { track, likeCount, setSelf } = useEventLikes()
|
||||
|
||||
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))
|
||||
|
||||
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) {
|
||||
toast.info('Log in to save favorites', {
|
||||
action: {
|
||||
|
|
@ -31,18 +75,26 @@ function handleToggle() {
|
|||
})
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
size="sm"
|
||||
class="h-8 gap-1 px-2"
|
||||
: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"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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<any>(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(() => {
|
||||
|
|
|
|||
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 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue