diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index 4f8fe16c..9bb2f97a 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -157,7 +157,7 @@ const router = createRouter({ { path: 'web5', name: 'web5', - component: () => import('../views/Web5.vue'), + component: () => import('../views/web5/Web5.vue'), }, { path: 'web5/credentials', diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 78e0abfd..d015f3ee 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -4,8 +4,9 @@ import { useMeshStore } from '@/stores/mesh' import { useTransportStore } from '@/stores/transport' import type { MeshPeer, SessionStatus } from '@/stores/mesh' import AnimatedLogo from '@/components/AnimatedLogo.vue' -import ToggleSwitch from '@/components/ToggleSwitch.vue' import MeshMap from '@/components/MeshMap.vue' +import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue' +import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue' import { rpcClient } from '@/api/rpc-client' const mesh = useMeshStore() @@ -76,13 +77,11 @@ async function sendArchMessage() { sent++ } catch { /* some peers may be offline */ } } - // Persist sent message on backend (survives restarts) try { await rpcClient.call({ method: 'node-store-sent', params: { message: msg } }) } catch { /* non-fatal */ } messageText.value = '' if (sent === 0) sendError.value = 'No peers reachable — message may arrive when they come online' - // Reload to show the persisted sent message await loadArchMessages() } catch (e) { sendError.value = e instanceof Error ? e.message : 'Send failed' @@ -96,20 +95,6 @@ const peerSessionInfo = ref(null) // Phase 4: Off-grid Bitcoin + Dead Man's Switch const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'map'>('chat') -const txHexInput = ref('') -const bolt11Input = ref('') -const bolt11AmountInput = ref('') -const relayingTx = ref(false) -const relayingLn = ref(false) -const relayResult = ref('') -const meshSendAddr = ref('') -const meshSendAmount = ref('') -const relayMode = ref<'archy' | 'broadcast'>('archy') -const sendTab = ref<'onchain' | 'lightning'>('onchain') -const deadmanConfiguring = ref(false) -const deadmanInterval = ref('21600') -const deadmanEnabled = ref(false) -const deadmanCustomMsg = ref('') // Tools tab for 3rd column on wide desktop and mobile below-chat const toolsTab = ref<'bitcoin' | 'deadman' | 'map'>('bitcoin') @@ -118,7 +103,6 @@ const toolsTab = ref<'bitcoin' | 'deadman' | 'map'>('bitcoin') const showChatPanel = computed(() => activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value) ) -// On wide desktop + mobile first view: tools use their own tab bar const showBitcoinPanel = computed(() => { if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin' return activeTab.value === 'bitcoin' @@ -131,9 +115,7 @@ const showMapPanel = computed(() => { if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'map' return activeTab.value === 'map' }) -// Mobile tools: show on first view (peers), hide when in chat const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value) -// Medium desktop: show 3-tab bar. Wide + mobile: hidden (tools has own tab bar) const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value) // Fetch session status when active peer changes @@ -156,134 +138,9 @@ async function handleToggleOffGrid() { } finally { togglingOffGrid.value = false } } -async function handleMeshSendBitcoin() { - if (!meshSendAddr.value.trim() || !meshSendAmount.value) return - relayingTx.value = true - relayResult.value = '' - try { - // Step 1: Create signed raw TX locally (no broadcast) - relayResult.value = 'Creating signed transaction...' - const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({ - method: 'lnd.create-raw-tx', - params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) }, - }) - // Step 2: Relay via mesh - relayResult.value = relayMode.value === 'broadcast' - ? 'Broadcasting via mesh network...' - : 'Sending to Archy peers (encrypted)...' - const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex, relayMode.value) - relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for relay peer to broadcast...` - meshSendAddr.value = '' - meshSendAmount.value = '' - // Step 3: Poll for relay result (every 3s for 90s) - pollRelayStatus(relayRes.request_id) - } catch (err: unknown) { - relayResult.value = err instanceof Error ? err.message : 'Send failed' - } finally { - relayingTx.value = false - } -} - -function pollRelayStatus(requestId: number) { - let attempts = 0 - const maxAttempts = 30 - const interval = setInterval(async () => { - attempts++ - try { - const res = await mesh.relayStatus(requestId) - if (res.status === 'confirmed' && res.txid) { - relayResult.value = `TX broadcast! txid: ${res.txid.slice(0, 8)}...${res.txid.slice(-8)}` - clearInterval(interval) - } else if (res.status === 'failed') { - const code = res.error_code ? ` [${res.error_code}]` : '' - relayResult.value = `Relay failed${code}: ${res.error || 'unknown error'}` - clearInterval(interval) - } else if (attempts >= maxAttempts) { - relayResult.value += ' (timed out waiting for confirmation)' - clearInterval(interval) - } - } catch { - if (attempts >= maxAttempts) clearInterval(interval) - } - }, 3000) -} - -async function handleRelayTx() { - if (!txHexInput.value.trim()) return - relayingTx.value = true - relayResult.value = '' - try { - const res = await mesh.relayTransaction(txHexInput.value.trim()) - relayResult.value = `TX queued (request #${res.request_id})` - txHexInput.value = '' - } catch (err: unknown) { - relayResult.value = err instanceof Error ? err.message : 'Relay failed' - } finally { - relayingTx.value = false - } -} - -async function handleRelayLightning() { - if (!bolt11Input.value.trim() || !bolt11AmountInput.value) return - relayingLn.value = true - relayResult.value = '' - try { - const res = await mesh.relayLightning(bolt11Input.value.trim(), parseInt(bolt11AmountInput.value)) - relayResult.value = `Lightning relay queued (request #${res.request_id})` - bolt11Input.value = '' - bolt11AmountInput.value = '' - } catch (err: unknown) { - relayResult.value = err instanceof Error ? err.message : 'Relay failed' - } finally { - relayingLn.value = false - } -} - -async function handleDeadmanToggle() { - // Instant enable/disable without waiting for full save - deadmanConfiguring.value = true - try { - await mesh.configureDeadman({ enabled: deadmanEnabled.value }) - await mesh.fetchDeadmanStatus() - } finally { - deadmanConfiguring.value = false - } -} - -async function handleDeadmanConfigure() { - deadmanConfiguring.value = true - try { - await mesh.configureDeadman({ - enabled: deadmanEnabled.value, - interval_secs: parseInt(deadmanInterval.value) || 21600, - custom_message: deadmanCustomMsg.value || undefined, - }) - await mesh.fetchDeadmanStatus() - } finally { - deadmanConfiguring.value = false - } -} - -async function handleDeadmanCheckin() { - await mesh.deadmanCheckin() - await mesh.fetchDeadmanStatus() -} - -function formatTimeRemaining(secs: number): string { - if (secs >= 86400) return `${Math.floor(secs / 3600)}h` - if (secs >= 3600) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m` - if (secs >= 60) return `${Math.floor(secs / 60)}m ${secs % 60}s` - return `${secs}s` -} - onMounted(async () => { window.addEventListener('resize', handleResize) await Promise.all([mesh.refreshAll(), transport.fetchStatus()]) - // Sync deadman UI state from server - if (mesh.deadmanStatus) { - deadmanEnabled.value = mesh.deadmanStatus.dead_man_enabled - deadmanInterval.value = String(mesh.deadmanStatus.dead_man_interval_secs) - } pollInterval = setInterval(() => { mesh.fetchStatus() mesh.fetchPeers() @@ -318,7 +175,6 @@ const hasActiveChat = computed(() => !!activeChatPeer.value || !!activeChatChann // Messages filtered to the active chat const chatMessages = computed(() => { if (archChannelActive.value) { - // Map Tor messages to mesh message format for rendering return archMessages.value.map((m, i) => { const isSent = (m as Record).direction === 'sent' || m.from_pubkey === 'me' return { @@ -336,7 +192,6 @@ const chatMessages = computed(() => { }) } if (activeChatChannel.value) { - // Channel messages have negative contact_id = -(channel_index + 1) const chanId = -(activeChatChannel.value.index + 1) return mesh.messages.filter(m => m.peer_contact_id === chanId) } @@ -347,12 +202,10 @@ const chatMessages = computed(() => { return [] }) -// Check if a peer is an Archipelago node (name starts with "Archy-") function isArchyNode(peer: MeshPeer): boolean { return peer.advert_name.startsWith('Archy-') } -// Sort peers: Archy nodes first, then alphabetical const sortedPeers = computed(() => { return [...mesh.peers].sort((a, b) => { const aArchy = isArchyNode(a) ? 0 : 1 @@ -465,7 +318,7 @@ function truncatePubkey(hex: string | null): string { - - \ No newline at end of file diff --git a/neode-ui/src/views/mesh/MeshBitcoinPanel.vue b/neode-ui/src/views/mesh/MeshBitcoinPanel.vue new file mode 100644 index 00000000..70cdf3f7 --- /dev/null +++ b/neode-ui/src/views/mesh/MeshBitcoinPanel.vue @@ -0,0 +1,180 @@ + + + diff --git a/neode-ui/src/views/web5/Web5.vue b/neode-ui/src/views/web5/Web5.vue new file mode 100644 index 00000000..5c0c1af7 --- /dev/null +++ b/neode-ui/src/views/web5/Web5.vue @@ -0,0 +1,462 @@ + + + + + diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue new file mode 100644 index 00000000..f409574b --- /dev/null +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -0,0 +1,449 @@ + + + diff --git a/neode-ui/src/views/web5/Web5CredentialsSummary.vue b/neode-ui/src/views/web5/Web5CredentialsSummary.vue new file mode 100644 index 00000000..e1d3de20 --- /dev/null +++ b/neode-ui/src/views/web5/Web5CredentialsSummary.vue @@ -0,0 +1,100 @@ + + + diff --git a/neode-ui/src/views/web5/Web5DWN.vue b/neode-ui/src/views/web5/Web5DWN.vue new file mode 100644 index 00000000..479119d8 --- /dev/null +++ b/neode-ui/src/views/web5/Web5DWN.vue @@ -0,0 +1,276 @@ + + + diff --git a/neode-ui/src/views/web5/Web5Domains.vue b/neode-ui/src/views/web5/Web5Domains.vue new file mode 100644 index 00000000..a0942589 --- /dev/null +++ b/neode-ui/src/views/web5/Web5Domains.vue @@ -0,0 +1,219 @@ + + + diff --git a/neode-ui/src/views/web5/Web5Identities.vue b/neode-ui/src/views/web5/Web5Identities.vue new file mode 100644 index 00000000..77a124a1 --- /dev/null +++ b/neode-ui/src/views/web5/Web5Identities.vue @@ -0,0 +1,573 @@ + + + diff --git a/neode-ui/src/views/web5/Web5NodeVisibility.vue b/neode-ui/src/views/web5/Web5NodeVisibility.vue new file mode 100644 index 00000000..aa5adbca --- /dev/null +++ b/neode-ui/src/views/web5/Web5NodeVisibility.vue @@ -0,0 +1,135 @@ + + + diff --git a/neode-ui/src/views/web5/Web5NostrRelays.vue b/neode-ui/src/views/web5/Web5NostrRelays.vue new file mode 100644 index 00000000..07af8973 --- /dev/null +++ b/neode-ui/src/views/web5/Web5NostrRelays.vue @@ -0,0 +1,168 @@ + + + diff --git a/neode-ui/src/views/web5/Web5QuickActions.vue b/neode-ui/src/views/web5/Web5QuickActions.vue new file mode 100644 index 00000000..d69d074f --- /dev/null +++ b/neode-ui/src/views/web5/Web5QuickActions.vue @@ -0,0 +1,219 @@ + + + diff --git a/neode-ui/src/views/web5/Web5SendReceiveModals.vue b/neode-ui/src/views/web5/Web5SendReceiveModals.vue new file mode 100644 index 00000000..176ece6c --- /dev/null +++ b/neode-ui/src/views/web5/Web5SendReceiveModals.vue @@ -0,0 +1,519 @@ + + + diff --git a/neode-ui/src/views/web5/Web5SharedContent.vue b/neode-ui/src/views/web5/Web5SharedContent.vue new file mode 100644 index 00000000..4ab8ee1a --- /dev/null +++ b/neode-ui/src/views/web5/Web5SharedContent.vue @@ -0,0 +1,550 @@ + + + diff --git a/neode-ui/src/views/web5/Web5Wallet.vue b/neode-ui/src/views/web5/Web5Wallet.vue new file mode 100644 index 00000000..7108451c --- /dev/null +++ b/neode-ui/src/views/web5/Web5Wallet.vue @@ -0,0 +1,184 @@ + + + diff --git a/neode-ui/src/views/web5/types.ts b/neode-ui/src/views/web5/types.ts new file mode 100644 index 00000000..4730abe7 --- /dev/null +++ b/neode-ui/src/views/web5/types.ts @@ -0,0 +1,162 @@ +// Shared types for Web5 subcomponents + +export interface ProfitsData { + total_sats: number + content_sales_sats: number + routing_fees_sats: number +} + +export interface RegisteredNameData { + id: string + name: string + domain: string + nip05: string + identity_id: string + did: string + nostr_pubkey: string | null + status: string + registered_at: string + expires_at: string | null +} + +export interface Nip05Result { + name: string + domain: string + nostr_pubkey: string | null + relays: string[] + verified: boolean +} + +export interface VCData { + id: string + issuer: string + subject: string + type: string + claims: Record + issued_at: string + expires_at: string | null + status: string +} + +export interface NostrRelayData { + url: string + connected: boolean + enabled: boolean + added_at: string +} + +export interface NostrRelayStatsData { + total_relays: number + connected_count: number + enabled_count: number +} + +export interface WalletTransaction { + tx_hash: string + amount_sats: number + direction: 'incoming' | 'outgoing' + num_confirmations: number + time_stamp: number + total_fees: number + dest_addresses: string[] + label: string + block_height: number +} + +export interface HwWalletDevice { + type: string + vendor_id: string + product_id: string + manufacturer: string + product: string +} + +export interface ContentItemData { + id: string + filename: string + mime_type: string + size_bytes: number + description: string + access: string | { paid: { price_sats: number } } + added_at: string +} + +export interface PeerContentItem { + id: string + filename: string + mime_type: string + size_bytes: number + description: string + access: string | { paid: { price_sats: number } } +} + +export interface ConnectionRequest { + id: string + from_did: string + from_onion?: string + from_pubkey?: string + message?: string + created_at: string +} + +export interface IdentityProfile { + display_name?: string + about?: string + picture?: string + banner?: string + website?: string + nip05?: string + lud16?: string +} + +export interface ManagedIdentity { + id: string + name: string + purpose: string + pubkey: string + did: string + created_at: string + is_default: boolean + nostr_pubkey?: string + nostr_npub?: string + profile?: IdentityProfile +} + +export interface DwnStatusData { + running: boolean + version: string + sync_status: string + last_sync: string | null + messages_synced: number + storage_bytes: number + message_count: number + protocol_count: number + registered_protocols: string[] + peer_sync_targets: string[] +} + +export interface DwnProtocol { + protocol: string + published: boolean + types: Record + structure: Record + dateRegistered: string +} + +export interface DwnMessageEntry { + record_id: string + author: string + date_created: string + descriptor: { + interface: string + method: string + protocol?: string + schema?: string + dataFormat?: string + } + data?: unknown +} + +export type VisibilityLevel = 'hidden' | 'discoverable' | 'public' + +export type Peer = { onion: string; pubkey: string; name?: string } diff --git a/neode-ui/src/views/web5/utils.ts b/neode-ui/src/views/web5/utils.ts new file mode 100644 index 00000000..aae1889c --- /dev/null +++ b/neode-ui/src/views/web5/utils.ts @@ -0,0 +1,73 @@ +// Shared utility functions for Web5 subcomponents + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +export function formatTxTime(timestamp: number): string { + if (!timestamp) return '' + const date = new Date(timestamp * 1000) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + if (diffMin < 1) return 'Just now' + if (diffMin < 60) return `${diffMin}m ago` + const diffHours = Math.floor(diffMin / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + if (diffDays < 7) return `${diffDays}d ago` + return date.toLocaleDateString() +} + +export function formatMessageTime(ts: string): string { + try { + const d = new Date(ts) + const now = new Date() + const diff = now.getTime() - d.getTime() + if (diff < 60000) return 'Just now' + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago` + return d.toLocaleDateString() + } catch { + return ts + } +} + +export async function safeClipboardWrite(text: string): Promise { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } else { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + } +} + +export function isMediaType(mime: string): boolean { + return mime.startsWith('audio/') || mime.startsWith('video/') +} + +export function getAccessType(access: string | { paid: { price_sats: number } }): 'free' | 'peers_only' | 'paid' { + if (typeof access === 'string') { + if (access === 'peersonly' || access === 'peers_only') return 'peers_only' + if (access === 'paid') return 'paid' + return 'free' + } + if (access && typeof access === 'object' && 'paid' in access) return 'paid' + return 'free' +} + +export function getItemPrice(access: string | { paid: { price_sats: number } }): number { + if (typeof access === 'object' && access && 'paid' in access) { + return access.paid.price_sats + } + return 0 +}