package main import ( "git.smesh.lol/smesh/web/common/helpers" "git.smesh.lol/smesh/web/common/jsbridge/dom" "git.smesh.lol/smesh/web/common/jsbridge/signer" ) // Wallet popover. NWC (NIP-47) wallet connection + send. // // State model: // - NWC connections live in the signer vault (sovereignty: secrets never leave). // - We hold a local cached NwcConn list (pubkey, relay, alias) for UI. // - The active connection is the first one; multi-wallet management is a // future concern. // // UI layout: // 1. Active wallet row: alias, balance (sats), refresh, remove. // 2. Add wallet form: URI + alias (hidden until "add wallet" clicked). // 3. Send form: recipient (invoice | lightning address) + optional amount // and comment. Pay button. var ( walletBalanceSats int64 = -1 // -1 = not fetched, -2 = fetch failed walletActiveID string walletStatus string walletEnc string // "nip04" | "nip44", auto-picked on first get_info walletPayResultEl dom.Element walletShowAddForm bool walletLud16 string // cached from get_info walletInfoLoaded bool ) // buildWalletPanel creates the wallet popover (hidden). func buildWalletPanel() { walletPanel = dom.CreateElement("div") dom.SetStyle(walletPanel, "marginTop", "24px") dom.SetStyle(walletPanel, "padding", "12px 0 0 0") dom.SetStyle(walletPanel, "borderTop", "1px solid var(--border)") dom.SetStyle(walletPanel, "fontSize", "13px") walletContent = dom.CreateElement("div") dom.AppendChild(walletPanel, walletContent) } // toggleWalletPanel shows/hides the wallet popover. func toggleWalletPanel() { if int32(walletPanel) == 0 { return } if walletPanelOpen { hideWalletPanel() return } // Close other popovers so they don't overlap. if signerOpen { hideSignerPanel() } if popoverOpen { togglePopover() } closeFeedPopover() walletPanelOpen = true dom.SetStyle(walletPanel, "display", "block") if int32(walletBtnEl) != 0 { dom.SetStyle(walletBtnEl, "background", "var(--accent)") dom.SetStyle(walletBtnEl, "color", "var(--bg)") } renderWalletUI() } func hideWalletPanel() { if !walletPanelOpen { return } walletPanelOpen = false dom.SetStyle(walletPanel, "display", "none") if int32(walletBtnEl) != 0 { dom.SetStyle(walletBtnEl, "background", "transparent") dom.SetStyle(walletBtnEl, "color", "var(--muted)") } } // renderWalletUI rebuilds the panel based on current state. func renderWalletUI() { dom.SetTextContent(walletContent, "") if !signer.HasSigner() { p := dom.CreateElement("p") dom.SetStyle(p, "color", "var(--muted)") dom.SetTextContent(p, t("no_signer_backend")) dom.AppendChild(walletContent, p) return } // Refresh list every open to catch external changes. signer.NwcList(func(raw string) { nwcConns = parseNwcListJSON(raw) dom.ConsoleLog("[wallet] NwcList raw=" | raw | " parsed=" | helpers.Itoa(int64(len(nwcConns)))) renderWalletBody() }) } func renderWalletBody() { dom.SetTextContent(walletContent, "") h := dom.CreateElement("h3") dom.SetTextContent(h, t("wallet")) dom.AppendChild(walletContent, h) if len(nwcConns) == 0 { p := dom.CreateElement("p") dom.SetStyle(p, "color", "var(--muted)") dom.SetTextContent(p, t("wallet_no_wallet")) dom.AppendChild(walletContent, p) renderAddWalletForm() return } // Active wallet is the first one; multi-wallet switch TBD. active := &nwcConns[0] walletActiveID = active.ID row := dom.CreateElement("div") dom.SetAttribute(row, "class", "signer-identity") label := dom.CreateElement("span") name := active.Alias if name == "" { name = active.WalletPubkey[:8] | "..." } balStr := t("wallet_loading_balance") if walletBalanceSats == -2 { balStr = "-" } else if walletBalanceSats >= 0 { balStr = formatSats(walletBalanceSats) | " " | t("wallet_sats") } line := name if walletLud16 != "" { line = line | " · " | walletLud16 } line = line | " - " | balStr dom.SetTextContent(label, line) dom.AppendChild(row, label) btns := dom.CreateElement("span") refreshBtn := dom.CreateElement("button") dom.SetAttribute(refreshBtn, "class", "signer-btn-sm") dom.SetTextContent(refreshBtn, t("wallet_refresh")) dom.AddEventListener(refreshBtn, "click", dom.RegisterCallback(func() { walletBalanceSats = -1 walletInfoLoaded = false walletLud16 = "" renderWalletBody() fetchWalletInfo() fetchWalletBalance() })) dom.AppendChild(btns, refreshBtn) rmBtn := dom.CreateElement("button") dom.SetAttribute(rmBtn, "class", "signer-btn-sm signer-btn-danger") dom.SetTextContent(rmBtn, "\u00d7") rmID := active.ID dom.AddEventListener(rmBtn, "click", dom.RegisterCallback(func() { if !dom.Confirm(t("wallet_remove_confirm")) { return } NwcRemove(rmID, func(ok bool) { walletBalanceSats = -1 walletActiveID = "" walletEnc = "" walletLud16 = "" walletInfoLoaded = false renderWalletUI() }) })) dom.AppendChild(btns, rmBtn) dom.AppendChild(row, btns) dom.AppendChild(walletContent, row) if !walletInfoLoaded { fetchWalletInfo() } if walletBalanceSats == -1 { fetchWalletBalance() } renderSendForm() // Add-another (hidden behind a small link). renderAddWalletForm() } func renderAddWalletForm() { if !walletShowAddForm && len(nwcConns) > 0 { toggle := dom.CreateElement("button") dom.SetAttribute(toggle, "class", "signer-btn signer-btn-secondary") dom.SetTextContent(toggle, t("wallet_add_another")) dom.AddEventListener(toggle, "click", dom.RegisterCallback(func() { walletShowAddForm = true renderWalletBody() })) dom.AppendChild(walletContent, toggle) return } h3 := dom.CreateElement("h3") dom.SetTextContent(h3, t("wallet_add")) dom.AppendChild(walletContent, h3) p := dom.CreateElement("p") dom.SetStyle(p, "fontSize", "12px") dom.SetStyle(p, "color", "var(--muted)") dom.SetTextContent(p, t("wallet_add_hint")) dom.AppendChild(walletContent, p) uriInput := dom.CreateElement("textarea") dom.SetAttribute(uriInput, "placeholder", "nostr+walletconnect://...") dom.SetAttribute(uriInput, "class", "signer-input signer-textarea") dom.SetAttribute(uriInput, "rows", "3") dom.AppendChild(walletContent, uriInput) aliasInput := dom.CreateElement("input") dom.SetAttribute(aliasInput, "type", "text") dom.SetAttribute(aliasInput, "placeholder", t("wallet_alias_placeholder")) dom.SetAttribute(aliasInput, "class", "signer-input") dom.AppendChild(walletContent, aliasInput) status := dom.CreateElement("p") dom.SetStyle(status, "fontSize", "12px") dom.SetStyle(status, "marginTop", "4px") dom.AppendChild(walletContent, status) addBtn := dom.CreateElement("button") dom.SetAttribute(addBtn, "class", "signer-btn") dom.SetTextContent(addBtn, t("wallet_add")) dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() { uri := dom.GetProperty(uriInput, "value") alias := dom.GetProperty(aliasInput, "value") if uri == "" { dom.ReadClipboard(func(clip string) { if clip != "" { dom.SetProperty(uriInput, "value", clip) } }) return } dom.SetTextContent(status, t("wallet_adding")) dom.SetAttribute(addBtn, "disabled", "true") NwcAdd(uri, alias, func(id string) { if id == "" { dom.SetTextContent(status, t("wallet_add_failed")) dom.SetAttribute(addBtn, "disabled", "") return } walletShowAddForm = false walletBalanceSats = -1 walletEnc = "" renderWalletUI() }) })) dom.AppendChild(walletContent, addBtn) } // renderSendForm draws the invoice/lightning-address send UI. func renderSendForm() { h3 := dom.CreateElement("h3") dom.SetTextContent(h3, t("wallet_send")) dom.AppendChild(walletContent, h3) recipInput := dom.CreateElement("input") dom.SetAttribute(recipInput, "type", "text") dom.SetAttribute(recipInput, "placeholder", t("wallet_recipient_placeholder")) dom.SetAttribute(recipInput, "class", "signer-input") dom.AppendChild(walletContent, recipInput) amountInput := dom.CreateElement("input") dom.SetAttribute(amountInput, "type", "number") dom.SetAttribute(amountInput, "placeholder", t("wallet_amount_placeholder")) dom.SetAttribute(amountInput, "class", "signer-input") dom.AppendChild(walletContent, amountInput) commentInput := dom.CreateElement("input") dom.SetAttribute(commentInput, "type", "text") dom.SetAttribute(commentInput, "placeholder", t("wallet_comment_placeholder")) dom.SetAttribute(commentInput, "class", "signer-input") dom.AppendChild(walletContent, commentInput) walletPayResultEl = dom.CreateElement("p") dom.SetStyle(walletPayResultEl, "fontSize", "12px") dom.SetStyle(walletPayResultEl, "marginTop", "4px") dom.SetStyle(walletPayResultEl, "wordBreak", "break-all") dom.AppendChild(walletContent, walletPayResultEl) payBtn := dom.CreateElement("button") dom.SetAttribute(payBtn, "class", "signer-btn") dom.SetTextContent(payBtn, t("wallet_pay")) dom.AddEventListener(payBtn, "click", dom.RegisterCallback(func() { recip := trimText(dom.GetProperty(recipInput, "value")) amtStr := dom.GetProperty(amountInput, "value") comment := dom.GetProperty(commentInput, "value") if recip == "" { return } dom.SetAttribute(payBtn, "disabled", "true") dom.SetTextContent(payBtn, t("wallet_paying")) dom.SetTextContent(walletPayResultEl, "") finish := func(plain string) { dom.SetTextContent(payBtn, t("wallet_pay")) dom.SetAttribute(payBtn, "disabled", "") handlePayResult(plain) } if isLightningAddress(recip) { msats := parseI64(amtStr) * 1000 if msats <= 0 { dom.SetTextContent(walletPayResultEl, t("wallet_need_amount")) dom.SetTextContent(payBtn, t("wallet_pay")) dom.SetAttribute(payBtn, "disabled", "") return } ensureEncAnd(func(enc string) { PayLightningAddress(walletActiveID, recip, msats, comment, enc, finish) }) return } if isBolt11(recip) { inner := `{"invoice":` | helpers.JsonString(recip) | `}` ensureEncAnd(func(enc string) { NwcCall(walletActiveID, "pay_invoice", inner, enc, finish) }) return } dom.SetTextContent(walletPayResultEl, t("wallet_unknown_recipient")) dom.SetTextContent(payBtn, t("wallet_pay")) dom.SetAttribute(payBtn, "disabled", "") })) dom.AppendChild(walletContent, payBtn) } // handlePayResult parses an NWC response and updates walletPayResultEl. func handlePayResult(plain string) { if plain == "" { dom.SetTextContent(walletPayResultEl, t("wallet_pay_failed")) dom.SetStyle(walletPayResultEl, "color", "var(--accent)") return } errVal := helpers.JsonGetValue(plain, "error") if errVal != "" && errVal != "null" { reason := helpers.JsonGetString(plain, "message") if reason == "" { reason = helpers.JsonGetString(errVal, "message") } if reason == "" { reason = errVal } dom.SetTextContent(walletPayResultEl, t("wallet_pay_failed") | ": " | reason) dom.SetStyle(walletPayResultEl, "color", "var(--accent)") return } result := helpers.JsonGetValue(plain, "result") preimage := helpers.JsonGetString(result, "preimage") if preimage == "" { dom.SetTextContent(walletPayResultEl, t("wallet_pay_ok")) } else { pre := preimage if len(pre) > 16 { pre = pre[:16] | "..." } dom.SetTextContent(walletPayResultEl, t("wallet_pay_ok") | " - " | pre) } dom.SetStyle(walletPayResultEl, "color", "var(--accent)") // Re-fetch balance after successful pay. walletBalanceSats = -1 fetchWalletBalance() } // fetchWalletBalance calls get_balance and updates the UI. func fetchWalletBalance() { if walletActiveID == "" { dom.ConsoleLog("[wallet] fetchWalletBalance: no active wallet") return } dom.ConsoleLog("[wallet] fetchWalletBalance: start id=" | walletActiveID) ensureEncAnd(func(enc string) { dom.ConsoleLog("[wallet] fetchWalletBalance: enc=" | enc | " → get_balance") NwcCall(walletActiveID, "get_balance", "{}", enc, func(plain string) { if plain == "" { dom.ConsoleLog("[wallet] get_balance: empty (timeout or decrypt failed)") walletBalanceSats = -2 renderWalletBody() return } errVal := helpers.JsonGetValue(plain, "error") if errVal != "" && errVal != "null" { dom.ConsoleLog("[wallet] get_balance error: " | errVal) walletBalanceSats = -2 renderWalletBody() return } result := helpers.JsonGetValue(plain, "result") if result == "" { dom.ConsoleLog("[wallet] get_balance: no result field in " | plain) walletBalanceSats = -2 renderWalletBody() return } msats := parseI64(helpers.JsonGetValue(result, "balance")) walletBalanceSats = msats / 1000 dom.ConsoleLog("[wallet] get_balance: ok sats=" | helpers.Itoa(walletBalanceSats)) renderWalletBody() }) }) } // fetchWalletInfo calls get_info to retrieve lud16 + alias and cache them. func fetchWalletInfo() { if walletActiveID == "" { return } dom.ConsoleLog("[wallet] fetchWalletInfo: start") ensureEncAnd(func(enc string) { NwcCall(walletActiveID, "get_info", "{}", enc, func(plain string) { walletInfoLoaded = true if plain == "" { dom.ConsoleLog("[wallet] get_info: empty") return } result := helpers.JsonGetValue(plain, "result") if result == "" { dom.ConsoleLog("[wallet] get_info: no result in " | plain) return } walletLud16 = helpers.JsonGetString(result, "lud16") dom.ConsoleLog("[wallet] get_info: lud16=" | walletLud16) renderWalletBody() }) }) } func ensureEncAnd(fn func(string)) { if walletEnc != "" { fn(walletEnc) return } NwcCall(walletActiveID, "get_info", "{}", "nip44", func(plain string) { if plain != "" && helpers.JsonGetValue(plain, "result") != "" { walletEnc = "nip44" fn("nip44") return } NwcCall(walletActiveID, "get_info", "{}", "nip04", func(plain2 string) { if plain2 != "" && helpers.JsonGetValue(plain2, "result") != "" { walletEnc = "nip04" } else { walletEnc = "nip04" } fn(walletEnc) }) }) } // trimText strips leading/trailing ASCII whitespace. func trimText(s string) (sv string) { a := 0 b := len(s) for a < b && (s[a] == ' ' || s[a] == '\t' || s[a] == '\n' || s[a] == '\r') { a++ } for b > a && (s[b-1] == ' ' || s[b-1] == '\t' || s[b-1] == '\n' || s[b-1] == '\r') { b-- } return s[a:b] } func isLightningAddress(s string) (ok bool) { at := -1 for i := 0; i < len(s); i++ { if s[i] == '@' { at = i break } } return at > 0 && at < len(s)-1 } func isBolt11(s string) (ok bool) { // bolt11: starts with lnbc, lntb, lnbcrt, lnsb (case-insensitive). if len(s) < 4 { return false } return (s[0] == 'l' || s[0] == 'L') && (s[1] == 'n' || s[1] == 'N') } // formatSats returns a thousands-separated string for n sats. func formatSats(n int64) (s string) { if n < 0 { return "-" | formatSats(-n) } if n < 1000 { return i64toa(n) } return formatSats(n/1000) | "," | padZero3(n%1000) } func padZero3(n int64) (s string) { if n < 10 { return "00" | i64toa(n) } if n < 100 { return "0" | i64toa(n) } return i64toa(n) }