feat(events): show a live like count on the favorite heart
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) <noreply@anthropic.com>
This commit is contained in:
parent
75ec28b4dc
commit
373e52dd79
2 changed files with 154 additions and 11 deletions
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } 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,21 +16,53 @@ 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))
|
||||||
|
|
||||||
// Brief scale "pop" when a favorite is added, for tactile feedback. The
|
// Live count of how many people have favorited (liked) this event.
|
||||||
// state flip is already optimistic (see useBookmarks), so this fires
|
const count = computed(() => likeCount(coord.value))
|
||||||
// immediately on tap.
|
|
||||||
|
// 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)
|
const popping = ref(false)
|
||||||
watch(bookmarked, (now, was) => {
|
function pop() {
|
||||||
if (now && !was) {
|
|
||||||
popping.value = true
|
popping.value = true
|
||||||
setTimeout(() => (popping.value = false), 220)
|
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() {
|
async function handleToggle() {
|
||||||
|
|
@ -52,14 +85,16 @@ async function handleToggle() {
|
||||||
<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
|
<Heart
|
||||||
class="w-4 h-4 transition-transform duration-200 ease-out"
|
class="w-4 h-4 transition-transform duration-200 ease-out"
|
||||||
:class="[{ 'fill-current': bookmarked }, popping ? 'scale-125' : 'scale-100']"
|
: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>
|
||||||
|
|
|
||||||
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 }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue