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">
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue