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
2 changed files with 57 additions and 12 deletions
Showing only changes of commit 4f4452057a - Show all commits

feat(events): make favoriting instant (optimistic) + pop animation

The heart took ~1s to fill because toggleBookmark awaited the remote
LNbits signer + relay publish before updating state. Flip local state
optimistically so the heart responds on tap, then sign/publish in the
background and roll back (with an error toast) if it fails. Add a brief
scale pop on the heart when a favorite is added for tactile feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Padreug 2026-06-16 00:41:16 +02:00 committed by padreug

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch } 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'
@ -21,7 +21,18 @@ const { isBookmarked, toggleBookmark } = useBookmarks()
const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT) const eventKind = computed(() => props.kind ?? NIP52_KINDS.CALENDAR_TIME_EVENT)
const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag)) const bookmarked = computed(() => isBookmarked(eventKind.value, props.pubkey, props.dTag))
function handleToggle() { // Brief scale "pop" when a favorite is added, for tactile feedback. The
// state flip is already optimistic (see useBookmarks), so this fires
// immediately on tap.
const popping = ref(false)
watch(bookmarked, (now, was) => {
if (now && !was) {
popping.value = true
setTimeout(() => (popping.value = false), 220)
}
})
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,7 +42,10 @@ 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>
@ -43,6 +57,9 @@ function handleToggle() {
: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'"
@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']"
/>
</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(() => {