From ea8cc43e303877360cc191986b4e10c9195cba08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Sun, 24 Dec 2023 15:39:55 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20NWC=20connector=20=F0=9F=A4=99=20(#?= =?UTF-8?q?2898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: basic NWC connector implementation * fix: remove outdated connector template * feat: add nwc to umbrel * fix: close connection on unload * fix: more methods * feat: getinfo and keysend methods * feat: improve NWC connector instructions * feat: add getTransactions method * fix: nwc getInfo * fix: nwc transaction amount * fix: remove signMessage from nwc supported methods * fix: receive invoice container larger than popup * fix: response types in nwc connector methods fix getInfo, sendPayment, keysend, checkPayment, makeInvoice * chore: simplify nwc instructions * chore: remove old TODO * chore: add getTransaction in list of supported methods * chore: bump alby js sdk version --------- Co-authored-by: pavanjoshi914 Co-authored-by: Roland Bewick Co-authored-by: Roland <33993199+rolznz@users.noreply.github.com> --- package.json | 3 +- src/app/router/connectorRoutes.tsx | 10 + src/app/screens/ReceiveInvoice/index.tsx | 8 +- .../screens/connectors/ConnectNWC/index.tsx | 125 ++++++++++++ .../actions/ln/checkPayment.ts | 5 +- .../connectors/connector.example | 62 ------ .../background-script/connectors/index.ts | 2 + .../background-script/connectors/nwc.ts | 191 ++++++++++++++++++ src/extension/providers/webln/index.ts | 6 - src/i18n/locales/en/translation.json | 13 ++ static/assets/icons/nwc.svg | 1 + yarn.lock | 13 +- 12 files changed, 362 insertions(+), 77 deletions(-) create mode 100644 src/app/screens/connectors/ConnectNWC/index.tsx delete mode 100644 src/extension/background-script/connectors/connector.example create mode 100644 src/extension/background-script/connectors/nwc.ts create mode 100644 static/assets/icons/nwc.svg diff --git a/package.json b/package.json index ea2d6b0121..c4c90f19a8 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dependencies": { "@bitcoin-design/bitcoin-icons-react": "^0.1.10", "@bitcoinerlab/secp256k1": "^1.0.5", - "@getalby/sdk": "^3.1.0", + "@getalby/sdk": "^3.2.2", "@headlessui/react": "^1.7.16", "@lightninglabs/lnc-web": "^0.2.4-alpha", "@noble/curves": "^1.1.0", @@ -104,6 +104,7 @@ "@types/webextension-polyfill": "^0.10.5", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", + "@webbtc/webln-types": "^3.0.0", "autoprefixer": "^10.4.16", "buffer": "^6.0.3", "clean-webpack-plugin": "^4.0.0", diff --git a/src/app/router/connectorRoutes.tsx b/src/app/router/connectorRoutes.tsx index 417770ed69..c094d323f4 100644 --- a/src/app/router/connectorRoutes.tsx +++ b/src/app/router/connectorRoutes.tsx @@ -14,6 +14,7 @@ import ConnectUmbrel from "@screens/connectors/ConnectUmbrel"; import { Route } from "react-router-dom"; import i18n from "~/i18n/i18nConfig"; +import ConnectNWC from "~/app/screens/connectors/ConnectNWC"; import ConnectCommando from "../screens/connectors/ConnectCommando"; import btcpay from "/static/assets/icons/btcpay.svg"; import citadel from "/static/assets/icons/citadel.png"; @@ -27,6 +28,7 @@ import lnbits from "/static/assets/icons/lnbits.png"; import lnd from "/static/assets/icons/lnd.png"; import lndhubGo from "/static/assets/icons/lndhub_go.png"; import mynode from "/static/assets/icons/mynode.png"; +import nwc from "/static/assets/icons/nwc.svg"; import raspiblitz from "/static/assets/icons/raspiblitz.png"; import start9 from "/static/assets/icons/start9.png"; import umbrel from "/static/assets/icons/umbrel.png"; @@ -149,6 +151,12 @@ const connectorMap: { [key: string]: ConnectorRoute } = { title: i18n.t("translation:choose_connector.btcpay.title"), logo: btcpay, }, + nwc: { + path: "nwc", + element: , + title: i18n.t("translation:choose_connector.nwc.title"), + logo: nwc, + }, }; function getDistribution(key: string): ConnectorRoute { @@ -188,6 +196,7 @@ const distributionMap: { [key: string]: { logo: string; children: Route[] } } = connectorMap["lnc"], connectorMap["commando"], connectorMap["lnbits"], + connectorMap["nwc"], ], }, mynode: { @@ -235,6 +244,7 @@ function getConnectorRoutes(): ConnectorRoute[] { getDistribution("mynode"), getDistribution("start9"), getDistribution("raspiblitz"), + connectorMap["nwc"], ]; } diff --git a/src/app/screens/ReceiveInvoice/index.tsx b/src/app/screens/ReceiveInvoice/index.tsx index 02242e8e2c..5460dc8472 100644 --- a/src/app/screens/ReceiveInvoice/index.tsx +++ b/src/app/screens/ReceiveInvoice/index.tsx @@ -228,9 +228,11 @@ function ReceiveInvoice() { {t("title")} {invoice ? ( - - {renderInvoice()} - +
+ + {renderInvoice()} + +
) : (
diff --git a/src/app/screens/connectors/ConnectNWC/index.tsx b/src/app/screens/connectors/ConnectNWC/index.tsx new file mode 100644 index 0000000000..5aa1111f00 --- /dev/null +++ b/src/app/screens/connectors/ConnectNWC/index.tsx @@ -0,0 +1,125 @@ +import ConnectorForm from "@components/ConnectorForm"; +import TextField from "@components/form/TextField"; +import ConnectionErrorToast from "@components/toasts/ConnectionErrorToast"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import toast from "~/app/components/Toast"; +import msg from "~/common/lib/msg"; + +import logo from "/static/assets/icons/nwc.svg"; + +export default function ConnectNWC() { + const navigate = useNavigate(); + const { t } = useTranslation("translation", { + keyPrefix: "choose_connector.nwc", + }); + const [formData, setFormData] = useState({ + nostrWalletConnectUrl: "", + }); + const [loading, setLoading] = useState(false); + + function handleChange(event: React.ChangeEvent) { + setFormData({ + ...formData, + [event.target.name]: event.target.value.trim(), + }); + } + + function getConnectorType() { + return "nwc"; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + const { nostrWalletConnectUrl } = formData; + const account = { + name: "NWC", + config: { + nostrWalletConnectUrl, + }, + connector: getConnectorType(), + }; + + try { + const validation = await msg.request("validateAccount", account); + if (validation.valid) { + const addResult = await msg.request("addAccount", account); + if (addResult.accountId) { + await msg.request("selectAccount", { + id: addResult.accountId, + }); + navigate("/test-connection"); + } + } else { + console.error(validation); + toast.error( + + ); + } + } catch (e) { + console.error(e); + let message = t("page.errors.connection_failed"); + if (e instanceof Error) { + message += `\n\n${e.message}`; + } + toast.error(message); + } + setLoading(false); + } + + return ( + + + + } + description={ + , + // eslint-disable-next-line react/jsx-key + , + // eslint-disable-next-line react/jsx-key + , + ]} + /> + } + logo={logo} + submitLoading={loading} + submitDisabled={formData.nostrWalletConnectUrl === ""} + onSubmit={handleSubmit} + > +
+ +
+
+ ); +} diff --git a/src/extension/background-script/actions/ln/checkPayment.ts b/src/extension/background-script/actions/ln/checkPayment.ts index 0c3051b95b..a187f59edb 100644 --- a/src/extension/background-script/actions/ln/checkPayment.ts +++ b/src/extension/background-script/actions/ln/checkPayment.ts @@ -3,7 +3,10 @@ import { Message } from "~/types"; import state from "../../state"; const checkPayment = async (message: Message) => { - if (typeof message.args.paymentHash !== "string") { + if ( + typeof message.args.paymentHash !== "string" || + !message.args.paymentHash + ) { return { error: "Payment hash missing.", }; diff --git a/src/extension/background-script/connectors/connector.example b/src/extension/background-script/connectors/connector.example deleted file mode 100644 index 06a9aaf6c6..0000000000 --- a/src/extension/background-script/connectors/connector.example +++ /dev/null @@ -1,62 +0,0 @@ -import Connector, { - SendPaymentArgs, - SendPaymentResponse, - CheckPaymentArgs, - CheckPaymentResponse, - GetInfoResponse, - GetBalanceResponse, - MakeInvoiceArgs, - MakeInvoiceResponse, - SignMessageArgs, - SignMessageResponse, - VerifyMessageArgs, - VerifyMessageResponse, -} from "./connector.interface"; - -interface Config {} - -class ConnectorExample implements Connector { - config: Config; - - constructor(config: Config) { - this.config = config; - } - - init() { - // add your own implementation or return Promise.resolve(); - } - - unload() { - // add your own implementation or return Promise.resolve(); - } - - getInfo(): Promise { - // Add your own implementation. - } - - getBalance(): Promise { - // Add your own implementation. - } - - sendPayment(args: SendPaymentArgs): Promise { - // Add your own implementation. - } - - checkPayment(args: CheckPaymentArgs): Promise { - // Add your own implementation. - } - - signMessage(args: SignMessageArgs): Promise { - // Add your own implementation. - } - - verifyMessage(args: VerifyMessageArgs): Promise { - // Add your own implementation. - } - - makeInvoice(args: MakeInvoiceArgs): Promise { - // Add your own implementation. - } -} - -export default ConnectorExample; diff --git a/src/extension/background-script/connectors/index.ts b/src/extension/background-script/connectors/index.ts index f98ee5b268..95bbaef680 100644 --- a/src/extension/background-script/connectors/index.ts +++ b/src/extension/background-script/connectors/index.ts @@ -12,6 +12,7 @@ import NativeCitadel from "./nativecitadel"; import NativeLnBits from "./nativelnbits"; import NativeLnd from "./nativelnd"; import NativeLndHub from "./nativelndhub"; +import NWC from "./nwc"; /* const initialize = (account, password) => { @@ -36,6 +37,7 @@ const connectors = { nativecitadel: NativeCitadel, commando: Commando, alby: Alby, + nwc: NWC, }; export default connectors; diff --git a/src/extension/background-script/connectors/nwc.ts b/src/extension/background-script/connectors/nwc.ts new file mode 100644 index 0000000000..5f065f77d1 --- /dev/null +++ b/src/extension/background-script/connectors/nwc.ts @@ -0,0 +1,191 @@ +import { webln } from "@getalby/sdk"; +import { NostrWebLNProvider } from "@getalby/sdk/dist/webln"; +import lightningPayReq from "bolt11"; +import Hex from "crypto-js/enc-hex"; +import SHA256 from "crypto-js/sha256"; +import { Account } from "~/types"; +import Connector, { + CheckPaymentArgs, + CheckPaymentResponse, + ConnectPeerArgs, + ConnectPeerResponse, + ConnectorTransaction, + GetBalanceResponse, + GetInfoResponse, + GetTransactionsResponse, + KeysendArgs, + MakeInvoiceArgs, + MakeInvoiceResponse, + SendPaymentArgs, + SendPaymentResponse, + SignMessageArgs, + SignMessageResponse, +} from "./connector.interface"; + +interface Config { + nostrWalletConnectUrl: string; +} + +class NWCConnector implements Connector { + config: Config; + nwc: NostrWebLNProvider; + + get supportedMethods() { + return [ + "getInfo", + "makeInvoice", + "sendPayment", + "sendPaymentAsync", + "getBalance", + "keysend", + "getTransactions", + ]; + } + + constructor(account: Account, config: Config) { + this.config = config; + this.nwc = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: this.config.nostrWalletConnectUrl, + }); + } + + async init() { + return this.nwc.enable(); + } + + async unload() { + this.nwc.close(); + } + + async getInfo(): Promise { + const info = await this.nwc.getInfo(); + return { + data: info.node, + }; + } + + async getBalance(): Promise { + const balance = await this.nwc.getBalance(); + return { + data: { balance: balance.balance, currency: "BTC" }, + }; + } + + async getTransactions(): Promise { + const listTransactionsResponse = await this.nwc.listTransactions({ + unpaid: false, + limit: 50, // restricted by relay max event payload size + }); + + const transactions: ConnectorTransaction[] = + listTransactionsResponse.transactions.map( + (transaction, index): ConnectorTransaction => ({ + id: `${index}`, + memo: transaction.description, + preimage: transaction.preimage, + payment_hash: transaction.payment_hash, + settled: true, + settleDate: new Date(transaction.settled_at).getTime(), + totalAmount: transaction.amount, + type: transaction.type == "incoming" ? "received" : "sent", + }) + ); + return { + data: { + transactions, + }, + }; + } + + async makeInvoice(args: MakeInvoiceArgs): Promise { + const invoice = await this.nwc.makeInvoice({ + amount: args.amount, + defaultMemo: args.memo, + }); + + const decodedInvoice = lightningPayReq.decode(invoice.paymentRequest); + const paymentHash = decodedInvoice.tags.find( + (tag) => tag.tagName === "payment_hash" + )?.data as string | undefined; + if (!paymentHash) { + throw new Error("Could not find payment hash in invoice"); + } + + return { + data: { + paymentRequest: invoice.paymentRequest, + rHash: paymentHash, + }, + }; + } + + async sendPayment(args: SendPaymentArgs): Promise { + const invoice = lightningPayReq.decode(args.paymentRequest); + const paymentHash = invoice.tags.find( + (tag) => tag.tagName === "payment_hash" + )?.data as string | undefined; + if (!paymentHash) { + throw new Error("Could not find payment hash in invoice"); + } + + const response = await this.nwc.sendPayment(args.paymentRequest); + return { + data: { + preimage: response.preimage, + paymentHash, + route: { + // TODO: how to get amount paid for zero-amount invoices? + total_amt: parseInt(invoice.millisatoshis || "0") / 1000, + // TODO: How to get fees from WebLN? + total_fees: 0, + }, + }, + }; + } + + async keysend(args: KeysendArgs): Promise { + const data = await this.nwc.keysend({ + destination: args.pubkey, + amount: args.amount, + customRecords: args.customRecords, + }); + + const paymentHash = SHA256(data.preimage).toString(Hex); + + return { + data: { + preimage: data.preimage, + paymentHash, + + route: { + total_amt: args.amount, + // TODO: How to get fees from WebLN? + total_fees: 0, + }, + }, + }; + } + + async checkPayment(args: CheckPaymentArgs): Promise { + const response = await this.nwc.lookupInvoice({ + paymentHash: args.paymentHash, + }); + + return { + data: { + paid: response.paid, + preimage: response.preimage, + }, + }; + } + + signMessage(args: SignMessageArgs): Promise { + throw new Error("Method not implemented."); + } + + connectPeer(args: ConnectPeerArgs): Promise { + throw new Error("Method not implemented."); + } +} + +export default NWCConnector; diff --git a/src/extension/providers/webln/index.ts b/src/extension/providers/webln/index.ts index 2ed7815354..1b2e85d6d5 100644 --- a/src/extension/providers/webln/index.ts +++ b/src/extension/providers/webln/index.ts @@ -1,11 +1,5 @@ import ProviderBase from "~/extension/providers/providerBase"; -declare global { - interface Window { - webln: WebLNProvider; - } -} - type RequestInvoiceArgs = { amount?: string | number; defaultAmount?: string | number; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 26fc7a1d51..906eaf96ba 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -285,6 +285,19 @@ "errors": { "connection_failed": "Connection failed. Is your Core Lightning node online and using the commando plugin?" } + }, + "nwc": { + "title": "Nostr Wallet Connect", + "page": { + "instructions": "Paste a NWC connection string from <0>Alby Nostr Wallet Connect, <1>Umbrel Nostr Wallet Connect, or <2>Mutiny Wallet", + "url": { + "label": "Nostr Wallet Connect URL", + "placeholder": "nostr+walletconnect://69effe..." + }, + "errors": { + "connection_failed": "Connection failed. Is your Core Lightning node online and using the commando plugin?" + } + } } }, "distributions": { diff --git a/static/assets/icons/nwc.svg b/static/assets/icons/nwc.svg new file mode 100644 index 0000000000..7d9c015292 --- /dev/null +++ b/static/assets/icons/nwc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8d6a01111a..5694c21c67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -666,10 +666,10 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.52.0.tgz#78fe5f117840f69dc4a353adf9b9cd926353378c" integrity sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA== -"@getalby/sdk@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-3.1.0.tgz#49a6d7b292f3c6ab1c37e72422aa0f0ec8f43226" - integrity sha512-1WwwMfrCRtlUv3BnT/rqYiE5giztH5ZxfT1fDwhaJGeC8EJXxGjFBbaUhE0Wq98Fcs/hKoGM4gSmp3UHFDuQxg== +"@getalby/sdk@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-3.2.2.tgz#a640fef78f4462fd8924eab9ab8a8f9a0339a11e" + integrity sha512-G4Ooteo/5D6SXB+y8OK8gxXWALGh4HFgq8ZqT3rBMo3FV7U/fDjf+/jn/SMsJ7ub/nEzUBBTGdfARdVoYqMvSQ== dependencies: events "^3.3.0" nostr-tools "^1.17.0" @@ -2102,6 +2102,11 @@ "@webassemblyjs/ast" "1.11.6" "@xtuc/long" "4.2.2" +"@webbtc/webln-types@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-3.0.0.tgz#448b2138423865087ba8859e9e6430fc2463b864" + integrity sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ== + "@webpack-cli/configtest@^2.1.1": version "2.1.1" resolved "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz" From ac7464b566b9d20487b283abb234a74b1f640ec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Sun, 24 Dec 2023 15:47:59 +0100 Subject: [PATCH 2/6] fix: core-lightning apis (#2951) * fix: channel_sat is undefined for unconfirmed channels * fix: update type from cln-application * fix: filter channels by state * Update src/extension/background-script/connectors/commando.ts --- .../background-script/connectors/commando.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/extension/background-script/connectors/commando.ts b/src/extension/background-script/connectors/commando.ts index bb4cce861c..c04488f17f 100644 --- a/src/extension/background-script/connectors/commando.ts +++ b/src/extension/background-script/connectors/commando.ts @@ -47,13 +47,16 @@ type CommandoMakeInvoiceResponse = { payment_secret: string; }; type CommandoChannel = { - peer_id: string; - channel_sat: number; - amount_msat: number; - funding_txid: string; - funding_output: number; - connected: boolean; - state: string; + peer_id?: string; + funding_txid?: string; + funding_output?: string; + connected?: boolean; + state?: string; + short_channel_id?: string; + our_amount_msat?: number; + channel_sat?: number; + channel_total_sat?: number; + amount_msat?: number; }; type CommandoListFundsResponse = { channels: CommandoChannel[]; @@ -80,7 +83,7 @@ type CommandoInvoice = { label: string; status: string; description: string; - msatoshi: number; + amount_msat: number; bolt11: string; payment_preimage: string; paid_at: number; @@ -233,7 +236,7 @@ export default class Commando implements Connector { payment_hash: invoice.payment_hash, settleDate: invoice.paid_at * 1000, type: "received", - totalAmount: Math.floor(invoice.msatoshi / 1000), + totalAmount: Math.floor(invoice.amount_msat / 1000), }) ) .filter((invoice) => invoice.settled); @@ -303,10 +306,17 @@ export default class Commando implements Connector { params: {}, rune: this.config.rune, })) as CommandoListFundsResponse; - const lnBalance = response.channels.reduce( - (balance, channel) => balance + channel.channel_sat, - 0 - ); + // https://github.com/ElementsProject/cln-application/blob/main/apps/frontend/src/store/AppContext.tsx#L139 + const lnBalance = response.channels + .filter((x) => x.connected && x.state == "CHANNELD_NORMAL") + .reduce( + (balance, channel) => + balance + + Math.floor( + channel.channel_sat || (channel.our_amount_msat || 0) / 1000 + ), + 0 + ); return { data: { balance: lnBalance, From 299be7b1c42b682c76cb0fb5b77c0793723b31cb Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sat, 23 Dec 2023 12:08:28 +0000 Subject: [PATCH 3/6] Translated using Weblate (Portuguese (Brazil)) Currently translated at 84.6% (605 of 715 strings) Co-authored-by: Leonardo Translate-URL: https://hosted.weblate.org/projects/getalby-lightning-browser-extension/getalby-lightning-browser-extension/pt_BR/ Translation: getAlby - lightning-browser-extension/getAlby - lightning-browser-extension --- src/i18n/locales/pt_BR/translation.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/pt_BR/translation.json b/src/i18n/locales/pt_BR/translation.json index 4750b51447..77fabf7a4c 100644 --- a/src/i18n/locales/pt_BR/translation.json +++ b/src/i18n/locales/pt_BR/translation.json @@ -149,12 +149,18 @@ "title": "Carteira Blink", "page": { "title": "Conecte-se na <0>Carteira Blink" + }, + "token": { + "label": "Insira a chave API" } }, "bitcoin_jungle": { "title": "Carteira Bitcoin Jungle", "page": { "title": "Conecte-se na <0>Carteira Bitcoin Jungle" + }, + "token": { + "label": "Insira o token de acesso" } }, "btcpay": { @@ -317,7 +323,7 @@ "description": "Você pode gerar chaves Nostr a partir de sua chave mestra ou importar sua chave privada existente colando-a no campo \"Chave privada Nostr\".", "no_secret_key": "💡 Você ainda não tem uma chave mestra. <0>Clique aqui
para gerar sua chave mestra e suas chaves Nostr.", "imported_key_warning": "⚠️ No momento, você está usando uma chave Nostr importada ou gerada aleatoriamente. Sua chave privada Nostr não pode ser restaurada por sua frase de recuperação, então lembre-se de fazer o backup de sua chave privada Nostr separadamente.", - "remove": "Remover chaves existentes" + "remove": "Remover esta chave" }, "setup": { "title": "Configure suas chaves Nostr", @@ -372,7 +378,7 @@ }, "backup": { "save": "Salvar chave mestra", - "button": "Ver frase de recuperação", + "button": "Frase de recuperação", "protocols": { "nostr": "Nostr" }, @@ -1001,7 +1007,8 @@ "block_added": "{{host}} adicionado à lista de bloqueio, recarregue o site.", "request1": "Solicitar aprovação para transações", "allow": "Permitir este site:", - "block_and_ignore": "Bloquear e ignorar {{host}}" + "block_and_ignore": "Bloquear e ignorar {{host}}", + "insecure_domain_warn": "⚠️ Você está se conectando a um domínio inseguro." }, "website": "Site", "apps": "Apps" From b8ba558d9667c6200b18688ff0aa7d39a99bace2 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sat, 23 Dec 2023 12:08:29 +0000 Subject: [PATCH 4/6] Translated using Weblate (German) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (715 of 715 strings) Co-authored-by: BSN ∞/21M ₿ Translate-URL: https://hosted.weblate.org/projects/getalby-lightning-browser-extension/getalby-lightning-browser-extension/de/ Translation: getAlby - lightning-browser-extension/getAlby - lightning-browser-extension --- src/i18n/locales/de/translation.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index eef68ab752..c6d3ab8916 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -1137,7 +1137,8 @@ "block_added": "{{host}} zur Blockliste hinzugefügt, bitte lade die Webseite neu.", "request1": "Genehmigung von Transaktionen beantragen", "allow": "Erlaube dieser Webseite:", - "block_and_ignore": "Blockieren und Ignorieren von {{host}}" + "block_and_ignore": "Blockieren und Ignorieren von {{host}}", + "insecure_domain_warn": "⚠️ Du stellst eine Verbindung zu einer unsicheren Domäne her." }, "website": "Website", "apps": "Apps" From 279dc88212cd00b5a396ee8f73a977fd5ba2e8b4 Mon Sep 17 00:00:00 2001 From: Hosted Weblate Date: Sat, 23 Dec 2023 12:08:29 +0000 Subject: [PATCH 5/6] Translated using Weblate (Greek) Currently translated at 18.1% (130 of 715 strings) Co-authored-by: Nikos Charonitakis Translate-URL: https://hosted.weblate.org/projects/getalby-lightning-browser-extension/getalby-lightning-browser-extension/el/ Translation: getAlby - lightning-browser-extension/getAlby - lightning-browser-extension --- src/i18n/locales/el/translation.json | 223 ++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/el/translation.json b/src/i18n/locales/el/translation.json index ac7980b5b7..1c56921a6b 100644 --- a/src/i18n/locales/el/translation.json +++ b/src/i18n/locales/el/translation.json @@ -36,10 +36,12 @@ "choose_path": { "other": { "title": "Άλλα πορτοφόλια", - "connect": "Σύνδεση" + "connect": "Σύνδεση", + "and_more": "& περισσότερα..." }, "alby": { - "title": "Λογαριασμός Alby" + "title": "Λογαριασμός Alby", + "connect": "Σύνδεση με Alby" } }, "alby": { @@ -64,7 +66,10 @@ "title": "LNbits" }, "lndhub_go": { - "title": "LNDHub" + "title": "LNDHub", + "page": { + "title": "Σύνδεση σε LNDHub" + } }, "citadel": { "title": "Citadel", @@ -83,10 +88,16 @@ "title": "Start9" }, "btcpay": { - "title": "BTCPay Server" + "title": "BTCPay Server", + "page": { + "title": "Σύνδεση στο BTCPay LND κόμβο σας" + } }, "lnd": { - "title": "LND" + "title": "LND", + "page": { + "title": "Σύνδεση στον LND κόμβο σας" + } }, "eclair": { "title": "Eclair", @@ -101,7 +112,11 @@ "commando": { "port": { "label": "Θύρα" - } + }, + "pubkey": { + "label": "Δημόσιο κλειδί" + }, + "title": "Core Lightning" }, "title": "Σύνδεση με πορτοφόλι Lightning", "description": "Σύνδεση σε εξωτερικό πορτοφόλι lightning ή σε κόμβο", @@ -110,6 +125,26 @@ "title": "Σύνδεση σε κόμβο <0>Umbrel" }, "title": "Umbrel" + }, + "umbrel_lightning_node": { + "title": "Κόμβος Lightning" + }, + "galoy": { + "actions": { + "login": "Σύνδεση" + }, + "phone_number": { + "label": "Εισάγετε τον αριθμό τηλεφώνου σας" + }, + "errors": { + "setup_failed": "Η ρύθμιση απέτυχε" + } + }, + "raspiblitz": { + "page": { + "title": "Σύνδεση στον <0>RaspiBlitz<0> κόμβο σας" + }, + "title": "RaspiBlitz" } }, "distributions": { @@ -124,6 +159,9 @@ }, "start9": { "name": "Start9" + }, + "citadel": { + "name": "Citadel" } }, "accounts": { @@ -136,7 +174,8 @@ "protocols": { "nostr": "Nostr" } - } + }, + "title": "Διαχείριση κλειδιών" }, "network": { "title": "Δίκτυο" @@ -150,9 +189,179 @@ "setup": { "import": { "label": "Εισαγωγή ενός λογαριασμού Nostr" + }, + "new": { + "label": "Δημιουργία νέου λογαριασμού Nostr" } + }, + "public_key": { + "label": "Δημόσιο κλειδί Nostr" } + }, + "export": { + "your_ln_address": "Η Lightning διεύθυνση σας:", + "screen_reader": "Εξαγωγή λεπτομερειών λογαριασμού", + "title": "Σύνδεση με το πορτοφόλι του κινητού σας" + }, + "remove": { + "title": "Διαγραφή αυτού του λογαριασμού" + }, + "actions": { + "export": "Εξαγωγή" } + }, + "actions": { + "add_account": "Προσθήκη λογαριασμού" + } + }, + "discover": { + "list": { + "gaming": "Παιχνίδια", + "nostr": "Nostr", + "shopping": "Αγορές", + "entertainment": "Διασκέδαση" + }, + "tips": { + "top_up_wallet": { + "title": "Αγορά Bitcoin" + } + } + }, + "nostr_enable": { + "title": "Σύνδεση στο Nostr" + }, + "lnurlredeem": { + "actions": { + "withdraw": "Ανάληψη" + }, + "input": { + "placeholder": "LNURL..." + } + }, + "receive": { + "amount": { + "placeholder": "Ποσό σε Satoshi...", + "label": "Ποσό" + }, + "description": { + "label": "Περιγραφήή" + }, + "actions": { + "bitcoin_address": { + "title": "Διεύθυνση Bitcoin" + } + }, + "payment": { + "waiting": "αναμονή για πληρωμή..." + } + }, + "settings": { + "name": { + "title": "Όνομα" + }, + "currency": { + "title": "Νόμισμα" + }, + "nostr": { + "title": "Nostr" + }, + "language": { + "title": "Γλώσσα" + }, + "theme": { + "options": { + "system": "Σύστημα", + "dark": "Σκοτεινό", + "light": "Φωτεινό" + } + }, + "camera_access": { + "title": "Πρόσβαση κάμερας" + }, + "title": "Ρυθμίσεις" + }, + "home": { + "default_view": { + "see_all": "Δείτε τα όλα" + }, + "actions": { + "enable_now": "Ενεργοποίηση τώρα" + } + }, + "webbtc_enable": { + "title": "Σύνδεση στο WebBTC" + }, + "lnurlpay": { + "comment": { + "label": "Σχόλιο" + } + }, + "lnurlwithdraw": { + "title": "Ανάληψη", + "amount": { + "label": "Ποσό" + }, + "content_message": { + "heading": "Ποσό" + } + }, + "liquid_enable": { + "title": "Σύνδεση στο Liquid" + }, + "send": { + "input": { + "label": "Παραλήπτης" + } + }, + "unlock": { + "help_contact": { + "part2": "Υποστήριξη Alby" + } + }, + "alby_enable": { + "title": "Σύνδεση στο Alby" + }, + "transactions": { + "title": "Συναλλαγές" + } + }, + "common": { + "confirm_password": "Επιβεβαίωση κωδικού", + "actions": { + "edit": "Επεξεργασία", + "download": "Λήψη", + "send": "Αποστολή", + "delete": "Διαγραφή", + "back": "Πίσω", + "continue": "Συνέχεια", + "more": "Περισσότερα", + "next": "Επόμενο", + "confirm": "Επιβεβαίωση", + "lock": "Κλείδωμα", + "transactions": "Συναλλαγές", + "export": "Εξαγωγή", + "disconnect": "Αποσύνδεση" + }, + "message": "Μήνυμα", + "help": "Βοήθεια", + "error": "Σφάλμα", + "description": "Περιγραφή", + "optional": "Προαιρετικό", + "wallet": "Πορτοφόλι", + "password": "Κωδικός", + "amount": "Ποσό", + "copied": "Έγινε αντιγραφή!", + "discover": "Ανακάλυψε", + "pasted": "Έγινε επικόλληση!", + "accounts": "Λογαριασμοί", + "settings": "Ρυθμίσεις", + "success": "Επιτυχία", + "description_full": "Πλήρης περιγραφή" + }, + "components": { + "transactions_table": { + "boostagram": { + "totalAmount": "Συνολικό ποσό" } } } From a566ab36dbbc367da9d66c20747007d3cf4a88d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Sun, 24 Dec 2023 15:56:34 +0100 Subject: [PATCH 6/6] feat: new onboarding (#2952) * feat: new onboarding * fix: links * fix: translations & buttons * fix: remove fixed width * fix: translations * fix: text color * fix: update link * fix: connector screen * feat: voltage connector * fix: default name * fix: react keys --------- Co-authored-by: Michael Bumann --- package.json | 1 + src/app/components/ConnectorPath/index.tsx | 16 +- src/app/components/LinkButton/index.tsx | 6 +- src/app/router/connectorRoutes.tsx | 15 ++ .../connectors/ChooseConnector/index.tsx | 42 +++-- .../connectors/ChooseConnectorPath/index.tsx | 149 +++++++++++++----- .../screens/connectors/ConnectAlby/index.tsx | 2 +- .../connectors/ConnectVoltage/index.tsx | 142 +++++++++++++++++ src/i18n/locales/en/translation.json | 50 +++++- static/assets/icons/voltage.png | Bin 0 -> 3643 bytes yarn.lock | 5 + 11 files changed, 351 insertions(+), 77 deletions(-) create mode 100644 src/app/screens/connectors/ConnectVoltage/index.tsx create mode 100644 static/assets/icons/voltage.png diff --git a/package.json b/package.json index c4c90f19a8..7af7807546 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@lightninglabs/lnc-web": "^0.2.4-alpha", "@noble/curves": "^1.1.0", "@noble/secp256k1": "^2.0.0", + "@popicons/react": "^0.0.8", "@scure/bip32": "^1.3.1", "@scure/bip39": "^1.2.1", "@tailwindcss/forms": "^0.5.4", diff --git a/src/app/components/ConnectorPath/index.tsx b/src/app/components/ConnectorPath/index.tsx index 8fa143c010..8a524597f9 100644 --- a/src/app/components/ConnectorPath/index.tsx +++ b/src/app/components/ConnectorPath/index.tsx @@ -3,16 +3,18 @@ type Props = { description: string; content: React.ReactNode; actions: React.ReactNode; + icon: React.ReactNode; }; -function ConnectorPath({ title, description, content, actions }: Props) { +function ConnectorPath({ title, icon, description, content, actions }: Props) { return ( -
-

{title}

-

- {description} -

-
+
+
+ {icon} +

{title}

+
+

{description}

+
{content}
{actions}
diff --git a/src/app/components/LinkButton/index.tsx b/src/app/components/LinkButton/index.tsx index d8d19a7d05..10f2a50c67 100644 --- a/src/app/components/LinkButton/index.tsx +++ b/src/app/components/LinkButton/index.tsx @@ -8,9 +8,9 @@ type Props = { export default function LinkButton({ to, title, logo }: Props) { return ( - -
-
+ +
+
logo
diff --git a/src/app/router/connectorRoutes.tsx b/src/app/router/connectorRoutes.tsx index c094d323f4..0c59c75083 100644 --- a/src/app/router/connectorRoutes.tsx +++ b/src/app/router/connectorRoutes.tsx @@ -14,6 +14,7 @@ import ConnectUmbrel from "@screens/connectors/ConnectUmbrel"; import { Route } from "react-router-dom"; import i18n from "~/i18n/i18nConfig"; +import ConnectVoltage from "~/app/screens/connectors/ConnectVoltage"; import ConnectNWC from "~/app/screens/connectors/ConnectNWC"; import ConnectCommando from "../screens/connectors/ConnectCommando"; import btcpay from "/static/assets/icons/btcpay.svg"; @@ -32,6 +33,7 @@ import nwc from "/static/assets/icons/nwc.svg"; import raspiblitz from "/static/assets/icons/raspiblitz.png"; import start9 from "/static/assets/icons/start9.png"; import umbrel from "/static/assets/icons/umbrel.png"; +import voltage from "/static/assets/icons/voltage.png"; export const normalizeKey = (key: string) => key as unknown as TemplateStringsArray; @@ -97,6 +99,12 @@ const connectorMap: { [key: string]: ConnectorRoute } = { title: i18n.t("translation:choose_connector.lnd.title"), logo: lnd, }, + "voltage-lnd": { + path: "lnd", + element: , + title: i18n.t("translation:choose_connector.lnd.title"), + logo: lnd, + }, lnc: { path: "lnc", element: , @@ -151,6 +159,12 @@ const connectorMap: { [key: string]: ConnectorRoute } = { title: i18n.t("translation:choose_connector.btcpay.title"), logo: btcpay, }, + voltage: { + path: "voltage", + element: , + title: i18n.t("translation:choose_connector.voltage.title"), + logo: voltage, + }, nwc: { path: "nwc", element: , @@ -237,6 +251,7 @@ function getConnectorRoutes(): ConnectorRoute[] { connectorMap["lnd-hub-go"], connectorMap["eclair"], connectorMap["btcpay"], + connectorMap["voltage"], connectorMap[galoyPaths.blink], connectorMap[galoyPaths.bitcoinJungle], getDistribution("citadel"), diff --git a/src/app/screens/connectors/ChooseConnector/index.tsx b/src/app/screens/connectors/ChooseConnector/index.tsx index 9405a956ac..e209298570 100644 --- a/src/app/screens/connectors/ChooseConnector/index.tsx +++ b/src/app/screens/connectors/ChooseConnector/index.tsx @@ -14,28 +14,26 @@ export default function ChooseConnector({ }: Props) { return ( <> -
-
-
-

- {title} -

- {description && ( -

- {description} -

- )} -
-
- {connectorRoutes.map(({ path, title, logo }) => ( -
- -
- ))} -
+
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {connectorRoutes.map(({ path, title, logo }) => ( +
+ +
+ ))}
diff --git a/src/app/screens/connectors/ChooseConnectorPath/index.tsx b/src/app/screens/connectors/ChooseConnectorPath/index.tsx index 5301695f60..87fb98ce6e 100644 --- a/src/app/screens/connectors/ChooseConnectorPath/index.tsx +++ b/src/app/screens/connectors/ChooseConnectorPath/index.tsx @@ -1,66 +1,143 @@ +import { + PopiconsCircleCheckLine, + PopiconsCircleExclamationLine, + PopiconsCircleXLine, +} from "@popicons/react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import Button from "~/app/components/Button"; import ConnectorPath from "~/app/components/ConnectorPath"; -import { getConnectorRoutes } from "~/app/router/connectorRoutes"; import ConnectAlby from "~/app/screens/connectors/ConnectAlby"; -import i18n from "~/i18n/i18nConfig"; - -import alby from "/static/assets/icons/alby.png"; export default function ChooseConnectorPath() { - let connectorRoutes = getConnectorRoutes(); - - i18n.on("languageChanged", () => { - connectorRoutes = getConnectorRoutes(); - }); const { t } = useTranslation("translation", { keyPrefix: "choose_path", }); return ( -
+
-
+

+ {t("title")} +

+
+ + + + +
+ } + description={t("other.description")} content={ - logo + <> + {t("other.point1")} + {t("other.point2")} + {t("other.point3")} + {t("other.point4")} + + } + actions={ + +
); } + +const FeatureItem = ({ + type, + children, +}: { + type: "success" | "disabled" | "warning"; + children: React.ReactNode; +}) => ( +
+
+ {type == "success" && ( + + )} + {type == "disabled" && ( + + )} + {type == "warning" && ( + + )} +
+
{children}
+
+); diff --git a/src/app/screens/connectors/ConnectAlby/index.tsx b/src/app/screens/connectors/ConnectAlby/index.tsx index 5f6c4d29a2..b0aeaf187e 100644 --- a/src/app/screens/connectors/ConnectAlby/index.tsx +++ b/src/app/screens/connectors/ConnectAlby/index.tsx @@ -78,8 +78,8 @@ export default function ConnectAlby() { label={t("connect")} loading={loading} disabled={loading} - primary flex + outline onClick={connectAlby} /> ); diff --git a/src/app/screens/connectors/ConnectVoltage/index.tsx b/src/app/screens/connectors/ConnectVoltage/index.tsx new file mode 100644 index 0000000000..0ff0e11dab --- /dev/null +++ b/src/app/screens/connectors/ConnectVoltage/index.tsx @@ -0,0 +1,142 @@ +import ConnectorForm from "@components/ConnectorForm"; +import TextField from "@components/form/TextField"; +import ConnectionErrorToast from "@components/toasts/ConnectionErrorToast"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import Hyperlink from "~/app/components/Hyperlink"; +import PasswordViewAdornment from "~/app/components/PasswordViewAdornment"; +import toast from "~/app/components/Toast"; +import msg from "~/common/lib/msg"; +import logo from "/static/assets/icons/voltage.png"; + +const initialFormData = { + url: "", + macaroon: "", +}; + +export default function ConnectVoltage() { + const navigate = useNavigate(); + const { t } = useTranslation("translation", { + keyPrefix: "choose_connector.voltage", + }); + const [formData, setFormData] = useState(initialFormData); + const [loading, setLoading] = useState(false); + const [macaroonVisible, setMacaroonVisible] = useState(false); + + function handleChange(event: React.ChangeEvent) { + setFormData({ + ...formData, + [event.target.name]: event.target.value.trim(), + }); + } + + function getConnectorType() { + return "lnd"; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + const { url, macaroon } = formData; + const account = { + name: "Voltage", + config: { + macaroon, + url, + }, + connector: getConnectorType(), + }; + + try { + const validation = await msg.request("validateAccount", account); + + if (validation.valid) { + const addResult = await msg.request("addAccount", account); + if (addResult.accountId) { + await msg.request("selectAccount", { + id: addResult.accountId, + }); + navigate("/test-connection"); + } + } else { + toast.error( + , + // Don't auto-close + { duration: 100_000 } + ); + } + } catch (e) { + console.error(e); + let message = ""; + if (e instanceof Error) { + message += `${e.message}`; + } + toast.error( + + ); + } + setLoading(false); + } + + return ( + , + ]} + /> + } + logo={logo} + submitLoading={loading} + submitDisabled={formData.url === "" || formData.macaroon === ""} + onSubmit={handleSubmit} + > +
+ +
+
+ { + setMacaroonVisible(passwordView); + }} + /> + } + /> +
+
+ ); +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 906eaf96ba..dbcf1306e4 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -36,16 +36,33 @@ } }, "choose_path": { + "title": "Connect Wallet to Alby Extension", + "other": { + "title": "Bring Your Own Wallet", + "description": "Best if you already have a wallet or need to do high-volume payments.", + "connect": "Connect", + "point1": "Start instantly, no waiting for an invite", + "point2": "No transaction or volume limits", + "point3": "Both self-custody and custodial options", + "point4": "No lightning and nostr address" + }, + "voltage": { + "title": "Voltage Cloud Node", + "description": "Great if you don't mind paying for a lightning node cloud service.", + "connect": "Connect", + "point1": "Start instantly, no waiting for an invite", + "point2": "No transaction or volume limits", + "point3": "Full self-custody and higher privacy", + "point4": "Unique lightning and nostr address" + }, "alby": { "title": "Alby Account", - "description": "Sign up or use your existing Alby Account. Great to quickly get started and for small amounts.", - "connect": "Connect with Alby" - }, - "other": { - "title": "Other Wallets", - "description": "Connect to your existing lightning wallet or node and choose from various connectors. Great for full flexibility. Run your own node.", - "and_more": "& more...", - "connect": "Connect" + "description": "Ideal for beginners or anyone looking for a great daily spending wallet.", + "connect": "Connect with Alby", + "point1": "New signups require an invite code", + "point2": "Transaction volume limits apply", + "point3": "Set up and login with your email", + "point4": "Unique lightning and nostr address" } }, "alby": { @@ -286,6 +303,23 @@ "connection_failed": "Connection failed. Is your Core Lightning node online and using the commando plugin?" } }, + "voltage": { + "title": "Voltage", + "page": { + "title": "Connect to your Voltage node", + "description": "Enterprise-grade lightning node hosting. Get on Lightning Network faster than ever. Total control with zero management. <0>Learn more" + }, + "url": { + "label": "Node URL", + "placeholder": "https://your-node.m.voltageapp.io:8080" + }, + "macaroon": { + "label": "Admin Macaroon (hex)" + }, + "errors": { + "connection_failed": "Connection failed. Are your credentials correct?" + } + }, "nwc": { "title": "Nostr Wallet Connect", "page": { diff --git a/static/assets/icons/voltage.png b/static/assets/icons/voltage.png new file mode 100644 index 0000000000000000000000000000000000000000..93ac44568bdcfc741cec7ae4fbe4081682c72371 GIT binary patch literal 3643 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