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>
This commit is contained in:
Padreug 2026-06-16 00:41:16 +02:00
commit 9823f8d955
2 changed files with 57 additions and 12 deletions

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
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 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) {
toast.info('Log in to save favorites', {
action: {
@ -31,7 +42,10 @@ 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>
@ -43,6 +57,9 @@ function handleToggle() {
:class="bookmarked ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-foreground'"
@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>
</template>

View file

@ -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(() => {