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:
parent
9810b11cc5
commit
4f4452057a
2 changed files with 57 additions and 12 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue