feat(release): stage GitWorkshop and next node updates

This commit is contained in:
archipelago
2026-09-09 18:15:21 -04:00
parent 973356df16
commit f5c0ba85cd
97 changed files with 5716 additions and 1327 deletions
+58 -30
View File
@@ -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')
})
})