diff --git a/neode-ui/src/composables/peerPaymentOperations.test.ts b/neode-ui/src/composables/peerPaymentOperations.test.ts new file mode 100644 index 00000000..5df37c66 --- /dev/null +++ b/neode-ui/src/composables/peerPaymentOperations.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { usePeerPaymentOperations } from './peerPaymentOperations' + +describe('payment operation completion ownership', () => { + it('does not let a duplicated old completion clear the next operation in the same modal', () => { + const ui = usePeerPaymentOperations(() => ({ peer: 'seller', item: 'file', generation: 1 })) + const first = ui.begin('invoice', 'seller', 'file')! + expect(ui.finish(first)).toBe(true) + const second = ui.begin('invoice-status', 'seller', 'file')! + expect(ui.selected(first)).toBe(false) + expect(ui.finish(first)).toBe(false) + expect(ui.current.value?.token).toBe(second.token) + expect(ui.busy.value).toBe(true) + expect(ui.finish(second)).toBe(true) + expect(ui.busy.value).toBe(false) + }) +}) diff --git a/neode-ui/src/composables/peerPaymentOperations.ts b/neode-ui/src/composables/peerPaymentOperations.ts new file mode 100644 index 00000000..17dc8215 --- /dev/null +++ b/neode-ui/src/composables/peerPaymentOperations.ts @@ -0,0 +1,38 @@ +import { computed, reactive } from 'vue' + +export type PaymentSelection = { peer: string; item: string; generation: number } | null +export type PaymentOperation = { peer: string; item: string; generation: number; kind: string; token: symbol } + +/** In-flight UI ownership complements durable node/browser attempt journals. + * An old callback may persist its own receipt, but cannot own a new modal. */ +export function usePeerPaymentOperations(selection: () => PaymentSelection) { + const slots = reactive(new Map()) + const key = (peer: string, item: string) => JSON.stringify([peer, item]) + function begin(kind: string, peer: string, item: string): PaymentOperation | null { + const index = key(peer, item) + if (slots.has(index)) return null + const current = selection() + if (!current || current.peer !== peer || current.item !== item) return null + const operation = { peer, item, generation: current.generation, kind, token: Symbol(kind) } + slots.set(index, operation) + return operation + } + function selected(operation: PaymentOperation): boolean { + const current = selection() + return slots.get(key(operation.peer, operation.item))?.token === operation.token + && current?.peer === operation.peer && current?.item === operation.item && current?.generation === operation.generation + } + function finish(operation: PaymentOperation): boolean { + const index = key(operation.peer, operation.item) + // Never reset a newer operation's busy flag, even for the same file. + const owned = slots.get(index)?.token === operation.token + const wasSelected = owned && selected(operation) + if (owned) slots.delete(index) + return wasSelected + } + const current = computed(() => { + const value = selection() + return value ? slots.get(key(value.peer, value.item)) : undefined + }) + return { begin, selected, finish, current, busy: computed(() => current.value !== undefined) } +} diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 748843eb..7c6bc2f7 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -443,7 +443,7 @@ + >{{ paymentActionBusy ? 'Paying…' : 'Pay' }} @@ -595,6 +595,7 @@