feat(release): stage GitWorkshop and next node updates
This commit is contained in:
@@ -392,6 +392,17 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
||||
<IdentitySuccessPane
|
||||
v-if="profileSuccess"
|
||||
:identity-name="profileSuccess.identityName"
|
||||
:event-id="profileSuccess.eventId"
|
||||
:accepted="profileSuccess.accepted"
|
||||
:attempted="profileSuccess.attempted"
|
||||
:relay-note="profileSuccess.relayNote"
|
||||
@again="profileSuccess = null"
|
||||
@done="closeProfileEditor"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
||||
<img
|
||||
@@ -455,11 +466,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
|
||||
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -471,6 +482,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
import type { ManagedIdentity, IdentityProfile } from './types'
|
||||
import IdentitySuccessPane from '@/components/IdentitySuccessPane.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -618,7 +630,14 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||
}
|
||||
}
|
||||
const profileError = ref('')
|
||||
const profileSuccess = ref('')
|
||||
interface ProfilePublishSuccess {
|
||||
identityName: string
|
||||
eventId: string
|
||||
accepted: number
|
||||
attempted: number
|
||||
relayNote: string
|
||||
}
|
||||
const profileSuccess = ref<ProfilePublishSuccess | null>(null)
|
||||
|
||||
async function loadIdentities() {
|
||||
const hadIdentities = managedIdentities.value.length > 0
|
||||
@@ -739,58 +758,67 @@ function openProfileEditor(identity: ManagedIdentity) {
|
||||
profileEditorIdentity.value = identity
|
||||
profileForm.value = { ...identity.profile }
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
profileSuccess.value = null
|
||||
}
|
||||
|
||||
function closeProfileEditor() {
|
||||
profileEditorIdentity.value = null
|
||||
profileForm.value = {}
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
profileSuccess.value = null
|
||||
}
|
||||
|
||||
async function publishProfile() {
|
||||
if (!profileEditorIdentity.value || profilePublishing.value) return
|
||||
profilePublishing.value = true
|
||||
profileError.value = ''
|
||||
profileSuccess.value = ''
|
||||
profileSuccess.value = null
|
||||
try {
|
||||
const identity = profileEditorIdentity.value
|
||||
await rpcClient.call({
|
||||
method: 'identity.update-profile',
|
||||
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
||||
})
|
||||
const res = await rpcClient.call<{
|
||||
event_id: string
|
||||
accepted: string[]
|
||||
rejected: Array<[string, string]>
|
||||
relays_attempted: number
|
||||
published: boolean
|
||||
}>({
|
||||
method: 'identity.publish-profile',
|
||||
params: { id: profileEditorIdentity.value.id },
|
||||
params: { id: identity.id, ...profileForm.value },
|
||||
})
|
||||
await loadIdentities()
|
||||
const n = res.accepted?.length ?? 0
|
||||
const total = res.relays_attempted ?? 0
|
||||
const tail = `(${res.event_id.slice(0, 12)}…)`
|
||||
if (n === total) {
|
||||
profileSuccess.value = `Published to all ${total} relays ${tail}`
|
||||
} else if (n > 0) {
|
||||
profileSuccess.value = `Published to ${n}/${total} relays ${tail}`
|
||||
const first = res.rejected?.[0]
|
||||
if (first) profileError.value = `Rejected by ${first[0]}: ${first[1]}`
|
||||
} else {
|
||||
profileError.value = `Published to 0/${total} relays — check Manage Relays`
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
event_id: string
|
||||
accepted: string[]
|
||||
rejected: Array<[string, string]>
|
||||
relays_attempted: number
|
||||
published: boolean
|
||||
}>({ method: 'identity.publish-profile', params: { id: identity.id } })
|
||||
const accepted = res.accepted?.length ?? 0
|
||||
const attempted = res.relays_attempted ?? 0
|
||||
const rejected = res.rejected?.[0]
|
||||
profileSuccess.value = {
|
||||
identityName: profileForm.value.display_name?.trim() || identity.name,
|
||||
eventId: res.event_id || '',
|
||||
accepted,
|
||||
attempted,
|
||||
relayNote: accepted === attempted
|
||||
? ''
|
||||
: rejected
|
||||
? `${rejected[0]} rejected the event: ${rejected[1]}`
|
||||
: 'The profile is saved on this node. Check Manage Relays before retrying publication.',
|
||||
}
|
||||
} catch (publishError: unknown) {
|
||||
profileSuccess.value = {
|
||||
identityName: profileForm.value.display_name?.trim() || identity.name,
|
||||
eventId: '',
|
||||
accepted: 0,
|
||||
attempted: 0,
|
||||
relayNote: `The profile is saved on this node, but relay publication failed: ${publishError instanceof Error ? publishError.message : 'unknown error'}`,
|
||||
}
|
||||
}
|
||||
setTimeout(() => { profileSuccess.value = '' }, 5000)
|
||||
} catch (err: unknown) {
|
||||
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
|
||||
profileError.value = err instanceof Error ? err.message : 'Failed to save profile'
|
||||
} finally {
|
||||
profilePublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ loadIdentities, managedIdentities })
|
||||
defineExpose({ loadIdentities, managedIdentities, openProfileEditor, publishProfile })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
|
||||
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
|
||||
|
||||
<PaymentSuccessPane
|
||||
v-if="sendSuccess"
|
||||
:amount="sendSuccess.amount"
|
||||
verb="SENT"
|
||||
:method-label="sendSuccess.methodLabel"
|
||||
:rows="sendSuccess.rows"
|
||||
:note="sendSuccess.note"
|
||||
again-label="Send another"
|
||||
@again="sendAnother"
|
||||
@done="closeUnifiedSendModal"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- Method tabs -->
|
||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||
<button
|
||||
@@ -32,12 +45,6 @@
|
||||
<textarea v-model="unifiedSendDest" rows="2" :placeholder="effectiveSendMethod === 'lightning' ? 'lnbc...' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="ecashSendToken && effectiveSendMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
|
||||
<p class="text-white/50 text-xs mb-1">Token (share with recipient):</p>
|
||||
<p class="text-xs font-mono text-white/80 break-all">{{ ecashSendToken }}</p>
|
||||
<button @click="copyEcashToken(ecashSendToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">Copy</button>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveSendMethod === 'onchain'" class="mb-3 flex items-center gap-3 p-3 bg-white/5 rounded-lg">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" v-model="useHardwareWallet" class="sr-only peer" />
|
||||
@@ -93,8 +100,6 @@
|
||||
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="sendResultTxid" class="mb-3 alert-success"><p class="text-xs">Sent! TX: {{ sendResultTxid }}</p></div>
|
||||
<div v-if="sendResultHash" class="mb-3 alert-success"><p class="text-xs">Paid! Hash: {{ sendResultHash }}</p></div>
|
||||
<div v-if="unifiedSendError" class="mb-3 text-xs text-red-400">{{ unifiedSendError }}</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
@@ -106,6 +111,7 @@
|
||||
{{ unifiedSendProcessing ? 'Sending...' : (useHardwareWallet && effectiveSendMethod === 'onchain' ? 'Create PSBT' : 'Send') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -177,7 +183,6 @@
|
||||
<label class="text-white/60 text-sm block mb-1">Paste ecash token (Cashu or Fedimint)</label>
|
||||
<textarea v-model="ecashReceiveToken" rows="3" placeholder="cashuB… or Fedimint notes" class="w-full input-glass"></textarea>
|
||||
</div>
|
||||
<div v-if="ecashReceiveResult" class="mb-3 text-xs text-green-400">{{ ecashReceiveResult }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="unifiedReceiveError" class="mb-3 text-xs text-red-400">{{ unifiedReceiveError }}</div>
|
||||
@@ -233,7 +238,12 @@ const showMeshRelayPrompt = ref(false)
|
||||
const psbtData = ref('')
|
||||
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
|
||||
const signedPsbtInput = ref('')
|
||||
const ecashSendToken = ref('')
|
||||
const sendSuccess = ref<{
|
||||
amount: number
|
||||
methodLabel: string
|
||||
rows: SuccessRow[]
|
||||
note?: string
|
||||
} | null>(null)
|
||||
|
||||
// Receive state
|
||||
const showUnifiedReceiveModal = ref(false)
|
||||
@@ -246,7 +256,6 @@ const onchainQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const unifiedReceiveProcessing = ref(false)
|
||||
const unifiedReceiveError = ref('')
|
||||
const ecashReceiveToken = ref('')
|
||||
const ecashReceiveResult = ref('')
|
||||
// Details of the last successful ecash receive, for the success screen.
|
||||
// Null = nothing to celebrate yet, so the form shows.
|
||||
const ecashSuccess = ref<{
|
||||
@@ -265,12 +274,15 @@ const effectiveSendMethod = computed(() => {
|
||||
return 'lightning'
|
||||
})
|
||||
|
||||
function openSend() { showUnifiedSendModal.value = true }
|
||||
function openSend() {
|
||||
sendSuccess.value = null
|
||||
showUnifiedSendModal.value = true
|
||||
}
|
||||
function openReceive() { showUnifiedReceiveModal.value = true }
|
||||
|
||||
function closeUnifiedSendModal() {
|
||||
showUnifiedSendModal.value = false
|
||||
ecashSendToken.value = ''
|
||||
sendSuccess.value = null
|
||||
unifiedSendError.value = ''
|
||||
sendResultTxid.value = ''
|
||||
sendResultHash.value = ''
|
||||
@@ -284,16 +296,10 @@ function closeUnifiedReceiveModal() {
|
||||
receiveInvoiceResult.value = ''
|
||||
receiveOnchainAddress.value = ''
|
||||
ecashReceiveToken.value = ''
|
||||
ecashReceiveResult.value = ''
|
||||
ecashSuccess.value = null
|
||||
unifiedReceiveError.value = ''
|
||||
}
|
||||
|
||||
function copyEcashToken(token: string) {
|
||||
safeClipboardWrite(token)
|
||||
emit('toast', t('web5.ecashTokenCopied'))
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string, msg: string) {
|
||||
safeClipboardWrite(text)
|
||||
emit('toast', msg)
|
||||
@@ -303,7 +309,7 @@ async function unifiedSend() {
|
||||
if (!unifiedSendAmount.value || unifiedSendProcessing.value) return
|
||||
unifiedSendProcessing.value = true
|
||||
unifiedSendError.value = ''
|
||||
ecashSendToken.value = ''
|
||||
sendSuccess.value = null
|
||||
sendResultTxid.value = ''
|
||||
sendResultHash.value = ''
|
||||
meshRelayActive.value = false
|
||||
@@ -316,7 +322,16 @@ async function unifiedSend() {
|
||||
method: 'wallet.ecash-send',
|
||||
params: { amount_sats: unifiedSendAmount.value },
|
||||
})
|
||||
ecashSendToken.value = res.token
|
||||
sendSuccess.value = {
|
||||
amount: unifiedSendAmount.value,
|
||||
methodLabel: 'Sent as Cashu',
|
||||
rows: [{
|
||||
label: 'Token to share',
|
||||
value: res.token,
|
||||
hint: 'The recipient needs this token to claim the sats. Keep it until they confirm receipt.',
|
||||
truncate: true,
|
||||
}],
|
||||
}
|
||||
} else if (method === 'lightning') {
|
||||
if (!unifiedSendDest.value.trim()) {
|
||||
unifiedSendError.value = t('web5.pasteInvoice')
|
||||
@@ -327,6 +342,14 @@ async function unifiedSend() {
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
sendResultHash.value = res.payment_hash
|
||||
sendSuccess.value = {
|
||||
amount: res.amount_sats || unifiedSendAmount.value,
|
||||
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
|
||||
rows: res.payment_hash ? [{ label: 'Payment hash', value: res.payment_hash }] : [],
|
||||
...(res.status === 'pending'
|
||||
? { note: 'This payment is taking longer than usual to settle. Check transactions before retrying.' }
|
||||
: {}),
|
||||
}
|
||||
} else {
|
||||
if (!unifiedSendDest.value.trim()) {
|
||||
unifiedSendError.value = t('web5.enterBitcoinAddress')
|
||||
@@ -354,6 +377,12 @@ async function unifiedSend() {
|
||||
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
|
||||
})
|
||||
sendResultTxid.value = res.txid
|
||||
sendSuccess.value = {
|
||||
amount: unifiedSendAmount.value,
|
||||
methodLabel: 'Sent on-chain',
|
||||
rows: [{ label: 'Transaction ID', value: res.txid }],
|
||||
note: 'The transaction has been broadcast and will confirm over the next blocks.',
|
||||
}
|
||||
} catch (sendErr: unknown) {
|
||||
const errMsg = sendErr instanceof Error ? sendErr.message : ''
|
||||
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
|
||||
@@ -416,6 +445,12 @@ function startMeshRelayPolling(_requestId: number) {
|
||||
const match = text.match(/txid:\s*(\w+)/)
|
||||
if (match && match[1]) {
|
||||
sendResultTxid.value = match[1]
|
||||
sendSuccess.value = {
|
||||
amount: unifiedSendAmount.value,
|
||||
methodLabel: 'Sent on-chain over mesh',
|
||||
rows: [{ label: 'Transaction ID', value: match[1] }],
|
||||
note: 'Broadcast confirmed. The transaction is waiting for block confirmations.',
|
||||
}
|
||||
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... -- waiting for confirmations`
|
||||
}
|
||||
}
|
||||
@@ -454,6 +489,12 @@ async function finalizePsbt() {
|
||||
psbtData.value = ''
|
||||
signedPsbtInput.value = ''
|
||||
sendResultTxid.value = t('web5.broadcastViaHwWallet')
|
||||
sendSuccess.value = {
|
||||
amount: unifiedSendAmount.value,
|
||||
methodLabel: 'Sent on-chain with hardware wallet',
|
||||
rows: [],
|
||||
note: t('web5.broadcastViaHwWallet'),
|
||||
}
|
||||
emit('balancesChanged')
|
||||
} catch (err: unknown) {
|
||||
unifiedSendError.value = err instanceof Error ? err.message : t('web5.broadcastFailed')
|
||||
@@ -462,6 +503,19 @@ async function finalizePsbt() {
|
||||
}
|
||||
}
|
||||
|
||||
function sendAnother() {
|
||||
sendSuccess.value = null
|
||||
unifiedSendAmount.value = 0
|
||||
unifiedSendDest.value = ''
|
||||
unifiedSendError.value = ''
|
||||
sendResultTxid.value = ''
|
||||
sendResultHash.value = ''
|
||||
psbtData.value = ''
|
||||
psbtStep.value = 'idle'
|
||||
signedPsbtInput.value = ''
|
||||
useHardwareWallet.value = false
|
||||
}
|
||||
|
||||
function copyPsbt() {
|
||||
if (!psbtData.value) return
|
||||
safeClipboardWrite(psbtData.value)
|
||||
@@ -526,7 +580,6 @@ async function unifiedReceive() {
|
||||
params: { token: ecashReceiveToken.value.trim() },
|
||||
})
|
||||
const label = res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'
|
||||
ecashReceiveResult.value = `Received ${res.received_sats} sats (${label})!`
|
||||
// Ecash leaves no public ledger entry behind, so the issuer and the
|
||||
// redeemed token are the only things a person can quote later if the
|
||||
// payment is ever questioned. Capture them before clearing the box.
|
||||
|
||||
@@ -73,4 +73,36 @@ describe('Web5Identities', () => {
|
||||
expect(wrapper.text()).toContain('Personal')
|
||||
expect(wrapper.text()).not.toContain('Refreshing identities...')
|
||||
})
|
||||
|
||||
it('turns a saved profile into the identity success screen with honest relay coverage', async () => {
|
||||
const identity = makeIdentity('Personal')
|
||||
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
|
||||
if (request.method === 'identity.update-profile') return Promise.resolve({})
|
||||
if (request.method === 'identity.list') return Promise.resolve({ identities: [identity] })
|
||||
if (request.method === 'identity.publish-profile') {
|
||||
return Promise.resolve({
|
||||
event_id: 'event-123', accepted: ['wss://one'],
|
||||
rejected: [['wss://two', 'write denied']], relays_attempted: 2, published: true,
|
||||
})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const wrapper = mount(Web5Identities, {
|
||||
props: { showStagger: false },
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
const vm = wrapper.vm as unknown as {
|
||||
openProfileEditor: (identity: ManagedIdentity) => void
|
||||
publishProfile: () => Promise<void>
|
||||
}
|
||||
vm.openProfileEditor(identity)
|
||||
await wrapper.vm.$nextTick()
|
||||
await vm.publishProfile()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('IDENTITY UPDATED')
|
||||
expect(wrapper.text()).toContain('Published to 1/2 configured relays.')
|
||||
expect(wrapper.text()).toContain('wss://two rejected the event: write denied')
|
||||
expect(wrapper.text()).toContain('event-123')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user