Compare commits
No commits in common. "e17b4e05ad2b5b643ff5501f9127a4842fda59e8" and "9810b11cc5dff99806ae1b14f96beb48f7da6e7f" have entirely different histories.
e17b4e05ad
...
9810b11cc5
4 changed files with 15 additions and 216 deletions
|
|
@ -1,12 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from 'vue'
|
import { computed } 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<{
|
||||||
|
|
@ -16,56 +15,13 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated, user } = useAuth()
|
const { isAuthenticated } = 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))
|
||||||
|
|
||||||
// Live count of how many people have favorited (liked) this event.
|
function handleToggle() {
|
||||||
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: {
|
||||||
|
|
@ -75,26 +31,18 @@ async function handleToggle() {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ok = await toggleBookmark(eventKind.value, props.pubkey, props.dTag)
|
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="sm"
|
size="icon"
|
||||||
class="h-8 gap-1 px-2"
|
class="h-8 w-8"
|
||||||
: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
|
<Heart class="w-4 h-4" :class="{ 'fill-current': bookmarked }" />
|
||||||
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,20 +88,9 @@ 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(
|
async function toggleBookmark(eventKind: number, pubkey: string, dTag: string) {
|
||||||
eventKind: number,
|
if (!isAuthenticated.value || !currentUser.value?.pubkey) return
|
||||||
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)
|
||||||
|
|
@ -112,17 +101,6 @@ 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])
|
||||||
|
|
||||||
|
|
@ -138,25 +116,19 @@ 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)
|
||||||
rollback()
|
return
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = tryInjectService<any>(SERVICE_TOKENS.RELAY_HUB)
|
||||||
if (!relayHub) {
|
if (!relayHub) return
|
||||||
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 = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
|
state.value = {
|
||||||
;(state.value as any).lastCreatedAt = template.created_at
|
bookmarkedCoords: newCoords,
|
||||||
return true
|
lastEventId: signedEvent.id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rollback()
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
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,19 +5,12 @@ 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()
|
||||||
|
|
||||||
|
|
@ -185,12 +178,6 @@ 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