diff options
author | Christian Blättler <blatc2@bfh.ch> | 2024-05-01 08:00:06 +0200 |
---|---|---|
committer | Christian Blättler <blatc2@bfh.ch> | 2024-05-01 08:00:06 +0200 |
commit | 8d1ce9dae1fd94204c142ac599b498bec9680b6c (patch) | |
tree | fc6a55104ca6a457d67336db5757ec442824e074 /packages/taler-wallet-webextension | |
parent | 09046010252b134348de8b18c0c99ffea4e3c95d (diff) | |
parent | 20d2861508df18da18e66c94a5a268067565121b (diff) |
Merge branch 'master' into feature/tokens
# Conflicts:
# packages/auditor-backoffice-ui/src/InstanceRoutes.tsx
# packages/merchant-backoffice-ui/src/declaration.d.ts
# packages/merchant-backoffice-ui/src/schemas/index.ts
Diffstat (limited to 'packages/taler-wallet-webextension')
123 files changed, 8797 insertions, 3651 deletions
diff --git a/packages/taler-wallet-webextension/.eslintrc.cjs b/packages/taler-wallet-webextension/.eslintrc.cjs new file mode 100644 index 000000000..05618b499 --- /dev/null +++ b/packages/taler-wallet-webextension/.eslintrc.cjs @@ -0,0 +1,28 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'header'], + root: true, + rules: { + "react/no-unknown-property": 0, + "react/no-unescaped-entities": 0, + "@typescript-eslint/no-namespace": 0, + "@typescript-eslint/no-unused-vars": [2,{argsIgnorePattern:"^_"}], + "header/header": [2,"copyleft-header.js"] + }, + parserOptions: { + ecmaVersion: 6, + sourceType: 'module', + jsx: true, + }, + settings: { + react: { + version: "18", + pragma: "h", + } + }, +}; diff --git a/packages/taler-wallet-webextension/manifest-common.json b/packages/taler-wallet-webextension/manifest-common.json index a08192d79..32bd5267f 100644 --- a/packages/taler-wallet-webextension/manifest-common.json +++ b/packages/taler-wallet-webextension/manifest-common.json @@ -2,7 +2,7 @@ "name": "GNU Taler Wallet (git)", "description": "Privacy preserving and transparent payments", "author": "GNU Taler Developers", - "version": "0.9.3.34", + "version": "0.10.7", "icons": { "16": "static/img/taler-logo-16.png", "19": "static/img/taler-logo-19.png", @@ -13,5 +13,6 @@ "128": "static/img/taler-logo-128.png", "256": "static/img/taler-logo-256.png", "512": "static/img/taler-logo-512.png" - } -}
\ No newline at end of file + }, + "version_name": "0.10.7" +} diff --git a/packages/taler-wallet-webextension/manifest-v2.json b/packages/taler-wallet-webextension/manifest-v2.json index 3475cd8aa..6f2096b05 100644 --- a/packages/taler-wallet-webextension/manifest-v2.json +++ b/packages/taler-wallet-webextension/manifest-v2.json @@ -18,7 +18,6 @@ "permissions": [ "unlimitedStorage", "storage", - "webRequest", "<all_urls>", "activeTab" ], diff --git a/packages/taler-wallet-webextension/manifest-v3.json b/packages/taler-wallet-webextension/manifest-v3.json index d6a303ed6..65a75824b 100644 --- a/packages/taler-wallet-webextension/manifest-v3.json +++ b/packages/taler-wallet-webextension/manifest-v3.json @@ -17,7 +17,6 @@ "storage", "activeTab", "scripting", - "webRequest", "declarativeContent", "alarms" ], diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 75024fec0..bf063d76e 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-webextension", - "version": "0.9.4-dev.2", + "version": "0.10.7", "description": "GNU Taler Wallet browser extension", "main": "./build/index.js", "types": "./build/index.d.ts", @@ -9,11 +9,14 @@ "license": "AGPL-3.0-or-later", "private": false, "scripts": { - "clean": "rm -rf dist lib tsconfig.tsbuildinfo", + "clean": "rm -rf dist lib tsconfig.tsbuildinfo extension", "test": "./test.mjs && mocha --require source-map-support/register 'dist/test/**/*.test.js' 'dist/test/**/test.js'", "test:coverage": "nyc pnpm test", + "test:firefox": "web-ext run --source-dir extension/v2/unpacked --verbose --firefox-profile $(mktemp -d) --browser-console --devtools -f $FIREFOX_PATH/firefox-bin", + "test:firefox-private": "web-ext run --source-dir extension/v2/unpacked --verbose --firefox-profile $(mktemp -d) --browser-console --devtools -f $FIREFOX_PATH/firefox-bin --pref browser.privatebrowsing.autostart=true", "compile": "tsc && ./build.mjs", "typedoc": "typedoc --out dist/typedoc ./src/ --entryPointStrategy expand", + "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "dev": "./dev.mjs", "pretty": "prettier --write src", "i18n:extract": "pogen extract", @@ -32,18 +35,12 @@ "qrcode-generator": "^1.4.4", "tslib": "^2.6.2" }, - "eslintConfig": { - "plugins": [ - "header" - ], - "rules": { - "header/header": [ - 2, - "copyleft-header.js" - ] - } - }, "devDependencies": { + "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react": "^7.33.2", "@babel/preset-react": "^7.22.3", "@babel/preset-typescript": "7.18.6", "@gnu-taler/pogen": "workspace:*", @@ -65,7 +62,8 @@ "polished": "^4.1.4", "preact-cli": "^3.3.5", "preact-render-to-string": "^5.1.19", - "typescript": "5.3.3" + "typescript": "5.3.3", + "web-ext": "^7.11.0" }, "nyc": { "include": [ diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx index 167f1797c..fe348f7fb 100644 --- a/packages/taler-wallet-webextension/src/NavigationBar.tsx +++ b/packages/taler-wallet-webextension/src/NavigationBar.tsx @@ -34,6 +34,7 @@ import { } from "./components/styled/index.js"; import { useBackendContext } from "./context/backend.js"; import { useAsyncAsHook } from "./hooks/useAsyncAsHook.js"; +import searchIcon from "./svg/search_24px.inline.svg"; import qrIcon from "./svg/qr_code_24px.inline.svg"; import settingsIcon from "./svg/settings_black_24dp.inline.svg"; import warningIcon from "./svg/warning_24px.inline.svg"; @@ -55,7 +56,7 @@ type PageLocation<DynamicPart extends object> = { function replaceAll( pattern: string, vars: Record<string, string>, - values: Record<string, any>, + values: Record<string, string>, ): string { let result = pattern; for (const v in vars) { @@ -75,16 +76,20 @@ function pageDefinition<T extends object>(pattern: string): PageLocation<T> { `page definition pattern ${pattern} doesn't have any parameter`, ); - const vars = patternParams.reduce((prev, cur) => { - const pName = cur.match(/(\w+)/g); + const vars = patternParams.reduce( + (prev, cur) => { + const pName = cur.match(/(\w+)/g); - //skip things like :? in the path pattern - if (!pName || !pName[0]) return prev; - const name = pName[0]; - return { ...prev, [name]: cur }; - }, {} as Record<string, string>); + //skip things like :? in the path pattern + if (!pName || !pName[0]) return prev; + const name = pName[0]; + return { ...prev, [name]: cur }; + }, + {} as Record<string, string>, + ); - const f = (values: T): string => replaceAll(pattern, vars, values ?? {}); + const f = (values: T): string => + replaceAll(pattern, vars, (values ?? {}) as Record<string, string>); f.pattern = pattern; return f; } @@ -95,6 +100,9 @@ export const Pages = { balanceHistory: pageDefinition<{ currency?: string }>( "/balance/history/:currency?", ), + searchHistory: pageDefinition<{ currency?: string }>( + "/search/history/:currency?", + ), balanceDeposit: pageDefinition<{ amount: string }>( "/balance/deposit/:amount", ), @@ -125,9 +133,10 @@ export const Pages = { ctaPayTemplate: "/cta/pay/template", ctaRecovery: "/cta/recovery", ctaRefund: "/cta/refund", - ctaTips: "/cta/tip", ctaWithdraw: "/cta/withdraw", ctaDeposit: "/cta/deposit", + ctaExperiment: "/cta/experiment", + ctaAddExchange: "/cta/add/exchange", ctaInvoiceCreate: pageDefinition<{ amount?: string }>( "/cta/invoice/create/:amount?", ), @@ -146,16 +155,14 @@ const talerUriActionToPageName: { } = { [TalerUriAction.Withdraw]: "ctaWithdraw", [TalerUriAction.Pay]: "ctaPay", - [TalerUriAction.Reward]: "ctaTips", [TalerUriAction.Refund]: "ctaRefund", [TalerUriAction.PayPull]: "ctaInvoicePay", [TalerUriAction.PayPush]: "ctaTransferPickup", [TalerUriAction.Restore]: "ctaRecovery", [TalerUriAction.PayTemplate]: "ctaPayTemplate", [TalerUriAction.WithdrawExchange]: "ctaWithdrawManual", - [TalerUriAction.DevExperiment]: undefined, - [TalerUriAction.Exchange]: undefined, - [TalerUriAction.Auditor]: undefined, + [TalerUriAction.DevExperiment]: "ctaExperiment", + [TalerUriAction.AddExchange]: "ctaAddExchange", }; export function getPathnameForTalerURI(talerUri: string): string | undefined { @@ -260,7 +267,7 @@ export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode { <Fragment /> )} - <EnabledBySettings name="advanceMode"> + <EnabledBySettings name="advancedMode"> <a href={Pages.dev} class={path === "dev" ? "active" : ""}> <i18n.Translate>Dev tools</i18n.Translate> </a> @@ -269,6 +276,13 @@ export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode { <div style={{ display: "flex", paddingTop: 4, justifyContent: "right" }} > + <a href={Pages.searchHistory({})}> + <SvgIcon + title={i18n.str`Search transactions`} + dangerouslySetInnerHTML={{ __html: searchIcon }} + color="white" + /> + </a> <a href={Pages.qr}> <SvgIcon title={i18n.str`QR Reader and Taler URI`} diff --git a/packages/taler-wallet-webextension/src/components/BalanceTable.tsx b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx index d3733e6cc..6dd577b88 100644 --- a/packages/taler-wallet-webextension/src/components/BalanceTable.tsx +++ b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx @@ -14,9 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, WalletBalance } from "@gnu-taler/taler-util"; -import { VNode, h } from "preact"; -import { TableWithRoundRows as TableWithRoundedRows } from "./styled/index.js"; +import { Amounts, ScopeType, WalletBalance } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { + TableWithRoundRows as TableWithRoundedRows +} from "./styled/index.js"; export function BalanceTable({ balances, @@ -26,29 +28,37 @@ export function BalanceTable({ goToWalletHistory: (currency: string) => void; }): VNode { return ( - <TableWithRoundedRows> - {balances.map((entry, idx) => { - const av = Amounts.parseOrThrow(entry.available); + <Fragment> + <TableWithRoundedRows> + {balances.map((entry, idx) => { + const av = Amounts.parseOrThrow(entry.available); - return ( - <tr - key={idx} - onClick={() => goToWalletHistory(av.currency)} - style={{ cursor: "pointer" }} - > - <td>{av.currency}</td> - <td - style={{ - fontSize: "2em", - textAlign: "right", - width: "100%", - }} + return ( + <tr + key={idx} + onClick={() => goToWalletHistory(av.currency)} + style={{ cursor: "pointer" }} > - {Amounts.stringifyValue(av, 2)} - </td> - </tr> - ); - })} - </TableWithRoundedRows> + <td>{av.currency}</td> + <td + style={{ + fontSize: "2em", + textAlign: "right", + width: "100%", + }} + > + {Amounts.stringifyValue(av, 2)} + <div style={{ fontSize: "small", color: "grey" }}> + {entry.scopeInfo.type === ScopeType.Exchange || + entry.scopeInfo.type === ScopeType.Auditor + ? entry.scopeInfo.url + : undefined} + </div> + </td> + </tr> + ); + })} + </TableWithRoundedRows> + </Fragment> ); } diff --git a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx index 9fd117b08..8b6377fc5 100644 --- a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx +++ b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx @@ -21,11 +21,9 @@ import { segwitMinAmount, stringifyPaytoUri, TranslatedString, - WithdrawalExchangeAccountDetails + WithdrawalExchangeAccountDetails, } from "@gnu-taler/taler-util"; -import { - useTranslationContext -} from "@gnu-taler/web-util/browser"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { CopiedIcon, CopyIcon } from "../svg/index.js"; @@ -36,75 +34,94 @@ import { Button } from "../mui/Button.js"; export interface BankDetailsProps { subject: string; amount: AmountJson; - accounts: WithdrawalExchangeAccountDetails[], + accounts: WithdrawalExchangeAccountDetails[]; } export function BankDetailsByPaytoType({ subject, amount, - accounts, + accounts: unsortedAccounts, }: BankDetailsProps): VNode { const { i18n } = useTranslationContext(); - const [index, setIndex] = useState(0) - const [currency, setCurrency] = useState(amount.currency) - if (!accounts.length) { - return <div>the exchange account list is empty</div> + const [index, setIndex] = useState(0); + + if (!unsortedAccounts.length) { + return <div>the exchange account list is empty</div>; } + + const accounts = unsortedAccounts.sort((a, b) => { + return (b.priority ?? 0) - (a.priority ?? 0); + }); + const selectedAccount = accounts[index]; - const altCurrency = selectedAccount.currencySpecification?.name + const altCurrency = selectedAccount.currencySpecification?.name; const payto = parsePaytoUri(selectedAccount.paytoUri); if (!payto) return <Fragment />; - payto.params["amount"] = currency === altCurrency ? selectedAccount.transferAmount! :Amounts.stringify(amount) ; + payto.params["amount"] = altCurrency + ? selectedAccount.transferAmount! + : Amounts.stringify(amount); payto.params["message"] = subject; + function Frame({ + title, + children, + }: { + title: TranslatedString; + children: ComponentChildren; + }): VNode { + return ( + <section + style={{ + textAlign: "left", + border: "solid 1px black", + padding: 8, + borderRadius: 4, + }} + > + <div + style={{ + display: "flex", + width: "100%", + justifyContent: "space-between", + }} + > + <p style={{ marginTop: 0 }}>{title}</p> + <div></div> + </div> - function Frame({ title, children }: { title: TranslatedString, children: ComponentChildren }): VNode { - return <section - style={{ - textAlign: "left", - border: "solid 1px black", - padding: 8, - borderRadius: 4, - }} - > - <div style={{ display: "flex", width: "100%", justifyContent: "space-between" }}> - <p style={{ marginTop: 0 }}> - {title} - </p> - {accounts.length > 1 ? - <Button variant="contained" - onClick={async () => { - setIndex((index + 1) % accounts.length) - }} - > - <i18n.Translate>Next</i18n.Translate> - </Button> - : undefined} - </div> + {children} - {children} + {accounts.length > 1 ? ( + <Fragment> + {accounts.map((ac, acIdx) => { + const accountLabel = ac.bankLabel ?? `Account #${acIdx + 1}`; + return ( + <Button + key={acIdx} + variant={acIdx === index ? "contained" : "outlined"} + onClick={async () => { + setIndex(acIdx); + }} + > + {accountLabel} ( + {ac.currencySpecification?.name ?? amount.currency}) + </Button> + ); + })} - {altCurrency ? - <Fragment> - <Button variant={currency === amount.currency ? "contained" : "outlined"} - onClick={async () => { - setCurrency(amount.currency) - }} - > - <i18n.Translate>{amount.currency}</i18n.Translate> - </Button> - <Button variant={currency === altCurrency ? "contained" : "outlined"} + {/* <Button variant={currency === altCurrency ? "contained" : "outlined"} onClick={async () => { setCurrency(altCurrency) }} > <i18n.Translate>{altCurrency}</i18n.Translate> - </Button> - </Fragment> - : undefined} - </section> + </Button> */} + </Fragment> + ) : undefined} + </section> + ); } if (payto.isKnown && payto.targetType === "bitcoin") { @@ -160,7 +177,9 @@ export function BankDetailsByPaytoType({ } const accountPart = !payto.isKnown ? ( - <Row name={i18n.str`Account`} value={payto.targetPath} /> + <Fragment> + <Row name={i18n.str`Account`} value={payto.targetPath} /> + </Fragment> ) : payto.targetType === "x-taler-bank" ? ( <Fragment> <Row name={i18n.str`Bank host`} value={payto.host} /> @@ -175,51 +194,90 @@ export function BankDetailsByPaytoType({ </Fragment> ) : undefined; - const receiver = payto.params["receiver"] || undefined; + const receiver = + payto.params["receiver-name"] || payto.params["receiver"] || undefined; return ( <Frame title={i18n.str`Bank transfer details`}> <table> - {accountPart} - {currency === altCurrency ? <Fragment> - <Row - name={i18n.str`Amount`} - value={<Amount value={selectedAccount.transferAmount!} />} - /> - <Row - name={i18n.str`Converted`} - value={<Amount value={amount} />} - /> + <tbody> + <tr> + <td colSpan={3}> + <i18n.Translate>Step 1:</i18n.Translate> + + <i18n.Translate> + Copy this code and paste it into the subject/purpose field in + your banking app or bank website + </i18n.Translate> + </td> + </tr> + <Row name={i18n.str`Subject`} value={subject} literal /> - </Fragment> : + <tr> + <td colSpan={3}> + <i18n.Translate>Step 2:</i18n.Translate> + + <i18n.Translate> + If you don't already have it in your banking favourites list, + then copy and paste this IBAN and the name into the receiver + fields in your banking app or website + </i18n.Translate> + </td> + </tr> + {accountPart} + {receiver ? ( + <Row name={i18n.str`Receiver name`} value={receiver} /> + ) : undefined} + + <tr> + <td colSpan={3}> + <i18n.Translate>Step 3:</i18n.Translate> + + <i18n.Translate> + Finish the wire transfer setting the amount in your banking app + or website, then this withdrawal will proceed automatically. + </i18n.Translate> + </td> + </tr> <Row name={i18n.str`Amount`} - value={<Amount value={amount} />} + value={ + <Amount + value={altCurrency ? selectedAccount.transferAmount! : amount} + hideCurrency + /> + } /> - } - <Row name={i18n.str`Subject`} value={subject} literal /> - {receiver ? ( - <Row name={i18n.str`Receiver name`} value={receiver} /> - ) : undefined} - </table> - <table> - <tbody> + <tr> - <td> - <pre> - <b> - <a - target="_bank" - rel="noreferrer" - title="RFC 8905 for designating targets for payments" - href="https://tools.ietf.org/html/rfc8905" - > - Payto URI - </a> - </b> - </pre> + <td colSpan={3}> + <WarningBox style={{ margin: 0 }}> + <span> + <i18n.Translate> + Make sure ALL data is correct, including the subject; + otherwise, the money will not arrive in this wallet. You can + use the copy buttons (<CopyIcon />) to prevent typing errors + or the "payto://" URI below to copy just one value. + </i18n.Translate> + </span> + </WarningBox> </td> - <td width="100%" style={{ wordBreak: "break-all" }}> - {stringifyPaytoUri(payto)} + </tr> + + <tr> + <td colSpan={2} width="100%" style={{ wordBreak: "break-all" }}> + <i18n.Translate> + Alternative if your bank already supports PayTo URI, you can use + this{" "} + <a + target="_bank" + rel="noreferrer" + title="RFC 8905 for designating targets for payments" + href="https://tools.ietf.org/html/rfc8905" + > + PayTo URI + </a>{" "} + link instead + </i18n.Translate> </td> <td> <CopyButton getContent={() => stringifyPaytoUri(payto)} /> @@ -227,14 +285,6 @@ export function BankDetailsByPaytoType({ </tr> </tbody> </table> - <p> - <WarningBox> - <i18n.Translate> - Make sure to use the correct subject, otherwise the money will not - arrive in this wallet. - </i18n.Translate> - </WarningBox> - </p> </Frame> ); } diff --git a/packages/taler-wallet-webextension/src/components/Checkbox.tsx b/packages/taler-wallet-webextension/src/components/Checkbox.tsx index 70dfab597..ec1b93a01 100644 --- a/packages/taler-wallet-webextension/src/components/Checkbox.tsx +++ b/packages/taler-wallet-webextension/src/components/Checkbox.tsx @@ -31,6 +31,7 @@ export function Checkbox({ label, description, }: Props): VNode { + return ( <div> <input diff --git a/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx b/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx index 0a53d33ba..06c8a81ef 100644 --- a/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx +++ b/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx @@ -18,15 +18,18 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import arrowDown from "../svg/chevron-down.inline.svg"; import { ErrorBox } from "./styled/index.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; export function ErrorMessage({ title, description, }: { title: TranslatedString; - description?: string | VNode; + description?: string | VNode | Error; }): VNode | null { const [showErrorDetail, setShowErrorDetail] = useState(false); + const [showMore, setShowMore] = useState(false); + const { i18n } = useTranslationContext(); return ( <ErrorBox style={{ paddingTop: 0, paddingBottom: 0 }}> <div> @@ -44,7 +47,14 @@ export function ErrorMessage({ </button> )} </div> - {showErrorDetail && <p>{description}</p>} + {showErrorDetail && description && <p> + {description instanceof Error && !showMore ? description.message : description.toString()} + {description instanceof Error && <div> + <a href="#" onClick={(e) => { + setShowMore(!showMore) + e.preventDefault() + }}>{showMore ? i18n.str`show less` : i18n.str`show more`} </a> </div>} + </p>} </ErrorBox> ); } diff --git a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx index 72881c746..9be9326b2 100644 --- a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx +++ b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx @@ -23,6 +23,8 @@ import { TransactionType, WithdrawalType, TransactionMajorState, + DenomLossEventType, + parsePaytoUri, } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -134,23 +136,6 @@ export function HistoryItem(props: { tx: Transaction }): VNode { } /> ); - case TransactionType.Reward: - return ( - <Layout - id={tx.transactionId} - amount={tx.amountEffective} - debitCreditIndicator={"credit"} - title={new URL(tx.merchantBaseUrl).hostname} - timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)} - iconPath={"T"} - currentState={tx.txState.major} - description={ - tx.txState.major === TransactionMajorState.Pending - ? i18n.str`Grabbing the tipping...` - : undefined - } - /> - ); case TransactionType.Refresh: return ( <Layout @@ -168,13 +153,16 @@ export function HistoryItem(props: { tx: Transaction }): VNode { } /> ); - case TransactionType.Deposit: + case TransactionType.Deposit:{ + const payto = parsePaytoUri(tx.targetPaytoUri); + const title = payto === undefined || !payto.isKnown ? tx.targetPaytoUri : + payto.params["receiver-name"] ; return ( <Layout id={tx.transactionId} amount={tx.amountEffective} debitCreditIndicator={"debit"} - title={tx.targetPaytoUri} + title={title} timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)} iconPath={"D"} currentState={tx.txState.major} @@ -185,6 +173,7 @@ export function HistoryItem(props: { tx: Transaction }): VNode { } /> ); + } case TransactionType.PeerPullCredit: return ( <Layout @@ -253,6 +242,58 @@ export function HistoryItem(props: { tx: Transaction }): VNode { } /> ); + case TransactionType.DenomLoss: { + switch (tx.lossEventType) { + case DenomLossEventType.DenomExpired: { + return ( + <Layout + id={tx.transactionId} + amount={tx.amountEffective} + debitCreditIndicator={"debit"} + title={i18n.str`Denomination expired`} + timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)} + iconPath={"L"} + currentState={tx.txState.major} + description={undefined} + /> + ); + } + case DenomLossEventType.DenomVanished: { + return ( + <Layout + id={tx.transactionId} + amount={tx.amountEffective} + debitCreditIndicator={"debit"} + title={i18n.str`Denomination vanished`} + timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)} + iconPath={"L"} + currentState={tx.txState.major} + description={undefined} + /> + ); + } + case DenomLossEventType.DenomUnoffered: { + return ( + <Layout + id={tx.transactionId} + amount={tx.amountEffective} + debitCreditIndicator={"debit"} + title={i18n.str`Denomination unoffered`} + timestamp={AbsoluteTime.fromPreciseTimestamp(tx.timestamp)} + iconPath={"L"} + currentState={tx.txState.major} + description={undefined} + /> + ); + } + default: { + assertUnreachable(tx.lossEventType); + } + } + break; + } + case TransactionType.Recoup: + throw Error("recoup transaction not implemented"); default: { assertUnreachable(tx); } @@ -267,12 +308,12 @@ function Layout(props: LayoutProps): VNode { style={{ backgroundColor: props.currentState === TransactionMajorState.Pending || - props.currentState === TransactionMajorState.Dialog + props.currentState === TransactionMajorState.Dialog ? "lightcyan" : props.currentState === TransactionMajorState.Failed ? "#ff000040" : props.currentState === TransactionMajorState.Aborted || - props.currentState === TransactionMajorState.Aborting + props.currentState === TransactionMajorState.Aborting ? "#00000010" : "inherit", alignItems: "center", diff --git a/packages/taler-wallet-webextension/src/components/Modal.tsx b/packages/taler-wallet-webextension/src/components/Modal.tsx index 11fa72181..f8c0f1651 100644 --- a/packages/taler-wallet-webextension/src/components/Modal.tsx +++ b/packages/taler-wallet-webextension/src/components/Modal.tsx @@ -18,7 +18,7 @@ import { styled } from "@linaria/react"; import { ComponentChildren, h, VNode } from "preact"; import { ButtonHandler } from "../mui/handlers.js"; import closeIcon from "../svg/close_24px.inline.svg"; -import { Link, LinkPrimary, LinkWarning } from "./styled/index.js"; +import { Link } from "./styled/index.js"; interface Props { children: ComponentChildren; @@ -52,40 +52,44 @@ const Body = styled.div` export function Modal({ title, children, onClose }: Props): VNode { return ( - <FullSize onClick={onClose?.onClick}> - <div - onClick={(e) => e.stopPropagation()} - style={{ - background: "white", - width: 600, - height: "80%", - margin: "auto", - borderRadius: 8, - padding: 8, - // overflow: "scroll", - }} - > - <Header> - <div> - <h2>{title}</h2> - </div> - <Link onClick={onClose?.onClick}> - <div - style={{ - height: 24, - width: 24, - marginLeft: 4, - marginRight: 4, - // fill: "white", - }} - dangerouslySetInnerHTML={{ __html: closeIcon }} - /> - </Link> - </Header> - <hr /> + <div style={{ top: 0, width: "100%", height: "100%" }}> - <Body onClick={(e: any) => e.stopPropagation()}>{children}</Body> - </div> - </FullSize> + <FullSize onClick={onClose?.onClick}> + <div + onClick={(e) => e.stopPropagation()} + style={{ + background: "white", + width: 600, + height: "80%", + margin: "auto", + borderRadius: 8, + padding: 8, + zIndex: 100, + // overflow: "scroll", + }} + > + <Header> + <div> + <h2>{title}</h2> + </div> + <Link onClick={onClose?.onClick}> + <div + style={{ + height: 24, + width: 24, + marginLeft: 4, + marginRight: 4, + // fill: "white", + }} + dangerouslySetInnerHTML={{ __html: closeIcon }} + /> + </Link> + </Header> + <hr /> + + <Body onClick={(e: any) => e.stopPropagation()}>{children}</Body> + </div> + </FullSize> + </div> ); } diff --git a/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx b/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx index 8cb1c49dd..7fa0376c9 100644 --- a/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx +++ b/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx @@ -17,25 +17,24 @@ import { AmountJson, Amounts, - PayMerchantInsufficientBalanceDetails, + PaymentInsufficientBalanceDetails, PreparePayResult, PreparePayResultType, TranslatedString, parsePayUri, - stringifyPayUri, } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; +import { useBackendContext } from "../context/backend.js"; +import { Button } from "../mui/Button.js"; +import { ButtonHandler } from "../mui/handlers.js"; +import { assertUnreachable } from "../utils/index.js"; import { Amount } from "./Amount.js"; import { Part } from "./Part.js"; import { QR } from "./QR.js"; import { LinkSuccess, WarningBox } from "./styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Button } from "../mui/Button.js"; -import { ButtonHandler } from "../mui/handlers.js"; -import { assertUnreachable } from "../utils/index.js"; -import { useBackendContext } from "../context/backend.js"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; interface Props { payStatus: PreparePayResult; @@ -81,47 +80,46 @@ export function PaymentButtons({ case "age-acceptable": { BalanceMessage = i18n.str`Balance is not enough because you have ${Amounts.stringifyValue( payStatus.balanceDetails.balanceAgeAcceptable, - )} ${amount.currency} to pay for contracts restricted for age above ${ - payStatus.contractTerms.minimum_age - } years old`; + )} ${amount.currency} to pay for this contract which is restricted.`; break; } case "available": { BalanceMessage = i18n.str`Balance is not enough because you have ${Amounts.stringifyValue( payStatus.balanceDetails.balanceAvailable, - )} ${amount.currency} available.`; + )} ${amount.currency} available.`; break; } case "merchant-acceptable": { BalanceMessage = i18n.str`Balance is not enough because merchant will just accept ${Amounts.stringifyValue( - payStatus.balanceDetails.balanceMerchantAcceptable, - )} ${ - amount.currency - } . To know more you can check which exchange and auditors the merchant trust.`; + payStatus.balanceDetails.balanceReceiverAcceptable, + )} ${amount.currency + } . To know more you can check which exchange and auditors the merchant trust.`; break; } case "merchant-depositable": { BalanceMessage = i18n.str`Balance is not enough because merchant will just accept ${Amounts.stringifyValue( - payStatus.balanceDetails.balanceMerchantDepositable, - )} ${ - amount.currency - } . To know more you can check which wire methods the merchant accepts.`; + payStatus.balanceDetails.balanceReceiverDepositable, + )} ${amount.currency + } . To know more you can check which wire methods the merchant accepts.`; break; } case "material": { BalanceMessage = i18n.str`Balance is not enough because you have ${Amounts.stringifyValue( payStatus.balanceDetails.balanceMaterial, - )} ${ - amount.currency - } to spend right know. There are some coins that need to be refreshed.`; + )} ${amount.currency + } to spend right know. There are some coins that need to be refreshed.`; break; } case "fee-gap": { BalanceMessage = i18n.str`Balance looks like it should be enough, but doesn't cover all fees requested by the merchant and payment processor. Please ensure there is at least ${Amounts.stringifyValue( - payStatus.balanceDetails.feeGapEstimate, - )} ${ - amount.currency - } more balance in your wallet or ask your merchant to cover more of the fees.`; + Amounts.stringify( + Amounts.sub( + amount, + payStatus.balanceDetails.maxEffectiveSpendAmount, + ).amount, + ), + )} ${amount.currency + } more balance in your wallet or ask your merchant to cover more of the fees.`; break; } default: @@ -188,6 +186,9 @@ function PayWithMobile({ uri }: { uri: string }): VNode { setShowQR(undefined); } } + if (!payUri) { + return <Fragment /> + } return ( <section> <LinkSuccess upperCased onClick={sharePrivatePaymentURI}> @@ -217,7 +218,7 @@ type NoEnoughBalanceReason = | "fee-gap"; function getReason( - info: PayMerchantInsufficientBalanceDetails, + info: PaymentInsufficientBalanceDetails, ): NoEnoughBalanceReason { if (Amounts.cmp(info.amountRequested, info.balanceAvailable) > 0) { return "available"; @@ -228,10 +229,10 @@ function getReason( if (Amounts.cmp(info.amountRequested, info.balanceAgeAcceptable) > 0) { return "age-acceptable"; } - if (Amounts.cmp(info.amountRequested, info.balanceMerchantAcceptable) > 0) { + if (Amounts.cmp(info.amountRequested, info.balanceReceiverAcceptable) > 0) { return "merchant-acceptable"; } - if (Amounts.cmp(info.amountRequested, info.balanceMerchantDepositable) > 0) { + if (Amounts.cmp(info.amountRequested, info.balanceReceiverDepositable) > 0) { return "merchant-depositable"; } return "fee-gap"; diff --git a/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx b/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx index 372ca7cb7..c94010ede 100644 --- a/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx +++ b/packages/taler-wallet-webextension/src/components/PendingTransactions.tsx @@ -42,7 +42,10 @@ interface Props extends JSX.HTMLAttributes { */ const cache = { tx: [] as Transaction[] }; -export function PendingTransactions({ goToTransaction, goToURL }: Props): VNode { +export function PendingTransactions({ + goToTransaction, + goToURL, +}: Props): VNode { const api = useBackendContext(); const state = useAsyncAsHook(() => api.wallet.call(WalletApiOperation.GetTransactions, {}), @@ -59,8 +62,8 @@ export function PendingTransactions({ goToTransaction, goToURL }: Props): VNode !state || state.hasError ? cache.tx : state.response.transactions.filter( - (t) => t.txState.major === TransactionMajorState.Pending, - ); + (t) => t.txState.major === TransactionMajorState.Pending, + ); if (state && !state.hasError) { cache.tx = transactions; @@ -87,50 +90,52 @@ export function PendingTransactionsView({ transactions: Transaction[]; }): VNode { const { i18n } = useTranslationContext(); - const kycTransaction = transactions.find(tx => tx.kycUrl) + const kycTransaction = transactions.find((tx) => tx.kycUrl); if (kycTransaction) { - return <div - style={{ - backgroundColor: "lightcyan", - display: "flex", - justifyContent: "center", - }} - > - <Banner - titleHead={i18n.str`KYC requirement`} + return ( + <div style={{ - backgroundColor: "lightred", - maxHeight: 150, - padding: 8, - flexGrow: 1, - maxWidth: 500, - overflowY: transactions.length > 3 ? "scroll" : "hidden", + backgroundColor: "#fff3cd", + color: "#664d03", + display: "flex", + justifyContent: "center", }} > - <Grid - container - item - xs={1} - wrap="nowrap" - role="button" - spacing={1} - alignItems="center" - onClick={() => { - goToURL(kycTransaction.kycUrl ?? "#") + <Banner + titleHead={i18n.str`KYC requirement`} + style={{ + backgroundColor: "lightred", + maxHeight: 150, + padding: 8, + flexGrow: 1, //#fff3cd //#ffecb5 + maxWidth: 500, + overflowY: transactions.length > 3 ? "scroll" : "hidden", }} > - <Grid item> - <Typography inline bold> - One or more transaction require a KYC step to complete - </Typography> + <Grid + container + item + xs={1} + wrap="nowrap" + role="button" + spacing={1} + alignItems="center" + onClick={() => { + goToURL(kycTransaction.kycUrl ?? "#"); + }} + > + <Grid item> + <Typography inline bold> + One or more transaction require a KYC step to complete + </Typography> + </Grid> </Grid> - - </Grid> - </Banner> - </div> + </Banner> + </div> + ); } - if (!goToTransaction) return <Fragment /> + if (!goToTransaction) return <Fragment />; return ( <div diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx index 555b300c2..0e23d5850 100644 --- a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx +++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.stories.tsx @@ -42,14 +42,12 @@ const cd: WalletContractData = { "0YA1WETV15R6K8QKS79QA3QMT16010F42Q49VSKYQ71HVQKAG0A4ZJCA4YTKHE9EA5SP156TJSKZEJJJ87305N6PS80PC48RNKYZE08", orderId: "2022.220-0281XKKB8W7YE", summary: "w", - maxWireFee: "ARS:1" as AmountString, payDeadline: { t_s: 1660002673, }, refundDeadline: { t_s: 1660002673, }, - wireFeeAmortization: 1, allowedExchanges: [ { exchangeBaseUrl: "https://exchange.taler.ar/", @@ -83,7 +81,7 @@ export const ShowingSimpleOrder = tests.createExample(ShowView, { contractTerms: cd, }); export const Error = tests.createExample(ErrorView, { - proposalId: "asd", + transactionId: "asd", error: { hasError: true, message: "message", diff --git a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx index 0b3cca0b2..e655def39 100644 --- a/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx +++ b/packages/taler-wallet-webextension/src/components/ShowFullContractTermPopup.tsx @@ -17,6 +17,7 @@ import { AbsoluteTime, Duration, Location, + TransactionIdStr, WalletContractData, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -83,7 +84,7 @@ export namespace States { } export interface Error { status: "error"; - proposalId: string; + transactionId: string; error: HookError; hideHandler: ButtonHandler; } @@ -99,17 +100,17 @@ export namespace States { } interface Props { - proposalId: string; + transactionId: TransactionIdStr; } -function useComponentState({ proposalId }: Props): State { +function useComponentState({ transactionId }: Props): State { const api = useBackendContext(); const [show, setShow] = useState(false); const { pushAlertOnError } = useAlertContext(); const hook = useAsyncAsHook(async () => { if (!show) return undefined; return await api.wallet.call(WalletApiOperation.GetContractTermsDetails, { - proposalId, + transactionId, }); }, [show]); @@ -127,7 +128,7 @@ function useComponentState({ proposalId }: Props): State { } if (!hook) return { status: "loading", hideHandler }; if (hook.hasError) - return { status: "error", proposalId, error: hook, hideHandler }; + return { status: "error", transactionId, error: hook, hideHandler }; if (!hook.response) return { status: "loading", hideHandler }; return { status: "show", @@ -160,16 +161,17 @@ export function LoadingView({ hideHandler }: States.Loading): VNode { export function ErrorView({ hideHandler, error, - proposalId, + transactionId, }: States.Error): VNode { const { i18n } = useTranslationContext(); return ( <Modal title="Full detail" onClose={hideHandler}> <ErrorAlertView error={alertFromError( + i18n, i18n.str`Could not load purchase proposal details`, error, - { proposalId }, + { transactionId }, )} /> </Modal> @@ -336,8 +338,8 @@ export function ShowView({ contractTerms, hideHandler }: States.Show): VNode { !contractTerms.autoRefund ? Duration.getZero() : Duration.fromTalerProtocolDuration( - contractTerms.autoRefund, - ), + contractTerms.autoRefund, + ), )} format="dd MMMM yyyy, HH:mm" /> @@ -383,20 +385,6 @@ export function ShowView({ contractTerms, hideHandler }: States.Show): VNode { <Amount value={contractTerms.maxDepositFee} /> </td> </tr> - <tr> - <td> - <i18n.Translate>Max fee</i18n.Translate> - </td> - <td> - <Amount value={contractTerms.maxWireFee} /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Minimum age</i18n.Translate> - </td> - <td>{contractTerms.minimumAge}</td> - </tr> {/* <tr> <td>Extra</td> <td> @@ -405,12 +393,6 @@ export function ShowView({ contractTerms, hideHandler }: States.Show): VNode { </tr> */} <tr> <td> - <i18n.Translate>Wire fee amortization</i18n.Translate> - </td> - <td>{contractTerms.wireFeeAmortization}</td> - </tr> - <tr> - <td> <i18n.Translate>Exchanges</i18n.Translate> </td> <td> diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts index b089e17a6..1585e3992 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/index.ts @@ -14,11 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { ExchangeListItem } from "@gnu-taler/taler-util"; +import { ComponentChildren } from "preact"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; -import { ToggleHandler } from "../../mui/handlers.js"; -import { compose, StateViewMap } from "../../utils/index.js"; +import { SelectFieldHandler, ToggleHandler } from "../../mui/handlers.js"; +import { StateViewMap, compose } from "../../utils/index.js"; import { ErrorAlertView } from "../CurrentAlerts.js"; import { useComponentState } from "./state.js"; import { TermsState } from "./utils.js"; @@ -27,7 +27,6 @@ import { ShowButtonsNonAcceptedTosView, ShowTosContentView, } from "./views.js"; -import { ComponentChildren } from "preact"; export interface Props { exchangeUrl: string; @@ -62,6 +61,8 @@ export namespace State { status: "show-content"; termsAccepted: ToggleHandler; showingTermsOfService?: ToggleHandler; + tosLang: SelectFieldHandler; + tosFormat: SelectFieldHandler; } export interface ShowButtonsAccepted extends BaseInfo { status: "show-buttons-accepted"; diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts index ed4715301..76524f0f4 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/state.ts @@ -23,12 +23,24 @@ import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { Props, State } from "./index.js"; import { buildTermsOfServiceState } from "./utils.js"; +const supportedFormats = { + "text/html": "HTML", + "text/xml" : "XML", + "text/markdown" : "Markdown", + "text/plain" : "Plain text", + "text/pdf" : "PDF", +} + export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, children }: Props): State { const api = useBackendContext(); const [showContent, setShowContent] = useState<boolean>(!!readOnly); - const { i18n } = useTranslationContext(); + const { i18n, lang } = useTranslationContext(); + const [tosLang, setTosLang] = useState<string>() const { pushAlertOnError } = useAlertContext(); + const [format, setFormat] = useState("text/html") + + const acceptedLang = tosLang ?? lang /** * For the exchange selected, bring the status of the terms of service */ @@ -37,14 +49,20 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c WalletApiOperation.GetExchangeTos, { exchangeBaseUrl: exchangeUrl, - acceptedFormat: ["text/xml"], + acceptedFormat: [format], + acceptLanguage: acceptedLang, }, ); + const supportedLangs = exchangeTos.tosAvailableLanguages.reduce((prev, cur) => { + prev[cur] = cur + return prev; + }, {} as Record<string, string>) + const state = buildTermsOfServiceState(exchangeTos); - return { state }; - }, []); + return { state, supportedLangs }; + }, [acceptedLang, format]); if (!terms) { return { @@ -56,12 +74,13 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load the status of the term of service`, terms, ), }; } - const { state } = terms.response; + const { state, supportedLangs } = terms.response; async function onUpdate(accepted: boolean): Promise<void> { if (!state) return; @@ -69,14 +88,9 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c if (accepted) { await api.wallet.call(WalletApiOperation.SetExchangeTosAccepted, { exchangeBaseUrl: exchangeUrl, - etag: state.version, }); } else { // mark as not accepted - await api.wallet.call(WalletApiOperation.SetExchangeTosAccepted, { - exchangeBaseUrl: exchangeUrl, - etag: undefined, - }); } terms?.retry() } @@ -121,6 +135,20 @@ export function useComponentState({ showEvenIfaccepted, exchangeUrl, readOnly, c terms: state, showingTermsOfService: readOnly ? undefined : base.showingTermsOfService, termsAccepted: base.termsAccepted, + tosFormat: { + onChange: pushAlertOnError(async (s) => { + setFormat(s) + }), + list: supportedFormats, + value: format ?? "" + }, + tosLang: { + onChange: pushAlertOnError(async (s) => { + setTosLang(s) + }), + list: supportedLangs, + value: tosLang ?? lang + } }; } //showing buttons diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx b/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx index c578774ed..a28729eae 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/stories.tsx @@ -20,10 +20,40 @@ */ import * as tests from "@gnu-taler/web-util/testing"; -// import { ReadyView } from "./views.js"; +import { ShowTosContentView } from "./views.js"; +import { ExchangeTosStatus } from "@gnu-taler/taler-util"; export default { title: "TermsOfService", }; -// export const Ready = tests.createExample(ReadyView, {}); +export const Ready = tests.createExample(ShowTosContentView, { + tosLang: { + list: { + es: "es", + en: "en", + }, + value: "es", + onChange: (() => { }) as any + }, + tosFormat: { + list: { + es: "es", + en: "en", + }, + value: "es", + onChange: (() => { }) as any + }, + terms: { + content: { + type: "plain", + content: "hola" + }, + status: ExchangeTosStatus.Accepted, + version: "1" + }, + status: "show-content", + termsAccepted: { + button: {}, + } +}); diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts b/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts index fdca78ee5..96e268689 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/utils.ts @@ -46,8 +46,7 @@ function parseTermsOfServiceContent( } } else if (type === "text/html") { try { - const href = new URL(text); - return { type: "html", href }; + return { type: "html", html: text }; } catch (e) { logger.error("error parsing url", e); } @@ -90,7 +89,7 @@ export interface TermsDocumentXml { export interface TermsDocumentHtml { type: "html"; - href: URL; + html: string; } export interface TermsDocumentPlain { diff --git a/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx b/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx index 3a9f9e85d..40cfba3bc 100644 --- a/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx +++ b/packages/taler-wallet-webextension/src/components/TermsOfService/views.tsx @@ -15,18 +15,20 @@ */ import { ExchangeTosStatus } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { CheckboxOutlined } from "../../components/CheckboxOutlined.js"; import { ExchangeXmlTos } from "../../components/ExchangeToS.js"; import { + Input, LinkSuccess, TermsOfServiceStyle, - WarningBox, - WarningText, + WarningBox } from "../../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Button } from "../../mui/Button.js"; import { State } from "./index.js"; +import { SelectList } from "../SelectList.js"; +import { EnabledBySettings } from "../EnabledBySettings.js"; export function ShowButtonsAcceptedTosView({ termsAccepted, @@ -120,6 +122,8 @@ export function ShowTosContentView({ termsAccepted, showingTermsOfService, terms, + tosLang, + tosFormat, }: State.ShowContent): VNode { const { i18n } = useTranslationContext(); const ableToReviewTermsOfService = @@ -127,6 +131,25 @@ export function ShowTosContentView({ return ( <section> + <Input style={{ display: "flex", justifyContent: "end" }}> + <EnabledBySettings name="selectTosFormat"> + <SelectList + label={i18n.str`Format`} + list={tosFormat.list} + name="format" + value={tosFormat.value} + onChange={tosFormat.onChange} + /> + </EnabledBySettings> + <SelectList + label={i18n.str`Language`} + list={tosLang.list} + name="lang" + value={tosLang.value} + onChange={tosLang.onChange} + /> + </Input> + {!terms.content && ( <section style={{ justifyContent: "space-around", display: "flex" }}> <WarningBox> @@ -164,7 +187,7 @@ export function ShowTosContentView({ </div> ))} {terms.content.type === "html" && ( - <iframe src={terms.content.href.toString()} /> + <iframe style={{ width: "100%" }} srcDoc={terms.content.html} /> )} {terms.content.type === "pdf" && ( <a href={terms.content.location.toString()} download="tos.pdf"> diff --git a/packages/taler-wallet-webextension/src/components/Time.tsx b/packages/taler-wallet-webextension/src/components/Time.tsx index 7ec91d56c..eee295756 100644 --- a/packages/taler-wallet-webextension/src/components/Time.tsx +++ b/packages/taler-wallet-webextension/src/components/Time.tsx @@ -18,6 +18,11 @@ import { AbsoluteTime } from "@gnu-taler/taler-util"; import { formatISO, format } from "date-fns"; import { h, VNode } from "preact"; +/** + * + * @deprecated use web-util + * @returns + */ export function Time({ timestamp, format: formatString, diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx new file mode 100644 index 000000000..69a2c0675 --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -0,0 +1,1100 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + NotificationType, + ObservabilityEventType, + RequestProgressNotification, + TalerErrorCode, + TalerErrorDetail, + TaskProgressNotification, + WalletNotification, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, JSX, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { Pages } from "../NavigationBar.js"; +import { useBackendContext } from "../context/backend.js"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { useSettings } from "../hooks/useSettings.js"; +import { Button } from "../mui/Button.js"; +import { WxApiType } from "../wxApi.js"; +import { Modal } from "./Modal.js"; +import { Time } from "./Time.js"; + +interface Props extends JSX.HTMLAttributes {} + +export function WalletActivity({}: Props): VNode { + const { i18n } = useTranslationContext(); + const [settings, updateSettings] = useSettings(); + const api = useBackendContext(); + useEffect(() => { + document.body.style.marginBottom = "250px"; + return () => { + document.body.style.marginBottom = "0px"; + }; + }); + const [table, setTable] = useState<"tasks" | "events">("tasks"); + return ( + <div + style={{ + position: "fixed", + bottom: 0, + background: "white", + zIndex: 1, + height: 250, + overflowY: "scroll", + width: "100%", + }} + > + <div + style={{ + display: "flex", + justifyContent: "space-between", + float: "right", + }} + > + <div /> + <div> + <div + style={{ padding: 4, margin: 2, border: "solid 1px black" }} + onClick={() => { + updateSettings("showWalletActivity", false); + }} + > + close + </div> + </div> + </div> + <div style={{ display: "flex", justifyContent: "space-around" }}> + <Button + variant={table === "tasks" ? "contained" : "outlined"} + style={{ margin: 4 }} + onClick={async () => { + setTable("tasks"); + }} + > + <i18n.Translate>Tasks</i18n.Translate> + </Button> + <Button + variant={table === "events" ? "contained" : "outlined"} + style={{ margin: 4 }} + onClick={async () => { + setTable("events"); + }} + > + <i18n.Translate>Events</i18n.Translate> + </Button> + </div> + {(function (): VNode { + switch (table) { + case "events": { + return <ObservabilityEventsTable />; + } + case "tasks": { + return <ActiveTasksTable />; + } + default: { + assertUnreachable(table); + } + } + })()} + </div> + ); +} + +interface MoreInfoPRops { + events: (WalletNotification & { when: AbsoluteTime })[]; + onClick: (content: VNode) => void; +} +type Notif = { + id: string; + events: (WalletNotification & { when: AbsoluteTime })[]; + description: string; + start: AbsoluteTime; + end: AbsoluteTime; + reference: + | { + eventType: NotificationType; + referenceType: "task" | "transaction" | "operation" | "exchange"; + id: string; + } + | undefined; + MoreInfo: (p: MoreInfoPRops) => VNode; +}; + +function ShowBalanceChange({ events }: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.BalanceChange) return <Fragment />; + return ( + <Fragment> + <dt>Transaction</dt> + <dd> + <a + title={not.hintTransactionId} + href={Pages.balanceTransaction({ tid: not.hintTransactionId })} + > + {not.hintTransactionId.substring(0, 10)} + </a> + </dd> + </Fragment> + ); +} + +function ShowBackupOperationError({ events, onClick }: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.BackupOperationError) return <Fragment />; + return ( + <Fragment> + <dt>Error</dt> + <dd> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + const error = not.error; + onClick( + <Fragment> + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Time</dt> + <dd> + <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" /> + </dd> + </dl> + <pre + style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }} + > + {JSON.stringify(error, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {TalerErrorCode[not.error.code]} + </a> + </dd> + </Fragment> + ); +} + +function ShowTransactionStateTransition({ + events, + onClick, +}: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.TransactionStateTransition) + return <Fragment />; + return ( + <Fragment> + <dt>Old state</dt> + <dd> + {not.oldTxState.major} - {not.oldTxState.minor ?? ""} + </dd> + <dt>New state</dt> + <dd> + {not.newTxState.major} - {not.newTxState.minor ?? ""} + </dd> + <dt>Transaction</dt> + <dd> + <a + title={not.transactionId} + href={Pages.balanceTransaction({ tid: not.transactionId })} + > + {not.transactionId.substring(0, 10)} + </a> + </dd> + {not.errorInfo ? ( + <Fragment> + <dt>Error</dt> + <dd> + <a + href="#" + onClick={(e) => { + if (!not.errorInfo) return; + e.preventDefault(); + const error = not.errorInfo; + onClick( + <Fragment> + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Message</dt> + <dd>{error.message ?? "--"}</dd> + </dl> + </Fragment>, + ); + }} + > + {TalerErrorCode[not.errorInfo.code]} + </a> + </dd> + </Fragment> + ) : undefined} + <dt>Experimental</dt> + <dd> + <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> + {JSON.stringify(not.experimentalUserData, undefined, 2)} + </pre> + </dd> + </Fragment> + ); +} +function ShowExchangeStateTransition({ + events, + onClick, +}: MoreInfoPRops): VNode { + if (!events.length) return <Fragment />; + const not = events[0]; + if (not.type !== NotificationType.ExchangeStateTransition) + return <Fragment />; + return ( + <Fragment> + <dt>Exchange</dt> + <dd>{not.exchangeBaseUrl}</dd> + {not.oldExchangeState && + not.newExchangeState.exchangeEntryStatus !== + not.oldExchangeState?.exchangeEntryStatus && ( + <Fragment> + <dt>Entry status</dt> + <dd> + from {not.oldExchangeState.exchangeEntryStatus} to{" "} + {not.newExchangeState.exchangeEntryStatus} + </dd> + </Fragment> + )} + {not.oldExchangeState && + not.newExchangeState.exchangeUpdateStatus !== + not.oldExchangeState?.exchangeUpdateStatus && ( + <Fragment> + <dt>Update status</dt> + <dd> + from {not.oldExchangeState.exchangeUpdateStatus} to{" "} + {not.newExchangeState.exchangeUpdateStatus} + </dd> + </Fragment> + )} + {not.oldExchangeState && + not.newExchangeState.tosStatus !== not.oldExchangeState?.tosStatus && ( + <Fragment> + <dt>Tos status</dt> + <dd> + from {not.oldExchangeState.tosStatus} to{" "} + {not.newExchangeState.tosStatus} + </dd> + </Fragment> + )} + </Fragment> + ); +} + +type ObservaNotifWithTime = ( + | TaskProgressNotification + | RequestProgressNotification +) & { + when: AbsoluteTime; +}; +function ShowObservabilityEvent({ events, onClick }: MoreInfoPRops): VNode { + // let prev: ObservaNotifWithTime; + const asd = events.map((not) => { + if ( + not.type !== NotificationType.RequestObservabilityEvent && + not.type !== NotificationType.TaskObservabilityEvent + ) + return <Fragment />; + + const title = (function () { + switch (not.event.type) { + case ObservabilityEventType.HttpFetchFinishError: + case ObservabilityEventType.HttpFetchFinishSuccess: + case ObservabilityEventType.HttpFetchStart: + return "HTTP Request"; + case ObservabilityEventType.DbQueryFinishSuccess: + case ObservabilityEventType.DbQueryFinishError: + case ObservabilityEventType.DbQueryStart: + return "Database"; + case ObservabilityEventType.RequestFinishSuccess: + case ObservabilityEventType.RequestFinishError: + case ObservabilityEventType.RequestStart: + return "Wallet"; + case ObservabilityEventType.CryptoFinishSuccess: + case ObservabilityEventType.CryptoFinishError: + case ObservabilityEventType.CryptoStart: + return "Crypto"; + case ObservabilityEventType.TaskStart: + return "Task start"; + case ObservabilityEventType.TaskStop: + return "Task stop"; + case ObservabilityEventType.TaskReset: + return "Task reset"; + case ObservabilityEventType.ShepherdTaskResult: + return "Schedule"; + case ObservabilityEventType.DeclareTaskDependency: + return "Task dependency"; + case ObservabilityEventType.Message: + return "Message"; + } + })(); + + return ( + <ShowObervavilityDetails title={title} notif={not} onClick={onClick} /> + ); + }); + return ( + <table> + <thead> + <td>Event</td> + <td>Info</td> + <td>Start</td> + <td>End</td> + </thead> + <tbody>{asd}</tbody> + </table> + ); +} + +function ShowObervavilityDetails({ + title, + notif, + onClick, + prev, +}: { + title: string; + notif: ObservaNotifWithTime; + prev?: ObservaNotifWithTime; + onClick: (content: VNode) => void; +}): VNode { + switch (notif.event.type) { + case ObservabilityEventType.HttpFetchStart: + case ObservabilityEventType.HttpFetchFinishError: + case ObservabilityEventType.HttpFetchFinishSuccess: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td> + {notif.event.url}{" "} + {prev?.event.type === + ObservabilityEventType.HttpFetchFinishSuccess ? ( + `(${prev.event.status})` + ) : prev?.event.type === + ObservabilityEventType.HttpFetchFinishError ? ( + <a + href="#" + onClick={(e) => { + e.preventDefault(); + if ( + prev.event.type !== + ObservabilityEventType.HttpFetchFinishError + ) + return; + const error = prev.event.error; + onClick( + <Fragment> + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Time</dt> + <dd> + <Time + timestamp={error.when} + format="yyyy/MM/dd HH:mm:ss" + /> + </dd> + </dl> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify(error, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + fail + </a> + ) : undefined} + </td> + <td> + {" "} + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + {" "} + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.DbQueryStart: + case ObservabilityEventType.DbQueryFinishSuccess: + case ObservabilityEventType.DbQueryFinishError: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td> + {notif.event.location} {notif.event.name} + </td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + + case ObservabilityEventType.TaskStart: + case ObservabilityEventType.TaskStop: + case ObservabilityEventType.DeclareTaskDependency: + case ObservabilityEventType.TaskReset: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.taskId}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.ShepherdTaskResult: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.resultType}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.CryptoStart: + case ObservabilityEventType.CryptoFinishSuccess: + case ObservabilityEventType.CryptoFinishError: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.operation}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.RequestStart: + case ObservabilityEventType.RequestFinishSuccess: + case ObservabilityEventType.RequestFinishError: { + return ( + <tr> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onClick( + <Fragment> + <pre + style={{ + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {JSON.stringify({ event: notif, prev }, undefined, 2)} + </pre> + </Fragment>, + ); + }} + > + {title} + </a> + </td> + <td>{notif.event.type}</td> + <td> + <Time timestamp={notif.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + <Time timestamp={prev?.when} format="yyyy/MM/dd HH:mm:ss" /> + </td> + </tr> + ); + } + case ObservabilityEventType.Message: + // FIXME + return <></>; + } +} + +function getNotificationFor( + id: string, + event: WalletNotification, + start: AbsoluteTime, + list: Notif[], +): Notif | undefined { + const eventWithTime = { ...event, when: start }; + switch (event.type) { + case NotificationType.BalanceChange: { + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "transaction", + id: event.hintTransactionId, + }, + description: "Balance change", + start, + end: AbsoluteTime.never(), + MoreInfo: ShowBalanceChange, + }; + } + case NotificationType.BackupOperationError: { + return { + id, + events: [eventWithTime], + reference: undefined, + description: "Backup error", + start, + end: AbsoluteTime.never(), + MoreInfo: ShowBackupOperationError, + }; + } + case NotificationType.TransactionStateTransition: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.transactionId, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "transaction", + id: event.transactionId, + }, + description: event.type, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowTransactionStateTransition, + }; + } + case NotificationType.ExchangeStateTransition: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.exchangeBaseUrl, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + description: "Exchange update", + reference: { + eventType: event.type, + referenceType: "exchange", + id: event.exchangeBaseUrl, + }, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowExchangeStateTransition, + }; + } + case NotificationType.TaskObservabilityEvent: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.taskId, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "task", + id: event.taskId, + }, + description: `Task update ${event.taskId}`, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowObservabilityEvent, + }; + } + case NotificationType.WithdrawalOperationTransition: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && a.reference.id === event.uri, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "task", + id: event.uri, + }, + description: `Withdrawal operation updated`, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowObservabilityEvent, + }; + } + case NotificationType.RequestObservabilityEvent: { + const found = list.find( + (a) => + a.reference?.eventType === event.type && + a.reference.id === event.requestId, + ); + if (found) { + found.end = start; + found.events.unshift(eventWithTime); + return undefined; + } + return { + id, + events: [eventWithTime], + reference: { + eventType: event.type, + referenceType: "operation", + id: event.requestId, + }, + description: `wallet.${event.operation}(${event.requestId})`, + start, + end: AbsoluteTime.never(), + MoreInfo: ShowObservabilityEvent, + }; + } + case NotificationType.Idle: + return undefined; + default: { + assertUnreachable(event); + } + } +} + +function refresh(api: WxApiType, onUpdate: (list: Notif[]) => void) { + api.background + .call("getNotifications", undefined) + .then((notif) => { + const list: Notif[] = []; + for (const n of notif) { + if ( + n.notification.type === NotificationType.RequestObservabilityEvent && + n.notification.operation === "getActiveTasks" + ) { + //ignore monitor request + continue; + } + const event = getNotificationFor( + String(list.length), + n.notification, + n.when, + list, + ); + // pepe. + if (event) { + list.unshift(event); + } + } + onUpdate(list); + }) + .catch((error) => { + console.log(error); + }); +} + +export function ObservabilityEventsTable({}: {}): VNode { + const { i18n } = useTranslationContext(); + const api = useBackendContext(); + + const [notifications, setNotifications] = useState<Notif[]>([]); + const [showDetails, setShowDetails] = useState<VNode>(); + + useEffect(() => { + let lastTimeout: ReturnType<typeof setTimeout>; + function periodicRefresh() { + refresh(api, setNotifications); + + lastTimeout = setTimeout(() => { + periodicRefresh(); + }, 1000); + + //clear on unload + return () => { + clearTimeout(lastTimeout); + }; + } + return periodicRefresh(); + }, [1]); + + return ( + <div> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div + style={{ padding: 4, margin: 2, border: "solid 1px black" }} + onClick={() => { + api.background.call("clearNotifications", undefined).then((d) => { + refresh(api, setNotifications); + }); + }} + > + clear + </div> + </div> + {showDetails && ( + <Modal + title="event details" + onClose={{ + onClick: (async () => { + setShowDetails(undefined); + }) as any, + }} + > + {showDetails} + </Modal> + )} + {notifications.map((not) => { + return ( + <details key={not.id}> + <summary> + <div + style={{ + width: "90%", + display: "inline-flex", + justifyContent: "space-between", + padding: 4, + }} + > + <div style={{ padding: 4 }}>{not.description}</div> + <div style={{ padding: 4 }}> + <Time timestamp={not.start} format="yyyy/MM/dd HH:mm:ss" /> + </div> + <div style={{ padding: 4 }}> + <Time timestamp={not.end} format="yyyy/MM/dd HH:mm:ss" /> + </div> + </div> + </summary> + <not.MoreInfo + events={not.events} + onClick={(details) => { + setShowDetails(details); + }} + /> + </details> + ); + })} + </div> + ); +} + +function ErroDetailModal({ + error, + onClose, +}: { + error: TalerErrorDetail; + onClose: () => void; +}): VNode { + return ( + <Modal + title="Full detail" + onClose={{ + onClick: onClose as any, + }} + > + <dl> + <dt>Code</dt> + <dd> + {TalerErrorCode[error.code]} ({error.code}) + </dd> + <dt>Hint</dt> + <dd>{error.hint ?? "--"}</dd> + <dt>Time</dt> + <dd> + <Time timestamp={error.when} format="yyyy/MM/dd HH:mm:ss" /> + </dd> + </dl> + <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> + {JSON.stringify(error, undefined, 2)} + </pre> + </Modal> + ); +} + +export function ActiveTasksTable({}: {}): VNode { + const { i18n } = useTranslationContext(); + const api = useBackendContext(); + const state = useAsyncAsHook(() => { + return api.wallet.call(WalletApiOperation.GetActiveTasks, {}); + }); + const [showError, setShowError] = useState<TalerErrorDetail>(); + const tasks = state && !state.hasError ? state.response.tasks : []; + + useEffect(() => { + if (!state || state.hasError) return; + const lastTimeout = setTimeout(() => { + state.retry(); + }, 1000); + return () => { + clearTimeout(lastTimeout); + }; + }, [tasks]); + + // const listenAllEvents = Array.from<NotificationType>({ length: 1 }); + // listenAllEvents.includes = () => true + // useEffect(() => { + // return api.listener.onUpdateNotification(listenAllEvents, (notif) => { + // state?.retry() + // }); + // }); + return ( + <Fragment> + {showError && ( + <ErroDetailModal + error={showError} + onClose={async () => { + setShowError(undefined); + }} + /> + )} + + <table style={{ width: "100%" }}> + <thead> + <tr> + <th> + <i18n.Translate>Type</i18n.Translate> + </th> + <th> + <i18n.Translate>Id</i18n.Translate> + </th> + <th> + <i18n.Translate>Since</i18n.Translate> + </th> + <th> + <i18n.Translate>Next try</i18n.Translate> + </th> + <th> + <i18n.Translate>Error</i18n.Translate> + </th> + <th> + <i18n.Translate>Transaction</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + {tasks.map((task) => { + const [type, id] = task.taskId.split(":"); + return ( + <tr> + <td>{type}</td> + <td title={id}>{id.substring(0, 10)}</td> + <td> + <Time + timestamp={task.firstTry} + format="yyyy/MM/dd HH:mm:ss" + /> + </td> + <td> + <Time timestamp={task.nextTry} format="yyyy/MM/dd HH:mm:ss" /> + </td> + <td> + {!task.lastError?.code ? ( + "" + ) : ( + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowError(task.lastError); + }} + > + {TalerErrorCode[task.lastError.code]} + </a> + )} + </td> + <td> + {task.transaction ? ( + <a + title={task.transaction} + href={Pages.balanceTransaction({ tid: task.transaction })} + > + {task.transaction.substring(0, 10)} + </a> + ) : ( + "--" + )} + </td> + </tr> + ); + })} + </tbody> + </table> + </Fragment> + ); +} diff --git a/packages/taler-wallet-webextension/src/components/styled/index.tsx b/packages/taler-wallet-webextension/src/components/styled/index.tsx index 2501c61c8..89678c74a 100644 --- a/packages/taler-wallet-webextension/src/components/styled/index.tsx +++ b/packages/taler-wallet-webextension/src/components/styled/index.tsx @@ -35,7 +35,6 @@ export const WalletAction = styled.div` align-items: center; margin: auto; - height: 100%; & h1:first-child { margin-top: 0; diff --git a/packages/taler-wallet-webextension/src/context/alert.ts b/packages/taler-wallet-webextension/src/context/alert.ts index 1ae15f1ec..e30fdd72c 100644 --- a/packages/taler-wallet-webextension/src/context/alert.ts +++ b/packages/taler-wallet-webextension/src/context/alert.ts @@ -19,13 +19,22 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerErrorDetail, TranslatedString } from "@gnu-taler/taler-util"; +import { + TalerError, + TalerErrorCode, + TalerErrorDetail, + TranslatedString, +} from "@gnu-taler/taler-util"; import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext, useState } from "preact/hooks"; import { HookError } from "../hooks/useAsyncAsHook.js"; import { SafeHandler, withSafe } from "../mui/handlers.js"; import { BackgroundError } from "../wxApi.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + InternationalizationAPI, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { platform } from "../platform/foreground.js"; export type AlertType = "info" | "warning" | "error" | "success"; @@ -102,24 +111,24 @@ export const AlertProvider = ({ children }: Props): VNode => { setAlerts((ns: AlertWithDate[]) => ns.filter((n) => n !== alert)); }; + const { i18n } = useTranslationContext(); + function pushAlertOnError<T>( handler: (p: T) => Promise<void>, ): SafeHandler<T> { return withSafe(handler, (e) => { - const a = alertFromError(e.message as TranslatedString, e); + const a = alertFromError(i18n, e.message as TranslatedString, e); pushAlert(a); }); } - const { i18n } = useTranslationContext(); - function safely<T>( name: string, handler: (p: T) => Promise<void>, ): SafeHandler<T> { const message = i18n.str`Error was thrown trying to: "${name}"`; return withSafe(handler, (e) => { - const a = alertFromError(message, e); + const a = alertFromError(i18n, message, e); pushAlert(a); }); } @@ -133,24 +142,28 @@ export const AlertProvider = ({ children }: Props): VNode => { export const useAlertContext = (): Type => useContext(Context); export function alertFromError( + i18n: InternationalizationAPI, message: TranslatedString, error: HookError, ...context: any[] ): ErrorAlert; export function alertFromError( + i18n: InternationalizationAPI, message: TranslatedString, error: Error, ...context: any[] ): ErrorAlert; export function alertFromError( + i18n: InternationalizationAPI, message: TranslatedString, error: TalerErrorDetail, ...context: any[] ): ErrorAlert; export function alertFromError( + i18n: InternationalizationAPI, message: TranslatedString, error: HookError | TalerErrorDetail | Error, ...context: any[] @@ -170,14 +183,33 @@ export function alertFromError( //HookError description = error.message as TranslatedString; if (error.type === "taler") { + const msg = isWalletNotAvailable(i18n, error.details); + if (msg) { + description = msg; + } else { + const msg2 = isHttpError(i18n, error.details); + if (msg2) { + description = msg2; + } + } cause = { details: error.details, }; } } else { if (error instanceof BackgroundError) { - description = (error.errorDetail.hint ?? - `Error code: ${error.errorDetail.code}`) as TranslatedString; + const msg = isWalletNotAvailable(i18n, error.errorDetail); + if (msg) { + description = msg; + } else { + const msg2 = isHttpError(i18n, error.errorDetail); + if (msg2) { + description = msg2; + } else { + description = (error.errorDetail.hint ?? + `Error code: ${error.errorDetail.code}`) as TranslatedString; + } + } cause = { details: error.errorDetail, stack: error.stack, @@ -202,3 +234,44 @@ export function alertFromError( context, }; } + +function isWalletNotAvailable( + i18n: InternationalizationAPI, + detail: TalerErrorDetail, +): TranslatedString | undefined { + if ( + detail.code === TalerErrorCode.WALLET_CORE_NOT_AVAILABLE && + detail.lastError + ) { + const le = detail.lastError as TalerErrorDetail; + if (le.code === TalerErrorCode.WALLET_DB_UNAVAILABLE) { + if (platform.isFirefox() && platform.runningOnPrivateMode()) { + return i18n.str`Could not open the wallet database. Firefox is known to run into this problem under "permanent private mode".`; + } else { + return i18n.str`Could not open the wallet database.`; + } + } else { + return (detail.hint ?? `Error code: ${detail.code}`) as TranslatedString; + } + } + return undefined; +} + +function isHttpError( + i18n: InternationalizationAPI, + detail: TalerErrorDetail, +): TranslatedString | undefined { + if ( + detail.code === TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR && + detail.errorResponse + ) { + const er = detail.errorResponse as TalerErrorDetail; + return ( + (er.hint as TranslatedString) ?? + detail.hint ?? + i18n.str`Unexpected request error, code: ${er.code}` + ); + } + return undefined; +} +// diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/state.ts b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts index ec0106f6e..efcef8c28 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Deposit/state.ts @@ -48,7 +48,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the status of deposit`, info, ), }; diff --git a/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx b/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx index c352e394e..c683a755c 100644 --- a/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Deposit/views.tsx @@ -15,12 +15,10 @@ */ import { Amounts } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { Amount } from "../../components/Amount.js"; -import { LogoHeader } from "../../components/LogoHeader.js"; import { Part } from "../../components/Part.js"; -import { SubTitle, WalletAction } from "../../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Button } from "../../mui/Button.js"; import { State } from "./index.js"; diff --git a/packages/taler-wallet-webextension/src/cta/Reward/index.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/index.ts index 5e56db7bc..ec09fd9f1 100644 --- a/packages/taler-wallet-webextension/src/cta/Reward/index.ts +++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/index.ts @@ -14,71 +14,60 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson } from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; import { ButtonHandler } from "../../mui/handlers.js"; -import { compose, StateViewMap } from "../../utils/index.js"; +import { StateViewMap, compose } from "../../utils/index.js"; import { useComponentState } from "./state.js"; -import { AcceptedView, IgnoredView, ReadyView } from "./views.js"; +import { InsertLostView, InsertPendingRefreshView, UnknownView } from "./views.js"; export interface Props { - talerTipUri?: string; + talerExperimentUri: string | undefined; onCancel: () => Promise<void>; - onSuccess: (tx: string) => Promise<void>; + onSuccess: () => Promise<void>; } -export type State = - | State.Loading - | State.LoadingUriError - | State.Ignored - | State.Accepted - | State.Ready - | State.Ignored; +export type State = State.Loading | State.LoadingUriError | State.Unknown | State.InsertLost | State.PendingRefresh; export namespace State { export interface Loading { status: "loading"; error: undefined; } - export interface LoadingUriError { status: "error"; error: ErrorAlert; } - - export interface BaseInfo { - merchantBaseUrl: string; - amount: AmountJson; - exchangeBaseUrl: string; + export interface InsertLost { + status: "insertLost"; error: undefined; - cancel: ButtonHandler; - } - - export interface Ignored extends BaseInfo { - status: "ignored"; + confirm: ButtonHandler; + cancel: () => Promise<void>; } - - export interface Accepted extends BaseInfo { - status: "accepted"; + export interface PendingRefresh { + status: "pendingRefresh"; + error: undefined; + confirm: ButtonHandler; + cancel: () => Promise<void>; } - export interface Ready extends BaseInfo { - status: "ready"; - accept: ButtonHandler; + export interface Unknown { + status: "unknown"; + experimentId: string; + error: undefined; } } const viewMapping: StateViewMap<State> = { loading: Loading, error: ErrorAlertView, - accepted: AcceptedView, - ignored: IgnoredView, - ready: ReadyView, + pendingRefresh: InsertPendingRefreshView, + insertLost: InsertLostView, + unknown: UnknownView, }; -export const TipPage = compose( - "Tip", +export const DevExperimentPage = compose( + "DevExperiment", (p: Props) => useComponentState(p), viewMapping, ); diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts new file mode 100644 index 000000000..774a1129d --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/state.ts @@ -0,0 +1,83 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { parseDevExperimentUri } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useAlertContext } from "../../context/alert.js"; +import { useBackendContext } from "../../context/backend.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ + talerExperimentUri, + onCancel, + onSuccess, +}: Props): State { + const api = useBackendContext(); + const { pushAlertOnError } = useAlertContext(); + const { i18n } = useTranslationContext(); + + async function doApply(): Promise<void> { + if (!talerExperimentUri) return; + await api.wallet.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: talerExperimentUri + }) + // const resp = await api.wallet.call(WalletApiOperation.CreateDepositGroup, { + // amount: Amounts.stringify(amount), + // depositPaytoUri: uri, + // }); + onSuccess(); + } + const uri = talerExperimentUri === undefined ? undefined : parseDevExperimentUri(talerExperimentUri); + + if (!uri) { + return { + status: "error", + error: { + type: "error", + message: i18n.str`Invalid dev experiment URI.`, + description: i18n.str`URI: ${talerExperimentUri}`, + cause: {}, + context: {}, + }, + }; + } + if (uri.devExperimentId === "insert-denom-loss") { + return { + status: "insertLost", + error: undefined, + confirm: { + onClick: pushAlertOnError(doApply), + }, + cancel: onCancel, + }; + } + if (uri.devExperimentId === "insert-pending-refresh") { + return { + status: "pendingRefresh", + error: undefined, + confirm: { + onClick: pushAlertOnError(doApply), + }, + cancel: onCancel, + }; + } + return { + status: "unknown", + error: undefined, + experimentId: uri.devExperimentId, + } +} diff --git a/packages/taler-wallet-webextension/src/cta/Reward/stories.tsx b/packages/taler-wallet-webextension/src/cta/DevExperiment/stories.tsx index bd5fdefd9..c9851495f 100644 --- a/packages/taler-wallet-webextension/src/cta/Reward/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/stories.tsx @@ -19,28 +19,15 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; -import { AcceptedView, ReadyView } from "./views.js"; +import { InsertLostView } from "./views.js"; export default { - title: "tip", + title: "dev-experiment", }; -export const Accepted = tests.createExample(AcceptedView, { - status: "accepted", +export const Ready = tests.createExample(InsertLostView, { + status: "insertLost", + confirm: {}, error: undefined, - amount: Amounts.parseOrThrow("EUR:1"), - exchangeBaseUrl: "", - merchantBaseUrl: "", -}); - -export const Ready = tests.createExample(ReadyView, { - status: "ready", - error: undefined, - amount: Amounts.parseOrThrow("EUR:1"), - merchantBaseUrl: "http://merchant.url/", - exchangeBaseUrl: "http://exchange.url/", - accept: {}, - cancel: {}, }); diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts new file mode 100644 index 000000000..d4f2ca8b1 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { createWalletApiMock } from "../../test-utils.js"; +import { Props } from "./index.js"; +import { useComponentState } from "./state.js"; + +describe("DevExperiment CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { handler, TestingContext } = createWalletApiMock(); + + const props: Props = { + talerExperimentUri: undefined, + onCancel: async () => { + null; + }, + onSuccess: async () => { + null; + }, + }; + + const hookBehavior = await tests.hookBehaveLikeThis( + useComponentState, + props, + [ + ({ status }) => { + expect(status).equals("error"); + }, + ({ status, error }) => { + expect(status).equals("error"); + + if (!error) expect.fail(); + // if (!error.hasError) expect.fail(); + // if (error.operational) expect.fail(); + // expect(error.description).eq("ERROR_NO-URI-FOR-DEPOSIT"); + }, + ], + TestingContext, + ); + + expect(hookBehavior).deep.equal({ result: "ok" }); + expect(handler.getCallingQueueState()).eq("empty"); + }); + +}); diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx b/packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx new file mode 100644 index 000000000..afad17ad1 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/DevExperiment/views.tsx @@ -0,0 +1,74 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Amounts } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { Amount } from "../../components/Amount.js"; +import { Part } from "../../components/Part.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; + +/** + * + * @author sebasjm + */ + +export function InsertLostView(state: State.InsertLost): VNode { + const { i18n } = useTranslationContext(); + return <Fragment> + <section> + <Part + title={i18n.str`Experiment`} + text={i18n.str`Insert lost denomination`} + /> + </section> + <section> + <Button + variant="contained" + color="success" + onClick={state.confirm.onClick} + > + <i18n.Translate>Apply</i18n.Translate> + </Button> + </section> + </Fragment> +} + +export function InsertPendingRefreshView(state: State.PendingRefresh): VNode { + const { i18n } = useTranslationContext(); + return <Fragment> + <section> + <Part + title={i18n.str`Experiment`} + text={i18n.str`Pending refresh`} + /> + </section> + <section> + <Button + variant="contained" + color="success" + onClick={state.confirm.onClick} + > + <i18n.Translate>Apply</i18n.Translate> + </Button> + </section> + </Fragment> +} + +export function UnknownView(state: State.Unknown): VNode { + return <div>unknown experiment "{state.experimentId}"</div> +} diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts index 81caf9878..daa3ee76d 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts @@ -50,7 +50,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the list of exchanges`, hook, ), }; @@ -103,7 +104,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the invoice status`, hook, ), }; @@ -166,8 +168,8 @@ export function useComponentState({ subject === undefined ? undefined : !subject - ? "Can't be empty" - : undefined, + ? "Can't be empty" + : undefined, value: subject ?? "", onInput: pushAlertOnError(async (e) => setSubject(e)), }, diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx index c6d3e689c..e2c37fbba 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx @@ -14,27 +14,19 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; -import { LogoHeader } from "../../components/LogoHeader.js"; import { Part } from "../../components/Part.js"; -import { - SubTitle, - SvgIcon, - WalletAction, -} from "../../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { TermsOfService } from "../../components/TermsOfService/index.js"; import { Button } from "../../mui/Button.js"; import { TextField } from "../../mui/TextField.js"; -import editIcon from "../../svg/edit_24px.inline.svg"; import { ExchangeDetails, getAmountWithFee, InvoiceCreationDetails, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; -import { TermsOfService } from "../../components/TermsOfService/index.js"; export function ReadyView({ exchangeUrl, @@ -43,7 +35,7 @@ export function ReadyView({ create, toBeReceived, requestAmount, - doSelectExchange, + doSelectExchange: _doSelectExchange, }: State.Ready): VNode { const { i18n } = useTranslationContext(); @@ -62,10 +54,10 @@ export function ReadyView({ ); } } - async function _20DaysExpiration(): Promise<void> { + async function _30DaysExpiration(): Promise<void> { if (expiration.onInput) { expiration.onInput( - format(new Date().getTime() + 1000 * 60 * 60 * 24 * 20, "dd/MM/yyyy"), + format(new Date().getTime() + 1000 * 60 * 60 * 24 * 30, "dd/MM/yyyy"), ); } } @@ -81,13 +73,13 @@ export function ReadyView({ }} > <i18n.Translate>Exchange</i18n.Translate> - <Button onClick={doSelectExchange.onClick} variant="text"> + {/* <Button onClick={doSelectExchange.onClick} variant="text"> <SvgIcon title="Edit" dangerouslySetInnerHTML={{ __html: editIcon }} color="black" /> - </Button> + </Button> */} </div> } text={<ExchangeDetails exchange={exchangeUrl} />} @@ -135,9 +127,9 @@ export function ReadyView({ <Button variant="outlined" disabled={!expiration.onInput} - onClick={_20DaysExpiration} + onClick={_30DaysExpiration} > - 20 days + 30 days </Button> </p> </p> diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts b/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts index 8bae9470f..99de03d2d 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/state.ts @@ -23,10 +23,10 @@ import { TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useEffect } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { Props, State } from "./index.js"; @@ -64,7 +64,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the transfer payment status`, hook, ), }; @@ -76,12 +77,8 @@ export function useComponentState({ // }; // } - const { - contractTerms, - peerPullDebitId, - amountEffective, - amountRaw, - } = hook.response.p2p; + const { contractTerms, transactionId, amountEffective, amountRaw } = + hook.response.p2p; const amountStr: string = contractTerms.amount; const amount = Amounts.parseOrThrow(amountStr); @@ -155,7 +152,7 @@ export function useComponentState({ const resp = await api.wallet.call( WalletApiOperation.ConfirmPeerPullDebit, { - peerPullDebitId, + transactionId, }, ); onSuccess(resp.transactionId); diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx index 986b31d77..547d5ac9a 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx @@ -16,7 +16,6 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { Amount } from "../../components/Amount.js"; import { Part } from "../../components/Part.js"; import { PaymentButtons } from "../../components/PaymentButtons.js"; import { Time } from "../../components/Time.js"; @@ -35,7 +34,6 @@ export function ReadyView( <Fragment> <section style={{ textAlign: "left" }}> <Part title={i18n.str`Subject`} text={<div>{summary}</div>} /> - <Part title={i18n.str`Amount`} text={<Amount value={raw} />} /> <Part title={i18n.str`Details`} text={ diff --git a/packages/taler-wallet-webextension/src/cta/Payment/state.ts b/packages/taler-wallet-webextension/src/cta/Payment/state.ts index d171ecbac..4733e5aee 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/state.ts @@ -84,7 +84,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the payment and balance status`, hook, ), }; diff --git a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx index eee5fb684..d03f48746 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx @@ -57,9 +57,11 @@ export const NoEnoughBalanceAvailable = tests.createExample(BaseView, { balanceAvailable: "USD:9" as AmountString, balanceMaterial: "USD:9" as AmountString, balanceAgeAcceptable: "USD:9" as AmountString, - balanceMerchantAcceptable: "USD:9" as AmountString, - balanceMerchantDepositable: "USD:9" as AmountString, - feeGapEstimate: "USD:1" as AmountString, + balanceReceiverAcceptable: "USD:9" as AmountString, + balanceReceiverDepositable: "USD:9" as AmountString, + maxEffectiveSpendAmount: "USD:9.5" as AmountString, + balanceExchangeDepositable: "USD:9.5" as AmountString, + perExchange: {}, }, talerUri: "taler://pay/..", @@ -97,9 +99,11 @@ export const NoEnoughBalanceMaterial = tests.createExample(BaseView, { balanceAvailable: "USD:10" as AmountString, balanceMaterial: "USD:9" as AmountString, balanceAgeAcceptable: "USD:9" as AmountString, - balanceMerchantAcceptable: "USD:9" as AmountString, - balanceMerchantDepositable: "USD:0" as AmountString, - feeGapEstimate: "USD:1" as AmountString, + balanceReceiverAcceptable: "USD:9" as AmountString, + balanceReceiverDepositable: "USD:0" as AmountString, + maxEffectiveSpendAmount: "USD:9.5" as AmountString, + balanceExchangeDepositable: "USD:9.5" as AmountString, + perExchange: {}, }, talerUri: "taler://pay/..", @@ -137,9 +141,11 @@ export const NoEnoughBalanceAgeAcceptable = tests.createExample(BaseView, { balanceAvailable: "USD:10" as AmountString, balanceMaterial: "USD:10" as AmountString, balanceAgeAcceptable: "USD:9" as AmountString, - balanceMerchantAcceptable: "USD:9" as AmountString, - balanceMerchantDepositable: "USD:9" as AmountString, - feeGapEstimate: "USD:1" as AmountString, + balanceReceiverAcceptable: "USD:9" as AmountString, + balanceReceiverDepositable: "USD:9" as AmountString, + maxEffectiveSpendAmount: "USD:9.5" as AmountString, + balanceExchangeDepositable: "USD:9.5" as AmountString, + perExchange: {}, }, talerUri: "taler://pay/..", @@ -178,9 +184,11 @@ export const NoEnoughBalanceMerchantAcceptable = tests.createExample(BaseView, { balanceAvailable: "USD:10" as AmountString, balanceMaterial: "USD:10" as AmountString, balanceAgeAcceptable: "USD:10" as AmountString, - balanceMerchantAcceptable: "USD:9" as AmountString, - balanceMerchantDepositable: "USD:9" as AmountString, - feeGapEstimate: "USD:1" as AmountString, + balanceReceiverAcceptable: "USD:9" as AmountString, + balanceReceiverDepositable: "USD:9" as AmountString, + maxEffectiveSpendAmount: "USD:9.5" as AmountString, + balanceExchangeDepositable: "USD:9.5" as AmountString, + perExchange: {}, }, talerUri: "taler://pay/..", @@ -220,9 +228,11 @@ export const NoEnoughBalanceMerchantDepositable = tests.createExample( balanceAvailable: "USD:10" as AmountString, balanceMaterial: "USD:10" as AmountString, balanceAgeAcceptable: "USD:10" as AmountString, - balanceMerchantAcceptable: "USD:10" as AmountString, - balanceMerchantDepositable: "USD:9" as AmountString, - feeGapEstimate: "USD:1" as AmountString, + balanceReceiverAcceptable: "USD:10" as AmountString, + balanceReceiverDepositable: "USD:9" as AmountString, + maxEffectiveSpendAmount: "USD:9.5" as AmountString, + balanceExchangeDepositable: "USD:9.5" as AmountString, + perExchange: {}, }, talerUri: "taler://pay/..", @@ -261,9 +271,11 @@ export const NoEnoughBalanceFeeGap = tests.createExample(BaseView, { balanceAvailable: "USD:10" as AmountString, balanceMaterial: "USD:10" as AmountString, balanceAgeAcceptable: "USD:10" as AmountString, - balanceMerchantAcceptable: "USD:10" as AmountString, - balanceMerchantDepositable: "USD:10" as AmountString, - feeGapEstimate: "USD:1" as AmountString, + balanceReceiverAcceptable: "USD:10" as AmountString, + balanceReceiverDepositable: "USD:10" as AmountString, + maxEffectiveSpendAmount: "USD:9.5" as AmountString, + balanceExchangeDepositable: "USD:9.5" as AmountString, + perExchange: {}, }, talerUri: "taler://pay/..", diff --git a/packages/taler-wallet-webextension/src/cta/Payment/test.ts b/packages/taler-wallet-webextension/src/cta/Payment/test.ts index 5e009b3de..5847cc833 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/test.ts @@ -29,6 +29,7 @@ import { PreparePayResultPaymentPossible, PreparePayResultType, ScopeType, + TransactionMajorState, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { expect } from "chai"; @@ -549,8 +550,13 @@ describe("Payment CTA states", () => { // expect(r.totalFees).deep.equal(Amounts.parseOrThrow("USD:1")); expect(state.payHandler.onClick).not.undefined; - handler.notifyEventFromWallet( - NotificationType.TransactionStateTransition, + handler.notifyEventFromWallet({ + type: NotificationType.TransactionStateTransition, + newTxState: {} as any, + oldTxState: {} as any, + transactionId: "123", + } + ); }, (state) => { diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx index c00e570f9..8bbb8dac2 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -21,18 +21,16 @@ import { PreparePayResultType, TranslatedString, } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { Part } from "../../components/Part.js"; import { PaymentButtons } from "../../components/PaymentButtons.js"; -import { SuccessBox, WarningBox } from "../../components/styled/index.js"; +import { ShowFullContractTermPopup } from "../../components/ShowFullContractTermPopup.js"; import { Time } from "../../components/Time.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { - getAmountWithFee, - MerchantDetails, - PurchaseDetails, -} from "../../wallet/Transaction.js"; +import { SuccessBox, WarningBox } from "../../components/styled/index.js"; +import { MerchantDetails } from "../../wallet/Transaction.js"; import { State } from "./index.js"; +import { EnabledBySettings } from "../../components/EnabledBySettings.js"; type SupportedStates = | State.Ready @@ -67,29 +65,6 @@ export function BaseView(state: SupportedStates): VNode { text={<MerchantDetails merchant={contractTerms.merchant} />} kind="neutral" /> - <Part - title={i18n.str`Details`} - text={ - <PurchaseDetails - price={getAmountWithFee(effective, state.amount, "debit")} - info={{ - ...contractTerms, - orderId: contractTerms.order_id, - contractTermsHash: "", - // products: contractTerms.products!, - }} - proposalId={state.payStatus.proposalId} - /> - } - kind="neutral" - /> - {contractTerms.order_id && ( - <Part - title={i18n.str`Receipt`} - text={`#${contractTerms.order_id}` as TranslatedString} - kind="neutral" - /> - )} {contractTerms.pay_deadline && ( <Part title={i18n.str`Valid until`} @@ -105,6 +80,13 @@ export function BaseView(state: SupportedStates): VNode { /> )} </section> + <EnabledBySettings name="advancedMode"> + <section style={{ textAlign: "left" }}> + <ShowFullContractTermPopup + transactionId={state.payStatus.transactionId} + /> + </section> + </EnabledBySettings> <PaymentButtons amount={effective} payStatus={state.payStatus} diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts index 4a0b2911a..6b4584fea 100644 --- a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts } from "@gnu-taler/taler-util"; +import { Amounts, PreparePayResult } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; @@ -54,7 +54,7 @@ export function useComponentState({ const hook = useAsyncAsHook(async () => { if (!talerTemplateUri) throw Error("ERROR_NO-URI-FOR-PAYMENT-TEMPLATE"); - let payStatus; + let payStatus: PreparePayResult | undefined = undefined; if (!amountParam && !summaryParam) { payStatus = await api.wallet.call( WalletApiOperation.PreparePayForTemplate, @@ -79,6 +79,7 @@ export function useComponentState({ return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load the status of the order template`, hook, ), @@ -124,7 +125,9 @@ export function useComponentState({ }, ); setNewOrder(payStatus.talerUri!); - } catch (e) {} + } catch (e) { + console.error(e); + } } const errors = undefinedIfEmpty({ amount: amount && Amounts.isZero(amount) ? i18n.str`required` : undefined, @@ -163,7 +166,9 @@ export function useComponentState({ } function undefinedIfEmpty<T extends object>(obj: T): T | undefined { - return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + return Object.keys(obj).some( + (k) => (obj as Record<string, unknown>)[k] !== undefined, + ) ? obj : undefined; } diff --git a/packages/taler-wallet-webextension/src/cta/Refund/state.ts b/packages/taler-wallet-webextension/src/cta/Refund/state.ts index 6c0f37471..6f0a98151 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Refund/state.ts @@ -72,7 +72,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the refund status`, info, ), }; diff --git a/packages/taler-wallet-webextension/src/cta/Refund/views.tsx b/packages/taler-wallet-webextension/src/cta/Refund/views.tsx index ef21a511e..ae4d728f3 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund/views.tsx @@ -29,7 +29,7 @@ export function IgnoredView(state: State.Ignored): VNode { <Fragment> <section> <p> - <i18n.Translate>You've ignored the tip.</i18n.Translate> + <i18n.Translate>You've ignored the refund.</i18n.Translate> </p> </section> </Fragment> diff --git a/packages/taler-wallet-webextension/src/cta/Reward/state.ts b/packages/taler-wallet-webextension/src/cta/Reward/state.ts deleted file mode 100644 index a71ad6acc..000000000 --- a/packages/taler-wallet-webextension/src/cta/Reward/state.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Amounts } from "@gnu-taler/taler-util"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { alertFromError, useAlertContext } from "../../context/alert.js"; -import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; -import { Props, State } from "./index.js"; - -export function useComponentState({ - talerTipUri: talerRewardUri, - onCancel, - onSuccess, -}: Props): State { - const api = useBackendContext(); - const { i18n } = useTranslationContext(); - const { pushAlertOnError } = useAlertContext(); - const tipInfo = useAsyncAsHook(async () => { - if (!talerRewardUri) throw Error("ERROR_NO-URI-FOR-TIP"); - const tip = await api.wallet.call(WalletApiOperation.PrepareReward, { - talerRewardUri, - }); - return { tip }; - }); - - if (!tipInfo) { - return { - status: "loading", - error: undefined, - }; - } - if (tipInfo.hasError) { - return { - status: "error", - error: alertFromError( - i18n.str`Could not load the status of the term of service`, - tipInfo, - ), - }; - } - // if (tipInfo.hasError) { - // return { - // status: "loading-uri", - // error: tipInfo, - // }; - // } - - const { tip } = tipInfo.response; - - const doAccept = async (): Promise<void> => { - const res = await api.wallet.call(WalletApiOperation.AcceptReward, { - walletRewardId: tip.transactionId, - }); - - //FIX: this may not be seen since we are moving to the success also - tipInfo.retry(); - onSuccess(res.transactionId); - }; - - const baseInfo = { - merchantBaseUrl: tip.merchantBaseUrl, - exchangeBaseUrl: tip.exchangeBaseUrl, - amount: Amounts.parseOrThrow(tip.rewardAmountEffective), - error: undefined, - cancel: { - onClick: pushAlertOnError(onCancel), - }, - }; - - if (tip.accepted) { - return { - status: "accepted", - ...baseInfo, - }; - } - - return { - status: "ready", - ...baseInfo, - accept: { - onClick: pushAlertOnError(doAccept), - }, - }; -} diff --git a/packages/taler-wallet-webextension/src/cta/Reward/test.ts b/packages/taler-wallet-webextension/src/cta/Reward/test.ts deleted file mode 100644 index 0e378f366..000000000 --- a/packages/taler-wallet-webextension/src/cta/Reward/test.ts +++ /dev/null @@ -1,228 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { AmountString, Amounts } from "@gnu-taler/taler-util"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { expect } from "chai"; -import * as tests from "@gnu-taler/web-util/testing"; -import { nullFunction } from "../../mui/handlers.js"; -import { createWalletApiMock } from "../../test-utils.js"; -import { Props } from "./index.js"; -import { useComponentState } from "./state.js"; - -describe("Tip CTA states", () => { - it("should tell the user that the URI is missing", async () => { - const { handler, TestingContext } = createWalletApiMock(); - - const props: Props = { - talerTipUri: undefined, - onCancel: nullFunction, - onSuccess: nullFunction, - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - ({ status, error }) => { - expect(status).equals("loading"); - expect(error).undefined; - }, - ({ status, error }) => { - expect(status).equals("error"); - if (!error) expect.fail(); - expect(error.description).eq("ERROR_NO-URI-FOR-TIP"); - }, - ], - TestingContext, - ); - - expect(hookBehavior).deep.equal({ result: "ok" }); - expect(handler.getCallingQueueState()).eq("empty"); - }); - - it("should be ready for accepting the tip", async () => { - const { handler, TestingContext } = createWalletApiMock(); - - handler.addWalletCallResponse(WalletApiOperation.PrepareReward, undefined, { - accepted: false, - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - rewardAmountEffective: "EUR:1" as AmountString, - walletRewardId: "tip_id", - transactionId: "txn:tip:ABC1234", - expirationTimestamp: { - t_s: 1, - }, - rewardAmountRaw: "EUR:0" as AmountString, - }); - - const props: Props = { - talerTipUri: "taler://tip/asd", - onCancel: nullFunction, - onSuccess: nullFunction, - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - ({ status, error }) => { - expect(status).equals("loading"); - expect(error).undefined; - }, - (state) => { - if (state.status !== "ready") { - expect(state).eq({ status: "ready" }); - return; - } - if (state.error) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - if (state.accept.onClick === undefined) expect.fail(); - - handler.addWalletCallResponse(WalletApiOperation.AcceptReward); - state.accept.onClick(); - - handler.addWalletCallResponse( - WalletApiOperation.PrepareReward, - undefined, - { - accepted: true, - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - rewardAmountEffective: "EUR:1" as AmountString, - walletRewardId: "tip_id", - transactionId: "txn:tip:ABC1234", - expirationTimestamp: { - t_s: 1, - }, - rewardAmountRaw: "EUR:0" as AmountString, - }, - ); - }, - (state) => { - if (state.status !== "accepted") expect.fail(); - if (state.error) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - }, - ], - TestingContext, - ); - - expect(hookBehavior).deep.equal({ result: "ok" }); - expect(handler.getCallingQueueState()).eq("empty"); - }); - - it.skip("should be ignored after clicking the ignore button", async () => { - const { handler, TestingContext } = createWalletApiMock(); - handler.addWalletCallResponse(WalletApiOperation.PrepareReward, undefined, { - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - rewardAmountEffective: "EUR:1" as AmountString, - walletRewardId: "tip_id", - transactionId: "txn:tip:ABC1234", - accepted: false, - expirationTimestamp: { - t_s: 1, - }, - rewardAmountRaw: "EUR:0" as AmountString, - }); - - const props: Props = { - talerTipUri: "taler://tip/asd", - onCancel: nullFunction, - onSuccess: nullFunction, - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - ({ status, error }) => { - expect(status).equals("loading"); - expect(error).undefined; - }, - (state) => { - if (state.status !== "ready") expect.fail(); - if (state.error) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - - //FIXME: add ignore button - }, - ], - TestingContext, - ); - - expect(hookBehavior).deep.equal({ result: "ok" }); - expect(handler.getCallingQueueState()).eq("empty"); - }); - - it("should render accepted if the tip has been used previously", async () => { - const { handler, TestingContext } = createWalletApiMock(); - - handler.addWalletCallResponse(WalletApiOperation.PrepareReward, undefined, { - accepted: true, - exchangeBaseUrl: "exchange url", - merchantBaseUrl: "merchant url", - rewardAmountEffective: "EUR:1" as AmountString, - walletRewardId: "tip_id", - transactionId: "txn:tip:ABC1234", - expirationTimestamp: { - t_s: 1, - }, - rewardAmountRaw: "EUR:0" as AmountString, - }); - - const props: Props = { - talerTipUri: "taler://tip/asd", - onCancel: nullFunction, - onSuccess: nullFunction, - }; - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - ({ status, error }) => { - expect(status).equals("loading"); - expect(error).undefined; - }, - (state) => { - if (state.status !== "accepted") expect.fail(); - if (state.error) expect.fail(); - expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:1")); - expect(state.merchantBaseUrl).eq("merchant url"); - expect(state.exchangeBaseUrl).eq("exchange url"); - }, - ], - TestingContext, - ); - - expect(hookBehavior).deep.equal({ result: "ok" }); - expect(handler.getCallingQueueState()).eq("empty"); - }); -}); diff --git a/packages/taler-wallet-webextension/src/cta/Reward/views.tsx b/packages/taler-wallet-webextension/src/cta/Reward/views.tsx deleted file mode 100644 index 3c3190a07..000000000 --- a/packages/taler-wallet-webextension/src/cta/Reward/views.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { TranslatedString } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { Amount } from "../../components/Amount.js"; -import { LogoHeader } from "../../components/LogoHeader.js"; -import { Part } from "../../components/Part.js"; -import { Link, SubTitle, WalletAction } from "../../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Button } from "../../mui/Button.js"; -import { State } from "./index.js"; -import { TermsOfService } from "../../components/TermsOfService/index.js"; - -export function IgnoredView(state: State.Ignored): VNode { - const { i18n } = useTranslationContext(); - return ( - <Fragment> - <span> - <i18n.Translate>You've ignored the tip.</i18n.Translate> - </span> - </Fragment> - ); -} - -export function ReadyView(state: State.Ready): VNode { - const { i18n } = useTranslationContext(); - return ( - <Fragment> - <section> - <p> - <i18n.Translate>The merchant is offering you a tip</i18n.Translate> - </p> - <Part - title={i18n.str`Amount`} - text={<Amount value={state.amount} />} - kind="positive" - /> - <Part - title={i18n.str`Merchant URL`} - text={state.merchantBaseUrl as TranslatedString} - kind="neutral" - /> - <Part - title={i18n.str`Exchange`} - text={state.exchangeBaseUrl as TranslatedString} - kind="neutral" - /> - </section> - <section> - <TermsOfService key="terms" exchangeUrl={state.exchangeBaseUrl} > - <Button - variant="contained" - color="success" - onClick={state.accept.onClick} - > - <i18n.Translate> - Receive {<Amount value={state.amount} />} - </i18n.Translate> - </Button> - </TermsOfService> - </section> - </Fragment> - ); -} - -export function AcceptedView(state: State.Accepted): VNode { - const { i18n } = useTranslationContext(); - return ( - <Fragment> - <section> - <i18n.Translate> - Tip from <code>{state.merchantBaseUrl}</code> accepted. Check your - transactions list for more details. - </i18n.Translate> - </section> - </Fragment> - ); -} diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts b/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts index 297e8a56b..f092801ed 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/state.ts @@ -17,19 +17,18 @@ import { AmountString, Amounts, - TalerError, TalerErrorCode, TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { isFuture, parse } from "date-fns"; -import { useEffect, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; -import { Props, State } from "./index.js"; import { BackgroundError, WxApiType } from "../../wxApi.js"; +import { Props, State } from "./index.js"; export function useComponentState({ amount: amountStr, @@ -59,7 +58,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the max amount to transfer`, hook, ), }; @@ -163,11 +163,14 @@ async function checkPeerPushDebitAndCheckMax( const material = Amounts.parseOrThrow( e.errorDetail.insufficientBalanceDetails.balanceMaterial, ); - const gap = Amounts.parseOrThrow( - e.errorDetail.insufficientBalanceDetails.feeGapEstimate, - ); - const newAmount = Amounts.sub(material, gap).amount; const amount = Amounts.parseOrThrow(amountState); + const gap = Amounts.sub( + amount, + Amounts.parseOrThrow( + e.errorDetail.insufficientBalanceDetails.maxEffectiveSpendAmount, + ), + ).amount; + const newAmount = Amounts.sub(material, gap).amount; if (Amounts.cmp(newAmount, amount) === 0) { //insufficient balance and the exception didn't give //a good response that allow us to try again diff --git a/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx b/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx index 8489b0643..bc855f33d 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/TransferCreate/views.tsx @@ -50,10 +50,10 @@ export function ReadyView({ ); } } - async function _20DaysExpiration() { + async function _30DaysExpiration() { if (expiration.onInput) { expiration.onInput( - format(new Date().getTime() + 1000 * 60 * 60 * 24 * 20, "dd/MM/yyyy"), + format(new Date().getTime() + 1000 * 60 * 60 * 24 * 30, "dd/MM/yyyy"), ); } } @@ -100,9 +100,9 @@ export function ReadyView({ <Button variant="outlined" disabled={!expiration.onInput} - onClick={_20DaysExpiration} + onClick={_30DaysExpiration} > - 20 days + 30 days </Button> </p> </p> diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts b/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts index 06ef80760..67f6d9113 100644 --- a/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts +++ b/packages/taler-wallet-webextension/src/cta/TransferPickup/state.ts @@ -20,9 +20,9 @@ import { TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { Props, State } from "./index.js"; @@ -50,7 +50,8 @@ export function useComponentState({ return { status: "error", error: alertFromError( - i18n.str`Could not load the status of the term of service`, + i18n, + i18n.str`Could not load the invoice payment status`, hook, ), }; @@ -58,10 +59,10 @@ export function useComponentState({ const { contractTerms, - peerPushCreditId, + transactionId, amountEffective, amountRaw, - exchangeBaseUrl + exchangeBaseUrl, } = hook.response; const effective = Amounts.parseOrThrow(amountEffective); @@ -73,7 +74,7 @@ export function useComponentState({ const resp = await api.wallet.call( WalletApiOperation.ConfirmPeerPushCredit, { - peerPushCreditId, + transactionId, }, ); onSuccess(resp.transactionId); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts index 04713f3c4..1f8745a5d 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -38,7 +38,7 @@ import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { ErrorAlert } from "../../context/alert.js"; import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js"; import { NoExchangesView } from "../../wallet/ExchangeSelection/views.js"; -import { SelectAmountView, SuccessView } from "./views.js"; +import { FinalStateOperation, SelectAmountView, SuccessView } from "./views.js"; export interface PropsFromURI { talerWithdrawUri: string | undefined; @@ -60,6 +60,7 @@ export type State = | SelectExchangeState.NoExchangeFound | SelectExchangeState.Selecting | State.SelectAmount + | State.AlreadyCompleted | State.Success; export namespace State { @@ -80,6 +81,12 @@ export namespace State { amount: AmountFieldHandler; currency: string; } + export interface AlreadyCompleted { + status: "already-completed"; + operationState: "confirmed" | "aborted" | "selected"; + confirmTransferUrl?: string, + error: undefined; + } export type Success = { status: "success"; @@ -116,6 +123,7 @@ const viewMapping: StateViewMap<State> = { "no-exchange-found": NoExchangesView, "selecting-exchange": ExchangeSelectionPage, success: SuccessView, + "already-completed": FinalStateOperation, }; export const WithdrawPageFromURI = compose( diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts index 7bff13e51..f2fa04902 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -14,21 +14,19 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -/* eslint-disable react-hooks/rules-of-hooks */ import { AmountJson, Amounts, ExchangeFullDetails, ExchangeListItem, - ExchangeTosStatus, - TalerError, - parseWithdrawExchangeUri, + NotificationType, + parseWithdrawExchangeUri } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { useEffect, useState } from "preact/hooks"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useCallback, useEffect, useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { useSelectedExchange } from "../../hooks/useSelectedExchange.js"; import { RecursiveState } from "../../utils/index.js"; @@ -54,7 +52,7 @@ export function useComponentStateFromParams({ : undefined; const exchangeByTalerUri = uri?.exchangeBaseUrl; let ex: ExchangeFullDetails | undefined; - if (exchangeByTalerUri && uri.exchangePub) { + if (exchangeByTalerUri) { await api.wallet.call(WalletApiOperation.AddExchange, { exchangeBaseUrl: exchangeByTalerUri, masterPub: uri.exchangePub, @@ -79,6 +77,7 @@ export function useComponentStateFromParams({ return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load the list of exchanges`, uriInfoHook, ), @@ -208,23 +207,52 @@ export function useComponentStateFromURI({ WalletApiOperation.GetWithdrawalDetailsForUri, { talerWithdrawUri, + notifyChangeFromPendingTimeoutMs: 30 * 1000, }, ); - const { amount, defaultExchangeBaseUrl, possibleExchanges } = uriInfo; + const { + amount, + defaultExchangeBaseUrl, + possibleExchanges, + operationId, + confirmTransferUrl, + status, + } = uriInfo; + const transaction = await api.wallet.call( + WalletApiOperation.GetWithdrawalTransactionByUri, + { talerWithdrawUri }, + ); return { talerWithdrawUri, + operationId, + status, + transaction, + confirmTransferUrl, amount: Amounts.parseOrThrow(amount), thisExchange: defaultExchangeBaseUrl, exchanges: possibleExchanges, }; }); + const readyToListen = uriInfoHook && !uriInfoHook.hasError; + + useEffect(() => { + if (!uriInfoHook) { + return; + } + return api.listener.onUpdateNotification( + [NotificationType.WithdrawalOperationTransition], + uriInfoHook.retry, + ); + }, [readyToListen]); + if (!uriInfoHook) return { status: "loading", error: undefined }; if (uriInfoHook.hasError) { return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load info from URI`, uriInfoHook, ), @@ -257,8 +285,20 @@ export function useComponentStateFromURI({ }; } - return () => - exchangeSelectionState( + if (uriInfoHook.response.status !== "pending") { + if (uriInfoHook.response.transaction) { + onSuccess(uriInfoHook.response.transaction.transactionId); + } + return { + status: "already-completed", + operationState: uriInfoHook.response.status, + confirmTransferUrl: uriInfoHook.response.confirmTransferUrl, + error: undefined, + }; + } + + return useCallback(() => { + return exchangeSelectionState( doManagedWithdraw, cancel, onSuccess, @@ -267,6 +307,7 @@ export function useComponentStateFromURI({ exchangeList, defaultExchange, ); + }, []); } type ManualOrManagedWithdrawFunction = ( @@ -294,13 +335,18 @@ function exchangeSelectionState( return selectedExchange; } - return (): State.Success | State.LoadingUriError | State.Loading => { + return useCallback((): + | State.Success + | State.LoadingUriError + | State.Loading => { const { i18n } = useTranslationContext(); const { pushAlertOnError } = useAlertContext(); const [ageRestricted, setAgeRestricted] = useState(0); const currentExchange = selectedExchange.selected; - const [selectedCurrency, setSelectedCurrency] = useState<string>(chosenAmount.currency) + const [selectedCurrency, setSelectedCurrency] = useState<string>( + chosenAmount.currency, + ); /** * With the exchange and amount, ask the wallet the information * about the withdrawal @@ -323,13 +369,10 @@ function exchangeSelectionState( return { amount: withdrawAmount, ageRestrictionOptions: info.ageRestrictionOptions, - accounts: info.withdrawalAccountsList + accounts: info.withdrawalAccountsList, }; }, []); - const [withdrawError, setWithdrawError] = useState<TalerError | undefined>( - undefined, - ); const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); async function doWithdrawAndCheckError(): Promise<void> { @@ -345,9 +388,9 @@ function exchangeSelectionState( onSuccess(res.transactionId); } } catch (e) { - if (e instanceof TalerError) { - setWithdrawError(e); - } + console.error(e); + // if (e instanceof TalerError) { + // } } setDoingWithdraw(false); } @@ -359,6 +402,7 @@ function exchangeSelectionState( return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load the withdrawal details`, amountHook, ), @@ -396,15 +440,26 @@ function exchangeSelectionState( } : undefined; - const altCurrencies = amountHook.response.accounts.filter(a => !!a.currencySpecification).map(a => a.currencySpecification!.name) - const chooseCurrencies = altCurrencies.length === 0 ? [] : [toBeReceived.currency, ...altCurrencies] - const convAccount = amountHook.response.accounts.find(c => { - return c.currencySpecification && c.currencySpecification.name === selectedCurrency - }) - const conversionInfo = !convAccount ? undefined : ({ - spec: convAccount.currencySpecification!, - amount: Amounts.parseOrThrow(convAccount.transferAmount!) - }) + const altCurrencies = amountHook.response.accounts + .filter((a) => !!a.currencySpecification) + .map((a) => a.currencySpecification!.name); + const chooseCurrencies = + altCurrencies.length === 0 + ? [] + : [toBeReceived.currency, ...altCurrencies]; + + const convAccount = amountHook.response.accounts.find((c) => { + return ( + c.currencySpecification && + c.currencySpecification.name === selectedCurrency + ); + }); + const conversionInfo = !convAccount + ? undefined + : { + spec: convAccount.currencySpecification!, + amount: Amounts.parseOrThrow(convAccount.transferAmount!), + }; return { status: "success", @@ -414,19 +469,20 @@ function exchangeSelectionState( toBeReceived, chooseCurrencies, selectedCurrency, - changeCurrency: (s) => { setSelectedCurrency(s) }, + changeCurrency: (s) => { + setSelectedCurrency(s); + }, conversionInfo, withdrawalFee, chosenAmount, talerWithdrawUri, ageRestriction, doWithdrawal: { - onClick: - doingWithdraw - ? undefined - : pushAlertOnError(doWithdrawAndCheckError), + onClick: doingWithdraw + ? undefined + : pushAlertOnError(doWithdrawAndCheckError), }, cancel, }; - }; + }, []); } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx index a3127fafc..29f39054f 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -23,7 +23,7 @@ import { CurrencySpecification, ExchangeListItem } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { nullFunction } from "../../mui/handlers.js"; // import { TermsState } from "../../utils/index.js"; -import { SuccessView } from "./views.js"; +import { SuccessView, FinalStateOperation } from "./views.js"; export default { title: "withdraw", @@ -67,6 +67,23 @@ export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { chooseCurrencies: [], }); +export const AlreadyAborted = tests.createExample(FinalStateOperation, { + error: undefined, + status: "already-completed", + operationState: "aborted" +}); +export const AlreadySelected = tests.createExample(FinalStateOperation, { + error: undefined, + status: "already-completed", + operationState: "selected" +}); +export const AlreadyConfirmed = tests.createExample(FinalStateOperation, { + error: undefined, + status: "already-completed", + operationState: "confirmed" +}); + + export const WithSomeFee = tests.createExample(SuccessView, { error: undefined, status: "success", diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index 3493415d9..f90f7bed7 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -111,10 +111,19 @@ describe("Withdraw CTA states", () => { WalletApiOperation.GetWithdrawalDetailsForUri, undefined, { + status: "pending", + operationId: "123", amount: "EUR:2" as AmountString, possibleExchanges: [], }, ); + handler.addWalletCallResponse( + WalletApiOperation.GetWithdrawalTransactionByUri, + undefined, + { + transactionId: "123" + } as any, + ); const hookBehavior = await tests.hookBehaveLikeThis( useComponentStateFromURI, @@ -147,12 +156,21 @@ describe("Withdraw CTA states", () => { WalletApiOperation.GetWithdrawalDetailsForUri, undefined, { + status: "pending", + operationId: "123", amount: "ARS:2" as AmountString, possibleExchanges: exchanges, defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl, }, ); handler.addWalletCallResponse( + WalletApiOperation.GetWithdrawalTransactionByUri, + undefined, + { + transactionId: "123" + } as any, + ); + handler.addWalletCallResponse( WalletApiOperation.GetWithdrawalDetailsForAmount, undefined, { @@ -217,6 +235,8 @@ describe("Withdraw CTA states", () => { WalletApiOperation.GetWithdrawalDetailsForUri, undefined, { + status: "pending", + operationId: "123", amount: "ARS:2" as AmountString, possibleExchanges: exchangeWithNewTos, defaultExchangeBaseUrl: exchangeWithNewTos[0].exchangeBaseUrl, @@ -245,6 +265,8 @@ describe("Withdraw CTA states", () => { WalletApiOperation.GetWithdrawalDetailsForUri, undefined, { + status: "pending", + operationId: "123", amount: "ARS:2" as AmountString, possibleExchanges: exchanges, defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx index 748b65817..aade67835 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -14,26 +14,53 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, ExchangeTosStatus } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Amount } from "../../components/Amount.js"; +import { AmountField } from "../../components/AmountField.js"; import { Part } from "../../components/Part.js"; import { QR } from "../../components/QR.js"; import { SelectList } from "../../components/SelectList.js"; -import { Input, LinkSuccess, SvgIcon } from "../../components/styled/index.js"; import { TermsOfService } from "../../components/TermsOfService/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Input, LinkSuccess, SvgIcon, WarningBox } from "../../components/styled/index.js"; import { Button } from "../../mui/Button.js"; +import { Grid } from "../../mui/Grid.js"; import editIcon from "../../svg/edit_24px.inline.svg"; import { ExchangeDetails, - getAmountWithFee, WithdrawDetails, + getAmountWithFee, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; -import { Grid } from "../../mui/Grid.js"; -import { AmountField } from "../../components/AmountField.js"; +import { EnabledBySettings } from "../../components/EnabledBySettings.js"; + +export function FinalStateOperation(state: State.AlreadyCompleted): VNode { + const { i18n } = useTranslationContext(); + + switch (state.operationState) { + case "confirmed": return <WarningBox> + <div style={{ justifyContent: "center", lineHeight: "25px" }}> + <i18n.Translate>This operation has already been completed by another wallet.</i18n.Translate> + </div> + </WarningBox> + case "aborted": return <WarningBox> + <div style={{ justifyContent: "center", lineHeight: "25px" }}> + <i18n.Translate>This operation has already been aborted</i18n.Translate> + </div> + </WarningBox> + case "selected": return <WarningBox> + <div style={{ justifyContent: "center", lineHeight: "25px" }}> + <i18n.Translate>This operation has already been used by another wallet.</i18n.Translate> + </div> + <div style={{ justifyContent: "center", lineHeight: "25px" }}> + <i18n.Translate>It can be confirmed in</i18n.Translate> <a target="_bank" rel="noreferrer" href={state.confirmTransferUrl}> + <i18n.Translate>this page</i18n.Translate> + </a> + </div> + </WarningBox> + } +} export function SuccessView(state: State.Success): VNode { const { i18n } = useTranslationContext(); @@ -51,13 +78,15 @@ export function SuccessView(state: State.Success): VNode { }} > <i18n.Translate>Exchange</i18n.Translate> - <Button onClick={state.doSelectExchange.onClick} variant="text"> - <SvgIcon - title="Edit" - dangerouslySetInnerHTML={{ __html: editIcon }} - color="black" - /> - </Button> + <EnabledBySettings name="showExchangeManagement"> + <Button onClick={state.doSelectExchange.onClick} variant="text"> + <SvgIcon + title="Edit" + dangerouslySetInnerHTML={{ __html: editIcon }} + color="black" + /> + </Button> + </EnabledBySettings> </div> } text={ @@ -109,6 +138,7 @@ export function SuccessView(state: State.Success): VNode { </section> <section> + {/* <div> */} <TermsOfService exchangeUrl={state.currentExchange.exchangeBaseUrl}> <Button variant="contained" @@ -121,6 +151,20 @@ export function SuccessView(state: State.Success): VNode { </i18n.Translate> </Button> </TermsOfService> + {/* </div> + <div style={{ marginTop: 20 }}> + <Button + variant="text" + color="success" + + disabled={!state.doAbort.onClick} + onClick={state.doAbort.onClick} + > + <i18n.Translate> + Cancel + </i18n.Translate> + </Button> + </div> */} </section> {state.talerWithdrawUri ? ( <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} /> diff --git a/packages/taler-wallet-webextension/src/cta/index.stories.ts b/packages/taler-wallet-webextension/src/cta/index.stories.ts index 06b11ef6d..36e9cd1b9 100644 --- a/packages/taler-wallet-webextension/src/cta/index.stories.ts +++ b/packages/taler-wallet-webextension/src/cta/index.stories.ts @@ -22,7 +22,6 @@ export * as a1 from "./Deposit/stories.jsx"; export * as a3 from "./Payment/stories.jsx"; export * as a4 from "./Refund/stories.jsx"; -export * as a5 from "./Reward/stories.js"; export * as a6 from "./Withdraw/stories.jsx"; export * as a8 from "./InvoiceCreate/stories.js"; export * as a9 from "./InvoicePay/stories.js"; diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts index a5e357f7d..bd430f2ef 100644 --- a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts +++ b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts @@ -48,14 +48,13 @@ export type HookResponseWithRetry<T> = export function useAsyncAsHook<T>( fn: () => Promise<T | false>, - deps?: any[], + deps?: unknown[], ): HookResponseWithRetry<T> { const [result, setHookResponse] = useState<HookResponse<T>>(undefined); const args = useMemo( () => ({ fn, - // eslint-disable-next-line react-hooks/exhaustive-deps }), deps || [], ); diff --git a/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts b/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts index ca2054931..e2ba5b285 100644 --- a/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts +++ b/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts @@ -14,7 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { ProviderInfo, WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { ProviderInfo } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useEffect, useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; diff --git a/packages/taler-wallet-webextension/src/hooks/useSettings.ts b/packages/taler-wallet-webextension/src/hooks/useSettings.ts index dd3822c1a..a79a71087 100644 --- a/packages/taler-wallet-webextension/src/hooks/useSettings.ts +++ b/packages/taler-wallet-webextension/src/hooks/useSettings.ts @@ -36,12 +36,16 @@ export const codecForSettings = (): Codec<Settings> => .property("walletAllowHttp", codecForBoolean()) .property("injectTalerSupport", codecForBoolean()) .property("autoOpen", codecForBoolean()) - .property("advanceMode", codecForBoolean()) + .property("advancedMode", codecForBoolean()) .property("backup", codecForBoolean()) .property("langSelector", codecForBoolean()) .property("showJsonOnError", codecForBoolean()) .property("extendedAccountTypes", codecForBoolean()) .property("suspendIndividualTransaction", codecForBoolean()) + .property("showRefeshTransactions", codecForBoolean()) + .property("showExchangeManagement", codecForBoolean()) + .property("selectTosFormat", codecForBoolean()) + .property("showWalletActivity", codecForBoolean()) .build("Settings"); const SETTINGS_KEY = buildStorageKey("wallet-settings", codecForSettings()); @@ -50,11 +54,11 @@ export function useSettings(): [ Readonly<Settings>, <T extends keyof Settings>(key: T, value: Settings[T]) => void, ] { - const { value, update } = useLocalStorage(SETTINGS_KEY); + const { value, update } = useLocalStorage(SETTINGS_KEY, defaultSettings); - const parsed: Settings = value ?? defaultSettings; function updateField<T extends keyof Settings>(k: T, v: Settings[T]) { - update({ ...parsed, [k]: v }); + update({ ...value, [k]: v }); } - return [parsed, updateField]; + + return [value, updateField]; } diff --git a/packages/taler-wallet-webextension/src/i18n/es.po b/packages/taler-wallet-webextension/src/i18n/es.po index a482b9550..ea1fa9803 100644 --- a/packages/taler-wallet-webextension/src/i18n/es.po +++ b/packages/taler-wallet-webextension/src/i18n/es.po @@ -17,7 +17,7 @@ msgstr "" "Project-Id-Version: Taler Wallet\n" "Report-Msgid-Bugs-To: languages@taler.net\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: 2023-08-13 10:14+0000\n" +"PO-Revision-Date: 2024-03-07 07:03+0000\n" "Last-Translator: Javier Sepulveda <javier.sepulveda@uv.es>\n" "Language-Team: Spanish <https://weblate.taler.net/projects/gnu-taler/" "webextensions/es/>\n" @@ -26,12 +26,12 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.13.1\n" +"X-Generator: Weblate 5.2.1\n" #: src/NavigationBar.tsx:139 #, c-format msgid "Balance" -msgstr "Balance" +msgstr "Saldo" #: src/NavigationBar.tsx:142 #, c-format @@ -598,7 +598,7 @@ msgstr "Confirmar" #: src/wallet/Transaction.tsx:267 #, c-format msgid "Withdrawal" -msgstr "Retirada" +msgstr "Extracción" #: src/wallet/Transaction.tsx:286 #, c-format @@ -1890,7 +1890,7 @@ msgstr "Escanear un código QR o ingresar taler:// URI debajo" #: src/wallet/QrReader.tsx:122 #, c-format msgid "Open" -msgstr "Abrir" +msgstr "Abierto" #: src/wallet/QrReader.tsx:128 #, c-format diff --git a/packages/taler-wallet-webextension/src/i18n/fi.po b/packages/taler-wallet-webextension/src/i18n/fi.po new file mode 100644 index 000000000..c6196b7f3 --- /dev/null +++ b/packages/taler-wallet-webextension/src/i18n/fi.po @@ -0,0 +1,1967 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: languages@taler.net\n" +"POT-Creation-Date: 2016-11-23 00:00+0100\n" +"PO-Revision-Date: 2024-03-20 00:10+0000\n" +"Last-Translator: Sara Korpinen <sara.a.korpinen@gmail.com>\n" +"Language-Team: Finnish <https://weblate.taler.net/projects/gnu-taler/" +"webextensions/fi/>\n" +"Language: fi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.2.1\n" + +#: src/NavigationBar.tsx:139 +#, c-format +msgid "Balance" +msgstr "Saldo" + +#: src/NavigationBar.tsx:142 +#, c-format +msgid "Backup" +msgstr "Varmuuskopio" + +#: src/NavigationBar.tsx:147 +#, c-format +msgid "QR Reader and Taler URI" +msgstr "QR -lukija ja Taler URI" + +#: src/NavigationBar.tsx:154 +#, c-format +msgid "Settings" +msgstr "Asetukset" + +#: src/NavigationBar.tsx:184 +#, c-format +msgid "Dev" +msgstr "Kehitys" + +#: src/mui/Typography.tsx:122 +#, c-format +msgid "%1$s" +msgstr "%1$s" + +#: src/components/PendingTransactions.tsx:74 +#, c-format +msgid "PENDING OPERATIONS" +msgstr "ODOTTAVAT TOIMINNOT" + +#: src/components/Loading.tsx:36 +#, c-format +msgid "Loading" +msgstr "Lataa" + +#: src/wallet/BackupPage.tsx:123 +#, c-format +msgid "Could not load backup providers" +msgstr "Varmuuskopion tarjoajia ei voitu ladata" + +#: src/wallet/BackupPage.tsx:202 +#, c-format +msgid "No backup providers configured" +msgstr "Varmuuskopion tarjoajia ei ole määritetty" + +#: src/wallet/BackupPage.tsx:205 +#, c-format +msgid "Add provider" +msgstr "Lisää palveluntarjoaja" + +#: src/wallet/BackupPage.tsx:219 +#, c-format +msgid "Sync all backups" +msgstr "Synkronoi kaikki varmuuskopiot" + +#: src/wallet/BackupPage.tsx:221 +#, c-format +msgid "Sync now" +msgstr "Synkronoi nyt" + +#: src/wallet/BackupPage.tsx:264 +#, c-format +msgid "Last synced" +msgstr "Viimeksi synkronoitu" + +#: src/wallet/BackupPage.tsx:269 +#, c-format +msgid "Not synced" +msgstr "Ei synkronoitu" + +#: src/wallet/BackupPage.tsx:289 +#, c-format +msgid "Expires in" +msgstr "Vanhenee" + +#: src/wallet/ProviderDetailPage.tsx:60 +#, c-format +msgid "There was an error loading the provider detail for " %1$s"" +msgstr "Virhe ladattaessa palveluntarjoajan tietoja kohteelle " %1$s"" + +#: src/wallet/ProviderDetailPage.tsx:108 +#, c-format +msgid "There is not known provider with url "%1$s"." +msgstr "Ei tunneta palveluntarjoajaa, jonka URL-osoite on "%1$s"." + +#: src/wallet/ProviderDetailPage.tsx:115 +#, c-format +msgid "See providers" +msgstr "Katso palveluntarjoajat" + +#: src/wallet/ProviderDetailPage.tsx:143 +#, c-format +msgid "Last backup" +msgstr "Viimeisin varmuuskopio" + +#: src/wallet/ProviderDetailPage.tsx:148 +#, c-format +msgid "Back up" +msgstr "Varmuuskopioi" + +#: src/wallet/ProviderDetailPage.tsx:154 +#, c-format +msgid "Provider fee" +msgstr "Palveluntarjoajan maksu" + +#: src/wallet/ProviderDetailPage.tsx:157 +#, c-format +msgid "per year" +msgstr "vuodessa" + +#: src/wallet/ProviderDetailPage.tsx:163 +#, c-format +msgid "Extend" +msgstr "Laajenna" + +#: src/wallet/ProviderDetailPage.tsx:169 +#, c-format +msgid "" +"terms has changed, extending the service will imply accepting the new terms of " +"service" +msgstr "" +"ehdot ovat muuttuneet, palvelun laajentaminen tarkoittaa uusien " +"käyttöehtojen hyväksymistä" + +#: src/wallet/ProviderDetailPage.tsx:179 +#, c-format +msgid "old" +msgstr "vanha" + +#: src/wallet/ProviderDetailPage.tsx:183 +#, c-format +msgid "new" +msgstr "uusi" + +#: src/wallet/ProviderDetailPage.tsx:190 +#, c-format +msgid "fee" +msgstr "maksu" + +#: src/wallet/ProviderDetailPage.tsx:198 +#, c-format +msgid "storage" +msgstr "tila" + +#: src/wallet/ProviderDetailPage.tsx:215 +#, c-format +msgid "Remove provider" +msgstr "Poista palveluntarjoaja" + +#: src/wallet/ProviderDetailPage.tsx:228 +#, c-format +msgid "This provider has reported an error" +msgstr "Tämä palveluntarjoaja on ilmoittanut virheestä" + +#: src/wallet/ProviderDetailPage.tsx:242 +#, c-format +msgid "There is conflict with another backup from %1$s" +msgstr "Ristiriita toisen varmuuskopion kanssa kohteesta %1$s" + +#: src/wallet/ProviderDetailPage.tsx:253 +#, c-format +msgid "Backup is not readable" +msgstr "Varmuuskopiota ei voi lukea" + +#: src/wallet/ProviderDetailPage.tsx:261 +#, c-format +msgid "Unknown backup problem: %1$s" +msgstr "Tuntematon varmuuskopiointi ongelma: %1$s" + +#: src/wallet/ProviderDetailPage.tsx:283 +#, c-format +msgid "service paid" +msgstr "palvelu maksettu" + +#: src/wallet/ProviderDetailPage.tsx:290 +#, c-format, fuzzy +msgid "Backup valid until" +msgstr "Varmuuskopio voimassa" + +#: src/wallet/AddNewActionView.tsx:57 +#, c-format +msgid "Cancel" +msgstr "Peruuta" + +#: src/wallet/AddNewActionView.tsx:68 +#, c-format +msgid "Open reserve page" +msgstr "Avaa varaussivu" + +#: src/wallet/AddNewActionView.tsx:70 +#, c-format +msgid "Open pay page" +msgstr "Avaa maksusivu" + +#: src/wallet/AddNewActionView.tsx:72 +#, c-format +msgid "Open refund page" +msgstr "Avaa hyvityssivu" + +#: src/wallet/AddNewActionView.tsx:74 +#, c-format +msgid "Open tip page" +msgstr "Avaa tippi sivu" + +#: src/wallet/AddNewActionView.tsx:76 +#, c-format +msgid "Open withdraw page" +msgstr "Avaa nostosivu" + +#: src/popup/NoBalanceHelp.tsx:43 +#, c-format +msgid "Get digital cash" +msgstr "Hanki digitaalista käteistä" + +#: src/popup/BalancePage.tsx:138 +#, c-format +msgid "Could not load balance page" +msgstr "Ei voitu ladata saldosivua" + +#: src/popup/BalancePage.tsx:175 +#, c-format +msgid "Add" +msgstr "Lisää" + +#: src/popup/BalancePage.tsx:179 +#, c-format +msgid "Send %1$s" +msgstr "Lähetä %1$s" + +#: src/popup/TalerActionFound.tsx:44 +#, c-format +msgid "Taler Action" +msgstr "Taler toiminta" + +#: src/popup/TalerActionFound.tsx:49 +#, c-format +msgid "This page has pay action." +msgstr "Tällä sivulla on maksutoiminto." + +#: src/popup/TalerActionFound.tsx:63 +#, c-format +msgid "This page has a withdrawal action." +msgstr "Tällä sivulla on nosto toiminto." + +#: src/popup/TalerActionFound.tsx:79 +#, c-format +msgid "This page has a tip action." +msgstr "Tällä sivulla on tippaus toiminto." + +#: src/popup/TalerActionFound.tsx:93 +#, c-format +msgid "This page has a notify reserve action." +msgstr "Tällä sivulla on ilmoitus varaus toiminto." + +#: src/popup/TalerActionFound.tsx:102 +#, c-format +msgid "Notify" +msgstr "Ilmoita" + +#: src/popup/TalerActionFound.tsx:109 +#, c-format +msgid "This page has a refund action." +msgstr "Tällä sivulla on hyvitys toiminto." + +#: src/popup/TalerActionFound.tsx:123 +#, c-format +msgid "This page has a malformed taler uri." +msgstr "Tällä sivulla on väärin muotoiltu taler uri." + +#: src/popup/TalerActionFound.tsx:134 +#, c-format +msgid "Dismiss" +msgstr "Hylkää" + +#: src/popup/Application.tsx:177 +#, c-format +msgid "this popup is being closed and you are being redirected to %1$s" +msgstr "tämä ponnahdusikkuna suljetaan ja sinut ohjataan osoitteeseen %1$s" + +#: src/components/ShowFullContractTermPopup.tsx:158 +#, c-format +msgid "Could not load purchase proposal details" +msgstr "Ostoehdotuksen tietoja ei voitu ladata" + +#: src/components/ShowFullContractTermPopup.tsx:183 +#, c-format +msgid "Order Id" +msgstr "Tilausnumero" + +#: src/components/ShowFullContractTermPopup.tsx:189 +#, c-format +msgid "Summary" +msgstr "Yhteenveto" + +#: src/components/ShowFullContractTermPopup.tsx:195 +#, c-format +msgid "Amount" +msgstr "Summa" + +#: src/components/ShowFullContractTermPopup.tsx:203 +#, c-format +msgid "Merchant name" +msgstr "Kauppiaan nimi" + +#: src/components/ShowFullContractTermPopup.tsx:209 +#, c-format +msgid "Merchant jurisdiction" +msgstr "Kauppiaan toimivalta" + +#: src/components/ShowFullContractTermPopup.tsx:215 +#, c-format +msgid "Merchant address" +msgstr "Kauppiaan osoite" + +#: src/components/ShowFullContractTermPopup.tsx:221 +#, c-format +msgid "Merchant logo" +msgstr "Kauppiaan logo" + +#: src/components/ShowFullContractTermPopup.tsx:234 +#, c-format +msgid "Merchant website" +msgstr "Kauppiaan nettisivut" + +#: src/components/ShowFullContractTermPopup.tsx:240 +#, c-format +msgid "Merchant email" +msgstr "Kauppiaan sähköposti" + +#: src/components/ShowFullContractTermPopup.tsx:246 +#, c-format +msgid "Merchant public key" +msgstr "Kauppiaan julkinen avain" + +#: src/components/ShowFullContractTermPopup.tsx:256 +#, c-format +msgid "Delivery date" +msgstr "Toimituspäivä" + +#: src/components/ShowFullContractTermPopup.tsx:271 +#, c-format +msgid "Delivery location" +msgstr "Toimituspaikka" + +#: src/components/ShowFullContractTermPopup.tsx:277 +#, c-format +msgid "Products" +msgstr "Tuotteet" + +#: src/components/ShowFullContractTermPopup.tsx:289 +#, c-format +msgid "Created at" +msgstr "Luotu" + +#: src/components/ShowFullContractTermPopup.tsx:304 +#, c-format +msgid "Refund deadline" +msgstr "Palautuksen määräaika" + +#: src/components/ShowFullContractTermPopup.tsx:319 +#, c-format +msgid "Auto refund" +msgstr "Automaattinen palautus" + +#: src/components/ShowFullContractTermPopup.tsx:339 +#, c-format +msgid "Pay deadline" +msgstr "Maksun määräaika" + +#: src/components/ShowFullContractTermPopup.tsx:354 +#, c-format +msgid "Fulfillment URL" +msgstr "Toteutus-URL" + +#: src/components/ShowFullContractTermPopup.tsx:360 +#, c-format +msgid "Fulfillment message" +msgstr "Toteutusviesti" + +#: src/components/ShowFullContractTermPopup.tsx:370 +#, c-format +msgid "Max deposit fee" +msgstr "Max talletusmaksu" + +#: src/components/ShowFullContractTermPopup.tsx:378 +#, c-format +msgid "Max fee" +msgstr "Max maksu" + +#: src/components/ShowFullContractTermPopup.tsx:386 +#, c-format +msgid "Minimum age" +msgstr "Alaikäraja" + +#: src/components/ShowFullContractTermPopup.tsx:398 +#, c-format +msgid "Wire fee amortization" +msgstr "Pankkimaksun lyhennys" + +#: src/components/ShowFullContractTermPopup.tsx:404 +#, c-format +msgid "Auditors" +msgstr "Tilintarkastajat" + +#: src/components/ShowFullContractTermPopup.tsx:419 +#, c-format +msgid "Exchanges" +msgstr "Vaihdot" + +#: src/components/Part.tsx:148 +#, c-format +msgid "Bank account" +msgstr "Pankkitili" + +#: src/components/Part.tsx:160 +#, c-format +msgid "Bitcoin address" +msgstr "Bitcoin osoite" + +#: src/components/Part.tsx:163 +#, c-format +msgid "IBAN" +msgstr "IBAN" + +#: src/cta/Deposit/views.tsx:38 +#, c-format +msgid "Could not load deposit status" +msgstr "Talletuksen tilaa ei voitu ladata" + +#: src/cta/Deposit/views.tsx:52 +#, c-format +msgid "Digital cash deposit" +msgstr "Digitaalinen käteistalletus" + +#: src/cta/Deposit/views.tsx:58 +#, c-format +msgid "Cost" +msgstr "Kustannus" + +#: src/cta/Deposit/views.tsx:66 +#, c-format +msgid "Fee" +msgstr "Maksu" + +#: src/cta/Deposit/views.tsx:73 +#, c-format +msgid "To be received" +msgstr "Vastaanotettava" + +#: src/cta/Deposit/views.tsx:84 +#, c-format +msgid "Send %1$s" +msgstr "Lähetä %1$s" + +#: src/components/BankDetailsByPaytoType.tsx:63 +#, c-format +msgid "Bitcoin transfer details" +msgstr "Bitcoin -siirron tiedot" + +#: src/components/BankDetailsByPaytoType.tsx:66 +#, c-format +msgid "" +"The exchange need a transaction with 3 output, one output is the exchange " +"account and the other two are segwit fake address for metadata with an minimum " +"amount." +msgstr "" +"Pörssi tarvitsee tapahtuman, jossa on 3 lähtöä, joista yksi on vaihtotili ja " +"kaksi muuta ovat segwit fake -osoitteita metatiedoille vähimmäismäärällä." + +#: src/components/BankDetailsByPaytoType.tsx:74 +#, c-format +msgid "" +"In bitcoincore wallet use 'Add Recipient' button to add two additional " +"recipient and copy addresses and amounts" +msgstr "" +"Käytä bitcoincore-lompakossa 'Lisää vastaanottaja' -painiketta " +"lisätäksesi kaksi muuta vastaanottajaa ja kopioidaksesi osoitteet ja summat" + +#: src/components/BankDetailsByPaytoType.tsx:98 +#, c-format +msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC" +msgstr "" +"Varmista, että summa näyttää %1$s BTC, muuten sinun on vaihdettava " +"perusyksikkö BTC:ksi" + +#: src/components/BankDetailsByPaytoType.tsx:110 +#, c-format +msgid "Account" +msgstr "Tili" + +#: src/components/BankDetailsByPaytoType.tsx:116 +#, c-format +msgid "Bank host" +msgstr "Pankin isäntä" + +#: src/components/BankDetailsByPaytoType.tsx:139 +#, c-format +msgid "Bank transfer details" +msgstr "Pankkisiirtotiedot" + +#: src/components/BankDetailsByPaytoType.tsx:148 +#, c-format +msgid "Subject" +msgstr "Aihe" + +#: src/components/BankDetailsByPaytoType.tsx:154 +#, c-format +msgid "Receiver name" +msgstr "Vastaanottajan nimi" + +#: src/wallet/Transaction.tsx:98 +#, c-format +msgid "Could not load the transaction information" +msgstr "Tapahtumatietoja ei voitu ladata" + +#: src/wallet/Transaction.tsx:191 +#, c-format +msgid "There was an error trying to complete the transaction" +msgstr "Tapahtuman suorittamisessa tapahtui virhe" + +#: src/wallet/Transaction.tsx:200 +#, c-format +msgid "This transaction is not completed" +msgstr "Tätä tapahtumaa ei ole suoritettu loppuun" + +#: src/wallet/Transaction.tsx:209 +#, c-format +msgid "Send" +msgstr "Lähetä" + +#: src/wallet/Transaction.tsx:216 +#, c-format +msgid "Retry" +msgstr "Yritä uudelleen" + +#: src/wallet/Transaction.tsx:224 +#, c-format +msgid "Forget" +msgstr "Unohda" + +#: src/wallet/Transaction.tsx:241 +#, c-format +msgid "Caution!" +msgstr "Varoitus!" + +#: src/wallet/Transaction.tsx:244 +#, c-format +msgid "" +"If you have already wired money to the exchange you will loose the chance to get " +"the coins form it." +msgstr "" +"Jos olet jo siirtänyt rahaa vaihtoon, menetät mahdollisuuden saada kolikot " +"siitä." + +#: src/wallet/Transaction.tsx:259 +#, c-format +msgid "Confirm" +msgstr "Vahvista" + +#: src/wallet/Transaction.tsx:267 +#, c-format +msgid "Withdrawal" +msgstr "Nosto" + +#: src/wallet/Transaction.tsx:286 +#, c-format +msgid "" +"Make sure to use the correct subject, otherwise the money will not arrive in " +"this wallet." +msgstr "" +"Varmista, että käytät oikeaa aihetta, muuten rahat eivät tule tähän " +"lompakkoon." + +#: src/wallet/Transaction.tsx:298 +#, c-format +msgid "" +"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check " +"there is no pending step." +msgstr "" +"Pankki ei ole vielä vahvistanut pankkisiirtoa. Siirry kohtaan %1$s %2$s ja " +"tarkista, ettei odottavaa vaihetta ole." + +#: src/wallet/Transaction.tsx:316 +#, c-format +msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins" +msgstr "" + +#: src/wallet/Transaction.tsx:325 +#, c-format +msgid "Details" +msgstr "" + +#: src/wallet/Transaction.tsx:360 +#, c-format +msgid "Payment" +msgstr "" + +#: src/wallet/Transaction.tsx:378 +#, c-format +msgid "Refunds" +msgstr "" + +#: src/wallet/Transaction.tsx:385 +#, c-format +msgid "%1$s %2$s on %3$s" +msgstr "" + +#: src/wallet/Transaction.tsx:415 +#, c-format +msgid "Merchant created a refund for this order but was not automatically picked up." +msgstr "" + +#: src/wallet/Transaction.tsx:420 +#, c-format +msgid "Offer" +msgstr "" + +#: src/wallet/Transaction.tsx:431 +#, c-format +msgid "Accept" +msgstr "" + +#: src/wallet/Transaction.tsx:438 +#, c-format +msgid "Merchant" +msgstr "" + +#: src/wallet/Transaction.tsx:443 +#, c-format +msgid "Invoice ID" +msgstr "" + +#: src/wallet/Transaction.tsx:470 +#, c-format +msgid "Deposit" +msgstr "" + +#: src/wallet/Transaction.tsx:496 +#, c-format +msgid "Refresh" +msgstr "" + +#: src/wallet/Transaction.tsx:517 +#, c-format +msgid "Tip" +msgstr "" + +#: src/wallet/Transaction.tsx:542 +#, c-format +msgid "Refund" +msgstr "" + +#: src/wallet/Transaction.tsx:555 +#, c-format +msgid "Original order ID" +msgstr "" + +#: src/wallet/Transaction.tsx:568 +#, c-format +msgid "Purchase summary" +msgstr "" + +#: src/wallet/Transaction.tsx:593 +#, c-format +msgid "copy" +msgstr "" + +#: src/wallet/Transaction.tsx:596 +#, c-format +msgid "hide qr" +msgstr "" + +#: src/wallet/Transaction.tsx:608 +#, c-format +msgid "show qr" +msgstr "" + +#: src/wallet/Transaction.tsx:620 +#, c-format +msgid "Credit" +msgstr "" + +#: src/wallet/Transaction.tsx:624 +#, c-format +msgid "Invoice" +msgstr "" + +#: src/wallet/Transaction.tsx:635 +#, c-format +msgid "Exchange" +msgstr "" + +#: src/wallet/Transaction.tsx:641 +#, c-format +msgid "URI" +msgstr "" + +#: src/wallet/Transaction.tsx:667 +#, c-format +msgid "Debit" +msgstr "" + +#: src/wallet/Transaction.tsx:710 +#, c-format +msgid "Transfer" +msgstr "" + +#: src/wallet/Transaction.tsx:844 +#, c-format +msgid "Country" +msgstr "" + +#: src/wallet/Transaction.tsx:852 +#, c-format +msgid "Address lines" +msgstr "" + +#: src/wallet/Transaction.tsx:860 +#, c-format +msgid "Building number" +msgstr "" + +#: src/wallet/Transaction.tsx:868 +#, c-format +msgid "Building name" +msgstr "" + +#: src/wallet/Transaction.tsx:876 +#, c-format +msgid "Street" +msgstr "" + +#: src/wallet/Transaction.tsx:884 +#, c-format +msgid "Post code" +msgstr "" + +#: src/wallet/Transaction.tsx:892 +#, c-format +msgid "Town location" +msgstr "" + +#: src/wallet/Transaction.tsx:900 +#, c-format +msgid "Town" +msgstr "" + +#: src/wallet/Transaction.tsx:908 +#, c-format +msgid "District" +msgstr "" + +#: src/wallet/Transaction.tsx:916 +#, c-format +msgid "Country subdivision" +msgstr "" + +#: src/wallet/Transaction.tsx:935 +#, c-format +msgid "Date" +msgstr "" + +#: src/wallet/Transaction.tsx:990 +#, c-format +msgid "Transaction fees" +msgstr "" + +#: src/wallet/Transaction.tsx:1004 +#, c-format +msgid "Total" +msgstr "" + +#: src/wallet/Transaction.tsx:1074 +#, c-format +msgid "Withdraw" +msgstr "" + +#: src/wallet/Transaction.tsx:1146 +#, c-format +msgid "Price" +msgstr "" + +#: src/wallet/Transaction.tsx:1156 +#, c-format +msgid "Refunded" +msgstr "" + +#: src/wallet/Transaction.tsx:1220 +#, c-format +msgid "Delivery" +msgstr "" + +#: src/wallet/Transaction.tsx:1335 +#, c-format +msgid "Total transfer" +msgstr "" + +#: src/cta/Payment/views.tsx:57 +#, c-format +msgid "Could not load pay status" +msgstr "" + +#: src/cta/Payment/views.tsx:87 +#, c-format +msgid "Digital cash payment" +msgstr "" + +#: src/cta/Payment/views.tsx:119 +#, c-format +msgid "Purchase" +msgstr "" + +#: src/cta/Payment/views.tsx:149 +#, c-format +msgid "Receipt" +msgstr "" + +#: src/cta/Payment/views.tsx:156 +#, c-format +msgid "Valid until" +msgstr "" + +#: src/cta/Payment/views.tsx:191 +#, c-format +msgid "List of products" +msgstr "" + +#: src/cta/Payment/views.tsx:242 +#, c-format +msgid "free" +msgstr "" + +#: src/cta/Payment/views.tsx:263 +#, c-format +msgid "Already paid, you are going to be redirected to %1$s" +msgstr "" + +#: src/cta/Payment/views.tsx:274 +#, c-format +msgid "Already paid" +msgstr "" + +#: src/cta/Payment/views.tsx:280 +#, c-format +msgid "Already claimed" +msgstr "" + +#: src/cta/Payment/views.tsx:296 +#, c-format +msgid "Pay with a mobile phone" +msgstr "" + +#: src/cta/Payment/views.tsx:298 +#, c-format +msgid "Hide QR" +msgstr "" + +#: src/cta/Payment/views.tsx:305 +#, c-format +msgid "Scan the QR code or %1$s" +msgstr "" + +#: src/cta/Payment/views.tsx:346 +#, c-format +msgid "Pay %1$s" +msgstr "" + +#: src/cta/Payment/views.tsx:360 +#, c-format +msgid "You have no balance for this currency. Withdraw digital cash first." +msgstr "" + +#: src/cta/Payment/views.tsx:364 +#, c-format +msgid "" +"Could not find enough coins to pay. Even if you have enough %1$s some " +"restriction may apply." +msgstr "" + +#: src/cta/Payment/views.tsx:366 +#, c-format +msgid "Your current balance is not enough." +msgstr "" + +#: src/cta/Payment/views.tsx:395 +#, c-format +msgid "Merchant message" +msgstr "" + +#: src/cta/Refund/views.tsx:34 +#, c-format +msgid "Could not load refund status" +msgstr "" + +#: src/cta/Refund/views.tsx:48 +#, c-format +msgid "Digital cash refund" +msgstr "" + +#: src/cta/Refund/views.tsx:52 +#, c-format +msgid "You've ignored the tip." +msgstr "" + +#: src/cta/Refund/views.tsx:70 +#, c-format +msgid "The refund is in progress." +msgstr "" + +#: src/cta/Refund/views.tsx:76 +#, c-format +msgid "Total to refund" +msgstr "" + +#: src/cta/Refund/views.tsx:106 +#, c-format +msgid "The merchant "%1$s" is offering you a refund." +msgstr "" + +#: src/cta/Refund/views.tsx:115 +#, c-format +msgid "Order amount" +msgstr "" + +#: src/cta/Refund/views.tsx:122 +#, c-format +msgid "Already refunded" +msgstr "" + +#: src/cta/Refund/views.tsx:129 +#, c-format +msgid "Refund offered" +msgstr "" + +#: src/cta/Refund/views.tsx:145 +#, c-format +msgid "Accept %1$s" +msgstr "" + +#: src/cta/Tip/views.tsx:32 +#, c-format +msgid "Could not load tip status" +msgstr "" + +#: src/cta/Tip/views.tsx:45 +#, c-format +msgid "Digital cash tip" +msgstr "" + +#: src/cta/Tip/views.tsx:66 +#, c-format +msgid "The merchant is offering you a tip" +msgstr "" + +#: src/cta/Tip/views.tsx:74 +#, c-format +msgid "Merchant URL" +msgstr "" + +#: src/cta/Tip/views.tsx:90 +#, c-format +msgid "Receive %1$s" +msgstr "" + +#: src/cta/Tip/views.tsx:114 +#, c-format +msgid "Tip from %1$s accepted. Check your transactions list for more details." +msgstr "" + +#: src/components/SelectList.tsx:66 +#, c-format +msgid "Select one option" +msgstr "" + +#: src/components/TermsOfService/views.tsx:39 +#, c-format +msgid "Could not load" +msgstr "" + +#: src/components/TermsOfService/views.tsx:73 +#, c-format +msgid "Show terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:81 +#, c-format +msgid "I accept the exchange terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:107 +#, c-format +msgid "Exchange doesn't have terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:135 +#, c-format +msgid "Review exchange terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:146 +#, c-format +msgid "Review new version of terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:170 +#, c-format +msgid "The exchange reply with a empty terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:193 +#, c-format +msgid "Download Terms of Service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:204 +#, c-format +msgid "Hide terms of service" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:117 +#, c-format +msgid "Could not load exchange fees" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:131 +#, c-format +msgid "Close" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:160 +#, c-format +msgid "could not find any exchange" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:166 +#, c-format +msgid "could not find any exchange for the currency %1$s" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:186 +#, c-format +msgid "Service fee description" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:201 +#, c-format +msgid "Select %1$s exchange" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:215 +#, c-format +msgid "Reset" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:218 +#, c-format +msgid "Use this exchange" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:230 +#, c-format +msgid "Doesn't have auditors" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:241 +#, c-format +msgid "currency" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:249 +#, c-format +msgid "Operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:252 +#, c-format +msgid "Deposits" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:259 +#, c-format +msgid "Denomination" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:265 +#, c-format +msgid "Until" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:274 +#, c-format +msgid "Withdrawals" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:423 +#, c-format +msgid "Currency" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:433 +#, c-format +msgid "Coin operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:436 +#, c-format +msgid "" +"Every operation in this section may be different by denomination value and is " +"valid for a period of time. The exchange will charge the indicated amount every " +"time a coin is used in such operation." +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:545 +#, c-format +msgid "Transfer operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:548 +#, c-format +msgid "" +"Every operation in this section may be different by transfer type and is valid " +"for a period of time. The exchange will charge the indicated amount every time a " +"transfer is made." +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:563 +#, c-format +msgid "Operation" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:583 +#, c-format +msgid "Wallet operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:597 +#, c-format +msgid "Feature" +msgstr "" + +#: src/cta/Withdraw/views.tsx:47 +#, c-format +msgid "Could not get the info from the URI" +msgstr "" + +#: src/cta/Withdraw/views.tsx:60 +#, c-format +msgid "Could not get info of withdrawal" +msgstr "" + +#: src/cta/Withdraw/views.tsx:74 +#, c-format +msgid "Digital cash withdrawal" +msgstr "" + +#: src/cta/Withdraw/views.tsx:79 +#, c-format +msgid "Could not finish the withdrawal operation" +msgstr "" + +#: src/cta/Withdraw/views.tsx:127 +#, c-format +msgid "Age restriction" +msgstr "" + +#: src/cta/Withdraw/views.tsx:145 +#, c-format +msgid "Withdraw %1$s" +msgstr "" + +#: src/cta/Withdraw/views.tsx:179 +#, c-format +msgid "Withdraw to a mobile phone" +msgstr "" + +#: src/cta/InvoiceCreate/views.tsx:65 +#, c-format +msgid "Digital invoice" +msgstr "" + +#: src/cta/InvoiceCreate/views.tsx:69 +#, c-format +msgid "Could not finish the invoice creation" +msgstr "" + +#: src/cta/InvoiceCreate/views.tsx:130 +#, c-format +msgid "Create" +msgstr "" + +#: src/cta/InvoicePay/views.tsx:63 +#, c-format +msgid "Could not finish the payment operation" +msgstr "" + +#: src/cta/TransferCreate/views.tsx:55 +#, c-format +msgid "Digital cash transfer" +msgstr "" + +#: src/cta/TransferCreate/views.tsx:59 +#, c-format +msgid "Could not finish the transfer creation" +msgstr "" + +#: src/cta/TransferPickup/views.tsx:57 +#, c-format +msgid "Could not finish the pickup operation" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:149 +#, c-format +msgid "Manual Withdrawal for %1$s" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:154 +#, c-format +msgid "" +"Choose a exchange from where the coins will be withdrawn. The exchange will send " +"the coins to this wallet after receiving a wire transfer with the correct " +"subject." +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:162 +#, c-format +msgid "No exchange found for %1$s" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:170 +#, c-format +msgid "Add Exchange" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:192 +#, c-format +msgid "No exchange configured" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:210 +#, c-format +msgid "Can't create the reserve" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:277 +#, c-format +msgid "Start withdrawal" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:38 +#, c-format +msgid "Could not load deposit balance" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:51 +#, c-format +msgid "A currency or an amount should be indicated" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:67 +#, c-format +msgid "There is no enough balance to make a deposit for currency %1$s" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:117 +#, c-format +msgid "Send %1$s to your account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:121 +#, c-format +msgid "There is no account to make a deposit for currency %1$s" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:127 +#, c-format +msgid "Add account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:151 +#, c-format +msgid "Select account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:163 +#, c-format +msgid "Add another account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:191 +#, c-format +msgid "Deposit fee" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:205 +#, c-format +msgid "Total deposit" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:233 +#, c-format +msgid "Deposit %1$s %2$s" +msgstr "" + +#: src/wallet/AddAccount/views.tsx:56 +#, c-format +msgid "Add bank account for %1$s" +msgstr "" + +#: src/wallet/AddAccount/views.tsx:59 +#, c-format +msgid "Enter the URL of an exchange you trust." +msgstr "" + +#: src/wallet/AddAccount/views.tsx:66 +#, c-format +msgid "Unable add this account" +msgstr "" + +#: src/wallet/AddAccount/views.tsx:73 +#, c-format +msgid "Select account type" +msgstr "" + +#: src/wallet/ExchangeAddConfirm.tsx:42 +#, c-format +msgid "Review terms of service" +msgstr "" + +#: src/wallet/ExchangeAddConfirm.tsx:45 +#, c-format +msgid "Exchange URL" +msgstr "" + +#: src/wallet/ExchangeAddConfirm.tsx:70 +#, c-format +msgid "Add exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:112 +#, c-format +msgid "Add new exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:116 +#, c-format +msgid "Add exchange for %1$s" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:128 +#, c-format +msgid "An exchange has been found! Review the information and click next" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:135 +#, c-format +msgid "This exchange doesn't match the expected currency %1$s" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:143 +#, c-format +msgid "Unable to verify this exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:151 +#, c-format +msgid "Unable to add this exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:167 +#, c-format +msgid "loading" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:174 +#, c-format +msgid "Version" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:206 +#, c-format +msgid "Next" +msgstr "" + +#: src/components/TransactionItem.tsx:201 +#, c-format +msgid "Waiting for confirmation" +msgstr "" + +#: src/components/TransactionItem.tsx:266 +#, c-format +msgid "PENDING" +msgstr "" + +#: src/wallet/History.tsx:75 +#, c-format +msgid "Could not load the list of transactions" +msgstr "" + +#: src/wallet/History.tsx:233 +#, c-format +msgid "Your transaction history is empty for this currency." +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:127 +#, c-format +msgid "Add backup provider" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:131 +#, c-format +msgid "Could not get provider information" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:140 +#, c-format +msgid "Backup providers may charge for their service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:147 +#, c-format +msgid "URL" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:158 +#, c-format +msgid "Name" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:212 +#, c-format +msgid "Provider URL" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:218 +#, c-format +msgid "Please review and accept this provider's terms of service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:223 +#, c-format +msgid "Pricing" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:226 +#, c-format +msgid "free of charge" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:228 +#, c-format +msgid "%1$s per year of service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:235 +#, c-format +msgid "Storage" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:238 +#, c-format +msgid "%1$s megabytes of storage per year of service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:244 +#, c-format +msgid "Accept terms of service" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:44 +#, c-format +msgid "Could not parse the payto URI" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:45 +#, c-format +msgid "Please check the uri" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:75 +#, c-format +msgid "Exchange is ready for withdrawal" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:78 +#, c-format +msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:87 +#, c-format +msgid "" +"Alternative, you can also scan this QR code or open %1$s if you have a banking " +"app installed that supports RFC 8905" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:98 +#, c-format +msgid "Cancel withdrawal" +msgstr "" + +#: src/wallet/Settings.tsx:115 +#, c-format +msgid "Could not toggle auto-open" +msgstr "" + +#: src/wallet/Settings.tsx:121 +#, c-format +msgid "Could not toggle clipboard" +msgstr "" + +#: src/wallet/Settings.tsx:126 +#, c-format +msgid "Navigator" +msgstr "" + +#: src/wallet/Settings.tsx:129 +#, c-format +msgid "Automatically open wallet based on page content" +msgstr "" + +#: src/wallet/Settings.tsx:135 +#, c-format +msgid "" +"Enabling this option below will make using the wallet faster, but requires more " +"permissions from your browser." +msgstr "" + +#: src/wallet/Settings.tsx:145 +#, c-format +msgid "Automatically check clipboard for Taler URI" +msgstr "" + +#: src/wallet/Settings.tsx:162 +#, c-format +msgid "Trust" +msgstr "" + +#: src/wallet/Settings.tsx:166 +#, c-format +msgid "No exchange yet" +msgstr "" + +#: src/wallet/Settings.tsx:180 +#, c-format +msgid "Term of Service" +msgstr "" + +#: src/wallet/Settings.tsx:191 +#, c-format +msgid "ok" +msgstr "" + +#: src/wallet/Settings.tsx:197 +#, c-format +msgid "changed" +msgstr "" + +#: src/wallet/Settings.tsx:204 +#, c-format +msgid "not accepted" +msgstr "" + +#: src/wallet/Settings.tsx:210 +#, c-format +msgid "unknown (exchange status should be updated)" +msgstr "" + +#: src/wallet/Settings.tsx:236 +#, c-format +msgid "Add an exchange" +msgstr "" + +#: src/wallet/Settings.tsx:241 +#, c-format +msgid "Troubleshooting" +msgstr "" + +#: src/wallet/Settings.tsx:244 +#, c-format +msgid "Developer mode" +msgstr "" + +#: src/wallet/Settings.tsx:246 +#, c-format +msgid "More options and information useful for debugging" +msgstr "" + +#: src/wallet/Settings.tsx:257 +#, c-format +msgid "Display" +msgstr "" + +#: src/wallet/Settings.tsx:261 +#, c-format +msgid "Current Language" +msgstr "" + +#: src/wallet/Settings.tsx:274 +#, c-format +msgid "Wallet Core" +msgstr "" + +#: src/wallet/Settings.tsx:284 +#, c-format +msgid "Web Extension" +msgstr "" + +#: src/wallet/Settings.tsx:295 +#, c-format +msgid "Exchange compatibility" +msgstr "" + +#: src/wallet/Settings.tsx:299 +#, c-format +msgid "Merchant compatibility" +msgstr "" + +#: src/wallet/Settings.tsx:303 +#, c-format +msgid "Bank compatibility" +msgstr "" + +#: src/wallet/Welcome.tsx:59 +#, c-format +msgid "Browser Extension Installed!" +msgstr "" + +#: src/wallet/Welcome.tsx:63 +#, c-format +msgid "You can open the GNU Taler Wallet using the combination %1$s ." +msgstr "" + +#: src/wallet/Welcome.tsx:72 +#, c-format +msgid "" +"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick " +"access without keyboard:" +msgstr "" + +#: src/wallet/Welcome.tsx:79 +#, c-format +msgid "Click the puzzle icon" +msgstr "" + +#: src/wallet/Welcome.tsx:82 +#, c-format +msgid "Search for GNU Taler Wallet" +msgstr "" + +#: src/wallet/Welcome.tsx:85 +#, c-format +msgid "Click the pin icon" +msgstr "" + +#: src/wallet/Welcome.tsx:91 +#, c-format +msgid "Permissions" +msgstr "" + +#: src/wallet/Welcome.tsx:100 +#, c-format +msgid "" +"(Enabling this option below will make using the wallet faster, but requires more " +"permissions from your browser.)" +msgstr "" + +#: src/wallet/Welcome.tsx:110 +#, c-format +msgid "Next Steps" +msgstr "" + +#: src/wallet/Welcome.tsx:113 +#, c-format +msgid "Try the demo" +msgstr "" + +#: src/wallet/Welcome.tsx:116 +#, c-format +msgid "Learn how to top up your wallet balance" +msgstr "" + +#: src/components/Diagnostics.tsx:31 +#, c-format +msgid "Diagnostics timed out. Could not talk to the wallet backend." +msgstr "" + +#: src/components/Diagnostics.tsx:52 +#, c-format +msgid "Problems detected:" +msgstr "" + +#: src/components/Diagnostics.tsx:61 +#, c-format +msgid "" +"Please check in your %1$s settings that you have IndexedDB enabled (check the " +"preference name %2$s)." +msgstr "" + +#: src/components/Diagnostics.tsx:70 +#, c-format +msgid "" +"Your wallet database is outdated. Currently automatic migration is not " +"supported. Please go %1$s to reset the wallet database." +msgstr "" + +#: src/components/Diagnostics.tsx:83 +#, c-format +msgid "Running diagnostics" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:163 +#, c-format +msgid "Debug tools" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:170 +#, c-format +msgid "" +"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL " +"YOUR COINS?" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:176 +#, c-format +msgid "reset" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:183 +#, c-format +msgid "TESTING: This may delete all your coin, proceed with caution" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:189 +#, c-format +msgid "run gc" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:197 +#, c-format +msgid "import database" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:219 +#, c-format +msgid "export database" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:225 +#, c-format +msgid "Database exported at %1$s %2$s to download" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:248 +#, c-format +msgid "Coins" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:282 +#, c-format +msgid "Pending operations" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:328 +#, c-format +msgid "usable coins" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:337 +#, c-format +msgid "id" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:340 +#, c-format +msgid "denom" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:343 +#, c-format +msgid "value" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:346 +#, c-format +msgid "status" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:349 +#, c-format +msgid "from refresh?" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:352 +#, c-format +msgid "age key count" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:369 +#, c-format +msgid "spent coins" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:373 +#, c-format +msgid "click to show" +msgstr "" + +#: src/wallet/QrReader.tsx:108 +#, c-format +msgid "Scan a QR code or enter taler:// URI below" +msgstr "" + +#: src/wallet/QrReader.tsx:122 +#, c-format +msgid "Open" +msgstr "Avoin" + +#: src/wallet/QrReader.tsx:128 +#, c-format +msgid "URI is not valid. Taler URI should start with `taler://`" +msgstr "" + +#: src/wallet/QrReader.tsx:133 +#, c-format +msgid "Try another" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:183 +#, c-format +msgid "Could not load list of exchange" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:209 +#, c-format +msgid "Choose a currency to proceed or add another exchange" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:217 +#, c-format +msgid "Known currencies" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:318 +#, c-format +msgid "Specify the amount and the origin" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:336 +#, c-format +msgid "Change currency" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:344 +#, c-format +msgid "Use previous origins:" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:364 +#, c-format +msgid "Or specify the origin of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:372 +#, c-format +msgid "Specify the origin of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:380 +#, c-format +msgid "From my bank account" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:395 +#, c-format +msgid "From another wallet" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:449 +#, c-format +msgid "currency not provided" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:459 +#, c-format +msgid "Specify the amount and the destination" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:483 +#, c-format +msgid "Use previous destinations:" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:503 +#, c-format +msgid "Or specify the destination of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:511 +#, c-format +msgid "Specify the destination of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:521 +#, c-format +msgid "To my bank account" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:534 +#, c-format +msgid "To another wallet" +msgstr "" + +#: src/cta/Recovery/views.tsx:30 +#, c-format +msgid "Could not load backup recovery information" +msgstr "" + +#: src/cta/Recovery/views.tsx:47 +#, c-format +msgid "Digital wallet recovery" +msgstr "" + +#: src/cta/Recovery/views.tsx:52 +#, c-format +msgid "Import backup, show info" +msgstr "" + +#: src/wallet/Application.tsx:189 +#, c-format +msgid "All done, your transaction is in progress" +msgstr "" + +#: src/components/EditableText.tsx:45 +#, c-format +msgid "Edit" +msgstr "" + +#: src/wallet/ManualWithdrawPage.tsx:102 +#, c-format +msgid "Could not load the list of known exchanges" +msgstr "" diff --git a/packages/taler-wallet-webextension/src/i18n/fr.po b/packages/taler-wallet-webextension/src/i18n/fr.po index 3ed1104b2..462eb30f7 100644 --- a/packages/taler-wallet-webextension/src/i18n/fr.po +++ b/packages/taler-wallet-webextension/src/i18n/fr.po @@ -17,8 +17,8 @@ msgstr "" "Project-Id-Version: Taler Wallet\n" "Report-Msgid-Bugs-To: languages@taler.net\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: 2023-03-06 22:06+0000\n" -"Last-Translator: Stefan Kügel <skuegel@web.de>\n" +"PO-Revision-Date: 2024-02-28 08:07+0000\n" +"Last-Translator: d0p1 <contact@d0p1.eu>\n" "Language-Team: French <https://weblate.taler.net/projects/gnu-taler/" "webextensions/fr/>\n" "Language: fr\n" @@ -26,7 +26,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n!=1);\n" -"X-Generator: Weblate 4.13.1\n" +"X-Generator: Weblate 5.2.1\n" #: src/NavigationBar.tsx:139 #, c-format @@ -213,7 +213,7 @@ msgstr "" #: src/wallet/AddNewActionView.tsx:57 #, c-format msgid "Cancel" -msgstr "" +msgstr "Annuler" #: src/wallet/AddNewActionView.tsx:68 #, c-format @@ -1051,7 +1051,7 @@ msgstr "" #: src/wallet/ExchangeSelection/views.tsx:131 #, c-format msgid "Close" -msgstr "" +msgstr "Fermer" #: src/wallet/ExchangeSelection/views.tsx:160 #, c-format diff --git a/packages/taler-wallet-webextension/src/i18n/nl.po b/packages/taler-wallet-webextension/src/i18n/nl.po index 26f543b52..4f11592dd 100644 --- a/packages/taler-wallet-webextension/src/i18n/nl.po +++ b/packages/taler-wallet-webextension/src/i18n/nl.po @@ -6,15 +6,18 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" +"Report-Msgid-Bugs-To: languages@taler.net\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2024-03-02 16:54+0000\n" +"Last-Translator: Midgard <midgard@users.noreply.weblate.taler.net>\n" +"Language-Team: Dutch <https://weblate.taler.net/projects/gnu-taler/" +"webextensions/nl/>\n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.2.1\n" #: src/NavigationBar.tsx:139 #, c-format @@ -697,7 +700,7 @@ msgstr "" #: src/wallet/Transaction.tsx:635 #, c-format msgid "Exchange" -msgstr "" +msgstr "Beurs" #: src/wallet/Transaction.tsx:641 #, c-format diff --git a/packages/taler-wallet-webextension/src/i18n/tr.po b/packages/taler-wallet-webextension/src/i18n/tr.po index 0d5132b61..5848b9f3a 100644 --- a/packages/taler-wallet-webextension/src/i18n/tr.po +++ b/packages/taler-wallet-webextension/src/i18n/tr.po @@ -17,7 +17,7 @@ msgstr "" "Project-Id-Version: Taler Wallet\n" "Report-Msgid-Bugs-To: languages@taler.net\n" "POT-Creation-Date: 2016-11-23 00:00+0100\n" -"PO-Revision-Date: 2023-12-05 21:51+0000\n" +"PO-Revision-Date: 2024-03-08 01:14+0000\n" "Last-Translator: Alp <berna.alp@digitalekho.com>\n" "Language-Team: Turkish <https://weblate.taler.net/projects/gnu-taler/" "webextensions/tr/>\n" @@ -1863,7 +1863,7 @@ msgstr "" #: src/wallet/QrReader.tsx:122 #, c-format msgid "Open" -msgstr "" +msgstr "Açık" #: src/wallet/QrReader.tsx:128 #, c-format diff --git a/packages/taler-wallet-webextension/src/i18n/uk.po b/packages/taler-wallet-webextension/src/i18n/uk.po new file mode 100644 index 000000000..c4f5d6537 --- /dev/null +++ b/packages/taler-wallet-webextension/src/i18n/uk.po @@ -0,0 +1,1956 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: languages@taler.net\n" +"POT-Creation-Date: 2016-11-23 00:00+0100\n" +"PO-Revision-Date: 2024-03-05 13:03+0000\n" +"Last-Translator: Tim Vutor <flukes.ostrich0p@icloud.com>\n" +"Language-Team: Ukrainian <https://weblate.taler.net/projects/gnu-taler/" +"webextensions/uk/>\n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" +"X-Generator: Weblate 5.2.1\n" + +#: src/NavigationBar.tsx:139 +#, c-format +msgid "Balance" +msgstr "Баланс" + +#: src/NavigationBar.tsx:142 +#, c-format +msgid "Backup" +msgstr "Бекап" + +#: src/NavigationBar.tsx:147 +#, c-format +msgid "QR Reader and Taler URI" +msgstr "QR-читалка та Taler URI" + +#: src/NavigationBar.tsx:154 +#, c-format +msgid "Settings" +msgstr "Налаштування" + +#: src/NavigationBar.tsx:184 +#, c-format +msgid "Dev" +msgstr "Розробка" + +#: src/mui/Typography.tsx:122 +#, c-format, fuzzy +msgid "%1$s" +msgstr "%1$s" + +#: src/components/PendingTransactions.tsx:74 +#, c-format +msgid "PENDING OPERATIONS" +msgstr "НЕЗАВЕРШЕНІ ОПЕРАЦІЇ" + +#: src/components/Loading.tsx:36 +#, c-format +msgid "Loading" +msgstr "Завантаження" + +#: src/wallet/BackupPage.tsx:123 +#, c-format +msgid "Could not load backup providers" +msgstr "Не вдалося завантажити зберігачів резервних копій" + +#: src/wallet/BackupPage.tsx:202 +#, c-format +msgid "No backup providers configured" +msgstr "Не налаштовано жодного зберігача резервних копій" + +#: src/wallet/BackupPage.tsx:205 +#, c-format +msgid "Add provider" +msgstr "Додати зберігача" + +#: src/wallet/BackupPage.tsx:219 +#, c-format +msgid "Sync all backups" +msgstr "Синхронізувати всі резервні копії" + +#: src/wallet/BackupPage.tsx:221 +#, c-format +msgid "Sync now" +msgstr "Синхронізувати зараз" + +#: src/wallet/BackupPage.tsx:264 +#, c-format +msgid "Last synced" +msgstr "Останній раз синхронізовано" + +#: src/wallet/BackupPage.tsx:269 +#, c-format +msgid "Not synced" +msgstr "Не синхронізовано" + +#: src/wallet/BackupPage.tsx:289 +#, c-format +msgid "Expires in" +msgstr "Термін дії закінчується в" + +#: src/wallet/ProviderDetailPage.tsx:60 +#, c-format +msgid "There was an error loading the provider detail for " %1$s"" +msgstr "Виникла помилка при завантаженні інформації зберігача " %1$s"" + +#: src/wallet/ProviderDetailPage.tsx:108 +#, c-format +msgid "There is not known provider with url "%1$s"." +msgstr "Зберігач з посиланням "%1$s" невідомий." + +#: src/wallet/ProviderDetailPage.tsx:115 +#, c-format +msgid "See providers" +msgstr "Подивитись зберігачів" + +#: src/wallet/ProviderDetailPage.tsx:143 +#, c-format +msgid "Last backup" +msgstr "Остання резервна копія" + +#: src/wallet/ProviderDetailPage.tsx:148 +#, c-format +msgid "Back up" +msgstr "Зробити резервну копію" + +#: src/wallet/ProviderDetailPage.tsx:154 +#, c-format +msgid "Provider fee" +msgstr "Комісія зберігача" + +#: src/wallet/ProviderDetailPage.tsx:157 +#, c-format +msgid "per year" +msgstr "на рік" + +#: src/wallet/ProviderDetailPage.tsx:163 +#, c-format +msgid "Extend" +msgstr "Подовжити" + +#: src/wallet/ProviderDetailPage.tsx:169 +#, c-format +msgid "" +"terms has changed, extending the service will imply accepting the new terms of " +"service" +msgstr "" +"умови надання послуг змінились, продовження послуги означатиме прийняття " +"нових умов" + +#: src/wallet/ProviderDetailPage.tsx:179 +#, c-format +msgid "old" +msgstr "старий" + +#: src/wallet/ProviderDetailPage.tsx:183 +#, c-format +msgid "new" +msgstr "новий" + +#: src/wallet/ProviderDetailPage.tsx:190 +#, c-format +msgid "fee" +msgstr "комісія" + +#: src/wallet/ProviderDetailPage.tsx:198 +#, c-format +msgid "storage" +msgstr "сховище" + +#: src/wallet/ProviderDetailPage.tsx:215 +#, c-format +msgid "Remove provider" +msgstr "Видалити зберігача" + +#: src/wallet/ProviderDetailPage.tsx:228 +#, c-format +msgid "This provider has reported an error" +msgstr "Цей постачальник повідомив про помилку" + +#: src/wallet/ProviderDetailPage.tsx:242 +#, c-format +msgid "There is conflict with another backup from %1$s" +msgstr "Конфлікт з іншою резервною копією з %1$s" + +#: src/wallet/ProviderDetailPage.tsx:253 +#, c-format +msgid "Backup is not readable" +msgstr "Резервна копія пошкоджена або не може бути прочитана" + +#: src/wallet/ProviderDetailPage.tsx:261 +#, c-format +msgid "Unknown backup problem: %1$s" +msgstr "Невідома помилка резервного копіювання: %1$s" + +#: src/wallet/ProviderDetailPage.tsx:283 +#, c-format +msgid "service paid" +msgstr "послуга сплачена" + +#: src/wallet/ProviderDetailPage.tsx:290 +#, c-format +msgid "Backup valid until" +msgstr "Резервна копія дійсна до" + +#: src/wallet/AddNewActionView.tsx:57 +#, c-format +msgid "Cancel" +msgstr "Відмінити" + +#: src/wallet/AddNewActionView.tsx:68 +#, c-format +msgid "Open reserve page" +msgstr "Показати резерв" + +#: src/wallet/AddNewActionView.tsx:70 +#, c-format +msgid "Open pay page" +msgstr "Показати сторінку оплати" + +#: src/wallet/AddNewActionView.tsx:72 +#, c-format +msgid "Open refund page" +msgstr "Показати відшкодування" + +#: src/wallet/AddNewActionView.tsx:74 +#, c-format +msgid "Open tip page" +msgstr "Показати чайові" + +#: src/wallet/AddNewActionView.tsx:76 +#, c-format +msgid "Open withdraw page" +msgstr "Показати списання" + +#: src/popup/NoBalanceHelp.tsx:43 +#, c-format +msgid "Get digital cash" +msgstr "Отримати е-готівку" + +#: src/popup/BalancePage.tsx:138 +#, c-format +msgid "Could not load balance page" +msgstr "Не вдалося показати залишок" + +#: src/popup/BalancePage.tsx:175 +#, c-format +msgid "Add" +msgstr "Додати" + +#: src/popup/BalancePage.tsx:179 +#, c-format +msgid "Send %1$s" +msgstr "Переказати %1$s" + +#: src/popup/TalerActionFound.tsx:44 +#, c-format +msgid "Taler Action" +msgstr "Taler Дія" + +#: src/popup/TalerActionFound.tsx:49 +#, c-format +msgid "This page has pay action." +msgstr "" + +#: src/popup/TalerActionFound.tsx:63 +#, c-format +msgid "This page has a withdrawal action." +msgstr "" + +#: src/popup/TalerActionFound.tsx:79 +#, c-format +msgid "This page has a tip action." +msgstr "" + +#: src/popup/TalerActionFound.tsx:93 +#, c-format +msgid "This page has a notify reserve action." +msgstr "" + +#: src/popup/TalerActionFound.tsx:102 +#, c-format +msgid "Notify" +msgstr "" + +#: src/popup/TalerActionFound.tsx:109 +#, c-format +msgid "This page has a refund action." +msgstr "" + +#: src/popup/TalerActionFound.tsx:123 +#, c-format +msgid "This page has a malformed taler uri." +msgstr "" + +#: src/popup/TalerActionFound.tsx:134 +#, c-format +msgid "Dismiss" +msgstr "" + +#: src/popup/Application.tsx:177 +#, c-format +msgid "this popup is being closed and you are being redirected to %1$s" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:158 +#, c-format +msgid "Could not load purchase proposal details" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:183 +#, c-format +msgid "Order Id" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:189 +#, c-format +msgid "Summary" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:195 +#, c-format +msgid "Amount" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:203 +#, c-format +msgid "Merchant name" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:209 +#, c-format +msgid "Merchant jurisdiction" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:215 +#, c-format +msgid "Merchant address" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:221 +#, c-format +msgid "Merchant logo" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:234 +#, c-format +msgid "Merchant website" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:240 +#, c-format +msgid "Merchant email" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:246 +#, c-format +msgid "Merchant public key" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:256 +#, c-format +msgid "Delivery date" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:271 +#, c-format +msgid "Delivery location" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:277 +#, c-format +msgid "Products" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:289 +#, c-format +msgid "Created at" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:304 +#, c-format +msgid "Refund deadline" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:319 +#, c-format +msgid "Auto refund" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:339 +#, c-format +msgid "Pay deadline" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:354 +#, c-format +msgid "Fulfillment URL" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:360 +#, c-format +msgid "Fulfillment message" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:370 +#, c-format +msgid "Max deposit fee" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:378 +#, c-format +msgid "Max fee" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:386 +#, c-format +msgid "Minimum age" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:398 +#, c-format +msgid "Wire fee amortization" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:404 +#, c-format +msgid "Auditors" +msgstr "" + +#: src/components/ShowFullContractTermPopup.tsx:419 +#, c-format +msgid "Exchanges" +msgstr "" + +#: src/components/Part.tsx:148 +#, c-format +msgid "Bank account" +msgstr "" + +#: src/components/Part.tsx:160 +#, c-format +msgid "Bitcoin address" +msgstr "" + +#: src/components/Part.tsx:163 +#, c-format +msgid "IBAN" +msgstr "" + +#: src/cta/Deposit/views.tsx:38 +#, c-format +msgid "Could not load deposit status" +msgstr "" + +#: src/cta/Deposit/views.tsx:52 +#, c-format +msgid "Digital cash deposit" +msgstr "" + +#: src/cta/Deposit/views.tsx:58 +#, c-format +msgid "Cost" +msgstr "" + +#: src/cta/Deposit/views.tsx:66 +#, c-format +msgid "Fee" +msgstr "" + +#: src/cta/Deposit/views.tsx:73 +#, c-format +msgid "To be received" +msgstr "" + +#: src/cta/Deposit/views.tsx:84 +#, c-format +msgid "Send %1$s" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:63 +#, c-format +msgid "Bitcoin transfer details" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:66 +#, c-format +msgid "" +"The exchange need a transaction with 3 output, one output is the exchange " +"account and the other two are segwit fake address for metadata with an minimum " +"amount." +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:74 +#, c-format +msgid "" +"In bitcoincore wallet use 'Add Recipient' button to add two additional " +"recipient and copy addresses and amounts" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:98 +#, c-format +msgid "Make sure the amount show %1$s BTC, else you have to change the base unit to BTC" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:110 +#, c-format +msgid "Account" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:116 +#, c-format +msgid "Bank host" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:139 +#, c-format +msgid "Bank transfer details" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:148 +#, c-format +msgid "Subject" +msgstr "" + +#: src/components/BankDetailsByPaytoType.tsx:154 +#, c-format +msgid "Receiver name" +msgstr "" + +#: src/wallet/Transaction.tsx:98 +#, c-format +msgid "Could not load the transaction information" +msgstr "" + +#: src/wallet/Transaction.tsx:191 +#, c-format +msgid "There was an error trying to complete the transaction" +msgstr "" + +#: src/wallet/Transaction.tsx:200 +#, c-format +msgid "This transaction is not completed" +msgstr "" + +#: src/wallet/Transaction.tsx:209 +#, c-format +msgid "Send" +msgstr "" + +#: src/wallet/Transaction.tsx:216 +#, c-format +msgid "Retry" +msgstr "" + +#: src/wallet/Transaction.tsx:224 +#, c-format +msgid "Forget" +msgstr "" + +#: src/wallet/Transaction.tsx:241 +#, c-format +msgid "Caution!" +msgstr "" + +#: src/wallet/Transaction.tsx:244 +#, c-format +msgid "" +"If you have already wired money to the exchange you will loose the chance to get " +"the coins form it." +msgstr "" + +#: src/wallet/Transaction.tsx:259 +#, c-format +msgid "Confirm" +msgstr "" + +#: src/wallet/Transaction.tsx:267 +#, c-format +msgid "Withdrawal" +msgstr "" + +#: src/wallet/Transaction.tsx:286 +#, c-format +msgid "" +"Make sure to use the correct subject, otherwise the money will not arrive in " +"this wallet." +msgstr "" + +#: src/wallet/Transaction.tsx:298 +#, c-format +msgid "" +"The bank did not yet confirmed the wire transfer. Go to the %1$s %2$s and check " +"there is no pending step." +msgstr "" + +#: src/wallet/Transaction.tsx:316 +#, c-format +msgid "Bank has confirmed the wire transfer. Waiting for the exchange to send the coins" +msgstr "" + +#: src/wallet/Transaction.tsx:325 +#, c-format +msgid "Details" +msgstr "" + +#: src/wallet/Transaction.tsx:360 +#, c-format +msgid "Payment" +msgstr "" + +#: src/wallet/Transaction.tsx:378 +#, c-format +msgid "Refunds" +msgstr "" + +#: src/wallet/Transaction.tsx:385 +#, c-format +msgid "%1$s %2$s on %3$s" +msgstr "" + +#: src/wallet/Transaction.tsx:415 +#, c-format +msgid "Merchant created a refund for this order but was not automatically picked up." +msgstr "" + +#: src/wallet/Transaction.tsx:420 +#, c-format +msgid "Offer" +msgstr "" + +#: src/wallet/Transaction.tsx:431 +#, c-format +msgid "Accept" +msgstr "" + +#: src/wallet/Transaction.tsx:438 +#, c-format +msgid "Merchant" +msgstr "" + +#: src/wallet/Transaction.tsx:443 +#, c-format +msgid "Invoice ID" +msgstr "" + +#: src/wallet/Transaction.tsx:470 +#, c-format +msgid "Deposit" +msgstr "" + +#: src/wallet/Transaction.tsx:496 +#, c-format +msgid "Refresh" +msgstr "" + +#: src/wallet/Transaction.tsx:517 +#, c-format +msgid "Tip" +msgstr "" + +#: src/wallet/Transaction.tsx:542 +#, c-format +msgid "Refund" +msgstr "" + +#: src/wallet/Transaction.tsx:555 +#, c-format +msgid "Original order ID" +msgstr "" + +#: src/wallet/Transaction.tsx:568 +#, c-format +msgid "Purchase summary" +msgstr "" + +#: src/wallet/Transaction.tsx:593 +#, c-format +msgid "copy" +msgstr "" + +#: src/wallet/Transaction.tsx:596 +#, c-format +msgid "hide qr" +msgstr "" + +#: src/wallet/Transaction.tsx:608 +#, c-format +msgid "show qr" +msgstr "" + +#: src/wallet/Transaction.tsx:620 +#, c-format +msgid "Credit" +msgstr "" + +#: src/wallet/Transaction.tsx:624 +#, c-format +msgid "Invoice" +msgstr "" + +#: src/wallet/Transaction.tsx:635 +#, c-format +msgid "Exchange" +msgstr "" + +#: src/wallet/Transaction.tsx:641 +#, c-format +msgid "URI" +msgstr "" + +#: src/wallet/Transaction.tsx:667 +#, c-format +msgid "Debit" +msgstr "" + +#: src/wallet/Transaction.tsx:710 +#, c-format +msgid "Transfer" +msgstr "" + +#: src/wallet/Transaction.tsx:844 +#, c-format +msgid "Country" +msgstr "" + +#: src/wallet/Transaction.tsx:852 +#, c-format +msgid "Address lines" +msgstr "" + +#: src/wallet/Transaction.tsx:860 +#, c-format +msgid "Building number" +msgstr "" + +#: src/wallet/Transaction.tsx:868 +#, c-format +msgid "Building name" +msgstr "" + +#: src/wallet/Transaction.tsx:876 +#, c-format +msgid "Street" +msgstr "" + +#: src/wallet/Transaction.tsx:884 +#, c-format +msgid "Post code" +msgstr "" + +#: src/wallet/Transaction.tsx:892 +#, c-format +msgid "Town location" +msgstr "" + +#: src/wallet/Transaction.tsx:900 +#, c-format +msgid "Town" +msgstr "" + +#: src/wallet/Transaction.tsx:908 +#, c-format +msgid "District" +msgstr "" + +#: src/wallet/Transaction.tsx:916 +#, c-format +msgid "Country subdivision" +msgstr "" + +#: src/wallet/Transaction.tsx:935 +#, c-format +msgid "Date" +msgstr "" + +#: src/wallet/Transaction.tsx:990 +#, c-format +msgid "Transaction fees" +msgstr "" + +#: src/wallet/Transaction.tsx:1004 +#, c-format +msgid "Total" +msgstr "" + +#: src/wallet/Transaction.tsx:1074 +#, c-format +msgid "Withdraw" +msgstr "" + +#: src/wallet/Transaction.tsx:1146 +#, c-format +msgid "Price" +msgstr "" + +#: src/wallet/Transaction.tsx:1156 +#, c-format +msgid "Refunded" +msgstr "" + +#: src/wallet/Transaction.tsx:1220 +#, c-format +msgid "Delivery" +msgstr "" + +#: src/wallet/Transaction.tsx:1335 +#, c-format +msgid "Total transfer" +msgstr "" + +#: src/cta/Payment/views.tsx:57 +#, c-format +msgid "Could not load pay status" +msgstr "" + +#: src/cta/Payment/views.tsx:87 +#, c-format +msgid "Digital cash payment" +msgstr "" + +#: src/cta/Payment/views.tsx:119 +#, c-format +msgid "Purchase" +msgstr "" + +#: src/cta/Payment/views.tsx:149 +#, c-format +msgid "Receipt" +msgstr "" + +#: src/cta/Payment/views.tsx:156 +#, c-format +msgid "Valid until" +msgstr "" + +#: src/cta/Payment/views.tsx:191 +#, c-format +msgid "List of products" +msgstr "" + +#: src/cta/Payment/views.tsx:242 +#, c-format +msgid "free" +msgstr "" + +#: src/cta/Payment/views.tsx:263 +#, c-format +msgid "Already paid, you are going to be redirected to %1$s" +msgstr "" + +#: src/cta/Payment/views.tsx:274 +#, c-format +msgid "Already paid" +msgstr "" + +#: src/cta/Payment/views.tsx:280 +#, c-format +msgid "Already claimed" +msgstr "" + +#: src/cta/Payment/views.tsx:296 +#, c-format +msgid "Pay with a mobile phone" +msgstr "" + +#: src/cta/Payment/views.tsx:298 +#, c-format +msgid "Hide QR" +msgstr "" + +#: src/cta/Payment/views.tsx:305 +#, c-format +msgid "Scan the QR code or %1$s" +msgstr "" + +#: src/cta/Payment/views.tsx:346 +#, c-format +msgid "Pay %1$s" +msgstr "" + +#: src/cta/Payment/views.tsx:360 +#, c-format +msgid "You have no balance for this currency. Withdraw digital cash first." +msgstr "" + +#: src/cta/Payment/views.tsx:364 +#, c-format +msgid "" +"Could not find enough coins to pay. Even if you have enough %1$s some " +"restriction may apply." +msgstr "" + +#: src/cta/Payment/views.tsx:366 +#, c-format +msgid "Your current balance is not enough." +msgstr "" + +#: src/cta/Payment/views.tsx:395 +#, c-format +msgid "Merchant message" +msgstr "" + +#: src/cta/Refund/views.tsx:34 +#, c-format +msgid "Could not load refund status" +msgstr "" + +#: src/cta/Refund/views.tsx:48 +#, c-format +msgid "Digital cash refund" +msgstr "" + +#: src/cta/Refund/views.tsx:52 +#, c-format +msgid "You've ignored the tip." +msgstr "" + +#: src/cta/Refund/views.tsx:70 +#, c-format +msgid "The refund is in progress." +msgstr "" + +#: src/cta/Refund/views.tsx:76 +#, c-format +msgid "Total to refund" +msgstr "" + +#: src/cta/Refund/views.tsx:106 +#, c-format +msgid "The merchant "%1$s" is offering you a refund." +msgstr "" + +#: src/cta/Refund/views.tsx:115 +#, c-format +msgid "Order amount" +msgstr "" + +#: src/cta/Refund/views.tsx:122 +#, c-format +msgid "Already refunded" +msgstr "" + +#: src/cta/Refund/views.tsx:129 +#, c-format +msgid "Refund offered" +msgstr "" + +#: src/cta/Refund/views.tsx:145 +#, c-format +msgid "Accept %1$s" +msgstr "" + +#: src/cta/Tip/views.tsx:32 +#, c-format +msgid "Could not load tip status" +msgstr "" + +#: src/cta/Tip/views.tsx:45 +#, c-format +msgid "Digital cash tip" +msgstr "" + +#: src/cta/Tip/views.tsx:66 +#, c-format +msgid "The merchant is offering you a tip" +msgstr "" + +#: src/cta/Tip/views.tsx:74 +#, c-format +msgid "Merchant URL" +msgstr "" + +#: src/cta/Tip/views.tsx:90 +#, c-format +msgid "Receive %1$s" +msgstr "" + +#: src/cta/Tip/views.tsx:114 +#, c-format +msgid "Tip from %1$s accepted. Check your transactions list for more details." +msgstr "" + +#: src/components/SelectList.tsx:66 +#, c-format +msgid "Select one option" +msgstr "" + +#: src/components/TermsOfService/views.tsx:39 +#, c-format +msgid "Could not load" +msgstr "" + +#: src/components/TermsOfService/views.tsx:73 +#, c-format +msgid "Show terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:81 +#, c-format +msgid "I accept the exchange terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:107 +#, c-format +msgid "Exchange doesn't have terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:135 +#, c-format +msgid "Review exchange terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:146 +#, c-format +msgid "Review new version of terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:170 +#, c-format +msgid "The exchange reply with a empty terms of service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:193 +#, c-format +msgid "Download Terms of Service" +msgstr "" + +#: src/components/TermsOfService/views.tsx:204 +#, c-format +msgid "Hide terms of service" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:117 +#, c-format +msgid "Could not load exchange fees" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:131 +#, c-format +msgid "Close" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:160 +#, c-format +msgid "could not find any exchange" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:166 +#, c-format +msgid "could not find any exchange for the currency %1$s" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:186 +#, c-format +msgid "Service fee description" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:201 +#, c-format +msgid "Select %1$s exchange" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:215 +#, c-format +msgid "Reset" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:218 +#, c-format +msgid "Use this exchange" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:230 +#, c-format +msgid "Doesn't have auditors" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:241 +#, c-format +msgid "currency" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:249 +#, c-format +msgid "Operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:252 +#, c-format +msgid "Deposits" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:259 +#, c-format +msgid "Denomination" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:265 +#, c-format +msgid "Until" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:274 +#, c-format +msgid "Withdrawals" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:423 +#, c-format +msgid "Currency" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:433 +#, c-format +msgid "Coin operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:436 +#, c-format +msgid "" +"Every operation in this section may be different by denomination value and is " +"valid for a period of time. The exchange will charge the indicated amount every " +"time a coin is used in such operation." +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:545 +#, c-format +msgid "Transfer operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:548 +#, c-format +msgid "" +"Every operation in this section may be different by transfer type and is valid " +"for a period of time. The exchange will charge the indicated amount every time a " +"transfer is made." +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:563 +#, c-format +msgid "Operation" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:583 +#, c-format +msgid "Wallet operations" +msgstr "" + +#: src/wallet/ExchangeSelection/views.tsx:597 +#, c-format +msgid "Feature" +msgstr "" + +#: src/cta/Withdraw/views.tsx:47 +#, c-format +msgid "Could not get the info from the URI" +msgstr "" + +#: src/cta/Withdraw/views.tsx:60 +#, c-format +msgid "Could not get info of withdrawal" +msgstr "" + +#: src/cta/Withdraw/views.tsx:74 +#, c-format +msgid "Digital cash withdrawal" +msgstr "" + +#: src/cta/Withdraw/views.tsx:79 +#, c-format +msgid "Could not finish the withdrawal operation" +msgstr "" + +#: src/cta/Withdraw/views.tsx:127 +#, c-format +msgid "Age restriction" +msgstr "" + +#: src/cta/Withdraw/views.tsx:145 +#, c-format +msgid "Withdraw %1$s" +msgstr "" + +#: src/cta/Withdraw/views.tsx:179 +#, c-format +msgid "Withdraw to a mobile phone" +msgstr "" + +#: src/cta/InvoiceCreate/views.tsx:65 +#, c-format +msgid "Digital invoice" +msgstr "" + +#: src/cta/InvoiceCreate/views.tsx:69 +#, c-format +msgid "Could not finish the invoice creation" +msgstr "" + +#: src/cta/InvoiceCreate/views.tsx:130 +#, c-format +msgid "Create" +msgstr "" + +#: src/cta/InvoicePay/views.tsx:63 +#, c-format +msgid "Could not finish the payment operation" +msgstr "" + +#: src/cta/TransferCreate/views.tsx:55 +#, c-format +msgid "Digital cash transfer" +msgstr "" + +#: src/cta/TransferCreate/views.tsx:59 +#, c-format +msgid "Could not finish the transfer creation" +msgstr "" + +#: src/cta/TransferPickup/views.tsx:57 +#, c-format +msgid "Could not finish the pickup operation" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:149 +#, c-format +msgid "Manual Withdrawal for %1$s" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:154 +#, c-format +msgid "" +"Choose a exchange from where the coins will be withdrawn. The exchange will send " +"the coins to this wallet after receiving a wire transfer with the correct " +"subject." +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:162 +#, c-format +msgid "No exchange found for %1$s" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:170 +#, c-format +msgid "Add Exchange" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:192 +#, c-format +msgid "No exchange configured" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:210 +#, c-format +msgid "Can't create the reserve" +msgstr "" + +#: src/wallet/CreateManualWithdraw.tsx:277 +#, c-format +msgid "Start withdrawal" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:38 +#, c-format +msgid "Could not load deposit balance" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:51 +#, c-format +msgid "A currency or an amount should be indicated" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:67 +#, c-format +msgid "There is no enough balance to make a deposit for currency %1$s" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:117 +#, c-format +msgid "Send %1$s to your account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:121 +#, c-format +msgid "There is no account to make a deposit for currency %1$s" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:127 +#, c-format +msgid "Add account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:151 +#, c-format +msgid "Select account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:163 +#, c-format +msgid "Add another account" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:191 +#, c-format +msgid "Deposit fee" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:205 +#, c-format +msgid "Total deposit" +msgstr "" + +#: src/wallet/DepositPage/views.tsx:233 +#, c-format +msgid "Deposit %1$s %2$s" +msgstr "" + +#: src/wallet/AddAccount/views.tsx:56 +#, c-format +msgid "Add bank account for %1$s" +msgstr "" + +#: src/wallet/AddAccount/views.tsx:59 +#, c-format +msgid "Enter the URL of an exchange you trust." +msgstr "" + +#: src/wallet/AddAccount/views.tsx:66 +#, c-format +msgid "Unable add this account" +msgstr "" + +#: src/wallet/AddAccount/views.tsx:73 +#, c-format +msgid "Select account type" +msgstr "" + +#: src/wallet/ExchangeAddConfirm.tsx:42 +#, c-format +msgid "Review terms of service" +msgstr "" + +#: src/wallet/ExchangeAddConfirm.tsx:45 +#, c-format +msgid "Exchange URL" +msgstr "" + +#: src/wallet/ExchangeAddConfirm.tsx:70 +#, c-format +msgid "Add exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:112 +#, c-format +msgid "Add new exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:116 +#, c-format +msgid "Add exchange for %1$s" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:128 +#, c-format +msgid "An exchange has been found! Review the information and click next" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:135 +#, c-format +msgid "This exchange doesn't match the expected currency %1$s" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:143 +#, c-format +msgid "Unable to verify this exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:151 +#, c-format +msgid "Unable to add this exchange" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:167 +#, c-format +msgid "loading" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:174 +#, c-format +msgid "Version" +msgstr "" + +#: src/wallet/ExchangeSetUrl.tsx:206 +#, c-format +msgid "Next" +msgstr "" + +#: src/components/TransactionItem.tsx:201 +#, c-format +msgid "Waiting for confirmation" +msgstr "" + +#: src/components/TransactionItem.tsx:266 +#, c-format +msgid "PENDING" +msgstr "" + +#: src/wallet/History.tsx:75 +#, c-format +msgid "Could not load the list of transactions" +msgstr "" + +#: src/wallet/History.tsx:233 +#, c-format +msgid "Your transaction history is empty for this currency." +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:127 +#, c-format +msgid "Add backup provider" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:131 +#, c-format +msgid "Could not get provider information" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:140 +#, c-format +msgid "Backup providers may charge for their service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:147 +#, c-format +msgid "URL" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:158 +#, c-format +msgid "Name" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:212 +#, c-format +msgid "Provider URL" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:218 +#, c-format +msgid "Please review and accept this provider's terms of service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:223 +#, c-format +msgid "Pricing" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:226 +#, c-format +msgid "free of charge" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:228 +#, c-format +msgid "%1$s per year of service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:235 +#, c-format +msgid "Storage" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:238 +#, c-format +msgid "%1$s megabytes of storage per year of service" +msgstr "" + +#: src/wallet/ProviderAddPage.tsx:244 +#, c-format +msgid "Accept terms of service" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:44 +#, c-format +msgid "Could not parse the payto URI" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:45 +#, c-format +msgid "Please check the uri" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:75 +#, c-format +msgid "Exchange is ready for withdrawal" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:78 +#, c-format +msgid "To complete the process you need to wire%1$s %2$s to the exchange bank account" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:87 +#, c-format +msgid "" +"Alternative, you can also scan this QR code or open %1$s if you have a banking " +"app installed that supports RFC 8905" +msgstr "" + +#: src/wallet/ReserveCreated.tsx:98 +#, c-format +msgid "Cancel withdrawal" +msgstr "" + +#: src/wallet/Settings.tsx:115 +#, c-format +msgid "Could not toggle auto-open" +msgstr "" + +#: src/wallet/Settings.tsx:121 +#, c-format +msgid "Could not toggle clipboard" +msgstr "" + +#: src/wallet/Settings.tsx:126 +#, c-format +msgid "Navigator" +msgstr "" + +#: src/wallet/Settings.tsx:129 +#, c-format +msgid "Automatically open wallet based on page content" +msgstr "" + +#: src/wallet/Settings.tsx:135 +#, c-format +msgid "" +"Enabling this option below will make using the wallet faster, but requires more " +"permissions from your browser." +msgstr "" + +#: src/wallet/Settings.tsx:145 +#, c-format +msgid "Automatically check clipboard for Taler URI" +msgstr "" + +#: src/wallet/Settings.tsx:162 +#, c-format +msgid "Trust" +msgstr "" + +#: src/wallet/Settings.tsx:166 +#, c-format +msgid "No exchange yet" +msgstr "" + +#: src/wallet/Settings.tsx:180 +#, c-format +msgid "Term of Service" +msgstr "" + +#: src/wallet/Settings.tsx:191 +#, c-format +msgid "ok" +msgstr "" + +#: src/wallet/Settings.tsx:197 +#, c-format +msgid "changed" +msgstr "" + +#: src/wallet/Settings.tsx:204 +#, c-format +msgid "not accepted" +msgstr "" + +#: src/wallet/Settings.tsx:210 +#, c-format +msgid "unknown (exchange status should be updated)" +msgstr "" + +#: src/wallet/Settings.tsx:236 +#, c-format +msgid "Add an exchange" +msgstr "" + +#: src/wallet/Settings.tsx:241 +#, c-format +msgid "Troubleshooting" +msgstr "" + +#: src/wallet/Settings.tsx:244 +#, c-format +msgid "Developer mode" +msgstr "" + +#: src/wallet/Settings.tsx:246 +#, c-format +msgid "More options and information useful for debugging" +msgstr "" + +#: src/wallet/Settings.tsx:257 +#, c-format +msgid "Display" +msgstr "" + +#: src/wallet/Settings.tsx:261 +#, c-format +msgid "Current Language" +msgstr "" + +#: src/wallet/Settings.tsx:274 +#, c-format +msgid "Wallet Core" +msgstr "" + +#: src/wallet/Settings.tsx:284 +#, c-format +msgid "Web Extension" +msgstr "" + +#: src/wallet/Settings.tsx:295 +#, c-format +msgid "Exchange compatibility" +msgstr "" + +#: src/wallet/Settings.tsx:299 +#, c-format +msgid "Merchant compatibility" +msgstr "" + +#: src/wallet/Settings.tsx:303 +#, c-format +msgid "Bank compatibility" +msgstr "" + +#: src/wallet/Welcome.tsx:59 +#, c-format +msgid "Browser Extension Installed!" +msgstr "" + +#: src/wallet/Welcome.tsx:63 +#, c-format +msgid "You can open the GNU Taler Wallet using the combination %1$s ." +msgstr "" + +#: src/wallet/Welcome.tsx:72 +#, c-format +msgid "" +"Also pinning the GNU Taler Wallet to your Chrome browser allows you to quick " +"access without keyboard:" +msgstr "" + +#: src/wallet/Welcome.tsx:79 +#, c-format +msgid "Click the puzzle icon" +msgstr "" + +#: src/wallet/Welcome.tsx:82 +#, c-format +msgid "Search for GNU Taler Wallet" +msgstr "" + +#: src/wallet/Welcome.tsx:85 +#, c-format +msgid "Click the pin icon" +msgstr "" + +#: src/wallet/Welcome.tsx:91 +#, c-format +msgid "Permissions" +msgstr "" + +#: src/wallet/Welcome.tsx:100 +#, c-format +msgid "" +"(Enabling this option below will make using the wallet faster, but requires more " +"permissions from your browser.)" +msgstr "" + +#: src/wallet/Welcome.tsx:110 +#, c-format +msgid "Next Steps" +msgstr "" + +#: src/wallet/Welcome.tsx:113 +#, c-format +msgid "Try the demo" +msgstr "" + +#: src/wallet/Welcome.tsx:116 +#, c-format +msgid "Learn how to top up your wallet balance" +msgstr "" + +#: src/components/Diagnostics.tsx:31 +#, c-format +msgid "Diagnostics timed out. Could not talk to the wallet backend." +msgstr "" + +#: src/components/Diagnostics.tsx:52 +#, c-format +msgid "Problems detected:" +msgstr "" + +#: src/components/Diagnostics.tsx:61 +#, c-format +msgid "" +"Please check in your %1$s settings that you have IndexedDB enabled (check the " +"preference name %2$s)." +msgstr "" + +#: src/components/Diagnostics.tsx:70 +#, c-format +msgid "" +"Your wallet database is outdated. Currently automatic migration is not " +"supported. Please go %1$s to reset the wallet database." +msgstr "" + +#: src/components/Diagnostics.tsx:83 +#, c-format +msgid "Running diagnostics" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:163 +#, c-format +msgid "Debug tools" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:170 +#, c-format +msgid "" +"Do you want to IRREVOCABLY DESTROY everything inside your wallet and LOSE ALL " +"YOUR COINS?" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:176 +#, c-format +msgid "reset" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:183 +#, c-format +msgid "TESTING: This may delete all your coin, proceed with caution" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:189 +#, c-format +msgid "run gc" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:197 +#, c-format +msgid "import database" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:219 +#, c-format +msgid "export database" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:225 +#, c-format +msgid "Database exported at %1$s %2$s to download" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:248 +#, c-format +msgid "Coins" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:282 +#, c-format +msgid "Pending operations" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:328 +#, c-format +msgid "usable coins" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:337 +#, c-format +msgid "id" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:340 +#, c-format +msgid "denom" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:343 +#, c-format +msgid "value" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:346 +#, c-format +msgid "status" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:349 +#, c-format +msgid "from refresh?" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:352 +#, c-format +msgid "age key count" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:369 +#, c-format +msgid "spent coins" +msgstr "" + +#: src/wallet/DeveloperPage.tsx:373 +#, c-format +msgid "click to show" +msgstr "" + +#: src/wallet/QrReader.tsx:108 +#, c-format +msgid "Scan a QR code or enter taler:// URI below" +msgstr "" + +#: src/wallet/QrReader.tsx:122 +#, c-format +msgid "Open" +msgstr "Доступні" + +#: src/wallet/QrReader.tsx:128 +#, c-format +msgid "URI is not valid. Taler URI should start with `taler://`" +msgstr "" + +#: src/wallet/QrReader.tsx:133 +#, c-format +msgid "Try another" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:183 +#, c-format +msgid "Could not load list of exchange" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:209 +#, c-format +msgid "Choose a currency to proceed or add another exchange" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:217 +#, c-format +msgid "Known currencies" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:318 +#, c-format +msgid "Specify the amount and the origin" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:336 +#, c-format +msgid "Change currency" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:344 +#, c-format +msgid "Use previous origins:" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:364 +#, c-format +msgid "Or specify the origin of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:372 +#, c-format +msgid "Specify the origin of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:380 +#, c-format +msgid "From my bank account" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:395 +#, c-format +msgid "From another wallet" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:449 +#, c-format +msgid "currency not provided" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:459 +#, c-format +msgid "Specify the amount and the destination" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:483 +#, c-format +msgid "Use previous destinations:" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:503 +#, c-format +msgid "Or specify the destination of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:511 +#, c-format +msgid "Specify the destination of the money" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:521 +#, c-format +msgid "To my bank account" +msgstr "" + +#: src/wallet/DestinationSelection.tsx:534 +#, c-format +msgid "To another wallet" +msgstr "" + +#: src/cta/Recovery/views.tsx:30 +#, c-format +msgid "Could not load backup recovery information" +msgstr "" + +#: src/cta/Recovery/views.tsx:47 +#, c-format +msgid "Digital wallet recovery" +msgstr "" + +#: src/cta/Recovery/views.tsx:52 +#, c-format +msgid "Import backup, show info" +msgstr "" + +#: src/wallet/Application.tsx:189 +#, c-format +msgid "All done, your transaction is in progress" +msgstr "" + +#: src/components/EditableText.tsx:45 +#, c-format +msgid "Edit" +msgstr "" + +#: src/wallet/ManualWithdrawPage.tsx:102 +#, c-format +msgid "Could not load the list of known exchanges" +msgstr "" diff --git a/packages/taler-wallet-webextension/src/mui/TextField.tsx b/packages/taler-wallet-webextension/src/mui/TextField.tsx index 4d7c9a472..ab29fb78d 100644 --- a/packages/taler-wallet-webextension/src/mui/TextField.tsx +++ b/packages/taler-wallet-webextension/src/mui/TextField.tsx @@ -30,7 +30,7 @@ export interface Props { autoFocus?: boolean; color?: Colors; disabled?: boolean; - error?: string; + error?: string | Error; fullWidth?: boolean; helperText?: VNode | string; id?: string; diff --git a/packages/taler-wallet-webextension/src/mui/handlers.ts b/packages/taler-wallet-webextension/src/mui/handlers.ts index 735e8523f..a194bd02a 100644 --- a/packages/taler-wallet-webextension/src/mui/handlers.ts +++ b/packages/taler-wallet-webextension/src/mui/handlers.ts @@ -18,13 +18,13 @@ import { AmountJson } from "@gnu-taler/taler-util"; export interface TextFieldHandler { onInput?: SafeHandler<string>; value: string; - error?: string; + error?: string | Error; } export interface AmountFieldHandler { onInput?: SafeHandler<AmountJson>; value: AmountJson; - error?: string; + error?: string | Error; } declare const __safe_handler: unique symbol; diff --git a/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx b/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx index 23dfcfd08..45f5a81d1 100644 --- a/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx +++ b/packages/taler-wallet-webextension/src/mui/input/FormControl.tsx @@ -22,7 +22,7 @@ import { Colors } from "../style.js"; export interface Props { color: Colors; disabled: boolean; - error?: string; + error?: string | Error; focused: boolean; fullWidth: boolean; hiddenLabel: boolean; @@ -124,7 +124,7 @@ export interface FCCProps { // setAdornedStart, color: Colors; disabled: boolean; - error: string | undefined; + error: string | undefined | Error; filled: boolean; focused: boolean; fullWidth: boolean; diff --git a/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx b/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx index 5fa48a169..3b80b0f23 100644 --- a/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx +++ b/packages/taler-wallet-webextension/src/mui/input/FormHelperText.tsx @@ -43,7 +43,7 @@ const containedStyle = css` interface Props { disabled?: boolean; - error?: string; + error?: string | Error; filled?: boolean; focused?: boolean; margin?: "dense"; diff --git a/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx b/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx index a984f8451..0707046f3 100644 --- a/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx +++ b/packages/taler-wallet-webextension/src/mui/input/InputFilled.tsx @@ -27,7 +27,7 @@ export interface Props { defaultValue?: string; disabled?: boolean; disableUnderline?: boolean; - error?: string; + error?: string | Error; fullWidth?: boolean; id?: string; margin?: "dense" | "normal" | "none"; @@ -89,9 +89,9 @@ const filledRootStyle = css` border-top-left-radius: ${theme.shape.borderRadius}px; border-top-right-radius: ${theme.shape.borderRadius}px; transition: ${theme.transitions.create("background-color", { - duration: theme.transitions.duration.shorter, - easing: theme.transitions.easing.easeOut, - })}; + duration: theme.transitions.duration.shorter, + easing: theme.transitions.easing.easeOut, +})}; // when is not disabled underline &:hover { background-color: ${backgroundColorHover}; @@ -124,9 +124,9 @@ const underlineStyle = css` right: 0px; transform: scaleX(0); transition: ${theme.transitions.create("transform", { - duration: theme.transitions.duration.shorter, - easing: theme.transitions.easing.easeOut, - })}; + duration: theme.transitions.duration.shorter, + easing: theme.transitions.easing.easeOut, +})}; pointer-events: none; } &[data-focused]:after { @@ -139,8 +139,8 @@ const underlineStyle = css` &:before { border-bottom: 1px solid ${theme.palette.mode === "light" - ? "rgba(0, 0, 0, 0.42)" - : "rgba(255, 255, 255, 0.7)"}; + ? "rgba(0, 0, 0, 0.42)" + : "rgba(255, 255, 255, 0.7)"}; left: 0px; bottom: 0px; right: 0px; @@ -156,8 +156,8 @@ const underlineStyle = css` @media (hover: none) { border-bottom: 1px solid ${theme.palette.mode === "light" - ? "rgba(0, 0, 0, 0.42)" - : "rgba(255, 255, 255, 0.7)"}; + ? "rgba(0, 0, 0, 0.42)" + : "rgba(255, 255, 255, 0.7)"}; } } &[data-disabled]:before { diff --git a/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx b/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx index f7b5040e4..7352c5ec1 100644 --- a/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx +++ b/packages/taler-wallet-webextension/src/mui/input/InputStandard.tsx @@ -27,7 +27,7 @@ export interface Props { disabled?: boolean; disableUnderline?: boolean; endAdornment?: VNode; - error?: string; + error?: string | Error; fullWidth?: boolean; id?: string; margin?: "dense" | "normal" | "none"; @@ -82,9 +82,9 @@ const underlineStyle = css` right: 0px; transform: scaleX(0); transition: ${theme.transitions.create("transform", { - duration: theme.transitions.duration.shorter, - easing: theme.transitions.easing.easeOut, - })}; + duration: theme.transitions.duration.shorter, + easing: theme.transitions.easing.easeOut, +})}; pointer-events: none; } &[data-focused]:after { @@ -97,8 +97,8 @@ const underlineStyle = css` &:before { border-bottom: 1px solid ${theme.palette.mode === "light" - ? "rgba(0, 0, 0, 0.42)" - : "rgba(255, 255, 255, 0.7)"}; + ? "rgba(0, 0, 0, 0.42)" + : "rgba(255, 255, 255, 0.7)"}; left: 0px; bottom: 0px; right: 0px; @@ -114,8 +114,8 @@ const underlineStyle = css` @media (hover: none) { border-bottom: 1px solid ${theme.palette.mode === "light" - ? "rgba(0, 0, 0, 0.42)" - : "rgba(255, 255, 255, 0.7)"}; + ? "rgba(0, 0, 0, 0.42)" + : "rgba(255, 255, 255, 0.7)"}; } } &[data-disabled]:before { diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts index a2b26441b..3c116fab2 100644 --- a/packages/taler-wallet-webextension/src/platform/api.ts +++ b/packages/taler-wallet-webextension/src/platform/api.ts @@ -17,12 +17,10 @@ import { CoreApiResponse, TalerUri, - WalletNotification + WalletNotification, + WalletRunConfig, } from "@gnu-taler/taler-util"; -import { - WalletConfig, - WalletOperations -} from "@gnu-taler/taler-wallet-core"; +import { WalletOperations } from "@gnu-taler/taler-wallet-core"; import { ExtensionOperations, MessageFromExtension, @@ -46,30 +44,48 @@ export interface Permissions { * Compatibility API that works on multiple browsers. */ export interface CrossBrowserPermissionsApi { - containsHostPermissions(): Promise<boolean>; - requestHostPermissions(): Promise<boolean>; - removeHostPermissions(): Promise<boolean>; - containsClipboardPermissions(): Promise<boolean>; requestClipboardPermissions(): Promise<boolean>; removeClipboardPermissions(): Promise<boolean>; +} - addPermissionsListener( - callback: (p: Permissions, lastError?: string) => void, - ): void; +export enum ExtensionNotificationType { + SettingsChange = "settings-change", + ClearNotifications = "clear-notifications", } -export type MessageFromBackend = WalletNotification; +export interface SettingsChangeNotification { + type: ExtensionNotificationType.SettingsChange; + + currentValue: Settings; +} +export interface ClearNotificaitonNotification { + type: ExtensionNotificationType.ClearNotifications; +} + +export type ExtensionNotification = + | SettingsChangeNotification + | ClearNotificaitonNotification; + +export type MessageFromBackend = + | { + type: "wallet"; + notification: WalletNotification; + } + | { + type: "web-extension"; + notification: ExtensionNotification; + }; export type MessageFromFrontend< Op extends BackgroundOperations | WalletOperations | ExtensionOperations, > = Op extends BackgroundOperations ? MessageFromFrontendBackground<keyof BackgroundOperations> : Op extends ExtensionOperations - ? MessageFromExtension<keyof ExtensionOperations> - : Op extends WalletOperations - ? MessageFromFrontendWallet<keyof WalletOperations> - : never; + ? MessageFromExtension<keyof ExtensionOperations> + : Op extends WalletOperations + ? MessageFromFrontendWallet<keyof WalletOperations> + : never; export type MessageFromFrontendBackground< Op extends keyof BackgroundOperations, @@ -92,8 +108,7 @@ export interface WalletWebExVersion { version: string; } -type F = WalletConfig["features"]; -type kf = keyof F; +type F = WalletRunConfig["features"]; type WebexWalletConfig = { [P in keyof F as `wallet${Capitalize<P>}`]: F[P]; }; @@ -101,24 +116,32 @@ type WebexWalletConfig = { export interface Settings extends WebexWalletConfig { injectTalerSupport: boolean; autoOpen: boolean; - advanceMode: boolean; + advancedMode: boolean; backup: boolean; langSelector: boolean; showJsonOnError: boolean; extendedAccountTypes: boolean; + showRefeshTransactions: boolean; suspendIndividualTransaction: boolean; + showExchangeManagement: boolean; + selectTosFormat: boolean; + showWalletActivity: boolean; } export const defaultSettings: Settings = { injectTalerSupport: true, autoOpen: true, - advanceMode: false, + advancedMode: false, backup: false, langSelector: false, + showRefeshTransactions: false, suspendIndividualTransaction: false, showJsonOnError: false, extendedAccountTypes: false, + showExchangeManagement: false, walletAllowHttp: false, + selectTosFormat: false, + showWalletActivity: false, }; /** @@ -208,15 +231,19 @@ export interface BackgroundPlatformAPI { ): void; /** - * Use by the wallet backend to activate the listener of HTTP request + * Change web extension Icon */ - registerTalerHeaderListener(): void; - - containsTalerHeaderListener(): boolean; - + setAlertedIcon(): void; + setNormalIcon(): void; } + export interface ForegroundPlatformAPI { /** + * Check if the extension is running under + * chrome incognito or firefox private mode. + */ + runningOnPrivateMode(): boolean; + /** * FIXME: should not be needed * * check if the platform is firefox @@ -248,7 +275,7 @@ export interface ForegroundPlatformAPI { /** * Open a page and close the popup - * @param url + * @param url */ openNewURLFromPopup(url: URL): void; /** @@ -287,6 +314,12 @@ export interface ForegroundPlatformAPI { ): Promise<MessageResponse>; /** + * Used by the wallet frontend to send notification about new information + * @param message + */ + triggerWalletEvent(message: MessageFromBackend): void; + + /** * Used from the frontend to receive notifications about new information * @param listener * @return function to unsubscribe the listener diff --git a/packages/taler-wallet-webextension/src/platform/background.ts b/packages/taler-wallet-webextension/src/platform/background.ts index 9f3764c25..13808af2b 100644 --- a/packages/taler-wallet-webextension/src/platform/background.ts +++ b/packages/taler-wallet-webextension/src/platform/background.ts @@ -16,7 +16,8 @@ import { BackgroundPlatformAPI } from "./api.js"; -export let platform: BackgroundPlatformAPI = undefined as any; +// it should never be undefined :) +export let platform: BackgroundPlatformAPI = undefined!; export function setupPlatform(impl: BackgroundPlatformAPI): void { platform = impl; } diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts index 20cf54035..e63040f5c 100644 --- a/packages/taler-wallet-webextension/src/platform/chrome.ts +++ b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -16,11 +16,10 @@ import { Logger, - TalerErrorCode, - TalerUriAction, TalerError, - parseTalerUri, + TalerErrorCode, TalerUri, + TalerUriAction, stringifyTalerUri, } from "@gnu-taler/taler-util"; import { WalletOperations } from "@gnu-taler/taler-wallet-core"; @@ -28,11 +27,11 @@ import { BackgroundOperations } from "../wxApi.js"; import { BackgroundPlatformAPI, CrossBrowserPermissionsApi, + ExtensionNotificationType, ForegroundPlatformAPI, MessageFromBackend, MessageFromFrontend, MessageResponse, - Permissions, Settings, defaultSettings, } from "./api.js"; @@ -43,7 +42,9 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { findTalerUriInActiveTab, findTalerUriInClipboard, getPermissionsApi, + runningOnPrivateMode, getWalletWebExVersion, + triggerWalletEvent, listenToWalletBackground, notifyWhenAppIsReady, openWalletPage, @@ -52,7 +53,7 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { redirectTabToWalletPage, registerAllIncomingConnections, registerOnInstalled, - listenToAllChannels: listenToAllChannels as any, + listenToAllChannels , registerReloadOnNewVersion, sendMessageToAllChannels, openNewURLFromPopup, @@ -60,28 +61,33 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { useServiceWorkerAsBackgroundProcess, keepAlive, listenNetworkConnectionState, - registerTalerHeaderListener, - containsTalerHeaderListener, + setAlertedIcon, + setNormalIcon, }; export default api; const logger = new Logger("chrome.ts"); -async function getSettingsFromStorage(): Promise<Settings> { - const data = await chrome.storage.local.get("wallet-settings"); - if (!data) return defaultSettings; - const settings = data["wallet-settings"]; - if (!settings) return defaultSettings; +const WALLET_STORAGE_KEY = "wallet-settings"; + +function jsonParseOrDefault(unparsed: string, def: unknown) { + if (!unparsed) return def; try { - const parsed = JSON.parse(settings); - return parsed; + return JSON.parse(unparsed); } catch (e) { - return defaultSettings; + return def; } } -function keepAlive(callback: any): void { +async function getSettingsFromStorage(): Promise<Settings> { + const data = await chrome.storage.local.get(WALLET_STORAGE_KEY); + if (!data) return defaultSettings; + const settings = data[WALLET_STORAGE_KEY]; + return jsonParseOrDefault(settings, defaultSettings); +} + +function keepAlive(callback: () => void): void { if (extensionIsManifestV3()) { chrome.alarms.create("wallet-worker", { periodInMinutes: 1 }); @@ -98,9 +104,8 @@ function isFirefox(): boolean { return false; } - export function containsClipboardPermissions(): Promise<boolean> { - return new Promise((res, rej) => { + return new Promise((res) => { res(false); // chrome.permissions.contains({ permissions: ["clipboardRead"] }, (resp) => { // const le = chrome.runtime.lastError?.message; @@ -113,7 +118,7 @@ export function containsClipboardPermissions(): Promise<boolean> { } export async function requestClipboardPermissions(): Promise<boolean> { - return new Promise((res, rej) => { + return new Promise((res) => { res(false); // chrome.permissions.request({ permissions: ["clipboardRead"] }, (resp) => { // const le = chrome.runtime.lastError?.message; @@ -125,10 +130,8 @@ export async function requestClipboardPermissions(): Promise<boolean> { }); } - - export function removeClipboardPermissions(): Promise<boolean> { - return new Promise((res, rej) => { + return new Promise((res) => { res(true); // chrome.permissions.remove({ permissions: ["clipboardRead"] }, (resp) => { // const le = chrome.runtime.lastError?.message; @@ -140,21 +143,8 @@ export function removeClipboardPermissions(): Promise<boolean> { }); } -function addPermissionsListener( - callback: (p: Permissions, lastError?: string) => void, -): void { - chrome.permissions.onAdded.addListener((perm: Permissions) => { - const lastError = chrome.runtime.lastError?.message; - callback(perm, lastError); - }); -} - function getPermissionsApi(): CrossBrowserPermissionsApi { return { - containsHostPermissions, - requestHostPermissions, - removeHostPermissions, - addPermissionsListener, requestClipboardPermissions, removeClipboardPermissions, containsClipboardPermissions, @@ -166,7 +156,7 @@ function getPermissionsApi(): CrossBrowserPermissionsApi { * @param callback function to be called */ function notifyWhenAppIsReady(): Promise<void> { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { if (extensionIsManifestV3()) { resolve(); } else { @@ -205,11 +195,6 @@ function openWalletURIFromPopup(uri: TalerUri): void { `static/wallet.html#/cta/pay?talerUri=${encodeURIComponent(talerUri)}`, ); break; - case TalerUriAction.Reward: - url = chrome.runtime.getURL( - `static/wallet.html#/cta/tip?talerUri=${encodeURIComponent(talerUri)}`, - ); - break; case TalerUriAction.Refund: url = chrome.runtime.getURL( `static/wallet.html#/cta/refund?talerUri=${encodeURIComponent( @@ -238,15 +223,16 @@ function openWalletURIFromPopup(uri: TalerUri): void { )}`, ); break; + case TalerUriAction.AddExchange: + url = chrome.runtime.getURL( + `static/wallet.html#/cta/add/exchange?talerUri=${encodeURIComponent( + talerUri, + )}`, + ); + break; case TalerUriAction.DevExperiment: logger.warn(`taler://dev-experiment URIs are not allowed in headers`); return; - case TalerUriAction.Exchange: - logger.warn(`taler://exchange not yet supported`); - return; - case TalerUriAction.Auditor: - logger.warn(`taler://auditor not yet supported`); - return; default: { const error: never = uri; logger.warn( @@ -271,7 +257,6 @@ function openWalletPageFromPopup(page: string): void { chrome.tabs.create({ active: true, url }, () => { window.close(); }); - } function openNewURLFromPopup(url: URL): void { // const url = chrome.runtime.getURL(`/static/wallet.html#${page}`); @@ -290,14 +275,19 @@ let nextMessageIndex = 0; async function sendMessageToBackground< Op extends WalletOperations | BackgroundOperations, >(message: MessageFromFrontend<Op>): Promise<MessageResponse> { - const messageWithId = { ...message, id: `id_${nextMessageIndex++ % 1000}` }; + nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100); + const messageWithId = { ...message, id: `id_${nextMessageIndex}` }; - return new Promise<any>((resolve, reject) => { + return new Promise<MessageResponse>((resolve, reject) => { logger.trace("send operation to the wallet background", message); let timedout = false; const timerId = setTimeout(() => { timedout = true; - reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {})); + reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { + requestMethod: "wallet", + requestUrl: message.operation, + timeoutMs: 20 * 1000, + })); }, 20 * 1000); chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { if (timedout) { @@ -319,7 +309,7 @@ async function sendMessageToBackground< * To be used by the foreground */ let notificationPort: chrome.runtime.Port | undefined; -function listenToWalletBackground(listener: (m: any) => void): () => void { +function listenToWalletBackground(listener: (message: MessageFromBackend) => void): () => void { if (notificationPort === undefined) { notificationPort = chrome.runtime.connect({ name: "notifications" }); } @@ -334,6 +324,17 @@ function listenToWalletBackground(listener: (m: any) => void): () => void { const allPorts: chrome.runtime.Port[] = []; +function triggerWalletEvent(message: MessageFromBackend): void { + for (const notif of allPorts) { + // const message: MessageFromBackend = { type: msg.type }; + try { + notif.postMessage(message); + } catch (e) { + logger.error("error posting a message", e); + } + } +} + function sendMessageToAllChannels(message: MessageFromBackend): void { for (const notif of allPorts) { // const message: MessageFromBackend = { type: msg.type }; @@ -363,6 +364,20 @@ function registerAllIncomingConnections(): void { logger.error("error trying to save incoming connection", e); } }); + chrome.storage.onChanged.addListener((event) => { + if (event[WALLET_STORAGE_KEY]) { + sendMessageToAllChannels({ + type: "web-extension", + notification: { + type: ExtensionNotificationType.SettingsChange, + currentValue: jsonParseOrDefault( + event[WALLET_STORAGE_KEY].newValue, + defaultSettings, + ), + }, + }); + } + }); } function listenToAllChannels( @@ -402,14 +417,17 @@ function registerReloadOnNewVersion(): void { }); } -async function redirectCurrentTabToWalletPage(page: string): Promise<void> { - let queryOptions = { active: true, lastFocusedWindow: true }; - let [tab] = await chrome.tabs.query(queryOptions); +// async function redirectCurrentTabToWalletPage(page: string): Promise<void> { +// let queryOptions = { active: true, lastFocusedWindow: true }; +// let [tab] = await chrome.tabs.query(queryOptions); - return redirectTabToWalletPage(tab.id!, page); -} +// return redirectTabToWalletPage(tab.id!, page); +// } -async function redirectTabToWalletPage(tabId: number, page: string): Promise<void> { +async function redirectTabToWalletPage( + tabId: number, + page: string, +): Promise<void> { const url = chrome.runtime.getURL(`/static/wallet.html#${page}`); logger.trace("redirecting tabId: ", tabId, " to: ", url); await chrome.tabs.update(tabId, { url }); @@ -650,7 +668,7 @@ async function findTalerUriInTab(tabId: number): Promise<string | undefined> { return; } } else { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { //manifest v2 chrome.tabs.executeScript( tabId, @@ -676,9 +694,9 @@ async function findTalerUriInTab(tabId: number): Promise<string | undefined> { } } -async function timeout(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +// async function timeout(ms: number): Promise<void> { +// return new Promise((resolve) => setTimeout(resolve, ms)); +// } async function findTalerUriInClipboard(): Promise<string | undefined> { //FIXME: add clipboard feature // try { @@ -723,253 +741,6 @@ function listenNetworkConnectionState( }; } -type HeaderListenerFunc = ( - details: chrome.webRequest.WebResponseHeadersDetails, -) => void; -let currentHeaderListener: HeaderListenerFunc | undefined = undefined; - -// type TabListenerFunc = (tabId: number, info: chrome.tabs.TabChangeInfo) => void; -// let currentTabListener: TabListenerFunc | undefined = undefined; - - -function containsTalerHeaderListener(): boolean { - return ( - currentHeaderListener !== undefined - // || currentTabListener !== undefined - ); +function runningOnPrivateMode(): boolean { + return chrome.extension.inIncognitoContext; } - -function headerListener( - details: chrome.webRequest.WebResponseHeadersDetails, -): chrome.webRequest.BlockingResponse | undefined { - logger.info("header listener run", details.statusCode, chrome.runtime.lastError) - if (chrome.runtime.lastError) { - logger.error(JSON.stringify(chrome.runtime.lastError)); - return; - } - - if ( - details.statusCode === 402 || - details.statusCode === 202 || - details.statusCode === 200 - ) { - const values = (details.responseHeaders || []) - .filter((h) => h.name.toLowerCase() === "taler") - .map((h) => h.value) - .filter((value): value is string => !!value); - - const talerUri = values.length > 0 ? values[0] : undefined - if (talerUri) { - logger.info( - `Found a Taler URI in a response header for the request ${details.url} from tab ${details.tabId}: ${talerUri}`, - ); - parseTalerUriAndRedirect(details.tabId, talerUri); - return; - } - } - return details; -} -function parseTalerUriAndRedirect(tabId: number, maybeTalerUri: string): void { - const talerUri = maybeTalerUri.startsWith("ext+") - ? maybeTalerUri.substring(4) - : maybeTalerUri; - const uri = parseTalerUri(talerUri); - if (!uri) { - logger.warn( - `Response with HTTP 402 the Taler header but could not classify ${talerUri}`, - ); - return; - } - redirectTabToWalletPage( - tabId, - `/taler-uri/${encodeURIComponent(talerUri)}`, - ); -} - -/** - * Not needed anymore since SPA use taler support - */ - -// async function tabListener( -// tabId: number, -// info: chrome.tabs.TabChangeInfo, -// ): Promise<void> { -// if (tabId < 0) return; -// const tabLocationHasBeenUpdated = info.status === "complete"; -// const tabTitleHasBeenUpdated = info.title !== undefined; -// if (tabLocationHasBeenUpdated || tabTitleHasBeenUpdated) { -// const uri = await findTalerUriInTab(tabId); -// if (!uri) return; -// logger.info(`Found a Taler URI in the tab ${tabId}`); -// parseTalerUriAndRedirect(tabId, uri); -// } -// } - -/** - * unused, declarative redirect is not good enough - * - */ -// async function registerDeclarativeRedirect() { -// await chrome.declarativeNetRequest.updateDynamicRules({ -// removeRuleIds: [1], -// addRules: [ -// { -// id: 1, -// priority: 1, -// condition: { -// urlFilter: "https://developer.chrome.com/docs/extensions/mv2/", -// regexFilter: ".*taler_uri=([^&]*).*", -// // isUrlFilterCaseSensitive: false, -// // requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET] -// // resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], -// }, -// action: { -// type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, -// redirect: { -// regexSubstitution: `chrome-extension://${chrome.runtime.id}/static/wallet.html?action=\\1`, -// }, -// }, -// }, -// ], -// }); -// } - -function registerTalerHeaderListener(): void { - logger.info("setting up header listener"); - - const prevHeaderListener = currentHeaderListener; - // const prevTabListener = currentTabListener; - - if ( - prevHeaderListener && - chrome?.webRequest?.onHeadersReceived?.hasListener(prevHeaderListener) - ) { - return; - // console.log("removming on header listener") - // chrome.webRequest.onHeadersReceived.removeListener(prevHeaderListener); - // chrome.webRequest.onCompleted.removeListener(prevHeaderListener); - // chrome.webRequest.onResponseStarted.removeListener(prevHeaderListener); - // chrome.webRequest.onErrorOccurred.removeListener(prevHeaderListener); - } - - // if ( - // prevTabListener && - // chrome?.tabs?.onUpdated?.hasListener(prevTabListener) - // ) { - // console.log("removming on tab listener") - // chrome.tabs.onUpdated.removeListener(prevTabListener); - // } - - console.log("headers on, disabled:", chrome?.webRequest?.onHeadersReceived === undefined) - if (chrome?.webRequest) { - if (extensionIsManifestV3()) { - chrome.webRequest.onHeadersReceived.addListener(headerListener, - { urls: ["<all_urls>"] }, - ["responseHeaders"] - ); - } else { - chrome.webRequest.onHeadersReceived.addListener(headerListener, - { urls: ["<all_urls>"] }, - ["responseHeaders"] - ); - } - // chrome.webRequest.onCompleted.addListener(headerListener, - // { urls: ["<all_urls>"] }, - // ["responseHeaders", "extraHeaders"] - // ); - // chrome.webRequest.onResponseStarted.addListener(headerListener, - // { urls: ["<all_urls>"] }, - // ["responseHeaders", "extraHeaders"] - // ); - // chrome.webRequest.onErrorOccurred.addListener(headerListener, - // { urls: ["<all_urls>"] }, - // ["extraHeaders"] - // ); - currentHeaderListener = headerListener; - } - - // const tabsEvent: chrome.tabs.TabUpdatedEvent | undefined = - // chrome?.tabs?.onUpdated; - // if (tabsEvent) { - // tabsEvent.addListener(tabListener); - // currentTabListener = tabListener; - // } - - //notify the browser about this change, this operation is expensive - chrome?.webRequest?.handlerBehaviorChanged(() => { - if (chrome.runtime.lastError) { - logger.error(JSON.stringify(chrome.runtime.lastError)); - } - }); -} - -const hostPermissions = { - permissions: ["webRequest"], - origins: ["http://*/*", "https://*/*"], -}; - -export function containsHostPermissions(): Promise<boolean> { - return new Promise((res, rej) => { - chrome.permissions.contains(hostPermissions, (resp) => { - const le = chrome.runtime.lastError?.message; - if (le) { - rej(le); - } - res(resp); - }); - }); -} - -export async function requestHostPermissions(): Promise<boolean> { - return new Promise((res, rej) => { - chrome.permissions.request(hostPermissions, (resp) => { - const le = chrome.runtime.lastError?.message; - if (le) { - rej(le); - } - res(resp); - }); - }); -} - -export async function removeHostPermissions(): Promise<boolean> { - //if there is a handler already, remove it - if ( - currentHeaderListener && - chrome?.webRequest?.onHeadersReceived?.hasListener(currentHeaderListener) - ) { - chrome.webRequest.onHeadersReceived.removeListener(currentHeaderListener); - } - // if ( - // currentTabListener && - // chrome?.tabs?.onUpdated?.hasListener(currentTabListener) - // ) { - // chrome.tabs.onUpdated.removeListener(currentTabListener); - // } - - currentHeaderListener = undefined; - // currentTabListener = undefined; - - //notify the browser about this change, this operation is expensive - if ("webRequest" in chrome) { - chrome.webRequest.handlerBehaviorChanged(() => { - if (chrome.runtime.lastError) { - logger.error(JSON.stringify(chrome.runtime.lastError)); - } - }); - } - - if (extensionIsManifestV3()) { - // Trying to remove host permissions with manifest >= v3 throws an error - return true; - } - return new Promise((res, rej) => { - chrome.permissions.remove(hostPermissions, (resp) => { - const le = chrome.runtime.lastError?.message; - if (le) { - rej(le); - } - res(resp); - }); - }); -}
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts index 51744e318..d6e743147 100644 --- a/packages/taler-wallet-webextension/src/platform/dev.ts +++ b/packages/taler-wallet-webextension/src/platform/dev.ts @@ -29,6 +29,7 @@ import { const logger = new Logger("dev.ts"); const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { + runningOnPrivateMode: () => false, isFirefox: () => false, getSettingsFromStorage: () => Promise.resolve(defaultSettings), keepAlive: (cb: VoidFunction) => cb(), @@ -36,19 +37,15 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { findTalerUriInClipboard: async () => undefined, listenNetworkConnectionState, openNewURLFromPopup: () => undefined, + triggerWalletEvent: () => undefined, + setAlertedIcon: () => undefined, + setNormalIcon : () => undefined, getPermissionsApi: () => ({ - addPermissionsListener: () => undefined, - containsHostPermissions: async () => true, - removeHostPermissions: async () => false, - requestHostPermissions: async () => false, containsClipboardPermissions: async () => true, removeClipboardPermissions: async () => false, requestClipboardPermissions: async () => false, }), - // registerDeclarativeRedirect: () => false, - registerTalerHeaderListener: () => false, - containsTalerHeaderListener: () => false, getWalletWebExVersion: () => ({ version: "none", }), diff --git a/packages/taler-wallet-webextension/src/platform/firefox.ts b/packages/taler-wallet-webextension/src/platform/firefox.ts index 0bbe805cf..3d67423fd 100644 --- a/packages/taler-wallet-webextension/src/platform/firefox.ts +++ b/packages/taler-wallet-webextension/src/platform/firefox.ts @@ -26,9 +26,6 @@ import chromePlatform, { containsClipboardPermissions as chromeClipContains, removeClipboardPermissions as chromeClipRemove, requestClipboardPermissions as chromeClipRequest, - containsHostPermissions as chromeHostContains, - requestHostPermissions as chromeHostRequest, - removeHostPermissions as chromeHostRemove, } from "./chrome.js"; const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { @@ -47,16 +44,8 @@ function isFirefox(): boolean { return true; } -function addPermissionsListener(callback: (p: Permissions) => void): void { - // throw Error("addPermissionListener is not supported for Firefox"); -} - function getPermissionsApi(): CrossBrowserPermissionsApi { return { - addPermissionsListener, - containsHostPermissions: chromeHostContains, - requestHostPermissions: chromeHostRequest, - removeHostPermissions: chromeHostRemove, containsClipboardPermissions: chromeClipContains, removeClipboardPermissions: chromeClipRemove, requestClipboardPermissions: chromeClipRequest, diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx index 23614e290..93770312e 100644 --- a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx @@ -105,7 +105,8 @@ function useComponentState({ if (state.hasError) { return { status: "error", - error: alertFromError(i18n.str`Could not load the balance`, state), + error: alertFromError( i18n, + i18n.str`Could not load the balance`, state), }; } if (addingAction) { @@ -153,7 +154,10 @@ export function BalanceView(state: State.Balances): VNode { const { i18n } = useTranslationContext(); const currencyWithNonZeroAmount = state.balances .filter((b) => !Amounts.isZero(b.available)) - .map((b) => b.available.split(":")[0]); + .map((b) => { + b.flags + return b.available.split(":")[0] + }); if (state.balances.length === 0) { return ( diff --git a/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx b/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx index 8d0e6876e..c698066e7 100644 --- a/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx +++ b/packages/taler-wallet-webextension/src/popup/NoBalanceHelp.tsx @@ -31,7 +31,8 @@ export function NoBalanceHelp({ goToWalletManualWithdraw: ButtonHandler; }): VNode { const { i18n } = useTranslationContext(); - return ( + return (<Fragment> + <Paper class={margin}> <Alert title={i18n.str`Your wallet is empty.`} severity="info"> <Button @@ -44,5 +45,9 @@ export function NoBalanceHelp({ </Button> </Alert> </Paper> + <a target="_bank" rel="noreferrer" href="https://demo.taler.net/" style={{ display: "block" }}> + <i18n.Translate>Try the demo bank and withdraw test money.</i18n.Translate> » + </a> + </Fragment> ); } diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx index a5b31b387..0388664b3 100644 --- a/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx @@ -34,10 +34,6 @@ export const WithdrawalAction = tests.createExample(TestedComponent, { url: "taler://withdraw/something", }); -export const TipAction = tests.createExample(TestedComponent, { - url: "taler://tip/something", -}); - export const NotifyAction = tests.createExample(TestedComponent, { url: "taler://notify-reserve/something", }); diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx index e120334e8..21373c7cd 100644 --- a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx +++ b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx @@ -65,26 +65,26 @@ function ContentByUriType({ </Button> </div> ); - case TalerUriAction.Reward: + + case TalerUriAction.Refund: return ( <div> <p> - <i18n.Translate>This page has a tip action.</i18n.Translate> + <i18n.Translate>This page has a refund action.</i18n.Translate> </p> <Button variant="contained" color="success" onClick={onConfirm}> - <i18n.Translate>Open tip page</i18n.Translate> + <i18n.Translate>Open refund page</i18n.Translate> </Button> </div> ); - - case TalerUriAction.Refund: + case TalerUriAction.AddExchange: return ( <div> <p> - <i18n.Translate>This page has a refund action.</i18n.Translate> + <i18n.Translate>This page has a add exchange action.</i18n.Translate> </p> <Button variant="contained" color="success" onClick={onConfirm}> - <i18n.Translate>Open refund page</i18n.Translate> + <i18n.Translate>Open add exchange page</i18n.Translate> </Button> </div> ); @@ -93,8 +93,6 @@ function ContentByUriType({ case TalerUriAction.PayPull: case TalerUriAction.PayPush: case TalerUriAction.Restore: - case TalerUriAction.Auditor: - case TalerUriAction.Exchange: return null; default: { const error: never = uri; diff --git a/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg b/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg new file mode 100644 index 000000000..d880cbf0f --- /dev/null +++ b/packages/taler-wallet-webextension/src/svg/search_24px.inline.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" height="24" width="24"> + <path fill-rule="evenodd" d="M10.5 3.75a6.75 6.75 0 1 0 0 13.5 6.75 6.75 0 0 0 0-13.5ZM2.25 10.5a8.25 8.25 0 1 1 14.59 5.28l4.69 4.69a.75.75 0 1 1-1.06 1.06l-4.69-4.69A8.25 8.25 0 0 1 2.25 10.5Z" clip-rule="evenodd" /> +</svg> + diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts index d1b1dc374..3b7cbcbb7 100644 --- a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts +++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts @@ -15,6 +15,7 @@ */ import { CoreApiResponse, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; +import type { MessageFromBackend } from "./platform/api.js"; /** * This will modify all the pages that the user load when navigating with Web Extension enabled @@ -46,6 +47,9 @@ const suffixIsNotXMLorPDF = const rootElementIsHTML = document.documentElement.nodeName && document.documentElement.nodeName.toLowerCase() === "html"; +// const pageAcceptsTalerSupport = document.head.querySelector( +// "meta[name=taler-support]", +// ); @@ -67,6 +71,7 @@ function convertURIToWebExtensionPath(uri: string) { const shouldNotInject = !documentDocTypeIsHTML || !suffixIsNotXMLorPDF || + // !pageAcceptsTalerSupport || !rootElementIsHTML; const logger = { @@ -93,16 +98,22 @@ function redirectToTalerActionHandler(element: HTMLMetaElement) { return; } - location.href = convertURIToWebExtensionPath(uri) + const walletPage = convertURIToWebExtensionPath(uri) + window.location.replace(walletPage) } -function injectTalerSupportScript(head: HTMLHeadElement) { +function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) { const meta = head.querySelector("meta[name=taler-support]") + if (!meta) return; + const content = meta.getAttribute("content"); + if (!content) return; + const features = content.split(",") - const debugEnabled = meta?.getAttribute("debug") === "true"; + const debugEnabled = meta.getAttribute("debug") === "true"; + const hijackEnabled = features.indexOf("uri") !== -1 + const talerApiEnabled = features.indexOf("api") !== -1 && trusted const scriptTag = document.createElement("script"); - scriptTag.setAttribute("async", "false"); const url = new URL( chrome.runtime.getURL("/dist/taler-wallet-interaction-support.js"), @@ -111,6 +122,12 @@ function injectTalerSupportScript(head: HTMLHeadElement) { if (debugEnabled) { url.searchParams.set("debug", "true"); } + if (talerApiEnabled) { + url.searchParams.set("api", "true"); + } + if (hijackEnabled) { + url.searchParams.set("hijack", "true"); + } scriptTag.src = url.href; try { @@ -123,12 +140,14 @@ function injectTalerSupportScript(head: HTMLHeadElement) { export interface ExtensionOperations { - isInjectionEnabled: { + isAutoOpenEnabled: { request: void; response: boolean; }; - isAutoOpenEnabled: { - request: void; + isDomainTrusted: { + request: { + domain: string; + }; response: boolean; }; } @@ -178,7 +197,11 @@ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( let timedout = false; const timerId = setTimeout(() => { timedout = true; - reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, {})) + reject(TalerError.fromDetail(TalerErrorCode.GENERIC_TIMEOUT, { + requestMethod: "wallet", + requestUrl: message.operation, + timeoutMs: 20 * 1000, + })) }, 20 * 1000); //five seconds try { chrome.runtime.sendMessage(messageWithId, (backgroundResponse) => { @@ -200,48 +223,89 @@ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( }); } +let notificationPort: chrome.runtime.Port | undefined; +function listenToWalletBackground(listener: (m: any) => void): () => void { + if (notificationPort === undefined) { + notificationPort = chrome.runtime.connect({ name: "notifications" }); + } + notificationPort.onMessage.addListener(listener); + function removeListener(): void { + if (notificationPort !== undefined) { + notificationPort.onMessage.removeListener(listener); + } + } + return removeListener; +} + +const loaderSettings = { + isAutoOpenEnabled: false, + isDomainTrusted: false, +} + function start( - onTalerMetaTagFound: (listener:(el: HTMLMetaElement)=>void) => void, - onHeadReady: (listener:(el: HTMLHeadElement)=>void) => void + onTalerMetaTagFound: (listener: (el: HTMLMetaElement) => void) => void, + onHeadReady: (listener: (el: HTMLHeadElement) => void) => void ) { - // do not run everywhere, this is just expected to run on html - // sites + // do not run everywhere, this is just expected to run on site + // that are aware of taler if (shouldNotInject) return; - const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined) - const isInjectionEnabled_promise = callBackground("isInjectionEnabled", undefined) + const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined).then(result => { + loaderSettings.isAutoOpenEnabled = result; + return result; + }) + const isDomainTrusted_promise = callBackground("isDomainTrusted", { + domain: window.location.origin + }).then(result => { + loaderSettings.isDomainTrusted = result; + return result; + }) - onTalerMetaTagFound(async (el)=> { - const enabled = await isAutoOpenEnabled_promise; - if (!enabled) return; + onTalerMetaTagFound(async (el) => { + await isAutoOpenEnabled_promise; + if (!loaderSettings.isAutoOpenEnabled) { + return; + } redirectToTalerActionHandler(el) }) onHeadReady(async (el) => { - const enabled = await isInjectionEnabled_promise; - if (!enabled) return; - injectTalerSupportScript(el) + const trusted = await isDomainTrusted_promise + injectTalerSupportScript(el, trusted) + }) + + listenToWalletBackground((e: MessageFromBackend) => { + if (e.type === "web-extension" && e.notification.type === "settings-change") { + const settings = e.notification.currentValue + loaderSettings.isAutoOpenEnabled = settings.autoOpen + } }) } +function isCorrectMetaElement(el: HTMLMetaElement): boolean { + const name = el.getAttribute("name") + if (!name) return false; + if (name !== "taler-uri") return false; + const uri = el.getAttribute("content"); + if (!uri) return false; + return true +} + /** * Tries to find taler meta tag ASAP and report * @param notify * @returns */ -function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { +function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) { if (document.head) { const element = document.head.querySelector("meta[name=taler-uri]") if (!element) return; if (!(element instanceof HTMLMetaElement)) return; - const name = element.getAttribute("name") - if (!name) return; - if (name !== "taler-uri") return; - const uri = element.getAttribute("content"); - if (!uri) return; - notify(element) + if (isCorrectMetaElement(element)) { + notify(element) + } return; } const obs = new MutationObserver(async function (mutations) { @@ -250,13 +314,10 @@ function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLMetaElement) { - const name = added.getAttribute("name") - if (!name) return; - if (name !== "taler-uri") return; - const uri = added.getAttribute("content"); - if (!uri) return; - notify(added) - obs.disconnect() + if (isCorrectMetaElement(added)) { + notify(added) + obs.disconnect() + } } }); } @@ -279,7 +340,7 @@ function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { * @param notify * @returns */ -function onHeaderReady(notify: (el: HTMLHeadElement) => void) { +function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) { if (document.head) { notify(document.head) return; @@ -290,7 +351,6 @@ function onHeaderReady(notify: (el: HTMLHeadElement) => void) { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLHeadElement) { - notify(added) obs.disconnect() } @@ -309,4 +369,4 @@ function onHeaderReady(notify: (el: HTMLHeadElement) => void) { }) } -start(onTalerMetaTag, onHeaderReady); +start(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound); diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts index b70ca2899..8b15380f9 100644 --- a/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts +++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts @@ -20,173 +20,181 @@ * This script will be loaded and run in every page while the * user us navigating. It must be short, simple and safe. */ +(() => { + const logger = { + debug: (...msg: any[]) => { }, + info: (...msg: any[]) => + console.log(`${new Date().toISOString()} TALER`, ...msg), + error: (...msg: any[]) => + console.error(`${new Date().toISOString()} TALER`, ...msg), + }; -const logger = { - debug: (...msg: any[]) => { }, - info: (...msg: any[]) => - console.log(`${new Date().toISOString()} TALER`, ...msg), - error: (...msg: any[]) => - console.error(`${new Date().toISOString()} TALER`, ...msg), -}; - -const documentDocTypeIsHTML = - window.document.doctype && window.document.doctype.name === "html"; -const suffixIsNotXMLorPDF = - !window.location.pathname.endsWith(".xml") && - !window.location.pathname.endsWith(".pdf"); -const rootElementIsHTML = - document.documentElement.nodeName && - document.documentElement.nodeName.toLowerCase() === "html"; -const pageAcceptsTalerSupport = document.head.querySelector( - "meta[name=taler-support]", -); - -// this is also checked by the loader -// but a double check will prevent running and breaking user navigation -// if loaded from other location -const shouldNotRun = - !documentDocTypeIsHTML || - !suffixIsNotXMLorPDF || - // !pageAcceptsTalerSupport || FIXME: removing this before release for testing - !rootElementIsHTML; - -interface Info { - extensionId: string; - protocol: string; - hostname: string; -} -interface API { - convertURIToWebExtensionPath: (uri: string) => string | undefined; - anchorOnClick: (ev: MouseEvent) => void; - registerProtocolHandler: () => void; -} -interface TalerSupport { - info: Readonly<Info>; - __internal: API; -} - -function buildApi(config: Readonly<Info>): API { - /** - * Takes an anchor href that starts with taler:// and - * returns the path to the web-extension page - */ - function convertURIToWebExtensionPath(uri: string): string | undefined { - if (!validateTalerUri(uri)) { - logger.error(`taler:// URI is invalid: ${uri}`); - return undefined; - } - const host = `${config.protocol}//${config.hostname}`; - const path = `static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`; - return `${host}/${path}`; + const documentDocTypeIsHTML = + window.document.doctype && window.document.doctype.name === "html"; + const suffixIsNotXMLorPDF = + !window.location.pathname.endsWith(".xml") && + !window.location.pathname.endsWith(".pdf"); + const rootElementIsHTML = + document.documentElement.nodeName && + document.documentElement.nodeName.toLowerCase() === "html"; + const pageAcceptsTalerSupport = document.head.querySelector( + "meta[name=taler-support]", + ); + + // this is also checked by the loader + // but a double check will prevent running and breaking user navigation + // if loaded from other location + const shouldNotRun = + !documentDocTypeIsHTML || + !suffixIsNotXMLorPDF || + !pageAcceptsTalerSupport || + !rootElementIsHTML; + + interface Info { + extensionId: string; + protocol: string; + hostname: string; + } + interface API { + convertURIToWebExtensionPath: (uri: string) => string | undefined; + anchorOnClick: (ev: MouseEvent) => void; + registerProtocolHandler: () => void; + } + interface TalerSupport { + info: Readonly<Info>; + __internal: API; } - function anchorOnClick(ev: MouseEvent) { - if (!(ev.currentTarget instanceof Element)) { - logger.debug(`onclick: registered in a link that is not an HTML element`); - return; + function buildApi(config: Readonly<Info>): API { + /** + * Takes an anchor href that starts with taler:// and + * returns the path to the web-extension page + */ + function convertURIToWebExtensionPath(uri: string): string | undefined { + if (!validateTalerUri(uri)) { + logger.error(`taler:// URI is invalid: ${uri}`); + return undefined; + } + const host = `${config.protocol}//${config.hostname}`; + const path = `static/wallet.html#/taler-uri/${encodeURIComponent(uri)}`; + return `${host}/${path}`; } - const hrefAttr = ev.currentTarget.attributes.getNamedItem("href"); - if (!hrefAttr) { - logger.debug(`onclick: link didn't have href with taler:// uri`); - return; + + function anchorOnClick(ev: MouseEvent) { + if (!(ev.currentTarget instanceof Element)) { + logger.debug(`onclick: registered in a link that is not an HTML element`); + return; + } + const hrefAttr = ev.currentTarget.attributes.getNamedItem("href"); + if (!hrefAttr) { + logger.debug(`onclick: link didn't have href with taler:// uri`); + return; + } + const targetAttr = ev.currentTarget.attributes.getNamedItem("target"); + const windowTarget = + targetAttr && targetAttr.value ? targetAttr.value : "_self"; + const page = convertURIToWebExtensionPath(hrefAttr.value); + if (!page) { + logger.debug(`onclick: could not convert "${hrefAttr.value}" into path`); + return; + } + // we can use window.open, but maybe some browser will block it? + window.open(page, windowTarget); + ev.preventDefault(); + ev.stopPropagation(); + ev.stopImmediatePropagation(); + return false; } - const targetAttr = ev.currentTarget.attributes.getNamedItem("target"); - const windowTarget = - targetAttr && targetAttr.value ? targetAttr.value : "_self"; - const page = convertURIToWebExtensionPath(hrefAttr.value); - if (!page) { - logger.debug(`onclick: could not convert "${hrefAttr.value}" into path`); - return; + + function overrideAllAnchor(root: HTMLElement) { + const allAnchors = root.querySelectorAll("a[href^=taler]"); + logger.debug(`registering taler protocol in ${allAnchors.length} links`); + allAnchors.forEach((link) => { + if (link instanceof HTMLElement) { + link.addEventListener("click", anchorOnClick); + } + }); } - // we can use window.open, but maybe some browser will block it? - window.open(page, windowTarget); - ev.preventDefault(); - ev.stopPropagation(); - ev.stopImmediatePropagation(); - return false; - } - function overrideAllAnchor(root: HTMLElement) { - const allAnchors = root.querySelectorAll("a[href^=taler]"); - logger.debug(`registering taler protocol in ${allAnchors.length} links`); - allAnchors.forEach((link) => { - if (link instanceof HTMLElement) { - link.addEventListener("click", anchorOnClick); - } - }); - } + function checkForNewAnchors( + mutations: MutationRecord[], + observer: MutationObserver, + ) { + mutations.forEach((mut) => { + if (mut.type === "childList") { + mut.addedNodes.forEach((added) => { + if (added instanceof HTMLElement) { + logger.debug(`new element`, added); + overrideAllAnchor(added); + } + }); + } + }); + } - function checkForNewAnchors( - mutations: MutationRecord[], - observer: MutationObserver, - ) { - mutations.forEach((mut) => { - if (mut.type === "childList") { - mut.addedNodes.forEach((added) => { - if (added instanceof HTMLElement) { - logger.debug(`new element`, added); - overrideAllAnchor(added); - } - }); - } - }); + /** + * Check of every anchor and observes for new one. + * Register the anchor handler when found + */ + function registerProtocolHandler() { + if (document.body) overrideAllAnchor(document.body) + new MutationObserver(checkForNewAnchors).observe(document, { + childList: true, + subtree: true, + attributes: false, + }); + } + + return { + convertURIToWebExtensionPath, + anchorOnClick, + registerProtocolHandler, + }; } - /** - * Check of every anchor and observes for new one. - * Register the anchor handler when found - */ - function registerProtocolHandler() { - overrideAllAnchor(document.body) - new MutationObserver(checkForNewAnchors).observe(document, { - childList: true, - subtree: true, - attributes: false, + function start() { + if (shouldNotRun) return; + if (!(document.currentScript instanceof HTMLScriptElement)) return; + + const url = new URL(document.currentScript.src); + const { protocol, searchParams, hostname } = url; + const extensionId = searchParams.get("id") ?? ""; + const debugEnabled = searchParams.get("debug") === "true"; + const apiEnabled = searchParams.get("api") === "true"; + const hijackEnabled = searchParams.get("hijack") === "true"; + + const info: Info = Object.freeze({ + extensionId, + protocol, + hostname, }); - } - return { - convertURIToWebExtensionPath, - anchorOnClick, - registerProtocolHandler, - }; -} - -function start() { - if (shouldNotRun) return; - // FIXME: we can remove this if the script caller send information we need - if (!(document.currentScript instanceof HTMLScriptElement)) return; - - const url = new URL(document.currentScript.src); - const { protocol, searchParams, hostname } = url; - const extensionId = searchParams.get("id") ?? ""; - const debugEnabled = searchParams.get("debug") === "true"; - if (debugEnabled) { - logger.debug = logger.info; - } + if (debugEnabled) { + logger.debug = logger.info; + } - const info: Info = Object.freeze({ - extensionId, - protocol, - hostname, - }); - const taler: TalerSupport = { - info, - __internal: buildApi(info), - }; + const taler: TalerSupport = { + info, + __internal: buildApi(info), + }; + + if (apiEnabled) { + //@ts-ignore + window.taler = taler; + } - //@ts-ignore - window.taler = taler; + if (hijackEnabled) { + taler.__internal.registerProtocolHandler(); + } + } - //default behavior: register on install - taler.__internal.registerProtocolHandler(); -} + // utils functions + function validateTalerUri(uri: string): boolean { + return ( + !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://")) + ); + } -// utils functions -function validateTalerUri(uri: string): boolean { - return ( - !!uri && (uri.startsWith("taler://") || uri.startsWith("taler+http://")) - ); -} + start(); +})() -start(); diff --git a/packages/taler-wallet-webextension/src/test-utils.ts b/packages/taler-wallet-webextension/src/test-utils.ts index e66693f53..452cc578e 100644 --- a/packages/taler-wallet-webextension/src/test-utils.ts +++ b/packages/taler-wallet-webextension/src/test-utils.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { NotificationType, TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util"; +import { NotificationType, TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient, WalletNotification } from "@gnu-taler/taler-util"; import { WalletCoreApiClient, WalletCoreOpKeys, @@ -46,7 +46,7 @@ interface MockHandler { getCallingQueueState(): "empty" | string; - notifyEventFromWallet(event: NotificationType): void; + notifyEventFromWallet(notif: WalletNotification): void; } type CallRecord = WalletCallRecord | BackgroundCallRecord; @@ -65,7 +65,7 @@ interface BackgroundCallRecord { } type Subscriptions = { - [key in NotificationType]?: VoidFunction; + [key in NotificationType]?: (d: WalletNotification) => void; }; export function createWalletApiMock(): { @@ -115,9 +115,12 @@ export function createWalletApiMock(): { }, }), listener: { + trigger: () => { + + }, onUpdateNotification( mTypes: NotificationType[], - callback: (() => void) | undefined, + callback: ((d: WalletNotification) => void) | undefined, ): () => void { mTypes.forEach((m) => { subscriptions[m] = callback; @@ -164,11 +167,11 @@ export function createWalletApiMock(): { }); return handler; }, - notifyEventFromWallet(event: NotificationType): void { - const callback = subscriptions[event]; + notifyEventFromWallet(event: WalletNotification): void { + const callback = subscriptions[event.type]; if (!callback) throw Error(`Expected to have a subscription for ${event}`); - return callback(); + return callback(event); }, getCallingQueueState() { return calls.length === 0 ? "empty" : `${calls.length} left`; @@ -187,7 +190,7 @@ export function createWalletApiMock(): { bankCore: new TalerCoreBankHttpClient("/"), bankIntegration: new TalerBankIntegrationHttpClient("/"), bankWire: new TalerWireGatewayHttpClient("/",""), - bankRevenue: new TalerRevenueHttpClient("/",""), + bankRevenue: new TalerRevenueHttpClient("/"), } children = create(ApiContextProvider, { value, children }, children); children = create( diff --git a/packages/taler-wallet-webextension/src/utils/index.ts b/packages/taler-wallet-webextension/src/utils/index.ts index ad4eabf15..d83e6f472 100644 --- a/packages/taler-wallet-webextension/src/utils/index.ts +++ b/packages/taler-wallet-webextension/src/utils/index.ts @@ -15,6 +15,7 @@ */ import { createElement, VNode } from "preact"; +import { useCallback, useMemo } from "preact/hooks"; function getJsonIfOk(r: Response): Promise<any> { if (r.ok) { @@ -26,8 +27,7 @@ function getJsonIfOk(r: Response): Promise<any> { } throw new Error( - `Try another server: (${r.status}) ${ - r.statusText || "internal server error" + `Try another server: (${r.status}) ${r.statusText || "internal server error" }`, ); } @@ -89,6 +89,7 @@ export function compose<SType extends { status: string }, PType>( ): (p: PType) => VNode { function withHook(stateHook: () => RecursiveState<SType>): () => VNode { function TheComponent(): VNode { + //if the function is the same, do not compute const state = stateHook(); if (typeof state === "function") { @@ -102,7 +103,9 @@ export function compose<SType extends { status: string }, PType>( } // TheComponent.name = `${name}`; - return TheComponent; + return useMemo(() => { + return TheComponent + }, [stateHook]); } return (p: PType) => { diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts index e0b79e060..daa6b425d 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts +++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/index.ts @@ -14,8 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TalerErrorDetail } from "@gnu-taler/taler-util"; -import { SyncTermsOfServiceResponse } from "@gnu-taler/taler-wallet-core"; +import { + SyncTermsOfServiceResponse, + TalerErrorDetail, +} from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; @@ -24,7 +26,7 @@ import { TextFieldHandler, ToggleHandler, } from "../../mui/handlers.js"; -import { compose, StateViewMap } from "../../utils/index.js"; +import { StateViewMap, compose } from "../../utils/index.js"; import { useComponentState } from "./state.js"; import { ConfirmProviderView, SelectProviderView } from "./views.js"; diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts index cf35abac7..75b8e53c0 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/state.ts @@ -14,11 +14,12 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { canonicalizeBaseUrl, Codec } from "@gnu-taler/taler-util"; import { + canonicalizeBaseUrl, + Codec, codecForSyncTermsOfServiceResponse, - WalletApiOperation, -} from "@gnu-taler/taler-wallet-core"; +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useEffect, useState } from "preact/hooks"; import { useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; @@ -98,42 +99,45 @@ function useUrlState<T>( } const constHref = href; - useDebounceEffect( - 500, - constHref == undefined - ? undefined - : async () => { - const req = await fetch(constHref).catch((e) => { - return setState({ - status: "network-error", - href: constHref, - }); - }); - if (!req) return; + async function checkURL() { + if (!constHref) { + return; + } + const req = await fetch(constHref).catch((e) => { + return setState({ + status: "network-error", + href: constHref, + }); + }); + if (!req) return; + + if (req.status >= 400 && req.status < 500) { + setState({ + status: "client-error", + code: req.status, + }); + return; + } + if (req.status > 500) { + setState({ + status: "server-error", + code: req.status, + }); + return; + } - if (req.status >= 400 && req.status < 500) { - setState({ - status: "client-error", - code: req.status, - }); - return; - } - if (req.status > 500) { - setState({ - status: "server-error", - code: req.status, - }); - return; - } + const json = await req.json(); + try { + const result = codec.decode(json); + setState({ status: "ok", result }); + } catch (e: any) { + setState({ status: "parsing-error", json }); + } + } - const json = await req.json(); - try { - const result = codec.decode(json); - setState({ status: "ok", result }); - } catch (e: any) { - setState({ status: "parsing-error", json }); - } - }, + useDebounceEffect( + 500, + constHref == undefined ? undefined : checkURL, [host, path], ); diff --git a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts index 598ca9369..058f4f460 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts +++ b/packages/taler-wallet-webextension/src/wallet/AddBackupProvider/test.ts @@ -32,7 +32,13 @@ const props: Props = { onPaymentRequired: nullFunction, }; describe("AddBackupProvider states", () => { - it("should start in 'select-provider' state", async () => { + /** + * FIXME: this test has inconsistent behavior. + * it should always expect one state but for some reason + * (maybe race condition) it sometime expect 1 update when + * it should no update + */ + it.skip("should start in 'select-provider' state", async () => { const { handler, TestingContext } = createWalletApiMock(); const hookBehavior = await tests.hookBehaveLikeThis( @@ -45,6 +51,13 @@ describe("AddBackupProvider states", () => { expect(state.name.value).eq(""); expect(state.url.value).eq(""); }, + //FIXME: this shouldn't take 2 updates, just + // (state) => { + // expect(state.status).equal("select-provider"); + // if (state.status !== "select-provider") return; + // expect(state.name.value).eq(""); + // expect(state.url.value).eq(""); + // }, ], TestingContext, ); diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts index 69f2a6028..94b32c157 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/index.ts @@ -14,15 +14,14 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpResponse } from "@gnu-taler/web-util/browser"; +import { OperationFailWithBody, OperationOk, TalerExchangeApi } from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; import { TextFieldHandler } from "../../mui/handlers.js"; -import { compose, StateViewMap } from "../../utils/index.js"; +import { StateViewMap, compose } from "../../utils/index.js"; import { useComponentState } from "./state.js"; -import { ConfirmView, VerifyView } from "./views.js"; -import { ExchangeListItem } from "@gnu-taler/taler-util"; +import { ConfirmAddExchangeView, VerifyView } from "./views.js"; export interface Props { currency?: string; @@ -35,6 +34,14 @@ export type State = State.Loading | State.Confirm | State.Verify; +export type CheckExchangeErrors = { + "invalid-version": string; + "invalid-currency": string; + "not-found": void; + "already-active": void; + "invalid-protocol": void; +} + export namespace State { export interface Loading { status: "loading"; @@ -64,8 +71,9 @@ export namespace State { onAccept: () => Promise<void>; url: TextFieldHandler, + loading: boolean; knownExchanges: URL[], - result: HttpResponse<{ currency_specification: { currency: string }, version: string }, unknown> | undefined, + result: OperationOk<TalerExchangeApi.ExchangeKeysResponse> | OperationFailWithBody<CheckExchangeErrors> | undefined, expectedCurrency: string | undefined, } } @@ -73,7 +81,7 @@ export namespace State { const viewMapping: StateViewMap<State> = { loading: Loading, error: ErrorAlertView, - confirm: ConfirmView, + confirm: ConfirmAddExchangeView, verify: VerifyView, }; diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts index 61f4308f4..4a04f762a 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/state.ts @@ -14,21 +14,37 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useState, useEffect, useCallback } from "preact/hooks"; -import { Props, State } from "./index.js"; -import { ExchangeEntryStatus, TalerCorebankApi, TalerExchangeApi, canonicalizeBaseUrl } from "@gnu-taler/taler-util"; +import { ExchangeEntryStatus, TalerExchangeHttpClient, canonicalizeBaseUrl, opKnownFailureWithBody } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser"; +import { useCallback, useEffect, useState } from "preact/hooks"; import { useBackendContext } from "../../context/backend.js"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { RecursiveState } from "../../utils/index.js"; -import { HttpResponse, useApiContext } from "@gnu-taler/web-util/browser"; -import { alertFromError } from "../../context/alert.js"; import { withSafe } from "../../mui/handlers.js"; +import { RecursiveState } from "../../utils/index.js"; +import { CheckExchangeErrors, Props, State } from "./index.js"; + +function urlFromInput(str: string): URL { + let result: URL; + try { + result = new URL(str) + } catch (original) { + try { + result = new URL(`https://${str}`) + } catch (e) { + throw original + } + } + if (!result.pathname.endsWith("/")) { + result.pathname = result.pathname + "/"; + } + result.search = ""; + result.hash = ""; + return result; +} export function useComponentState({ onBack, currency, noDebounce }: Props): RecursiveState<State> { - const [verified, setVerified] = useState< - { url: string; config: { currency_specification: {currency: string}, version: string} } | undefined - >(undefined); + const [verified, setVerified] = useState<string>(); const api = useBackendContext(); const hook = useAsyncAsHook(() => @@ -38,20 +54,49 @@ export function useComponentState({ onBack, currency, noDebounce }: Props): Recu const used = walletExchanges.filter(e => e.exchangeEntryStatus === ExchangeEntryStatus.Used); const preset = walletExchanges.filter(e => e.exchangeEntryStatus === ExchangeEntryStatus.Preset); - if (!verified) { return (): State => { - const { request } = useApiContext(); - const ccc = useCallback(async (str: string) => { - const c = canonicalizeBaseUrl(str) - const found = used.findIndex((e) => e.exchangeBaseUrl === c); + const checkExchangeBaseUrl_memo = useCallback(async function checkExchangeBaseUrl(str: string) { + const baseUrl = urlFromInput(str) + if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") { + return opKnownFailureWithBody<CheckExchangeErrors>("invalid-protocol", undefined) + } + const found = used.findIndex((e) => e.exchangeBaseUrl === baseUrl.href); if (found !== -1) { - throw Error("This exchange is already active") + return opKnownFailureWithBody<CheckExchangeErrors>("already-active", undefined); + } + + /** + * FIXME: For some reason typescript doesn't like the next BrowserFetchHttpLib + * + * │ src/wallet/AddExchange/state.ts(68,63): error TS2345: Argument of type 'BrowserFetchHttpLib' is not assignable to parameter of ty + * │ Types of property 'fetch' are incompatible. + * │ Type '(requestUrl: string, options?: HttpRequestOptions | undefined) => Promise<HttpResponse>' is not assignable to type '(ur + * │ Types of parameters 'options' and 'opt' are incompatible. + * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/http-common", { wi + * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/http-common", { + * │ Types of property 'cancellationToken' are incompatible. + * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/Cancellation + * │ Type 'import("$PATH/wallet.git/packages/taler-util/lib/Cancellati + * │ Types have separate declarations of a private property '_isCancelled'. + * + */ + const api = new TalerExchangeHttpClient(baseUrl.href, new BrowserFetchHttpLib() as any); + const config = await api.getConfig() + if (config.type === "fail") { + return opKnownFailureWithBody<CheckExchangeErrors>("not-found", undefined) } - const result = await request<{ currency_specification: {currency: string}, version: string}>(c, "/keys") - return result + if (!api.isCompatible(config.body.version)) { + return opKnownFailureWithBody<CheckExchangeErrors>("invalid-version", config.body.version) + } + if (currency !== undefined && currency !== config.body.currency) { + return opKnownFailureWithBody<CheckExchangeErrors>("invalid-currency", config.body.currency) + } + const keys = await api.getKeys() + return keys }, [used]) - const { result, value: url, update, error: requestError } = useDebounce<HttpResponse<{ currency_specification: {currency: string}, version: string}, unknown>>(ccc, noDebounce ?? false) + + const { result, value: url, loading, update, error: requestError } = useDebounce(checkExchangeBaseUrl_memo, noDebounce ?? false) const [inputError, setInputError] = useState<string>() return { @@ -60,10 +105,11 @@ export function useComponentState({ onBack, currency, noDebounce }: Props): Recu onCancel: onBack, expectedCurrency: currency, onAccept: async () => { - if (!url || !result || !result.ok) return; - setVerified({ url, config: result.data }) + if (!result || result.type !== "ok") return; + setVerified(result.body.base_url) }, result, + loading, knownExchanges: preset.map(e => new URL(e.exchangeBaseUrl)), url: { value: url ?? "", @@ -79,7 +125,7 @@ export function useComponentState({ onBack, currency, noDebounce }: Props): Recu async function onConfirm() { if (!verified) return; await api.wallet.call(WalletApiOperation.AddExchange, { - exchangeBaseUrl: canonicalizeBaseUrl(verified.url), + exchangeBaseUrl: canonicalizeBaseUrl(verified), forceUpdate: true, }); onBack(); @@ -90,7 +136,7 @@ export function useComponentState({ onBack, currency, noDebounce }: Props): Recu error: undefined, onCancel: onBack, onConfirm, - url: verified.url + url: verified }; } @@ -101,7 +147,7 @@ function useDebounce<T>( disabled: boolean, ): { loading: boolean; - error?: string; + error?: Error; value: string | undefined; result: T | undefined; update: (s: string) => void; @@ -110,9 +156,9 @@ function useDebounce<T>( const [dirty, setDirty] = useState(false); const [loading, setLoading] = useState(false); const [result, setResult] = useState<T | undefined>(undefined); - const [error, setError] = useState<string | undefined>(undefined); + const [error, setError] = useState<Error | undefined>(undefined); - const [handler, setHandler] = useState<any | undefined>(undefined); + const [handler, setHandler] = useState<number | undefined>(undefined); if (!disabled) { useEffect(() => { @@ -126,15 +172,18 @@ function useDebounce<T>( setResult(result); setError(undefined); setLoading(false); - } catch (e) { - const errorMessage = - e instanceof Error ? e.message : `unknown error: ${e}`; - setError(errorMessage); + } catch (er) { + if (er instanceof Error) { + setError(er); + } else { + // @ts-expect-error cause still not in typescript + setError(new Error('unkown error on debounce', { cause: er })) + } setLoading(false); setResult(undefined); } }, 500); - setHandler(h); + setHandler(h as unknown as number); }, [value, setHandler, onTrigger]); } @@ -143,7 +192,7 @@ function useDebounce<T>( loading: loading, result: result, value: value, - update: disabled ? onTrigger : setValue , + update: disabled ? onTrigger : setValue, }; } diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx index 4e2610743..f205b6415 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/stories.tsx @@ -19,8 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import * as tests from "@gnu-taler/web-util/testing"; -import { ConfirmView, VerifyView } from "./views.js"; export default { title: "example", diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts b/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts index f17872779..d0e78a94e 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/test.ts @@ -19,18 +19,19 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { expect } from "chai"; -import { createWalletApiMock } from "../../test-utils.js"; -import * as tests from "@gnu-taler/web-util/testing"; -import { Props } from "./index.js"; -import { useComponentState } from "./state.js"; -import { nullFunction } from "../../mui/handlers.js"; -import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { ExchangeEntryStatus, ExchangeTosStatus, ExchangeUpdateStatus, + ScopeType, } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; +import { nullFunction } from "../../mui/handlers.js"; +import { createWalletApiMock } from "../../test-utils.js"; +import { Props } from "./index.js"; +import { useComponentState } from "./state.js"; const props: Props = { onBack: nullFunction, noDebounce: true, @@ -48,12 +49,20 @@ describe("AddExchange states", () => { { exchangeBaseUrl: "http://exchange.local/", ageRestrictionOptions: [], - scopeInfo: undefined, + scopeInfo: { + currency: "ARS", + type: ScopeType.Exchange, + url: "http://exchange.local/", + }, + masterPub: "123qwe123", currency: "ARS", exchangeEntryStatus: ExchangeEntryStatus.Ephemeral, tosStatus: ExchangeTosStatus.Pending, exchangeUpdateStatus: ExchangeUpdateStatus.UnavailableUpdate, paytoUris: [], + lastUpdateTimestamp: undefined, + noFees: false, + peerPaymentsDisabled: false, }, ], }, @@ -85,116 +94,116 @@ describe("AddExchange states", () => { expect(handler.getCallingQueueState()).eq("empty"); }); - it("should not be able to add a known exchange", async () => { - const { handler, TestingContext } = createWalletApiMock(); - - handler.addWalletCallResponse( - WalletApiOperation.ListExchanges, - {}, - { - exchanges: [ - { - exchangeBaseUrl: "http://exchange.local/", - ageRestrictionOptions: [], - scopeInfo: undefined, - currency: "ARS", - exchangeEntryStatus: ExchangeEntryStatus.Used, - tosStatus: ExchangeTosStatus.Pending, - exchangeUpdateStatus: ExchangeUpdateStatus.Ready, - paytoUris: [], - }, - ], - }, - ); - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - (state) => { - expect(state.status).equal("verify"); - if (state.status !== "verify") return; - expect(state.url.value).eq(""); - expect(state.expectedCurrency).is.undefined; - expect(state.result).is.undefined; - }, - (state) => { - expect(state.status).equal("verify"); - if (state.status !== "verify") return; - expect(state.url.value).eq(""); - expect(state.expectedCurrency).is.undefined; - expect(state.result).is.undefined; - expect(state.error).is.undefined; - expect(state.url.onInput).is.not.undefined; - if (!state.url.onInput) return; - state.url.onInput("http://exchange.local/"); - }, - (state) => { - expect(state.status).equal("verify"); - if (state.status !== "verify") return; - expect(state.url.value).eq(""); - expect(state.expectedCurrency).is.undefined; - expect(state.result).is.undefined; - expect(state.url.error).eq("This exchange is already active"); - expect(state.url.onInput).is.not.undefined; - }, - ], - TestingContext, - ); - - expect(hookBehavior).deep.equal({ result: "ok" }); - expect(handler.getCallingQueueState()).eq("empty"); - }); - - it("should be able to add a preset exchange", async () => { - const { handler, TestingContext } = createWalletApiMock(); - - handler.addWalletCallResponse( - WalletApiOperation.ListExchanges, - {}, - { - exchanges: [ - { - exchangeBaseUrl: "http://exchange.local/", - ageRestrictionOptions: [], - scopeInfo: undefined, - currency: "ARS", - exchangeEntryStatus: ExchangeEntryStatus.Preset, - tosStatus: ExchangeTosStatus.Pending, - exchangeUpdateStatus: ExchangeUpdateStatus.Ready, - paytoUris: [], - }, - ], - }, - ); - - const hookBehavior = await tests.hookBehaveLikeThis( - useComponentState, - props, - [ - (state) => { - expect(state.status).equal("verify"); - if (state.status !== "verify") return; - expect(state.url.value).eq(""); - expect(state.expectedCurrency).is.undefined; - expect(state.result).is.undefined; - }, - (state) => { - expect(state.status).equal("verify"); - if (state.status !== "verify") return; - expect(state.url.value).eq(""); - expect(state.expectedCurrency).is.undefined; - expect(state.result).is.undefined; - expect(state.error).is.undefined; - expect(state.url.onInput).is.not.undefined; - if (!state.url.onInput) return; - state.url.onInput("http://exchange.local/"); - }, - ], - TestingContext, - ); - - expect(hookBehavior).deep.equal({ result: "ok" }); - expect(handler.getCallingQueueState()).eq("empty"); - }); + // it("should not be able to add a known exchange", async () => { + // const { handler, TestingContext } = createWalletApiMock(); + + // handler.addWalletCallResponse( + // WalletApiOperation.ListExchanges, + // {}, + // { + // exchanges: [ + // { + // exchangeBaseUrl: "http://exchange.local/", + // ageRestrictionOptions: [], + // scopeInfo: undefined, + // currency: "ARS", + // exchangeEntryStatus: ExchangeEntryStatus.Used, + // tosStatus: ExchangeTosStatus.Pending, + // exchangeUpdateStatus: ExchangeUpdateStatus.Ready, + // paytoUris: [], + // }, + // ], + // }, + // ); + + // const hookBehavior = await tests.hookBehaveLikeThis( + // useComponentState, + // props, + // [ + // (state) => { + // expect(state.status).equal("verify"); + // if (state.status !== "verify") return; + // expect(state.url.value).eq(""); + // expect(state.expectedCurrency).is.undefined; + // expect(state.result).is.undefined; + // }, + // (state) => { + // expect(state.status).equal("verify"); + // if (state.status !== "verify") return; + // expect(state.url.value).eq(""); + // expect(state.expectedCurrency).is.undefined; + // expect(state.result).is.undefined; + // expect(state.error).is.undefined; + // expect(state.url.onInput).is.not.undefined; + // if (!state.url.onInput) return; + // state.url.onInput("http://exchange.local/"); + // }, + // (state) => { + // expect(state.status).equal("verify"); + // if (state.status !== "verify") return; + // expect(state.url.value).eq(""); + // expect(state.expectedCurrency).is.undefined; + // expect(state.result).is.undefined; + // expect(state.url.error).eq("This exchange is already active"); + // expect(state.url.onInput).is.not.undefined; + // }, + // ], + // TestingContext, + // ); + + // expect(hookBehavior).deep.equal({ result: "ok" }); + // expect(handler.getCallingQueueState()).eq("empty"); + // }); + + // it("should be able to add a preset exchange", async () => { + // const { handler, TestingContext } = createWalletApiMock(); + + // handler.addWalletCallResponse( + // WalletApiOperation.ListExchanges, + // {}, + // { + // exchanges: [ + // { + // exchangeBaseUrl: "http://exchange.local/", + // ageRestrictionOptions: [], + // scopeInfo: undefined, + // currency: "ARS", + // exchangeEntryStatus: ExchangeEntryStatus.Preset, + // tosStatus: ExchangeTosStatus.Pending, + // exchangeUpdateStatus: ExchangeUpdateStatus.Ready, + // paytoUris: [], + // }, + // ], + // }, + // ); + + // const hookBehavior = await tests.hookBehaveLikeThis( + // useComponentState, + // props, + // [ + // (state) => { + // expect(state.status).equal("verify"); + // if (state.status !== "verify") return; + // expect(state.url.value).eq(""); + // expect(state.expectedCurrency).is.undefined; + // expect(state.result).is.undefined; + // }, + // (state) => { + // expect(state.status).equal("verify"); + // if (state.status !== "verify") return; + // expect(state.url.value).eq(""); + // expect(state.expectedCurrency).is.undefined; + // expect(state.result).is.undefined; + // expect(state.error).is.undefined; + // expect(state.url.onInput).is.not.undefined; + // if (!state.url.onInput) return; + // state.url.onInput("http://exchange.local/"); + // }, + // ], + // TestingContext, + // ); + + // expect(hookBehavior).deep.equal({ result: "ok" }); + // expect(handler.getCallingQueueState()).eq("empty"); + // }); }); diff --git a/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx b/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx index 53a46fe02..f6537bc68 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/AddExchange/views.tsx @@ -16,19 +16,25 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; import { ErrorMessage } from "../../components/ErrorMessage.js"; -import { Input, LightText, SubTitle, Title, WarningBox } from "../../components/styled/index.js"; +import { + Input, + LightText, + SubTitle, + Title, + WarningBox, +} from "../../components/styled/index.js"; import { TermsOfService } from "../../components/TermsOfService/index.js"; import { Button } from "../../mui/Button.js"; import { State } from "./index.js"; - +import { assertUnreachable } from "@gnu-taler/taler-util"; export function VerifyView({ expectedCurrency, onCancel, onAccept, result, + loading, knownExchanges, url, }: State.Verify): VNode { @@ -53,29 +59,74 @@ export function VerifyView({ </i18n.Translate> </LightText> )} - {result && ( - <LightText> - <i18n.Translate> - An exchange has been found! Review the information and click next - </i18n.Translate> - </LightText> - )} - {result && result.ok && expectedCurrency && expectedCurrency !== result.data.currency_specification.currency && ( - <WarningBox> - <i18n.Translate> - This exchange doesn't match the expected currency - <b>{expectedCurrency}</b> - </i18n.Translate> - </WarningBox> - )} - {result && !result.ok && !result.loading && ( - <ErrorMessage - title={i18n.str`Unable to verify this exchange`} - description={result.message} - /> - )} + {(() => { + if (!result) return; + if (result.type == "ok") { + return ( + <LightText> + <i18n.Translate> + An exchange has been found! Review the information and click + next + </i18n.Translate> + </LightText> + ); + } + switch (result.case) { + case "already-active": { + return ( + <WarningBox> + <i18n.Translate> + This exchange is already in your list. + </i18n.Translate> + </WarningBox> + ); + } + case "invalid-protocol": { + return ( + <WarningBox> + <i18n.Translate> + Only exchange accessible through "http" and "https" are + allowed. + </i18n.Translate> + </WarningBox> + ); + } + case "invalid-version": { + return ( + <WarningBox> + <i18n.Translate> + This exchange protocol version is not supported: " + {result.body}". + </i18n.Translate> + </WarningBox> + ); + } + case "invalid-currency": { + return ( + <WarningBox> + <i18n.Translate> + This exchange currency "{result.body}" doesn't match + the expected currency {expectedCurrency}. + </i18n.Translate> + </WarningBox> + ); + } + case "not-found": { + return ( + <WarningBox> + <i18n.Translate> + No exchange found in that URL. + </i18n.Translate> + </WarningBox> + ); + } + default: { + assertUnreachable(result.case); + } + } + })()} <p> - <Input invalid={result && !result.ok} > + <Input invalid={result && result.type !== "ok"}> <label>URL</label> <input type="text" @@ -83,36 +134,36 @@ export function VerifyView({ value={url.value} onInput={(e) => { if (url.onInput) { - url.onInput(e.currentTarget.value) + url.onInput(e.currentTarget.value); } }} /> </Input> - {result && result.loading && ( + {loading && ( <div> <i18n.Translate>loading</i18n.Translate>... </div> )} - {result && result.ok && !result.loading && ( + {result && result.type === "ok" && ( <Fragment> <Input> <label> <i18n.Translate>Version</i18n.Translate> </label> - <input type="text" disabled value={result.data.version} /> + <input type="text" disabled value={result.body.version} /> </Input> <Input> <label> <i18n.Translate>Currency</i18n.Translate> </label> - <input type="text" disabled value={result.data.currency_specification.currency} /> + <input type="text" disabled value={result.body.currency} /> </Input> </Fragment> )} </p> - {url.error && ( + {url.value && url.error && ( <ErrorMessage - title={i18n.str`Can't use this URL`} + title={i18n.str`Can't use the URL: "${url.value}"`} description={url.error} /> )} @@ -123,12 +174,7 @@ export function VerifyView({ </Button> <Button variant="contained" - disabled={ - !result || - result.loading || - !result.ok || - (!!expectedCurrency && expectedCurrency !== result.data.currency_specification.currency) - } + disabled={!result || result.type !== "ok"} onClick={onAccept} > <i18n.Translate>Next</i18n.Translate> @@ -136,14 +182,22 @@ export function VerifyView({ </footer> <section> <ul> - {knownExchanges.map(ex => { - return <li><a href="#" onClick={(e) => { - if (url.onInput) { - url.onInput(ex.href) - } - e.preventDefault() - }}> - {ex.href}</a></li> + {knownExchanges.map((ex) => { + return ( + <li key={ex.href}> + <a + href="#" + onClick={(e) => { + if (url.onInput) { + url.onInput(ex.href); + } + e.preventDefault(); + }} + > + {ex.href} + </a> + </li> + ); })} </ul> </section> @@ -151,8 +205,7 @@ export function VerifyView({ ); } - -export function ConfirmView({ +export function ConfirmAddExchangeView({ url, onCancel, onConfirm, @@ -173,8 +226,7 @@ export function ConfirmView({ </div> </section> - - <TermsOfService key="terms" exchangeUrl={url} > + <TermsOfService key="terms" exchangeUrl={url}> <footer> <Button key="cancel" diff --git a/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx b/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx index fc3a0916c..dd1777fd1 100644 --- a/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx +++ b/packages/taler-wallet-webextension/src/wallet/AddNewActionView.tsx @@ -66,8 +66,6 @@ export function AddNewActionView({ onCancel }: Props): VNode { return <i18n.Translate>Open pay page</i18n.Translate>; case TalerUriAction.Refund: return <i18n.Translate>Open refund page</i18n.Translate>; - case TalerUriAction.Reward: - return <i18n.Translate>Open tip page</i18n.Translate>; case TalerUriAction.Withdraw: return <i18n.Translate>Open withdraw page</i18n.Translate>; } diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx index df0e968b9..884c2eab7 100644 --- a/packages/taler-wallet-webextension/src/wallet/Application.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Application.tsx @@ -23,7 +23,9 @@ import { Amounts, TalerUri, + TalerUriAction, TranslatedString, + parseTalerUri, stringifyTalerUri, } from "@gnu-taler/taler-util"; import { @@ -59,7 +61,6 @@ import { PaymentPage } from "../cta/Payment/index.js"; import { PaymentTemplatePage } from "../cta/PaymentTemplate/index.js"; import { RecoveryPage } from "../cta/Recovery/index.js"; import { RefundPage } from "../cta/Refund/index.js"; -import { TipPage } from "../cta/Reward/index.js"; import { TransferCreatePage } from "../cta/TransferCreate/index.js"; import { TransferPickupPage } from "../cta/TransferPickup/index.js"; import { @@ -82,6 +83,10 @@ import { QrReaderPage } from "./QrReader.js"; import { SettingsPage } from "./Settings.js"; import { TransactionPage } from "./Transaction.js"; import { WelcomePage } from "./Welcome.js"; +import { WalletActivity } from "../components/WalletActivity.js"; +import { EnabledBySettings } from "../components/EnabledBySettings.js"; +import { DevExperimentPage } from "../cta/DevExperiment/index.js"; +import { ConfirmAddExchangeView } from "./AddExchange/views.js"; export function Application(): VNode { const { i18n } = useTranslationContext(); @@ -152,7 +157,7 @@ export function Application(): VNode { )} /> - <Route +<Route path={Pages.balanceHistory.pattern} component={({ currency }: { currency?: string }) => ( <WalletTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}> @@ -173,6 +178,27 @@ export function Application(): VNode { )} /> <Route + path={Pages.searchHistory.pattern} + component={({ currency }: { currency?: string }) => ( + <WalletTemplate path="balance" goToTransaction={redirectToTxInfo} goToURL={redirectToURL}> + <HistoryPage + currency={currency} + search + goToWalletDeposit={(currency: string) => + redirectTo(Pages.sendCash({ amount: `${currency}:0` })) + } + goToWalletManualWithdraw={(currency?: string) => + redirectTo( + Pages.receiveCash({ + amount: !currency ? undefined : `${currency}:0`, + }), + ) + } + /> + </WalletTemplate> + )} + /> + <Route path={Pages.sendCash.pattern} component={({ amount }: { amount?: string }) => ( <WalletTemplate path="balance" goToURL={redirectToURL}> @@ -366,20 +392,6 @@ export function Application(): VNode { )} /> <Route - path={Pages.ctaTips} - component={({ talerUri }: { talerUri: string }) => ( - <CallToActionTemplate title={i18n.str`Digital cash tip`}> - <TipPage - talerTipUri={decodeURIComponent(talerUri)} - onCancel={() => redirectTo(Pages.balance)} - onSuccess={(tid: string) => - redirectTo(Pages.balanceTransaction({ tid })) - } - /> - </CallToActionTemplate> - )} - /> - <Route path={Pages.ctaWithdraw} component={({ talerUri }: { talerUri: string }) => ( <CallToActionTemplate title={i18n.str`Digital cash withdrawal`}> @@ -510,7 +522,40 @@ export function Application(): VNode { </CallToActionTemplate> )} /> - + <Route + path={Pages.ctaExperiment} + component={({ talerUri }: { talerUri: string }) => ( + <CallToActionTemplate title={i18n.str`Development experiment`}> + <DevExperimentPage + talerExperimentUri={decodeURIComponent(talerUri)} + onCancel={() => redirectTo(Pages.balanceHistory({}))} + onSuccess={() => redirectTo(Pages.balanceHistory({}))} + /> + </CallToActionTemplate> + )} + /> + <Route + path={Pages.ctaAddExchange} + component={({ talerUri }: { talerUri: string }) => { + const tUri = parseTalerUri(decodeURIComponent(talerUri)) + const baseUrl = tUri?.type === TalerUriAction.AddExchange ? tUri.exchangeBaseUrl : undefined + if (!baseUrl) { + redirectTo(Pages.balanceHistory({})) + return <div> + invalid url {talerUri} + </div> + } + return <CallToActionTemplate title={i18n.str`Add exchange`}> + <ConfirmAddExchangeView + url={baseUrl} + status="confirm" + error={undefined} + onCancel={() => redirectTo(Pages.balanceHistory({}))} + onConfirm={() => redirectTo(Pages.balanceHistory({}))} + /> + </CallToActionTemplate> + }} + /> {/** * NOT FOUND * all redirects should be at the end @@ -525,6 +570,9 @@ export function Application(): VNode { component={() => <Redirect to={Pages.balanceHistory({})} />} /> </Router> + <EnabledBySettings name="showWalletActivity"> + <WalletActivity /> + </EnabledBySettings> </IoCProviderForRuntime> </TranslationProvider> ); @@ -541,17 +589,17 @@ function Redirect({ to }: { to: string }): null { return null; } -function matchesRoute(url: string, route: string): boolean { - type MatcherFunc = ( - url: string, - route: string, - opts: any, - ) => Record<string, string> | false; +// function matchesRoute(url: string, route: string): boolean { +// type MatcherFunc = ( +// url: string, +// route: string, +// opts: any, +// ) => Record<string, string> | false; - const internalPreactMatcher: MatcherFunc = (Router as any).exec; - const result = internalPreactMatcher(url, route, {}); - return !result ? false : true; -} +// const internalPreactMatcher: MatcherFunc = (Router as any).exec; +// const result = internalPreactMatcher(url, route, {}); +// return !result ? false : true; +// } function CallToActionTemplate({ title, diff --git a/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx index ae160a30c..cc7c9af67 100644 --- a/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx @@ -19,19 +19,18 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; -import { addDays } from "date-fns"; -import { - BackupView as TestedComponent, - ShowRecoveryInfo, -} from "./BackupPage.js"; -import * as tests from "@gnu-taler/web-util/testing"; import { AbsoluteTime, AmountString, + ProviderPaymentType, TalerPreciseTimestamp, - TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { addDays } from "date-fns"; +import { + ShowRecoveryInfo, + BackupView as TestedComponent, +} from "./BackupPage.js"; export default { title: "backup", diff --git a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx index 5ae52db6f..8a3710f69 100644 --- a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx @@ -16,23 +16,22 @@ import { AbsoluteTime, - constructRecoveryUri, - stringifyRestoreUri, -} from "@gnu-taler/taler-util"; -import { ProviderInfo, ProviderPaymentPaid, ProviderPaymentStatus, ProviderPaymentType, - WalletApiOperation, -} from "@gnu-taler/taler-wallet-core"; + stringifyRestoreUri, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { differenceInMonths, formatDuration, intervalToDuration, } from "date-fns"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; +import { Pages } from "../NavigationBar.js"; import { ErrorAlertView } from "../components/CurrentAlerts.js"; import { Loading } from "../components/Loading.js"; import { QR } from "../components/QR.js"; @@ -48,10 +47,8 @@ import { } from "../components/styled/index.js"; import { alertFromError } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { Button } from "../mui/Button.js"; -import { Pages } from "../NavigationBar.js"; interface Props { onAddProvider: () => Promise<void>; @@ -124,6 +121,7 @@ export function BackupPage({ onAddProvider }: Props): VNode { return ( <ErrorAlertView error={alertFromError( + i18n, i18n.str`Could not load backup providers`, status, )} @@ -325,12 +323,12 @@ function daysUntil(d: AbsoluteTime): string { duration?.years ? "years" : duration?.months - ? "months" - : duration?.days - ? "days" - : duration.hours - ? "hours" - : "minutes", + ? "months" + : duration?.days + ? "days" + : duration.hours + ? "hours" + : "minutes", ], }); return `${str}`; @@ -353,6 +351,6 @@ function getStatusPaidOrder( return a.paidUntil.t_ms === "never" ? -1 : b.paidUntil.t_ms === "never" - ? 1 - : a.paidUntil.t_ms - b.paidUntil.t_ms; + ? 1 + : a.paidUntil.t_ms - b.paidUntil.t_ms; } diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts index 8c773186e..97b2ab517 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts @@ -60,8 +60,8 @@ export function useComponentState({ parsed !== undefined ? parsed : currency !== undefined - ? Amounts.zeroOfCurrency(currency) - : undefined; + ? Amounts.zeroOfCurrency(currency) + : undefined; // const [accountIdx, setAccountIdx] = useState<number>(0); const [selectedAccount, setSelectedAccount] = useState<PaytoUri>(); @@ -83,7 +83,8 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", - error: alertFromError(i18n.str`Could not load balance information`, hook), + error: alertFromError(i18n, + i18n.str`Could not load balance information`, hook), }; } const { accounts, balances } = hook.response; @@ -169,6 +170,7 @@ export function useComponentState({ return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load fee for amount ${amountStr}`, hook, ), @@ -193,8 +195,8 @@ export function useComponentState({ const amountError = !isDirty ? undefined : Amounts.cmp(balance, amount) === -1 - ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}` - : undefined; + ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}` + : undefined; const unableToDeposit = Amounts.isZero(totalToDeposit) || //deposit may be zero because of fee diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts index a5d44e872..d4e270a6c 100644 --- a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/state.ts @@ -52,22 +52,22 @@ export function useComponentState(props: Props): RecursiveState<State> { const previous: Contact[] = true ? [] : [ - { - name: "International Bank", - icon_type: "bank", - description: "account ending with 3454", - }, - { - name: "Max", - icon_type: "bank", - description: "account ending with 3454", - }, - { - name: "Alex", - icon_type: "bank", - description: "account ending with 3454", - }, - ]; + { + name: "International Bank", + icon_type: "bank", + description: "account ending with 3454", + }, + { + name: "Max", + icon_type: "bank", + description: "account ending with 3454", + }, + { + name: "Alex", + icon_type: "bank", + description: "account ending with 3454", + }, + ]; if (!amount) { return () => { @@ -87,7 +87,8 @@ export function useComponentState(props: Props): RecursiveState<State> { if (hook.hasError) { return { status: "error", - error: alertFromError(i18n.str`Could not load exchanges`, hook), + error: alertFromError(i18n, + i18n.str`Could not load exchanges`, hook), }; } const currencies: Record<string, string> = {}; @@ -127,8 +128,8 @@ export function useComponentState(props: Props): RecursiveState<State> { onClick: invalid ? undefined : pushAlertOnError(async () => { - props.goToWalletBankDeposit(currencyAndAmount); - }), + props.goToWalletBankDeposit(currencyAndAmount); + }), }, selectMax: { onClick: pushAlertOnError(async () => { @@ -145,8 +146,8 @@ export function useComponentState(props: Props): RecursiveState<State> { onClick: invalid ? undefined : pushAlertOnError(async () => { - props.goToWalletWalletSend(currencyAndAmount); - }), + props.goToWalletWalletSend(currencyAndAmount); + }), }, amountHandler: { onInput: pushAlertOnError(async (s) => setAmount(s)), @@ -168,22 +169,22 @@ export function useComponentState(props: Props): RecursiveState<State> { onClick: invalid ? undefined : pushAlertOnError(async () => { - props.goToWalletManualWithdraw(currencyAndAmount); - }), + props.goToWalletManualWithdraw(currencyAndAmount); + }), }, goToBank: { onClick: invalid ? undefined : pushAlertOnError(async () => { - props.goToWalletManualWithdraw(currencyAndAmount); - }), + props.goToWalletManualWithdraw(currencyAndAmount); + }), }, goToWallet: { onClick: invalid ? undefined : pushAlertOnError(async () => { - props.goToWalletWalletInvoice(currencyAndAmount); - }), + props.goToWalletWalletInvoice(currencyAndAmount); + }), }, amountHandler: { onInput: pushAlertOnError(async (s) => setAmount(s)), diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts index d42a3477d..683378613 100644 --- a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/test.ts @@ -25,10 +25,11 @@ import { ExchangeListItem, ExchangeTosStatus, ExchangeUpdateStatus, + ScopeType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { expect } from "chai"; import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; import { nullFunction } from "../../mui/handlers.js"; import { createWalletApiMock } from "../../test-utils.js"; import { useComponentState } from "./state.js"; @@ -36,12 +37,20 @@ import { useComponentState } from "./state.js"; const exchangeArs: ExchangeListItem = { currency: "ARS", exchangeBaseUrl: "http://", - scopeInfo: undefined, + masterPub: "123qwe123", + scopeInfo: { + currency: "ARS", + type: ScopeType.Exchange, + url: "http://", + }, tosStatus: ExchangeTosStatus.Accepted, exchangeEntryStatus: ExchangeEntryStatus.Used, exchangeUpdateStatus: ExchangeUpdateStatus.Initial, paytoUris: [], ageRestrictionOptions: [], + lastUpdateTimestamp: undefined, + noFees: false, + peerPaymentsDisabled: false, }; describe("Destination selection states", () => { diff --git a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx index f8e2c6707..8a74a20f1 100644 --- a/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DestinationSelection/views.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { styled } from "@linaria/react"; import { Fragment, h, VNode } from "preact"; import { AmountField } from "../../components/AmountField.js"; @@ -25,7 +26,6 @@ import { LinkPrimary, SvgIcon, } from "../../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Button } from "../../mui/Button.js"; import { Grid } from "../../mui/Grid.js"; import { Paper } from "../../mui/Paper.js"; @@ -34,8 +34,6 @@ import arrowIcon from "../../svg/chevron-down.inline.svg"; import bankIcon from "../../svg/ri-bank-line.inline.svg"; import { assertUnreachable } from "../../utils/index.js"; import { Contact, State } from "./index.js"; -import { useEffect } from "preact/hooks"; -import { Checkbox } from "../../components/Checkbox.js"; export function SelectCurrencyView({ currencies, @@ -171,7 +169,9 @@ const CircleDiv = styled.div` text-align: center; text-decoration: none; text-transform: uppercase; - transition: background-color 0.15s ease, border-color 0.15s ease, + transition: + background-color 0.15s ease, + border-color 0.15s ease, color 0.15s ease; font-size: 16px; background-color: #86a7bd1a; @@ -277,6 +277,16 @@ export function ReadyGetView({ </Button> </Paper> </Grid> + <Grid item xs={1}> + <Paper style={{ padding: 8 }}> + <p> + <i18n.Translate>From a <pre style={{display:"inline"}}>taler://peer-push-credit</pre> URI</i18n.Translate> + </p> + <a href={Pages.qr}> + <i18n.Translate>Enter URI here</i18n.Translate> + </a> + </Paper> + </Grid> </Grid> </Grid> </Container> @@ -303,7 +313,7 @@ export function ReadySendView({ required handler={amountHandler} /> - <EnabledBySettings name="advanceMode"> + <EnabledBySettings name="advancedMode"> <Button onClick={selectMax.onClick}> <i18n.Translate>Send all</i18n.Translate> </Button> diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx index 2ca5305f5..e7c9111fd 100644 --- a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.stories.tsx @@ -19,10 +19,9 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { PendingTaskType, TaskId } from "@gnu-taler/taler-wallet-core"; +import { AbsoluteTime } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; -import { View as TestedComponent } from "./DeveloperPage.js"; -import { AbsoluteTime, PendingIdStr } from "@gnu-taler/taler-util"; +import { DeveloperPage as TestedComponent } from "./DeveloperPage.js"; export default { title: "developer", @@ -36,8 +35,8 @@ export const AllOff = tests.createExample(TestedComponent, { onDownloadDatabase: async () => "this is the content of the database", operations: [ { - id: " " as TaskId, - type: PendingTaskType.ExchangeUpdate, + id: " ", + type: "exchange-update", exchangeBaseUrl: "http://exchange.url.", givesLifeness: false, lastError: undefined, diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx index 0a01b8a95..53380e263 100644 --- a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx @@ -20,71 +20,33 @@ import { CoinDumpJson, CoinStatus, ExchangeListItem, + ExchangeTosStatus, LogLevel, NotificationType, + ScopeType, + parseWithdrawUri, + stringifyWithdrawExchange, } from "@gnu-taler/taler-util"; -import { - PendingTaskInfo, - WalletApiOperation, -} from "@gnu-taler/taler-wallet-core"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; +import { Checkbox } from "../components/Checkbox.js"; import { SelectList } from "../components/SelectList.js"; import { Time } from "../components/Time.js"; -import { NotifyUpdateFadeOut } from "../components/styled/index.js"; +import { DestructiveText, LinkPrimary, NotifyUpdateFadeOut, SubTitle, SuccessText, WarningText } from "../components/styled/index.js"; +import { useAlertContext } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { useSettings } from "../hooks/useSettings.js"; import { Button } from "../mui/Button.js"; import { Grid } from "../mui/Grid.js"; import { Paper } from "../mui/Paper.js"; import { TextField } from "../mui/TextField.js"; - -export function DeveloperPage(): VNode { - - const listenAllEvents = Array.from<NotificationType>({ length: 1 }); - - const api = useBackendContext(); - - const response = useAsyncAsHook(async () => { - const op = await api.wallet.call( - WalletApiOperation.GetPendingOperations, - {}, - ); - const c = await api.wallet.call(WalletApiOperation.DumpCoins, {}); - const ex = await api.wallet.call(WalletApiOperation.ListExchanges, {}); - return { - operations: op.pendingOperations, - coins: c.coins, - exchanges: ex.exchanges, - }; - }); - - useEffect(() => { - return api.listener.onUpdateNotification(listenAllEvents, response?.retry); - }); - - const nonResponse = { operations: [], coins: [], exchanges: [] }; - const { operations, coins, exchanges } = - response === undefined - ? nonResponse - : response.hasError - ? nonResponse - : response.response; - - return ( - <View - operations={operations} - coins={coins} - exchanges={exchanges} - onDownloadDatabase={async () => { - const db = await api.wallet.call(WalletApiOperation.ExportDb, {}); - return JSON.stringify(db); - }} - /> - ); -} +import { Pages } from "../NavigationBar.js"; +import { CoinInfo } from "@gnu-taler/taler-wallet-core/dbless"; +import { ActiveTasksTable } from "../components/WalletActivity.js"; type CoinsInfo = CoinDumpJson["coins"]; type CalculatedCoinfInfo = { @@ -103,27 +65,21 @@ type SplitedCoinInfo = { }; export interface Props { - operations: PendingTaskInfo[]; - coins: CoinsInfo; - exchanges: ExchangeListItem[]; - onDownloadDatabase: () => Promise<string>; + // FIXME: Pending operations don't exist anymore. } function hashObjectId(o: any): string { return JSON.stringify(o); } -export function View({ - operations, - coins, - onDownloadDatabase, -}: Props): VNode { +export function DeveloperPage({ }: Props): VNode { const { i18n } = useTranslationContext(); const [downloadedDatabase, setDownloadedDatabase] = useState< { time: Date; content: string } | undefined >(undefined); async function onExportDatabase(): Promise<void> { - const content = await onDownloadDatabase(); + const db = await api.wallet.call(WalletApiOperation.ExportDb, {}); + const content = JSON.stringify(db); setDownloadedDatabase({ time: new Date(), content, @@ -133,15 +89,31 @@ export function View({ const fileRef = useRef<HTMLInputElement>(null); async function onImportDatabase(str: string): Promise<void> { - return api.wallet.call(WalletApiOperation.ImportDb, { + await api.wallet.call(WalletApiOperation.ImportDb, { dump: JSON.parse(str), }); } + const [settings, updateSettings] = useSettings(); + const { safely } = useAlertContext(); - const hook = useAsyncAsHook(() => - api.wallet.call(WalletApiOperation.ListExchanges, {}), - ); + const listenAllEvents = Array.from<NotificationType>({ length: 1 }); + // listenAllEvents.includes = () => true + + const hook = useAsyncAsHook(async () => { + const list = await api.wallet.call(WalletApiOperation.ListExchanges, {}); + const version = await api.wallet.call(WalletApiOperation.GetVersion, {}); + const coins = await api.wallet.call(WalletApiOperation.DumpCoins, {}); + return { exchanges: list.exchanges, version, coins }; + }); const exchangeList = hook && !hook.hasError ? hook.response.exchanges : []; + const coins = hook && !hook.hasError ? hook.response.coins.coins : []; + + useEffect(() => { + return api.listener.onUpdateNotification(listenAllEvents, (ev) => { + console.log("event", ev) + return hook?.retry() + }); + }); const currencies: { [ex: string]: string } = {}; const money_by_exchange = coins.reduce( @@ -206,30 +178,6 @@ export function View({ <Grid item> <Button variant="contained" - onClick={() => { - return api.background.call("sum", [1, 2, 3]).then((r) => { - console.log("SUM", r); - }); - }} - > - <i18n.Translate>sum 123</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" - onClick={() => { - return api.background.call("freeze", 4000).then(() => { - console.log("WAIT"); - }); - }} - > - <i18n.Translate>freeze 4000</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" onClick={async () => fileRef?.current?.click()} > <i18n.Translate>import database</i18n.Translate> @@ -258,78 +206,6 @@ export function View({ </Button> </Grid> <Grid item> - <Button variant="contained" onClick={async () => { - api.background.call("toggleHeaderListener", true) - }}> - <i18n.Translate>enable header listener</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button variant="contained" onClick={async () => { - api.background.call("toggleHeaderListener", false) - }}> - <i18n.Translate>disable header listener</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" - onClick={async () => { - navigator.registerProtocolHandler( - "taler", - `${window.location.origin}/static/wallet.html#/cta/withdraw?talerWithdrawUri=%s`, - ); - }} - > - <i18n.Translate>Register taler:// handler</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" - onClick={async () => { - const n = navigator as any; - if ("unregisterProtocolHandler" in n) { - n.unregisterProtocolHandler( - "taler", - `${window.location.origin}/static/wallet.html#/cta/withdraw?talerWithdrawUri=%s`, - ); - } - }} - > - <i18n.Translate>Remove taler:// handler</i18n.Translate> - </Button> - </Grid>{" "} - <Grid item> - <Button - variant="contained" - onClick={async () => { - navigator.registerProtocolHandler( - "ext+taler", - `${window.location.origin}/static/wallet.html#/cta/withdraw?talerWithdrawUri=%s`, - ); - }} - > - <i18n.Translate>Register ext+taler:// handler</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" - onClick={async () => { - const n = navigator as any; - if ("unregisterProtocolHandler" in n) { - n.unregisterProtocolHandler( - "ext+taler", - `${window.location.origin}/static/wallet.html#/cta/withdraw?talerWithdrawUri=%s`, - ); - } - }} - > - <i18n.Translate>Remove ext+taler:// handler</i18n.Translate> - </Button> - </Grid> - <Grid item> <Button variant="contained" onClick={async () => { @@ -359,6 +235,241 @@ export function View({ </Button> </Grid>{" "} </Grid> + {downloadedDatabase && ( + <div> + <i18n.Translate> + Database exported at{" "} + <Time + timestamp={AbsoluteTime.fromMilliseconds( + downloadedDatabase.time.getTime(), + )} + format="yyyy/MM/dd HH:mm:ss" + />{" "} + <a + href={`data:text/plain;charset=utf-8;base64,${toBase64( + downloadedDatabase.content, + )}`} + download={`taler-wallet-database-${format( + downloadedDatabase.time, + "yyyy/MM/dd_HH:mm", + )}.json`} + > + <i18n.Translate>click here</i18n.Translate> + </a>{" "} + to download + </i18n.Translate> + </div> + )} + <Checkbox + label={i18n.str`Inject Taler support in all pages`} + name="inject" + description={ + <i18n.Translate> + Enabling this option will make `window.taler` be available in all + sites + </i18n.Translate> + } + enabled={settings.injectTalerSupport!} + onToggle={safely("update support injection", async () => { + updateSettings("injectTalerSupport", !settings.injectTalerSupport); + })} + /> + + + <SubTitle> + <i18n.Translate>Exchange Entries</i18n.Translate> + </SubTitle> + {!exchangeList || !exchangeList.length ? ( + <div> + <i18n.Translate>No exchange yet</i18n.Translate> + </div> + ) : ( + <Fragment> + <table> + <thead> + <tr> + <th> + <i18n.Translate>Currency</i18n.Translate> + </th> + <th> + <i18n.Translate>URL</i18n.Translate> + </th> + <th> + <i18n.Translate>Status</i18n.Translate> + </th> + <th> + <i18n.Translate>Terms of Service</i18n.Translate> + </th> + <th> + <i18n.Translate>Last Update</i18n.Translate> + </th> + <th> + <i18n.Translate>Actions</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + {exchangeList.map((e, idx) => { + function TosStatus(): VNode { + switch (e.tosStatus) { + case ExchangeTosStatus.Accepted: + return ( + <SuccessText> + <i18n.Translate>ok</i18n.Translate> + </SuccessText> + ); + case ExchangeTosStatus.Pending: + return ( + <WarningText> + <i18n.Translate>pending</i18n.Translate> + </WarningText> + ); + case ExchangeTosStatus.Proposed: + return <i18n.Translate>proposed</i18n.Translate>; + default: + return ( + <DestructiveText> + <i18n.Translate> + unknown (exchange status should be updated) + </i18n.Translate> + </DestructiveText> + ); + } + } + const uri = !e.masterPub ? undefined : stringifyWithdrawExchange({ + exchangeBaseUrl: e.exchangeBaseUrl, + exchangePub: e.masterPub, + }); + return ( + <tr key={idx}> + <td> + <a href={!uri ? undefined : Pages.defaultCta({ uri })}> + {e.scopeInfo ? `${e.scopeInfo.currency} (${e.scopeInfo.type === ScopeType.Global ? "global" : "regional"})` : e.currency} + </a> + </td> + <td> + <a href={new URL(`/keys`, e.exchangeBaseUrl).href} target="_blank">{e.exchangeBaseUrl}</a> + </td> + <td> + {e.exchangeEntryStatus} / {e.exchangeUpdateStatus} + </td> + <td> + <TosStatus /> + </td> + <td> + {e.lastUpdateTimestamp + ? AbsoluteTime.toIsoString( + AbsoluteTime.fromPreciseTimestamp( + e.lastUpdateTimestamp, + ), + ) + : "never"} + </td> + <td> + <button + onClick={() => { + api.wallet.call( + WalletApiOperation.UpdateExchangeEntry, + { + exchangeBaseUrl: e.exchangeBaseUrl, + force: true, + }, + ); + }} + > + Reload + </button> + <button + onClick={() => { + api.wallet.call( + WalletApiOperation.DeleteExchange, + { + exchangeBaseUrl: e.exchangeBaseUrl, + }, + ); + }} + > + Delete + </button> + <button + onClick={() => { + api.wallet.call( + WalletApiOperation.DeleteExchange, + { + exchangeBaseUrl: e.exchangeBaseUrl, + purge: true, + }, + ); + }} + > + Purge + </button> + {e.scopeInfo && e.masterPub && e.currency ? + (e.scopeInfo.type === ScopeType.Global ? + <button + onClick={() => { + api.wallet.call( + WalletApiOperation.RemoveGlobalCurrencyExchange, + { + exchangeBaseUrl: e.exchangeBaseUrl, + currency: e.currency!, + exchangeMasterPub: e.masterPub!, + }, + ); + }} + > + + Make regional + </button> + : e.scopeInfo.type === ScopeType.Auditor ? + undefined + + : e.scopeInfo.type === ScopeType.Exchange ? + <button + onClick={() => { + api.wallet.call( + WalletApiOperation.AddGlobalCurrencyExchange, + { + exchangeBaseUrl: e.exchangeBaseUrl, + currency: e.currency!, + exchangeMasterPub: e.masterPub!, + }, + ); + }} + > + + Make global + </button> + : undefined) : undefined + } + <button + onClick={() => { + api.wallet.call( + WalletApiOperation.SetExchangeTosForgotten, + { + exchangeBaseUrl: e.exchangeBaseUrl, + }, + ); + }} + > + Forget ToS + </button> + </td> + </tr> + ); + })} + </tbody> + </table> + </Fragment> + )} + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div /> + <LinkPrimary href={Pages.settingsExchangeAdd({})}> + <i18n.Translate>Add an exchange</i18n.Translate> + </LinkPrimary> + </div> + + <Paper style={{ padding: 10, margin: 10 }}> <h3>Logging</h3> <div> @@ -395,28 +506,7 @@ export function View({ Set log level </Button> </Paper> - {downloadedDatabase && ( - <div> - <i18n.Translate> - Database exported at <Time - timestamp={AbsoluteTime.fromMilliseconds( - downloadedDatabase.time.getTime(), - )} - format="yyyy/MM/dd HH:mm:ss" - /> <a - href={`data:text/plain;charset=utf-8;base64,${toBase64( - downloadedDatabase.content, - )}`} - download={`taler-wallet-database-${format( - downloadedDatabase.time, - "yyyy/MM/dd_HH:mm", - )}.json`} - > - <i18n.Translate>click here</i18n.Translate> - </a> to download - </i18n.Translate> - </div> - )} + <br /> <p> <i18n.Translate>Coins</i18n.Translate>: @@ -452,31 +542,9 @@ export function View({ ); })} <br /> - {operations && operations.length > 0 && ( - <Fragment> - <p> - <i18n.Translate>Pending operations</i18n.Translate> - </p> - <dl> - {operations.reverse().map((o) => { - return ( - <NotifyUpdateFadeOut key={hashObjectId(o)}> - <dt> - {o.type}{" "} - <Time - timestamp={o.timestampDue} - format="yy/MM/dd HH:mm:ss" - /> - </dt> - <dd> - <pre>{JSON.stringify(o, undefined, 2)}</pre> - </dd> - </NotifyUpdateFadeOut> - ); - })} - </dl> - </Fragment> - )} + <NotifyUpdateFadeOut> + <ActiveTasksTable /> + </NotifyUpdateFadeOut> </div> ); } diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts index b1cbbc2b2..d70b62de0 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts @@ -16,13 +16,13 @@ import { DenomOperationMap, FeeDescription } from "@gnu-taler/taler-util"; import { - createPairTimeline, WalletApiOperation, + createPairTimeline, } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { Props, State } from "./index.js"; @@ -90,6 +90,7 @@ export function useComponentState({ return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load exchange details info`, hook, ), @@ -151,7 +152,7 @@ export function useComponentState({ }; } - //this may be expensive, useMemo + // this may be expensive, useMemo const coinOperationTimeline: DenomOperationMap<FeeDescription[]> = { deposit: createPairTimeline( selected.denomFees.deposit, diff --git a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx index 8b4f64a93..482b8d698 100644 --- a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx @@ -35,7 +35,6 @@ import { TransactionPeerPushDebit, TransactionRefresh, TransactionRefund, - TransactionReward, TransactionType, TransactionWithdrawal, WithdrawalType, @@ -50,17 +49,17 @@ export default { let count = 0; const commonTransaction = (): TransactionCommon => -({ - amountRaw: "USD:10", - amountEffective: "USD:9", - txState: { - major: TransactionMajorState.Done, - }, - timestamp: TalerProtocolTimestamp.fromSeconds( - new Date().getTime() / 1000 - count++ * 60 * 60 * 7, - ), - transactionId: String(count), -} as TransactionCommon); + ({ + amountRaw: "USD:10", + amountEffective: "USD:9", + txState: { + major: TransactionMajorState.Done, + }, + timestamp: TalerProtocolTimestamp.fromSeconds( + new Date().getTime() / 1000 - count++ * 60 * 60 * 7, + ), + transactionId: String(count), + }) as TransactionCommon; const exampleData = { withdraw: { @@ -112,11 +111,6 @@ const exampleData = { exchangeBaseUrl: "http://exchange.taler", refreshReason: RefreshReason.PayMerchant, } as TransactionRefresh, - tip: { - ...commonTransaction(), - type: TransactionType.Reward, - merchantBaseUrl: "http://ads.merchant.taler.net/", - } as TransactionReward, refund: { ...commonTransaction(), type: TransactionType.Refund, @@ -168,15 +162,12 @@ const exampleData = { } as TransactionPeerPullDebit, }; -export const NoBalance = tests.createExample(TestedComponent, { - transactions: [], - balances: [], -}); - export const SomeBalanceWithNoTransactions = tests.createExample( TestedComponent, { - transactions: [], + transactionsByDate: { + "11/11/11": [], + }, balances: [ { available: "TESTKUDOS:10" as AmountString, @@ -192,11 +183,14 @@ export const SomeBalanceWithNoTransactions = tests.createExample( }, }, ], + balanceIndex: 0, }, ); export const OneSimpleTransaction = tests.createExample(TestedComponent, { - transactions: [exampleData.withdraw], + transactionsByDate: { + "11/11/11": [exampleData.withdraw], + }, balances: [ { flags: [], @@ -212,12 +206,15 @@ export const OneSimpleTransaction = tests.createExample(TestedComponent, { }, }, ], + balanceIndex: 0, }); export const TwoTransactionsAndZeroBalance = tests.createExample( TestedComponent, { - transactions: [exampleData.withdraw, exampleData.deposit], + transactionsByDate: { + "11/11/11": [exampleData.withdraw, exampleData.deposit], + }, balances: [ { flags: [], @@ -233,18 +230,21 @@ export const TwoTransactionsAndZeroBalance = tests.createExample( }, }, ], + balanceIndex: 0, }, ); export const OneTransactionPending = tests.createExample(TestedComponent, { - transactions: [ - { - ...exampleData.withdraw, - txState: { - major: TransactionMajorState.Pending, + transactionsByDate: { + "11/11/11": [ + { + ...exampleData.withdraw, + txState: { + major: TransactionMajorState.Pending, + }, }, - }, - ], + ], + }, balances: [ { flags: [], @@ -260,26 +260,28 @@ export const OneTransactionPending = tests.createExample(TestedComponent, { }, }, ], + balanceIndex: 0, }); export const SomeTransactions = tests.createExample(TestedComponent, { - transactions: [ - exampleData.withdraw, - exampleData.payment, - exampleData.withdraw, - exampleData.payment, - { - ...exampleData.payment, - info: { - ...exampleData.payment.info, - summary: - "this is a long summary that may be cropped because its too long", + transactionsByDate: { + "11/11/11": [ + exampleData.withdraw, + exampleData.payment, + exampleData.withdraw, + exampleData.payment, + { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + summary: + "this is a long summary that may be cropped because its too long", + }, }, - }, - exampleData.refund, - exampleData.tip, - exampleData.deposit, - ], + exampleData.refund, + exampleData.deposit, + ], + }, balances: [ { flags: [], @@ -295,85 +297,87 @@ export const SomeTransactions = tests.createExample(TestedComponent, { }, }, ], + balanceIndex: 0, }); export const SomeTransactionsInDifferentStates = tests.createExample( TestedComponent, { - transactions: [ - exampleData.withdraw, - { - ...exampleData.withdraw, - exchangeBaseUrl: "https://aborted/withdrawal", - txState: { - major: TransactionMajorState.Aborted, + transactionsByDate: { + "11/11/11": [ + exampleData.withdraw, + { + ...exampleData.withdraw, + exchangeBaseUrl: "https://aborted/withdrawal", + txState: { + major: TransactionMajorState.Aborted, + }, }, - }, - { - ...exampleData.withdraw, - exchangeBaseUrl: "https://pending/withdrawal", - txState: { - major: TransactionMajorState.Pending, + { + ...exampleData.withdraw, + exchangeBaseUrl: "https://pending/withdrawal", + txState: { + major: TransactionMajorState.Pending, + }, }, - }, - { - ...exampleData.withdraw, - exchangeBaseUrl: "https://failed/withdrawal", - txState: { - major: TransactionMajorState.Failed, + { + ...exampleData.withdraw, + exchangeBaseUrl: "https://failed/withdrawal", + txState: { + major: TransactionMajorState.Failed, + }, }, - }, - { - ...exampleData.payment, - info: { - ...exampleData.payment.info, - summary: "normal payment", + { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + summary: "normal payment", + }, }, - }, - { - ...exampleData.payment, - info: { - ...exampleData.payment.info, - summary: "aborting in progress", + { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + summary: "aborting in progress", + }, + txState: { + major: TransactionMajorState.Aborting, + }, }, - txState: { - major: TransactionMajorState.Aborting, + { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + summary: "aborted payment", + }, + txState: { + major: TransactionMajorState.Aborted, + }, }, - }, - { - ...exampleData.payment, - info: { - ...exampleData.payment.info, - summary: "aborted payment", + { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + summary: "pending payment", + }, + txState: { + major: TransactionMajorState.Pending, + }, }, - txState: { - major: TransactionMajorState.Aborted, - }, - }, - { - ...exampleData.payment, - info: { - ...exampleData.payment.info, - summary: "pending payment", - }, - txState: { - major: TransactionMajorState.Pending, + { + ...exampleData.payment, + info: { + ...exampleData.payment.info, + summary: "failed payment", + }, + txState: { + major: TransactionMajorState.Failed, + }, }, - }, - { - ...exampleData.payment, - info: { - ...exampleData.payment.info, - summary: "failed payment", - }, - txState: { - major: TransactionMajorState.Failed, - }, - }, - exampleData.refund, - exampleData.tip, - exampleData.deposit, - ], + exampleData.refund, + exampleData.deposit, + ], + }, balances: [ { flags: [], @@ -389,22 +393,24 @@ export const SomeTransactionsInDifferentStates = tests.createExample( }, }, ], + balanceIndex: 0, }, ); export const SomeTransactionsWithTwoCurrencies = tests.createExample( TestedComponent, { - transactions: [ - exampleData.withdraw, - exampleData.payment, - exampleData.withdraw, - exampleData.payment, - exampleData.refresh, - exampleData.refund, - exampleData.tip, - exampleData.deposit, - ], + transactionsByDate: { + "11/11/11": [ + exampleData.withdraw, + exampleData.payment, + exampleData.withdraw, + exampleData.payment, + exampleData.refresh, + exampleData.refund, + exampleData.deposit, + ], + }, balances: [ { flags: [], @@ -433,11 +439,14 @@ export const SomeTransactionsWithTwoCurrencies = tests.createExample( }, }, ], + balanceIndex: 0, }, ); export const FiveOfficialCurrencies = tests.createExample(TestedComponent, { - transactions: [exampleData.withdraw], + transactionsByDate: { + "11/11/11": [exampleData.withdraw], + }, balances: [ { flags: [], @@ -505,12 +514,15 @@ export const FiveOfficialCurrencies = tests.createExample(TestedComponent, { }, }, ], + balanceIndex: 0, }); export const FiveOfficialCurrenciesWithHighValue = tests.createExample( TestedComponent, { - transactions: [exampleData.withdraw], + transactionsByDate: { + "11/11/11": [exampleData.withdraw], + }, balances: [ { flags: [], @@ -578,16 +590,19 @@ export const FiveOfficialCurrenciesWithHighValue = tests.createExample( }, }, ], + balanceIndex: 0, }, ); export const PeerToPeer = tests.createExample(TestedComponent, { - transactions: [ - exampleData.pull_credit, - exampleData.pull_debit, - exampleData.push_credit, - exampleData.push_debit, - ], + transactionsByDate: { + "11/11/11": [ + exampleData.pull_credit, + exampleData.pull_debit, + exampleData.push_credit, + exampleData.push_debit, + ], + }, balances: [ { flags: [], @@ -603,4 +618,5 @@ export const PeerToPeer = tests.createExample(TestedComponent, { }, }, ], + balanceIndex: 0, }); diff --git a/packages/taler-wallet-webextension/src/wallet/History.tsx b/packages/taler-wallet-webextension/src/wallet/History.tsx index dcc3c43e3..f81e6db9f 100644 --- a/packages/taler-wallet-webextension/src/wallet/History.tsx +++ b/packages/taler-wallet-webextension/src/wallet/History.tsx @@ -18,11 +18,13 @@ import { AbsoluteTime, Amounts, NotificationType, + ScopeType, Transaction, WalletBalance, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { startOfDay } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorAlertView } from "../components/CurrentAlerts.js"; @@ -38,28 +40,44 @@ import { import { alertFromError, useAlertContext } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { useSettings } from "../hooks/useSettings.js"; import { Button } from "../mui/Button.js"; import { NoBalanceHelp } from "../popup/NoBalanceHelp.js"; import DownloadIcon from "../svg/download_24px.inline.svg"; import UploadIcon from "../svg/upload_24px.inline.svg"; -import { getDate, startOfDay } from "date-fns"; +import { TextField } from "../mui/TextField.js"; +import { TextFieldHandler } from "../mui/handlers.js"; interface Props { currency?: string; + search?: boolean; goToWalletDeposit: (currency: string) => Promise<void>; goToWalletManualWithdraw: (currency?: string) => Promise<void>; } export function HistoryPage({ - currency, + currency: _c, + search: showSearch, goToWalletManualWithdraw, goToWalletDeposit, }: Props): VNode { const { i18n } = useTranslationContext(); const api = useBackendContext(); - const state = useAsyncAsHook(async () => ({ - b: await api.wallet.call(WalletApiOperation.GetBalances, {}), - tx: await api.wallet.call(WalletApiOperation.GetTransactions, {}), - })); + const [balanceIndex, setBalanceIndex] = useState<number>(0); + const [search, setSearch] = useState<string>(); + + const [settings] = useSettings(); + const state = useAsyncAsHook(async () => { + const b = await api.wallet.call(WalletApiOperation.GetBalances, {}); + const balance = + b.balances.length > 0 ? b.balances[balanceIndex] : undefined; + const tx = await api.wallet.call(WalletApiOperation.GetTransactions, { + scopeInfo: showSearch ? undefined : balance?.scopeInfo, + sort: "descending", + includeRefreshes: settings.showRefeshTransactions, + search, + }); + return { b, tx }; + }, [balanceIndex, search]); useEffect(() => { return api.listener.onUpdateNotification( @@ -67,6 +85,7 @@ export function HistoryPage({ state?.retry, ); }); + const { pushAlertOnError } = useAlertContext(); if (!state) { return <Loading />; @@ -76,6 +95,7 @@ export function HistoryPage({ return ( <ErrorAlertView error={alertFromError( + i18n, i18n.str`Could not load the list of transactions`, state, )} @@ -83,100 +103,86 @@ export function HistoryPage({ ); } + if (!state.response.b.balances.length) { + return ( + <NoBalanceHelp + goToWalletManualWithdraw={{ + onClick: pushAlertOnError(goToWalletManualWithdraw), + }} + /> + ); + } + + const byDate = state.response.tx.transactions.reduce( + (rv, x) => { + const startDay = + x.timestamp.t_s === "never" + ? 0 + : startOfDay(x.timestamp.t_s * 1000).getTime(); + if (startDay) { + if (!rv[startDay]) { + rv[startDay] = []; + // datesWithTransaction.push(String(startDay)); + } + rv[startDay].push(x); + } + + return rv; + }, + {} as { [x: string]: Transaction[] }, + ); + + if (showSearch) { + return ( + <FilteredHistoryView + search={{ + value: search ?? "", + onInput: pushAlertOnError(async (d: string) => { + setSearch(d); + }), + }} + transactionsByDate={byDate} + /> + ); + } + return ( <HistoryView + balanceIndex={balanceIndex} + changeBalanceIndex={(b) => setBalanceIndex(b)} balances={state.response.b.balances} - defaultCurrency={currency} goToWalletManualWithdraw={goToWalletManualWithdraw} goToWalletDeposit={goToWalletDeposit} - transactions={[...state.response.tx.transactions].reverse()} + transactionsByDate={byDate} /> ); } -const term = 1000 * 60 * 60 * 24; -function normalizeToDay(x: number): number { - return Math.round(x / term) * term; -} - export function HistoryView({ - defaultCurrency, - transactions, balances, + balanceIndex, + changeBalanceIndex, + transactionsByDate, goToWalletManualWithdraw, goToWalletDeposit, }: { + balanceIndex: number; + changeBalanceIndex: (s: number) => void; goToWalletDeposit: (currency: string) => Promise<void>; goToWalletManualWithdraw: (currency?: string) => Promise<void>; - defaultCurrency?: string; - transactions: Transaction[]; + transactionsByDate: Record<string, Transaction[]>; balances: WalletBalance[]; }): VNode { const { i18n } = useTranslationContext(); - const { pushAlertOnError } = useAlertContext(); - - const transactionByCurrency = transactions.reduce((prev, cur) => { - const c = Amounts.parseOrThrow(cur.amountEffective).currency; - if (!prev[c]) { - prev[c] = []; - } - prev[c].push(cur); - return prev; - }, {} as Record<string, Transaction[]>); - const currencies = balances - .filter((b) => { - const av = Amounts.parseOrThrow(b.available); - return ( - Amounts.isNonZero(av) || - (transactionByCurrency[av.currency] && - transactionByCurrency[av.currency].length > 0) - ); - }) - .map((b) => b.available.split(":")[0]); + const balance = balances[balanceIndex]; - const defaultCurrencyIndex = currencies.findIndex( - (c) => c === defaultCurrency, - ); - const [currencyIndex, setCurrencyIndex] = useState( - defaultCurrencyIndex === -1 ? 0 : defaultCurrencyIndex, - ); - const selectedCurrency = - currencies.length > 0 ? currencies[currencyIndex] : undefined; - - const currencyAmount = balances[currencyIndex] - ? Amounts.jsonifyAmount(balances[currencyIndex].available) + const available = balance + ? Amounts.jsonifyAmount(balance.available) : undefined; - const ts = - selectedCurrency === undefined - ? [] - : transactionByCurrency[selectedCurrency] ?? []; - - const datesWithTransaction: string[] = []; - const byDate = ts.reduce((rv, x) => { - const startDay = - x.timestamp.t_s === "never" ? 0 : startOfDay(x.timestamp.t_s * 1000).getTime(); - if (startDay) { - if (!rv[startDay]) { - rv[startDay] = []; - datesWithTransaction.push(String(startDay)); - } - rv[startDay].push(x); - } + const datesWithTransaction: string[] = Object.keys(transactionsByDate); - return rv; - }, {} as { [x: string]: Transaction[] }); - - if (balances.length === 0 || !selectedCurrency) { - return ( - <NoBalanceHelp - goToWalletManualWithdraw={{ - onClick: pushAlertOnError(goToWalletManualWithdraw), - }} - /> - ); - } return ( <Fragment> <section> @@ -186,72 +192,151 @@ export function HistoryView({ flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", + marginRight: 20, }} > - <div - style={{ - width: "fit-content", - display: "flex", - }} - > - {currencies.length === 1 ? ( - <CenteredText style={{ fontSize: "x-large", margin: 8 }}> - {selectedCurrency} - </CenteredText> - ) : ( - <NiceSelect> - <select - style={{ - fontSize: "x-large", - }} - value={currencyIndex} - onChange={(e) => { - setCurrencyIndex(Number(e.currentTarget.value)); - }} - > - {currencies.map((currency, index) => { - return ( - <option value={index} key={currency}> - {currency} - </option> - ); - })} - </select> - </NiceSelect> - )} - {currencyAmount && ( - <CenteredBoldText - style={{ - display: "inline-block", - fontSize: "x-large", - margin: 8, - }} - > - {Amounts.stringifyValue(currencyAmount, 2)} - </CenteredBoldText> - )} - </div> <div> <Button tooltip="Transfer money to the wallet" startIcon={DownloadIcon} variant="contained" - onClick={() => goToWalletManualWithdraw(selectedCurrency)} + onClick={() => + goToWalletManualWithdraw(balance.scopeInfo.currency) + } > - <i18n.Translate>Add</i18n.Translate> + <i18n.Translate>Receive</i18n.Translate> </Button> - {currencyAmount && Amounts.isNonZero(currencyAmount) && ( + {available && Amounts.isNonZero(available) && ( <Button tooltip="Transfer money from the wallet" startIcon={UploadIcon} variant="outlined" color="primary" - onClick={() => goToWalletDeposit(selectedCurrency)} + onClick={() => goToWalletDeposit(balance.scopeInfo.currency)} > <i18n.Translate>Send</i18n.Translate> </Button> )} </div> + <div style={{ display: "flex", flexDirection: "column" }}> + <h3 style={{ marginBottom: 0 }}>Balance</h3> + <div + style={{ + width: "fit-content", + display: "flex", + }} + > + {balances.length === 1 ? ( + <CenteredText style={{ fontSize: "x-large", margin: 8 }}> + {balance.scopeInfo.currency} + </CenteredText> + ) : ( + <NiceSelect style={{ flexDirection: "column" }}> + <select + style={{ + fontSize: "x-large", + }} + value={balanceIndex} + onChange={(e) => { + changeBalanceIndex( + Number.parseInt(e.currentTarget.value, 10), + ); + }} + > + {balances.map((entry, index) => { + return ( + <option value={index} key={entry.scopeInfo.currency}> + {entry.scopeInfo.currency} + </option> + ); + })} + </select> + <div style={{ fontSize: "small", color: "grey" }}> + {balance.scopeInfo.type === ScopeType.Exchange || + balance.scopeInfo.type === ScopeType.Auditor + ? balance.scopeInfo.url + : undefined} + </div> + </NiceSelect> + )} + {available && ( + <CenteredBoldText + style={{ + display: "inline-block", + fontSize: "x-large", + margin: 8, + }} + > + {Amounts.stringifyValue(available, 2)} + </CenteredBoldText> + )} + </div> + </div> + </div> + </section> + {datesWithTransaction.length === 0 ? ( + <section> + <i18n.Translate> + Your transaction history is empty for this currency. + </i18n.Translate> + </section> + ) : ( + <section> + {datesWithTransaction.map((d, i) => { + return ( + <Fragment key={i}> + <DateSeparator> + <Time + timestamp={AbsoluteTime.fromMilliseconds( + Number.parseInt(d, 10), + )} + format="dd MMMM yyyy" + /> + </DateSeparator> + {transactionsByDate[d].map((tx, i) => ( + <HistoryItem key={i} tx={tx} /> + ))} + </Fragment> + ); + })} + </section> + )} + </Fragment> + ); +} + +export function FilteredHistoryView({ + search, + transactionsByDate, +}: { + search: TextFieldHandler; + transactionsByDate: Record<string, Transaction[]>; +}): VNode { + const { i18n } = useTranslationContext(); + + const datesWithTransaction: string[] = Object.keys(transactionsByDate); + + return ( + <Fragment> + <section> + <div + style={{ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "space-between", + marginRight: 20, + }} + > + <TextField + label="Search" + variant="filled" + error={search.error} + required + fullWidth + value={search.value} + onChange={search.onInput} + /> </div> </section> {datesWithTransaction.length === 0 ? ( @@ -273,7 +358,7 @@ export function HistoryView({ format="dd MMMM yyyy" /> </DateSeparator> - {byDate[d].map((tx, i) => ( + {transactionsByDate[d].map((tx, i) => ( <HistoryItem key={i} tx={tx} /> ))} </Fragment> diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts index 769fe4d10..a7b2fe90f 100644 --- a/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts @@ -61,7 +61,10 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", - error: alertFromError(i18n.str`Could not load known bank accounts`, hook), + error: alertFromError( + i18n, + i18n.str`Could not load known bank accounts`, + hook), }; } diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx index b77c456e5..c01797e31 100644 --- a/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/stories.tsx @@ -58,6 +58,7 @@ export const JustTwoBitcoinAccounts = tests.createExample(ReadyView, { targetType: "bitcoin", segwitAddrs: [], isKnown: true, + address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", params: {}, }, @@ -69,6 +70,7 @@ export const JustTwoBitcoinAccounts = tests.createExample(ReadyView, { uri: { targetType: "bitcoin", segwitAddrs: [], + address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", isKnown: true, targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", params: {}, @@ -138,6 +140,7 @@ export const WithAllTypeOfAccounts = tests.createExample(ReadyView, { targetType: "bitcoin", segwitAddrs: [], isKnown: true, + address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", params: {}, }, @@ -150,6 +153,7 @@ export const WithAllTypeOfAccounts = tests.createExample(ReadyView, { targetType: "bitcoin", segwitAddrs: [], isKnown: true, + address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", targetPath: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", params: {}, }, diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx index 4d045ee13..7b80977f3 100644 --- a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx @@ -23,13 +23,12 @@ import { stringifyPaytoUri, validateIban, } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { styled } from "@linaria/react"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ErrorMessage } from "../../components/ErrorMessage.js"; -import { SelectList } from "../../components/SelectList.js"; -import { Input, SubTitle, SvgIcon } from "../../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { SubTitle, SvgIcon } from "../../components/styled/index.js"; import { Button } from "../../mui/Button.js"; import { TextFieldHandler } from "../../mui/handlers.js"; import { TextField } from "../../mui/TextField.js"; @@ -110,6 +109,7 @@ export function ReadyView({ <div style={{ width: "100%", display: "flex" }}> {Object.entries(accountType.list).map(([key, name], idx) => ( <div + key={idx} style={{ marginLeft: 8, padding: 8, @@ -119,7 +119,7 @@ export function ReadyView({ accountType.value === key ? "#0042b2" : "unset", color: accountType.value === key ? "white" : "unset", }} - onClick={(e) => { + onClick={() => { if (accountType.onChange) { accountType.onChange(key); } @@ -130,6 +130,7 @@ export function ReadyView({ ))} </div> <div style={{ border: "1px solid gray", padding: 8, borderRadius: 5 }}> + --- {uri.value} --- <p> <CustomFieldByAccountType type={accountType.value as AccountType} @@ -431,7 +432,7 @@ function BitcoinAddressAccount({ field }: { field: TextFieldHandler }): VNode { } function undefinedIfEmpty<T extends object>(obj: T): T | undefined { - return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + return Object.keys(obj).some((k) => (obj as Record<string,unknown>)[k] !== undefined) ? obj : undefined; } @@ -488,20 +489,21 @@ function TalerBankAddressAccount({ } //Taken from libeufin and libeufin took it from the ISO20022 XSD schema -const bicRegex = /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/; -const ibanRegex = /^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/; +// const bicRegex = /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/; +// const ibanRegex = /^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/; function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode { const { i18n } = useTranslationContext(); - const [bic, setBic] = useState<string | undefined>(undefined); + // const [bic, setBic] = useState<string | undefined>(undefined); const [iban, setIban] = useState<string | undefined>(undefined); const [name, setName] = useState<string | undefined>(undefined); - const errors = undefinedIfEmpty({ - bic: !bic - ? undefined - : !bicRegex.test(bic) - ? i18n.str`Invalid bic` - : undefined, + const bic = "" + const errorsFN = (iban:string | undefined, name: string | undefined) => undefinedIfEmpty({ + // bic: !bic + // ? undefined + // : !bicRegex.test(bic) + // ? i18n.str`Invalid bic` + // : undefined, iban: !iban ? i18n.str`Can't be empty` : validateIban(iban).type === "invalid" @@ -509,16 +511,20 @@ function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode { : undefined, name: !name ? i18n.str`Can't be empty` : undefined, }); + const errors = errorsFN(iban, name) function sendUpdateIfNoErrors( bic: string | undefined, iban: string, name: string, ): void { - if (!errors && field.onInput) { + if (!field.onInput) return; + if (!errorsFN(iban, name)) { const p = buildPayto("iban", iban, bic); p.params["receiver-name"] = name; field.onInput(stringifyPaytoUri(p)); + } else { + field.onInput("") } } return ( @@ -584,7 +590,7 @@ function CustomFieldByAccountType({ type: AccountType; field: TextFieldHandler; }): VNode { - const { i18n } = useTranslationContext(); + // const { i18n } = useTranslationContext(); const AccountForm = formComponentByAccountType[type]; diff --git a/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts b/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts index f19fe260d..3ef8250ac 100644 --- a/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/Notifications/state.ts @@ -42,6 +42,7 @@ export function useComponentState(p: Props): State { return { status: "error", error: alertFromError( + i18n, i18n.str`Could not load user attention request`, hook, ), diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx index f81a86b9d..d4ee09b89 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx @@ -22,10 +22,9 @@ import { AbsoluteTime, AmountString, + ProviderPaymentType, TalerPreciseTimestamp, - TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; -import { ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; import * as tests from "@gnu-taler/web-util/testing"; import { ProviderView as TestedComponent } from "./ProviderDetailPage.js"; diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx index 19ae39106..d628b68e8 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx @@ -15,22 +15,22 @@ */ import * as utils from "@gnu-taler/taler-util"; -import { AbsoluteTime } from "@gnu-taler/taler-util"; import { + AbsoluteTime, ProviderInfo, ProviderPaymentStatus, ProviderPaymentType, - WalletApiOperation, -} from "@gnu-taler/taler-wallet-core"; -import { Fragment, h, VNode } from "preact"; +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { ErrorAlertView } from "../components/CurrentAlerts.js"; import { ErrorMessage } from "../components/ErrorMessage.js"; import { Loading } from "../components/Loading.js"; -import { PaymentStatus, SmallLightText } from "../components/styled/index.js"; import { Time } from "../components/Time.js"; +import { PaymentStatus, SmallLightText } from "../components/styled/index.js"; import { alertFromError } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { Button } from "../mui/Button.js"; @@ -68,6 +68,7 @@ export function ProviderDetailPage({ return ( <ErrorAlertView error={alertFromError( + i18n, i18n.str`There was an error loading the provider detail for "${providerURL}"`, state, )} diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx index 999223fd8..a01ea6967 100644 --- a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx +++ b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -15,16 +15,19 @@ */ import { + assertUnreachable, parseTalerUri, TalerUri, + TalerUriAction, TranslatedString, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { css } from "@linaria/core"; import { styled } from "@linaria/react"; import jsQR, * as pr from "jsqr"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } from "preact"; import { useRef, useState } from "preact/hooks"; +import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { Alert } from "../mui/Alert.js"; import { Button } from "../mui/Button.js"; import { Grid } from "../mui/Grid.js"; @@ -182,7 +185,7 @@ async function createCanvasFromFile( canvas.width = img.width; canvas.height = img.height; return new Promise<string | undefined>((ok, bad) => { - img.addEventListener("load", (e) => { + img.addEventListener("load", () => { try { const code = drawIntoCanvasAndGetQR(img, canvas); ok(code); @@ -194,7 +197,7 @@ async function createCanvasFromFile( } async function waitUntilReady(video: HTMLVideoElement): Promise<void> { - return new Promise((ok, bad) => { + return new Promise((ok, _bad) => { if (video.readyState === video.HAVE_ENOUGH_DATA) { return ok(); } @@ -211,8 +214,25 @@ export function QrReaderPage({ onDetected }: Props): VNode { const { i18n } = useTranslationContext(); + function onChangeDetect(str: string) { + if (str) { + const uri = parseTalerUri(str); + if (!uri) { + setError( + i18n.str`URI is not valid. Taler URI should start with "taler://"`, + ); + } else { + onDetected(uri); + setError(undefined); + } + } else { + setError(undefined); + } + setValue(str); + } + function onChange(str: string) { - if (!!str) { + if (str) { if (!parseTalerUri(str)) { setError( i18n.str`URI is not valid. Taler URI should start with "taler://"`, @@ -244,7 +264,7 @@ export function QrReaderPage({ onDetected }: Props): VNode { try { const code = await createCanvasFromVideo(video, canvasRef.current); if (code) { - onChange(code); + onChangeDetect(code); setShow("canvas"); } stream.getTracks().forEach((e) => { @@ -264,7 +284,7 @@ export function QrReaderPage({ onDetected }: Props): VNode { try { const code = await createCanvasFromFile(fileContent, canvasRef.current); if (code) { - onChange(code); + onChangeDetect(code); setShow("canvas"); } else { setError(i18n.str`Could not found a QR code in the file`); @@ -273,8 +293,8 @@ export function QrReaderPage({ onDetected }: Props): VNode { setError(i18n.str`something unexpected happen: ${error}`); } } + const uri = parseTalerUri(value); - const active = value === ""; return ( <Container> <section> @@ -283,59 +303,75 @@ export function QrReaderPage({ onDetected }: Props): VNode { Scan a QR code or enter taler:// URI below </i18n.Translate> </h1> - - <p> - <TextField - label="Taler URI" - variant="standard" - fullWidth - value={value} - onChange={onChange} - /> - </p> + <div style={{ justifyContent: "space-between", display: "flex" }}> + <div style={{ width: "75%" }}> + <TextField + label="Taler URI" + variant="filled" + fullWidth + value={value} + onChange={onChange} + /> + </div> + {uri && ( + <Button + disabled={!!error} + variant="contained" + color="success" + onClick={async () => { + if (uri) onDetected(uri); + }} + > + {(function (talerUri: TalerUri): VNode { + switch (talerUri.type) { + case TalerUriAction.Pay: + return <i18n.Translate>Pay invoice</i18n.Translate>; + case TalerUriAction.Withdraw: + return ( + <i18n.Translate>Withdrawal from bank</i18n.Translate> + ); + case TalerUriAction.Refund: + return <i18n.Translate>Claim refund</i18n.Translate>; + case TalerUriAction.PayPull: + return <i18n.Translate>Pay invoice</i18n.Translate>; + case TalerUriAction.PayPush: + return <i18n.Translate>Accept payment</i18n.Translate>; + case TalerUriAction.PayTemplate: + return <i18n.Translate>Complete order</i18n.Translate>; + case TalerUriAction.Restore: + return <i18n.Translate>Restore wallet</i18n.Translate>; + case TalerUriAction.DevExperiment: + return <i18n.Translate>Enable experiment</i18n.Translate>; + case TalerUriAction.WithdrawExchange: + return ( + <i18n.Translate>Withdraw from exchange</i18n.Translate> + ); + case TalerUriAction.AddExchange: + return <i18n.Translate>Add exchange</i18n.Translate>; + default: { + assertUnreachable(talerUri); + } + } + })(uri)} + </Button> + )} + </div> <Grid container justifyContent="space-around" columns={2}> <Grid item xs={2}> <p>{error && <Alert severity="error">{error}</Alert>}</p> </Grid> - <Grid item xs={1}> - {!active && ( - <Button - variant="contained" - onClick={async () => { - setShow("nothing"); - onChange(""); - }} - color="error" - > - <i18n.Translate>Clear</i18n.Translate> - </Button> - )} - </Grid> - <Grid item xs={1}> - {value && ( - <Button - disabled={!!error} - variant="contained" - color="success" - onClick={async () => { - const uri = parseTalerUri(value); - if (uri) onDetected(uri); - }} - > - <i18n.Translate>Open</i18n.Translate> - </Button> - )} - </Grid> - <Grid item xs={1}> - <InputFile onChange={onFileRead}>Read QR from file</InputFile> - </Grid> - <Grid item xs={1}> + <Grid item xs={2}> <p> <Button variant="contained" onClick={startVideo}> Use Camera </Button> </p> </Grid> + <EnabledBySettings name="advancedMode"> + <Grid item xs={2}> + <InputFile onChange={onFileRead}>Read QR from file</InputFile> + </Grid> + </EnabledBySettings> </Grid> </section> <div> diff --git a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx deleted file mode 100644 index 2fcf580ed..000000000 --- a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { parsePaytoUri } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; -import { ReserveCreated as TestedComponent } from "./ReserveCreated.js"; - -export default { - title: "reserve created", - component: TestedComponent, - argTypes: {}, -}; - -export const TalerBank = tests.createExample(TestedComponent, { - reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", - paytoURI: parsePaytoUri( - "payto://x-taler-bank/bank.taler:5882/exchangeminator?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", - ), - amount: { - currency: "USD", - value: 10, - fraction: 0, - }, - accounts: [] -}); - -export const IBAN = tests.createExample(TestedComponent, { - reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", - paytoURI: parsePaytoUri( - "payto://iban/ES8877998399652238?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", - ), - amount: { - currency: "USD", - value: 10, - fraction: 0, - }, - accounts: [] -}); - -export const WithReceiverName = tests.createExample(TestedComponent, { - reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", - paytoURI: parsePaytoUri( - "payto://iban/ES8877998399652238?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG&receiver=Sebastian", - ), - amount: { - currency: "USD", - value: 10, - fraction: 0, - }, - accounts: [] -}); - -export const Bitcoin = tests.createExample(TestedComponent, { - reservePub: "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", - paytoURI: parsePaytoUri( - "payto://bitcoin/bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4?amount=BTC:0.1&subject=0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", - ), - amount: { - currency: "BTC", - value: 0, - fraction: 14000000, - }, - accounts: [] -}); - -export const BitcoinRegTest = tests.createExample(TestedComponent, { - reservePub: "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", - paytoURI: parsePaytoUri( - "payto://bitcoin/bcrt1q6ps8qs6v8tkqrnru4xqqqa6rfwcx5ufpdfqht4?amount=BTC:0.1&subject=0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", - ), - amount: { - currency: "BTC", - value: 0, - fraction: 14000000, - }, - accounts: [] -}); -export const BitcoinTest = tests.createExample(TestedComponent, { - reservePub: "0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", - paytoURI: parsePaytoUri( - "payto://bitcoin/tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx?amount=BTC:0.1&subject=0ZSX8SH0M30KHX8K3Y1DAMVGDQV82XEF9DG1HC4QMQ3QWYT4AF00", - ), - amount: { - currency: "BTC", - value: 0, - fraction: 14000000, - }, - accounts: [] -}); diff --git a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx deleted file mode 100644 index 144413541..000000000 --- a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { AmountJson, PaytoUri, WithdrawalExchangeAccountDetails, stringifyPaytoUri } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { Amount } from "../components/Amount.js"; -import { BankDetailsByPaytoType } from "../components/BankDetailsByPaytoType.js"; -import { CopyButton } from "../components/CopyButton.js"; -import { ErrorMessage } from "../components/ErrorMessage.js"; -import { QR } from "../components/QR.js"; -import { Title, WarningBox } from "../components/styled/index.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Button } from "../mui/Button.js"; -export interface Props { - reservePub: string; - paytoURI: PaytoUri | undefined; - accounts: WithdrawalExchangeAccountDetails[]; - amount: AmountJson; - onCancel: () => Promise<void>; -} - -export function ReserveCreated({ - reservePub, - paytoURI, - onCancel, - accounts, - amount, -}: Props): VNode { - const { i18n } = useTranslationContext(); - if (!paytoURI) { - return ( - <ErrorMessage - title={i18n.str`Could not parse the payto URI`} - description={i18n.str`Please check the uri`} - /> - ); - } - return ( - <Fragment> - <section> - <Title> - <i18n.Translate>Exchange is ready for withdrawal</i18n.Translate> - </Title> - <p> - <i18n.Translate> - To complete the process you need to wire{` `} - <b>{<Amount value={amount} />}</b> to the exchange bank account - </i18n.Translate> - </p> - </section> - <BankDetailsByPaytoType - amount={amount} - accounts={accounts} - subject={reservePub} - /> - <section> - <p> - <i18n.Translate> - Alternative, you can also scan this QR code or open{" "} - <a href={stringifyPaytoUri(paytoURI)}>this link</a> if you have a - banking app installed that supports RFC 8905 - </i18n.Translate> - </p> - <QR text={stringifyPaytoUri(paytoURI)} /> - </section> - <footer> - <div /> - <Button variant="contained" color="error" onClick={onCancel}> - <i18n.Translate>Cancel withdrawal</i18n.Translate> - </Button> - </footer> - </Fragment> - ); -} diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx index bbf5bf0c8..cd43c4526 100644 --- a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx @@ -37,11 +37,13 @@ const version = { merchant: "2:0:1", bank: "0:0:0", hash: "d439c3e1bc743f2aa47de4457953dba6ecb0e20f", - version: "0.9.0-dev.1", + version: "1:2:3", devMode: false, bankConversionApiRange: "0:0:0", bankIntegrationApiRange: "0:0:0", corebankApiRange: "0:0:0", + implementationGitHash: "d439c3e1bc743f2aa47de4457953dba6ecb0e20f", + implementationSemver: "0.9.0-dev.1", } satisfies WalletCoreVersion, webexVersion: { version: "0.9.0.13", @@ -53,7 +55,6 @@ export const AllOff = tests.createExample(TestedComponent, { deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), ...version, @@ -63,7 +64,6 @@ export const OneChecked = tests.createExample(TestedComponent, { deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), ...version, @@ -73,22 +73,8 @@ export const WithOneExchange = tests.createExample(TestedComponent, { deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), - knownExchanges: [ - { - currency: "USD", - exchangeBaseUrl: "http://exchange.taler", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "content of tos", - contentType: "text/plain", - }, - paytoUris: ["payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator"], - } as any, //TODO: complete with auditors, wireInfo and denominations - ], ...version, }); @@ -98,49 +84,8 @@ export const WithExchangeInDifferentState = tests.createExample( deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), - knownExchanges: [ - { - currency: "USD", - exchangeBaseUrl: "http://exchange1.taler", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "content of tos", - contentType: "text/plain", - }, - paytoUris: [ - "payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator", - ], - }, - { - currency: "USD", - exchangeBaseUrl: "http://exchange2.taler", - tos: { - currentVersion: "2", - acceptedVersion: "1", - content: "content of tos", - contentType: "text/plain", - }, - paytoUris: [ - "payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator", - ], - } as any, //TODO: complete with auditors, wireInfo and denominations - { - currency: "USD", - exchangeBaseUrl: "http://exchange3.taler", - tos: { - currentVersion: "1", - content: "content of tos", - contentType: "text/plain", - }, - paytoUris: [ - "payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator", - ], - }, - ], ...version, }, ); diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.tsx index b27413a96..0d0a31a2d 100644 --- a/packages/taler-wallet-webextension/src/wallet/Settings.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Settings.tsx @@ -15,28 +15,21 @@ */ import { - ExchangeListItem, - ExchangeTosStatus, LibtoolVersion, TranslatedString, - WalletCoreVersion, + WalletCoreVersion } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { Pages } from "../NavigationBar.js"; import { Checkbox } from "../components/Checkbox.js"; import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { Part } from "../components/Part.js"; import { SelectList } from "../components/SelectList.js"; import { - DestructiveText, Input, - LinkPrimary, SubTitle, - SuccessText, - WarningBox, - WarningText, + WarningBox } from "../components/styled/index.js"; import { useAlertContext } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; @@ -57,19 +50,15 @@ export function SettingsPage(): VNode { const webex = platform.getWalletWebExVersion(); const api = useBackendContext(); - const exchangesHook = useAsyncAsHook(async () => { - const list = await api.wallet.call(WalletApiOperation.ListExchanges, {}); + const hook = useAsyncAsHook(async () => { const version = await api.wallet.call(WalletApiOperation.GetVersion, {}); - return { exchanges: list.exchanges, version }; + return { version }; }); - const { exchanges, version } = - !exchangesHook || exchangesHook.hasError - ? { exchanges: [], version: undefined } - : exchangesHook.response; + + const version = hook && !hook.hasError ? hook.response.version : undefined return ( <SettingsView - knownExchanges={exchanges} deviceName={name} setDeviceName={update} autoOpenToggle={{ @@ -80,19 +69,11 @@ export function SettingsPage(): VNode { }), }, }} - injectTalerToggle={{ - value: settings.injectTalerSupport, - button: { - onClick: safely("update support injection", async () => { - updateSettings("injectTalerSupport", !settings.injectTalerSupport); - }), - }, - }} advanceToggle={{ - value: settings.advanceMode, + value: settings.advancedMode, button: { onClick: safely("update advance mode", async () => { - updateSettings("advanceMode", !settings.advanceMode); + updateSettings("advancedMode", !settings.advancedMode); }), }, }} @@ -117,10 +98,8 @@ export interface ViewProps { deviceName: string; setDeviceName: (s: string) => Promise<void>; autoOpenToggle: ToggleHandler; - injectTalerToggle: ToggleHandler; advanceToggle: ToggleHandler; langToggle: ToggleHandler; - knownExchanges: Array<ExchangeListItem>; coreVersion: WalletCoreVersion | undefined; webexVersion: { version: string; @@ -129,9 +108,7 @@ export interface ViewProps { } export function SettingsView({ - knownExchanges, autoOpenToggle, - injectTalerToggle, advanceToggle, langToggle, coreVersion, @@ -139,123 +116,84 @@ export function SettingsView({ }: ViewProps): VNode { const { i18n, lang, supportedLang, changeLanguage } = useTranslationContext(); + const api = useBackendContext(); + return ( <Fragment> <section> <SubTitle> - <i18n.Translate>Trust</i18n.Translate> + <i18n.Translate>Navigator</i18n.Translate> + </SubTitle> + <Checkbox + label={i18n.str`Automatically open wallet`} + name="autoOpen" + description={ + <i18n.Translate> + Open the wallet when a payment action is found. + </i18n.Translate> + } + enabled={autoOpenToggle.value!} + onToggle={autoOpenToggle.button.onClick!} + /> + + <SubTitle> + <i18n.Translate>Version Info</i18n.Translate> </SubTitle> - {!knownExchanges || !knownExchanges.length ? ( - <div> - <i18n.Translate>No exchange yet</i18n.Translate> - </div> - ) : ( + <Part + title={i18n.str`Web Extension`} + text={ + <span> + {webexVersion.version}{" "} + <EnabledBySettings name="advancedMode"> + {webexVersion.hash} + </EnabledBySettings> + </span> + } + /> + {coreVersion && ( <Fragment> - <table> - <thead> - <tr> - <th> - <i18n.Translate>Currency</i18n.Translate> - </th> - <th> - <i18n.Translate>URL</i18n.Translate> - </th> - <th> - <i18n.Translate>Term of Service</i18n.Translate> - </th> - </tr> - </thead> - <tbody> - {knownExchanges.map((e, idx) => { - function Status(): VNode { - switch (e.tosStatus) { - case ExchangeTosStatus.Accepted: - return ( - <SuccessText> - <i18n.Translate>ok</i18n.Translate> - </SuccessText> - ); - case ExchangeTosStatus.Pending: - return ( - <WarningText> - <i18n.Translate>pending</i18n.Translate> - </WarningText> - ); - case ExchangeTosStatus.Proposed: - return ( - <i18n.Translate>proposed</i18n.Translate> - ); - default: - return ( - <DestructiveText> - <i18n.Translate> - unknown (exchange status should be updated) - </i18n.Translate> - </DestructiveText> - ); - } - } - return ( - <tr key={idx}> - <td>{e.currency}</td> - <td> - <a href={e.exchangeBaseUrl}>{e.exchangeBaseUrl}</a> - </td> - <td> - <Status /> - </td> - </tr> - ); - })} - </tbody> - </table> + {LibtoolVersion.compare( + coreVersion.version, + WALLET_CORE_SUPPORTED_VERSION, + )?.compatible ? undefined : ( + <WarningBox> + <i18n.Translate> + The version of wallet core is not supported. (supported + version: {WALLET_CORE_SUPPORTED_VERSION}, wallet version: {coreVersion.version}) + </i18n.Translate> + </WarningBox> + )} + <EnabledBySettings name="advancedMode"> + <Part + title={i18n.str`Exchange compatibility`} + text={<span>{coreVersion.exchange}</span>} + /> + <Part + title={i18n.str`Merchant compatibility`} + text={<span>{coreVersion.merchant}</span>} + /> + <Part + title={i18n.str`Bank compatibility`} + text={<span>{coreVersion.bank}</span>} + /> + <Part + title={i18n.str`Wallet Core compatibility`} + text={<span>{coreVersion.version}</span>} + /> + </EnabledBySettings> </Fragment> )} - <div style={{ display: "flex", justifyContent: "space-between" }}> - <div /> - <LinkPrimary href={Pages.settingsExchangeAdd({})}> - <i18n.Translate>Add an exchange</i18n.Translate> - </LinkPrimary> - </div> - - {coreVersion && (<Fragment> - {LibtoolVersion.compare(coreVersion.version, WALLET_CORE_SUPPORTED_VERSION)?.compatible ? undefined : - <WarningBox> - <i18n.Translate> - The version of wallet core is not supported. (supported version: {WALLET_CORE_SUPPORTED_VERSION}) - </i18n.Translate> - </WarningBox>} - <EnabledBySettings name="advanceMode"> - <Part - title={i18n.str`Exchange compatibility`} - text={<span>{coreVersion.exchange}</span>} - /> - <Part - title={i18n.str`Merchant compatibility`} - text={<span>{coreVersion.merchant}</span>} - /> - <Part - title={i18n.str`Bank compatibility`} - text={<span>{coreVersion.bank}</span>} - /> - <Part - title={i18n.str`Wallet Core compatibility`} - text={<span>{coreVersion.version}</span>} - /> - </EnabledBySettings> - </Fragment> - )} <SubTitle> - <i18n.Translate>Advance mode</i18n.Translate> + <i18n.Translate>Settings</i18n.Translate> </SubTitle> <Checkbox - label={i18n.str`Enable advance mode`} + label={i18n.str`Enable developer mode`} name="devMode" description={i18n.str`Show more information and options in the UI`} enabled={advanceToggle.value!} onToggle={advanceToggle.button.onClick!} /> - <EnabledBySettings name="advanceMode"> + <EnabledBySettings name="advancedMode"> <AdvanceSettings /> </EnabledBySettings> <EnabledBySettings name="langSelector"> @@ -272,47 +210,6 @@ export function SettingsView({ /> </Input> </EnabledBySettings> - <SubTitle> - <i18n.Translate>Navigator</i18n.Translate> - </SubTitle> - <Checkbox - label={i18n.str`Inject Taler support in all pages`} - name="inject" - description={ - <i18n.Translate> - Disabling this option will make some web application not able to - trigger the wallet when clicking links but you will be able to - open the wallet using the keyboard shortcut - </i18n.Translate> - } - enabled={injectTalerToggle.value!} - onToggle={injectTalerToggle.button.onClick!} - /> - <Checkbox - label={i18n.str`Automatically open wallet`} - name="autoOpen" - description={ - <i18n.Translate> - Open the wallet when a payment action is found. - </i18n.Translate> - } - enabled={autoOpenToggle.value!} - onToggle={autoOpenToggle.button.onClick!} - /> - <SubTitle> - <i18n.Translate>Version</i18n.Translate> - </SubTitle> - <Part - title={i18n.str`Web Extension`} - text={ - <span> - {webexVersion.version}{" "} - <EnabledBySettings name="advanceMode"> - {webexVersion.hash} - </EnabledBySettings> - </span> - } - /> </section> </Fragment> ); @@ -324,6 +221,7 @@ type Options = { }; function AdvanceSettings(): VNode { const [settings, updateSettings] = useSettings(); + const api = useBackendContext(); const { i18n } = useTranslationContext(); const o: Options = { backup: { @@ -334,6 +232,10 @@ function AdvanceSettings(): VNode { label: i18n.str`Show suspend/resume transaction`, description: i18n.str`Prevent transaction from doing network request.`, }, + showRefeshTransactions: { + label: i18n.str`Show refresh transaction type in the transaction list`, + description: i18n.str`Refresh transaction will be hidden by default if the refresh operation doesn't have fee.`, + }, extendedAccountTypes: { label: i18n.str`Show more account types on deposit`, description: i18n.str`Extends the UI to more payment target types.`, @@ -350,6 +252,18 @@ function AdvanceSettings(): VNode { label: i18n.str`Lang selector`, description: i18n.str`Allows to manually change the language of the UI. Otherwise it will be automatically selected by your browser configuration.`, }, + showExchangeManagement: { + label: i18n.str`Edit exchange management`, + description: i18n.str`Allows to see the list of exchange, remove, add and switch before withdrawal.`, + }, + selectTosFormat: { + label: i18n.str`Select terms of service format`, + description: i18n.str`Allows to render the terms of service on different format selected by the user.`, + }, + showWalletActivity: { + label: i18n.str`Show wallet activity`, + description: i18n.str`Show the wallet notification and observability event in the UI.`, + }, }; return ( <Fragment> @@ -360,10 +274,12 @@ function AdvanceSettings(): VNode { <Checkbox label={label} name={name} + key={name} description={description} enabled={settings[settingsName]} onToggle={async () => { updateSettings(settingsName, !settings[settingsName]); + await api.background.call("reinitWallet", undefined); }} /> ); diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx index c17d15b01..194f0e0bb 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx @@ -38,11 +38,10 @@ import { TransactionPeerPushDebit, TransactionRefresh, TransactionRefund, - TransactionReward, TransactionType, TransactionWithdrawal, WithdrawalDetails, - WithdrawalType, + WithdrawalType } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import beer from "../../static-dev/beer.png"; @@ -137,17 +136,6 @@ const exampleData = { exchangeBaseUrl: "http://exchange.taler", refreshReason: RefreshReason.Manual, } as TransactionRefresh, - tip: { - ...commonTransaction, - type: TransactionType.Reward, - // merchant: { - // name: "the merchant", - // logo: merchantIcon, - // website: "https://www.themerchant.taler", - // email: "contact@merchant.taler", - // }, - merchantBaseUrl: "http://merchant.taler", - } as TransactionReward, refund: { ...commonTransaction, type: TransactionType.Refund, @@ -584,26 +572,6 @@ export const RefreshError = tests.createExample(TestedComponent, { }, }); -export const Tip = tests.createExample(TestedComponent, { - transaction: exampleData.tip, -}); - -export const TipError = tests.createExample(TestedComponent, { - transaction: { - ...exampleData.tip, - error: transactionError, - }, -}); - -export const TipPending = tests.createExample(TestedComponent, { - transaction: { - ...exampleData.tip, - txState: { - major: TransactionMajorState.Pending, - }, - }, -}); - export const Refund = tests.createExample(TestedComponent, { transaction: exampleData.refund, }); diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index e7ab65722..1f0293352 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -19,6 +19,7 @@ import { AmountJson, Amounts, AmountString, + DenomLossEventType, MerchantInfo, NotificationType, OrderShortInfo, @@ -37,7 +38,7 @@ import { TransactionType, TransactionWithdrawal, TranslatedString, - WithdrawalType + WithdrawalType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -62,7 +63,7 @@ import { SmallLightText, SubTitle, SvgIcon, - WarningBox + WarningBox, } from "../components/styled/index.js"; import { Time } from "../components/Time.js"; import { alertFromError, useAlertContext } from "../context/alert.js"; @@ -107,6 +108,7 @@ export function TransactionPage({ tid, goToWalletHistory }: Props): VNode { return ( <ErrorAlertView error={alertFromError( + i18n, i18n.str`Could not load transaction information`, state, )} @@ -229,65 +231,75 @@ function TransactionTemplate({ <Fragment> <section style={{ padding: 8, textAlign: "center" }}> {transaction?.error && - // FIXME: wallet core should stop sending this error on KYC - transaction.error.code !== + // FIXME: wallet core should stop sending this error on KYC + transaction.error.code !== TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED ? ( <ErrorAlertView error={alertFromError( - i18n.str`There was an error trying to complete the transaction`, + i18n, + i18n.str`There was an error trying to complete the transaction.`, transaction.error, )} /> ) : undefined} - {transaction.txState.minor === TransactionMinorState.KycRequired && ( - <AlertView - alert={{ - type: "warning", - message: i18n.str`KYC check required for the transaction to complete`, - description: - transaction.kycUrl && typeof transaction.kycUrl === "string" ? ( - <div> - <i18n.Translate> - Follow this link to the{` `} - <a rel="noreferrer" target="_bank" href={transaction.kycUrl}>KYC verifier</a> - </i18n.Translate> - </div> - ) : ( - i18n.str`No more information has been provided` - ), - }} - /> - )} - {transaction.txState.minor === TransactionMinorState.AmlRequired && ( - <WarningBox> - <i18n.Translate> - The transaction has been blocked since the account required an AML - check - </i18n.Translate> - </WarningBox> - )} - {transaction.txState.major === TransactionMajorState.Pending && ( - <WarningBox> - <div style={{ justifyContent: "center", lineHeight: "25px" }}> - <i18n.Translate>This transaction is not completed</i18n.Translate> - <Link onClick={onRetry}> - <SvgIcon - title={i18n.str`Retry`} - dangerouslySetInnerHTML={{ __html: refreshIcon }} - color="black" - /> - </Link> - </div> - </WarningBox> - )} + {transaction.txState.major === TransactionMajorState.Pending && + (transaction.txState.minor === TransactionMinorState.KycRequired ? ( + <AlertView + alert={{ + type: "warning", + message: i18n.str`KYC check required for the transaction to complete.`, + description: + transaction.kycUrl && + typeof transaction.kycUrl === "string" ? ( + <div> + <i18n.Translate> + Follow this link to the{` `} + <a + rel="noreferrer" + target="_bank" + href={transaction.kycUrl} + > + KYC verifier. + </a> + </i18n.Translate> + </div> + ) : ( + i18n.str`No additional information has been provided.` + ), + }} + /> + ) : transaction.txState.minor === + TransactionMinorState.AmlRequired ? ( + <WarningBox> + <i18n.Translate> + The transaction has been blocked since the account required an + AML check. + </i18n.Translate> + </WarningBox> + ) : ( + <WarningBox> + <div style={{ justifyContent: "center", lineHeight: "25px" }}> + <i18n.Translate> + This transaction is not completed + </i18n.Translate> + <Link onClick={onRetry} style={{ padding: 0 }}> + <SvgIcon + title={i18n.str`Retry`} + dangerouslySetInnerHTML={{ __html: refreshIcon }} + color="black" + /> + </Link> + </div> + </WarningBox> + ))} {transaction.txState.major === TransactionMajorState.Aborted && ( <InfoBox> - <i18n.Translate>This transaction was aborted</i18n.Translate> + <i18n.Translate>This transaction was aborted.</i18n.Translate> </InfoBox> )} {transaction.txState.major === TransactionMajorState.Failed && ( <ErrorBox> - <i18n.Translate>This transaction failed</i18n.Translate> + <i18n.Translate>This transaction failed.</i18n.Translate> </ErrorBox> )} {confirmBeforeForget ? ( @@ -418,7 +430,7 @@ export function TransactionView({ transaction, onDelete, onAbort, - onBack, + // onBack, onResume, onSuspend, onRetry, @@ -435,8 +447,13 @@ export function TransactionView({ transaction.type === TransactionType.Withdrawal || transaction.type === TransactionType.InternalWithdrawal ) { - const conversion = transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer ? - transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] : [] + // const conversion = + // transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer + // ? transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] + // : []; + const blockedByKycOrAml = + transaction.txState.minor === TransactionMinorState.KycRequired || + transaction.txState.minor === TransactionMinorState.AmlRequired; return ( <TransactionTemplate transaction={transaction} @@ -456,22 +473,45 @@ export function TransactionView({ {transaction.exchangeBaseUrl} </Header> - {transaction.txState.major !== - TransactionMajorState.Pending ? undefined : - transaction.txState.minor === TransactionMinorState.KycRequired || - transaction.txState.minor === TransactionMinorState.AmlRequired ? undefined : - transaction - .withdrawalDetails.type === WithdrawalType.ManualTransfer ? ( - //manual withdrawal - <BankDetailsByPaytoType - amount={raw} - accounts={transaction.withdrawalDetails.exchangeCreditAccountDetails ?? []} - subject={transaction.withdrawalDetails.reservePub} - /> - ) : ( - //integrated bank withdrawal - <ShowWithdrawalDetailForBankIntegrated transaction={transaction} /> - )} + {transaction.txState.major !== TransactionMajorState.Pending || + blockedByKycOrAml ? undefined : transaction.withdrawalDetails.type === + WithdrawalType.ManualTransfer && + transaction.withdrawalDetails.exchangeCreditAccountDetails ? ( + <Fragment> + <InfoBox> + {transaction.withdrawalDetails.exchangeCreditAccountDetails + .length > 1 ? ( + <span> + <i18n.Translate> + Now the payment service provider is waiting for{" "} + <Amount value={raw} /> to be transferred. Select one of the + accounts and use the information below to complete the + operation by making a wire transfer from your bank account. + </i18n.Translate> + </span> + ) : ( + <span> + <i18n.Translate> + Now the payment service provider is waiting for{" "} + <Amount value={raw} /> to be transferred. Use the + information below to complete the operation by making a wire + transfer from your bank account. + </i18n.Translate> + </span> + )} + </InfoBox> + <BankDetailsByPaytoType + amount={raw} + accounts={ + transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] + } + subject={transaction.withdrawalDetails.reservePub} + /> + </Fragment> + ) : ( + //integrated bank withdrawal + <ShowWithdrawalDetailForBankIntegrated transaction={transaction} /> + )} <Part title={i18n.str`Details`} text={ @@ -550,6 +590,7 @@ export function TransactionView({ format="dd MMMM yyyy" /> } + . </i18n.Translate> </td> </tr> @@ -618,11 +659,11 @@ export function TransactionView({ price={getAmountWithFee(effective, raw, "debit")} effectiveRefund={effectiveRefund} info={transaction.info} - proposalId={transaction.proposalId} /> } kind="neutral" /> + <ShowFullContractTermPopup transactionId={transaction.transactionId} /> </TransactionTemplate> ); } @@ -664,7 +705,7 @@ export function TransactionView({ /> {!shouldBeWired ? ( <Part - title={i18n.str`Wire transfer deadline`} + title={i18n.str`Wire transfer deadline.`} text={ <Time timestamp={wireTime} format="dd MMMM yyyy 'at' HH:mm" /> } @@ -674,7 +715,7 @@ export function TransactionView({ <AlertView alert={{ type: "warning", - message: i18n.str`Wire transfer is not initiated`, + message: i18n.str`Wire transfer is not initiated.`, description: i18n.str` `, }} /> @@ -683,7 +724,7 @@ export function TransactionView({ <AlertView alert={{ type: "success", - message: i18n.str`Wire transfer completed`, + message: i18n.str`Wire transfer completed.`, description: i18n.str` `, }} /> @@ -701,7 +742,7 @@ export function TransactionView({ <AlertView alert={{ type: "info", - message: i18n.str`Wire transfer in progress`, + message: i18n.str`Wire transfer in progress.`, description: i18n.str` `, }} /> @@ -741,40 +782,6 @@ export function TransactionView({ ); } - if (transaction.type === TransactionType.Reward) { - return ( - <TransactionTemplate - transaction={transaction} - onDelete={onDelete} - onRetry={onRetry} - onAbort={onAbort} - onResume={onResume} - onSuspend={onSuspend} - onCancel={onCancel} - > - <Header - timestamp={transaction.timestamp} - type={i18n.str`Tip`} - total={effective} - kind="positive" - > - {transaction.merchantBaseUrl} - </Header> - {/* <Part - title={i18n.str`Merchant`} - text={<MerchantDetails merchant={transaction.merchant} />} - kind="neutral" - /> */} - <Part - title={i18n.str`Details`} - text={ - <TipDetails amount={getAmountWithFee(effective, raw, "credit")} /> - } - /> - </TransactionTemplate> - ); - } - if (transaction.type === TransactionType.Refund) { return ( <TransactionTemplate @@ -869,6 +876,7 @@ export function TransactionView({ /> {transaction.txState.major === TransactionMajorState.Pending && transaction.txState.minor === TransactionMinorState.Ready && + transaction.talerUri && !transaction.error && ( <Part title={i18n.str`URI`} @@ -1027,6 +1035,113 @@ export function TransactionView({ </TransactionTemplate> ); } + + if (transaction.type === TransactionType.DenomLoss) { + switch (transaction.lossEventType) { + case DenomLossEventType.DenomExpired: { + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > + <Header + timestamp={transaction.timestamp} + type={i18n.str`Debit`} + total={effective} + kind="negative" + > + <i18n.Translate>Lost</i18n.Translate> + </Header> + + <Part + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} + kind="neutral" + /> + <Part + title={i18n.str`Reason`} + text={i18n.str`Denomination expired.`} + /> + </TransactionTemplate> + ); + } + case DenomLossEventType.DenomVanished: { + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > + <Header + timestamp={transaction.timestamp} + type={i18n.str`Debit`} + total={effective} + kind="negative" + > + <i18n.Translate>Lost</i18n.Translate> + </Header> + + <Part + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} + kind="neutral" + /> + <Part + title={i18n.str`Reason`} + text={i18n.str`Denomination vanished.`} + /> + </TransactionTemplate> + ); + } + case DenomLossEventType.DenomUnoffered: { + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > + <Header + timestamp={transaction.timestamp} + type={i18n.str`Debit`} + total={effective} + kind="negative" + > + <i18n.Translate>Lost</i18n.Translate> + </Header> + + <Part + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} + kind="neutral" + /> + <Part + title={i18n.str`Reason`} + text={i18n.str`Denomination is unoffered.`} + /> + </TransactionTemplate> + ); + } + default: { + assertUnreachable(transaction.lossEventType); + } + } + } + if (transaction.type === TransactionType.Recoup) { + throw Error("recoup transaction not implemented"); + } assertUnreachable(transaction); } @@ -1070,127 +1185,6 @@ export function MerchantDetails({ ); } -// function DeliveryDetails({ -// date, -// location, -// }: { -// date: TalerProtocolTimestamp | undefined; -// location: Location | undefined; -// }): VNode { -// const { i18n } = useTranslationContext(); -// return ( -// <PurchaseDetailsTable> -// {location && ( -// <Fragment> -// {location.country && ( -// <tr> -// <td> -// <i18n.Translate>Country</i18n.Translate> -// </td> -// <td>{location.country}</td> -// </tr> -// )} -// {location.address_lines && ( -// <tr> -// <td> -// <i18n.Translate>Address lines</i18n.Translate> -// </td> -// <td>{location.address_lines}</td> -// </tr> -// )} -// {location.building_number && ( -// <tr> -// <td> -// <i18n.Translate>Building number</i18n.Translate> -// </td> -// <td>{location.building_number}</td> -// </tr> -// )} -// {location.building_name && ( -// <tr> -// <td> -// <i18n.Translate>Building name</i18n.Translate> -// </td> -// <td>{location.building_name}</td> -// </tr> -// )} -// {location.street && ( -// <tr> -// <td> -// <i18n.Translate>Street</i18n.Translate> -// </td> -// <td>{location.street}</td> -// </tr> -// )} -// {location.post_code && ( -// <tr> -// <td> -// <i18n.Translate>Post code</i18n.Translate> -// </td> -// <td>{location.post_code}</td> -// </tr> -// )} -// {location.town_location && ( -// <tr> -// <td> -// <i18n.Translate>Town location</i18n.Translate> -// </td> -// <td>{location.town_location}</td> -// </tr> -// )} -// {location.town && ( -// <tr> -// <td> -// <i18n.Translate>Town</i18n.Translate> -// </td> -// <td>{location.town}</td> -// </tr> -// )} -// {location.district && ( -// <tr> -// <td> -// <i18n.Translate>District</i18n.Translate> -// </td> -// <td>{location.district}</td> -// </tr> -// )} -// {location.country_subdivision && ( -// <tr> -// <td> -// <i18n.Translate>Country subdivision</i18n.Translate> -// </td> -// <td>{location.country_subdivision}</td> -// </tr> -// )} -// </Fragment> -// )} - -// {!location || !date ? undefined : ( -// <tr> -// <td colSpan={2}> -// <hr /> -// </td> -// </tr> -// )} -// {date && ( -// <Fragment> -// <tr> -// <td> -// <i18n.Translate>Date</i18n.Translate> -// </td> -// <td> -// <Time -// timestamp={AbsoluteTime.fromProtocolTimestamp(date)} -// format="dd MMMM yyyy, HH:mm" -// /> -// </td> -// </tr> -// </Fragment> -// )} -// </PurchaseDetailsTable> -// ); -// } - export function ExchangeDetails({ exchange }: { exchange: string }): VNode { return ( <div> @@ -1250,28 +1244,30 @@ export function InvoiceCreationDetails({ </tr> {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1295,28 +1291,30 @@ export function InvoicePaymentDetails({ </tr> {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.value} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1340,28 +1338,30 @@ export function TransferCreationDetails({ </tr> {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Transfer</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Transfer</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1385,43 +1385,46 @@ export function TransferPickupDetails({ </tr> {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } -export function WithdrawDetails({ conversion, amount }: { conversion?: AmountJson, amount: AmountWithFee }): VNode { - const { i18n } = useTranslationContext(); - - const maxFrac = [amount.fee, amount.fee] - .map((a) => Amounts.maxFractionalDigits(a)) - .reduce((c, p) => Math.max(c, p), 0); - const total = Amounts.add(amount.value, amount.fee).amount; +export function WithdrawDetails({ + conversion, + amount, +}: { + conversion?: AmountJson; + amount: AmountWithFee; +}): VNode { + const { i18n } = useTranslationContext(); return ( <PurchaseDetailsTable> - {conversion ? + {conversion ? ( <Fragment> <tr> <td> @@ -1431,7 +1434,8 @@ export function WithdrawDetails({ conversion, amount }: { conversion?: AmountJso <Amount value={conversion} maxFracSize={amount.maxFrac} /> </td> </tr> - {conversion.fraction === amount.value.fraction && conversion.value === amount.value.value ? undefined : + {conversion.fraction === amount.value.fraction && + conversion.value === amount.value.value ? undefined : ( <tr> <td> <i18n.Translate>Converted</i18n.Translate> @@ -1440,9 +1444,10 @@ export function WithdrawDetails({ conversion, amount }: { conversion?: AmountJso <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> - } + )} </Fragment> - : <tr> + ) : ( + <tr> <td> <i18n.Translate>Transfer</i18n.Translate> </td> @@ -1450,30 +1455,32 @@ export function WithdrawDetails({ conversion, amount }: { conversion?: AmountJso <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> - } + )} {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1481,27 +1488,16 @@ export function WithdrawDetails({ conversion, amount }: { conversion?: AmountJso export function PurchaseDetails({ price, effectiveRefund, - info, - proposalId, + info: _info, }: { price: AmountWithFee; effectiveRefund?: AmountJson; info: OrderShortInfo; - proposalId: string; }): VNode { const { i18n } = useTranslationContext(); const total = Amounts.add(price.value, price.fee).amount; - // const hasProducts = info.products && info.products.length > 0; - - // const hasShipping = - // info.delivery_date !== undefined || info.delivery_location !== undefined; - - const showLargePic = (): void => { - return; - }; - return ( <PurchaseDetailsTable> <tr> @@ -1513,69 +1509,72 @@ export function PurchaseDetails({ </td> </tr> {Amounts.isNonZero(price.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={price.fee} /> - </td> - </tr> - )} - {effectiveRefund && Amounts.isNonZero(effectiveRefund) ? ( - <Fragment> - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Subtotal</i18n.Translate> - </td> - <td> - <Amount value={price.total} /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Refunded</i18n.Translate> - </td> - <td> - <Amount value={effectiveRefund} negative /> - </td> - </tr> - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={Amounts.sub(total, effectiveRefund).amount} /> - </td> - </tr> - </Fragment> - ) : ( <Fragment> <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> <td> - <i18n.Translate>Total</i18n.Translate> + <i18n.Translate>Fees</i18n.Translate> </td> <td> - <Amount value={price.value} /> + <Amount value={price.fee} /> </td> </tr> + {effectiveRefund && Amounts.isNonZero(effectiveRefund) ? ( + <Fragment> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Subtotal</i18n.Translate> + </td> + <td> + <Amount value={price.total} /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Refunded</i18n.Translate> + </td> + <td> + <Amount value={effectiveRefund} negative /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={Amounts.sub(total, effectiveRefund).amount} /> + </td> + </tr> + </Fragment> + ) : ( + <Fragment> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={price.value} /> + </td> + </tr> + </Fragment> + )} </Fragment> )} + {/* {hasProducts && ( <tr> <td colSpan={2}> @@ -1621,11 +1620,6 @@ export function PurchaseDetails({ </td> </tr> )} */} - <tr> - <td> - <ShowFullContractTermPopup proposalId={proposalId} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1645,28 +1639,30 @@ function RefundDetails({ amount }: { amount: AmountWithFee }): VNode { </tr> {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1682,19 +1678,22 @@ function calculateAmountByWireTransfer( const allTracking = Object.values(state ?? {}); //group tracking by wtid, sum amounts - const trackByWtid = allTracking.reduce((prev, cur) => { - const fee = Amounts.parseOrThrow(cur.wireFee); - const raw = Amounts.parseOrThrow(cur.amountRaw); - const total = !prev[cur.wireTransferId] - ? raw - : Amounts.add(prev[cur.wireTransferId].total, raw).amount; - - prev[cur.wireTransferId] = { - total, - fee, - }; - return prev; - }, {} as Record<string, { total: AmountJson; fee: AmountJson }>); + const trackByWtid = allTracking.reduce( + (prev, cur) => { + const fee = Amounts.parseOrThrow(cur.wireFee); + const raw = Amounts.parseOrThrow(cur.amountRaw); + const total = !prev[cur.wireTransferId] + ? raw + : Amounts.add(prev[cur.wireTransferId].total, raw).amount; + + prev[cur.wireTransferId] = { + total, + fee, + }; + return prev; + }, + {} as Record<string, { total: AmountJson; fee: AmountJson }>, + ); //remove wire fee from total amount return Object.entries(trackByWtid).map(([id, info]) => ({ @@ -1724,7 +1723,7 @@ function TrackingDepositDetails({ </tr> {wireTransfers.map((wire) => ( - <tr> + <tr key={wire.id}> <td>{wire.id}</td> <td> <Amount value={wire.amount} /> @@ -1734,6 +1733,7 @@ function TrackingDepositDetails({ </PurchaseDetailsTable> ); } + function DepositDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); @@ -1749,28 +1749,30 @@ function DepositDetails({ amount }: { amount: AmountWithFee }): VNode { </tr> {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } @@ -1813,47 +1815,6 @@ function RefreshDetails({ amount }: { amount: AmountWithFee }): VNode { ); } -function TipDetails({ amount }: { amount: AmountWithFee }): VNode { - const { i18n } = useTranslationContext(); - - return ( - <PurchaseDetailsTable> - <tr> - <td> - <i18n.Translate>Tip</i18n.Translate> - </td> - <td> - <Amount value={amount.value} maxFracSize={amount.maxFrac} /> - </td> - </tr> - - {Amounts.isNonZero(amount.fee) && ( - <tr> - <td> - <i18n.Translate>Fees</i18n.Translate> - </td> - <td> - <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> - </td> - </tr> - )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.total} maxFracSize={amount.maxFrac} /> - </td> - </tr> - </PurchaseDetailsTable> - ); -} - function Header({ timestamp, total, @@ -2001,12 +1962,13 @@ function ShowWithdrawalDetailForBankIntegrated({ if ( transaction.txState.major !== TransactionMajorState.Pending || transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer - ) + ) { return <Fragment />; + } const raw = Amounts.parseOrThrow(transaction.amountRaw); return ( <Fragment> - <EnabledBySettings name="advanceMode"> + <EnabledBySettings name="advancedMode"> <a href="#" onClick={(e) => { @@ -2014,19 +1976,21 @@ function ShowWithdrawalDetailForBankIntegrated({ setShowDetails(!showDetails); }} > - show details + Show details. </a> </EnabledBySettings> {showDetails && ( <BankDetailsByPaytoType amount={raw} - accounts={transaction.withdrawalDetails.exchangeCreditAccountDetails ?? []} + accounts={ + transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] + } subject={transaction.withdrawalDetails.reservePub} /> )} {!transaction.withdrawalDetails.confirmed && - transaction.withdrawalDetails.bankConfirmationUrl ? ( + transaction.withdrawalDetails.bankConfirmationUrl ? ( <InfoBox> <div style={{ display: "block" }}> <i18n.Translate> @@ -2049,7 +2013,7 @@ function ShowWithdrawalDetailForBankIntegrated({ <InfoBox> <i18n.Translate> Bank has confirmed the wire transfer. Waiting for the exchange to - send the coins + send the coins. </i18n.Translate> </InfoBox> )} diff --git a/packages/taler-wallet-webextension/src/wallet/Welcome.tsx b/packages/taler-wallet-webextension/src/wallet/Welcome.tsx index e19152be2..6a57fe18a 100644 --- a/packages/taler-wallet-webextension/src/wallet/Welcome.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Welcome.tsx @@ -57,65 +57,41 @@ export function View({ return ( <Fragment> <Title> - <i18n.Translate>Browser Extension Installed!</i18n.Translate> + <i18n.Translate>GNU Taler Wallet installed!</i18n.Translate> </Title> <div> <p> <i18n.Translate> - You can open the GNU Taler Wallet using the combination{" "} + You can open the wallet using the combination{" "} <pre style="font-weight: bold; display: inline;"><ALT+W></pre> . </i18n.Translate> </p> - {!platform.isFirefox() && ( - <Fragment> - <p> - <i18n.Translate> - Also pinning the GNU Taler Wallet to your Chrome browser allows - you to quick access without keyboard: - </i18n.Translate> - </p> - <ol style={{ paddingLeft: 40 }}> - <li> - <i18n.Translate>Click the puzzle icon</i18n.Translate> - </li> - <li> - <i18n.Translate>Search for GNU Taler Wallet</i18n.Translate> - </li> - <li> - <i18n.Translate>Click the pin icon</i18n.Translate> - </li> - </ol> - </Fragment> - )} - <SubTitle> - <i18n.Translate>Navigator</i18n.Translate> - </SubTitle> - <Checkbox - label={i18n.str`Inject Taler support in all pages`} - name="inject" - description={ + <Fragment> + <p> <i18n.Translate> - Disabling this option will make some web application not able to - trigger the wallet when clicking links but you will be able to - open the wallet using the keyboard shortcut + Also pinning the GNU Taler Wallet to your browser allows + you to quick access without keyboard: </i18n.Translate> - } - enabled={permissionToggle.value!} - onToggle={permissionToggle.button.onClick!} - /> + </p> + <ol style={{ paddingLeft: 40 }}> + <li> + <i18n.Translate>Click the puzzle icon</i18n.Translate> + </li> + <li> + <i18n.Translate>Search for GNU Taler Wallet</i18n.Translate> + </li> + <li> + <i18n.Translate>Click the pin icon</i18n.Translate> + </li> + </ol> + </Fragment> <SubTitle> <i18n.Translate>Next Steps</i18n.Translate> </SubTitle> <a href="https://demo.taler.net/" style={{ display: "block" }}> <i18n.Translate>Try the demo</i18n.Translate> » </a> - <a href="https://demo.taler.net/" style={{ display: "block" }}> - <i18n.Translate> - Learn how to top up your wallet balance - </i18n.Translate>{" "} - » - </a> </div> </Fragment> ); diff --git a/packages/taler-wallet-webextension/src/wallet/index.stories.tsx b/packages/taler-wallet-webextension/src/wallet/index.stories.tsx index 989292326..89bb75b29 100644 --- a/packages/taler-wallet-webextension/src/wallet/index.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/index.stories.tsx @@ -24,7 +24,6 @@ export * as a4 from "./DepositPage/stories.js"; export * as a7 from "./History.stories.js"; export * as a8 from "./AddBackupProvider/stories.js"; export * as a10 from "./ProviderDetail.stories.js"; -export * as a11 from "./ReserveCreated.stories.js"; export * as a12 from "./Settings.stories.js"; export * as a13 from "./Transaction.stories.js"; export * as a14 from "./Welcome.stories.js"; diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 8fb8211ae..195efecd4 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -31,7 +31,7 @@ import { TalerError, TalerErrorCode, TalerErrorDetail, - WalletDiagnostics, + WalletNotification } from "@gnu-taler/taler-util"; import { WalletCoreApiClient, @@ -40,6 +40,7 @@ import { WalletCoreResponseType, } from "@gnu-taler/taler-wallet-core"; import { + ExtensionNotification, MessageFromBackend, MessageFromFrontendBackground, MessageFromFrontendWallet, @@ -53,26 +54,30 @@ import { platform } from "./platform/foreground.js"; const logger = new Logger("wxApi"); -export const WALLET_CORE_SUPPORTED_VERSION = "1:0:0" +export const WALLET_CORE_SUPPORTED_VERSION = "4:0:0" export interface ExtendedPermissionsResponse { newValue: boolean; } export interface BackgroundOperations { - freeze: { - request: number; + resetDb: { + request: void; response: void; }; - sum: { - request: number[]; - response: number; + runGarbageCollector: { + request: void; + response: void; }; - resetDb: { + reinitWallet: { request: void; response: void; }; - runGarbageCollector: { + getNotifications: { + request: void; + response: WalletEvent[]; + }; + clearNotifications: { request: void; response: void; }; @@ -83,16 +88,10 @@ export interface BackgroundOperations { }; response: void; }; - containsHeaderListener: { - request: void; - response: ExtendedPermissionsResponse; - }; - toggleHeaderListener: { - request: boolean; - response: ExtendedPermissionsResponse; - }; } +export type WalletEvent = { notification: WalletNotification, when: AbsoluteTime } + export interface BackgroundApiClient { call<Op extends keyof BackgroundOperations>( operation: Op, @@ -101,11 +100,13 @@ export interface BackgroundApiClient { } export class BackgroundError<T = any> extends Error { - public errorDetail: TalerErrorDetail & T; + public readonly errorDetail: TalerErrorDetail & T; + public readonly cause: Error; - constructor(title: string, e: TalerErrorDetail & T) { + constructor(title: string, e: TalerErrorDetail & T, cause: Error) { super(title); this.errorDetail = e; + this.cause = cause; } hasErrorCode<C extends keyof DetailsMap>( @@ -138,7 +139,7 @@ class BackgroundApiClientImpl implements BackgroundApiClient { throw new BackgroundError(operation, { code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, when: AbsoluteTime.now(), - }); + }, error); } throw error; } @@ -146,6 +147,7 @@ class BackgroundApiClientImpl implements BackgroundApiClient { throw new BackgroundError( `Background operation "${operation}" failed`, response.error, + TalerError.fromUncheckedDetail(response.error), ); } logger.trace("response", response); @@ -169,14 +171,20 @@ class WalletApiClientImpl implements WalletCoreApiClient { payload, }; response = await platform.sendMessageToBackground(message); - } catch (e) { - logger.error("Error calling backend", e); - throw new Error(`Error contacting backend: ${e}`); + } catch (error) { + if (error instanceof Error) { + throw new BackgroundError(operation, { + code: TalerErrorCode.GENERIC_UNEXPECTED_REQUEST_ERROR, + when: AbsoluteTime.now(), + }, error); + } + throw error; } if (response.type === "error") { throw new BackgroundError( `Wallet operation "${operation}" failed`, response.error, + TalerError.fromUncheckedDetail(response.error) ); } logger.trace("got response", response); @@ -186,7 +194,7 @@ class WalletApiClientImpl implements WalletCoreApiClient { function onUpdateNotification( messageTypes: Array<NotificationType>, - doCallback: undefined | (() => void), + doCallback: undefined | ((n: WalletNotification) => void), ): () => void { //if no callback, then ignore if (!doCallback) @@ -194,9 +202,9 @@ function onUpdateNotification( return; }; const onNewMessage = (message: MessageFromBackend): void => { - const shouldNotify = messageTypes.includes(message.type); + const shouldNotify = message.type === "wallet" && messageTypes.includes(message.notification.type); if (shouldNotify) { - doCallback(); + doCallback(message.notification); } }; return platform.listenToWalletBackground(onNewMessage); @@ -206,14 +214,23 @@ export type WxApiType = { wallet: WalletCoreApiClient; background: BackgroundApiClient; listener: { + trigger: (d: ExtensionNotification) => void; onUpdateNotification: typeof onUpdateNotification; }; }; +function trigger(w: ExtensionNotification) { + platform.triggerWalletEvent({ + type: "web-extension", + notification: w, + }) +} + export const wxApi = { wallet: new WalletApiClientImpl(), background: new BackgroundApiClientImpl(), listener: { + trigger, onUpdateNotification, }, }; diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts index a194de0ff..008f80c57 100644 --- a/packages/taler-wallet-webextension/src/wxBackend.ts +++ b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -24,36 +24,42 @@ * Imports. */ import { + AbsoluteTime, + BalanceFlag, LogLevel, Logger, + NotificationType, + OpenedPromise, + SetTimeoutTimerAPI, + TalerError, TalerErrorCode, + TalerErrorDetail, + TransactionMajorState, + TransactionMinorState, + WalletNotification, getErrorDetailFromException, makeErrorDetail, + openPromise, setGlobalLogLevelFromString, - setLogLevelFromString + setLogLevelFromString, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { DbAccess, - OpenedPromise, - SetTimeoutTimerAPI, SynchronousCryptoWorkerFactoryPlain, Wallet, + WalletApiOperation, WalletOperations, WalletStoresV1, deleteTalerDatabase, exportDb, importDb, - openPromise, } from "@gnu-taler/taler-wallet-core"; -import { - BrowserHttpLib, - ServiceWorkerHttpLib, -} from "@gnu-taler/web-util/browser"; import { MessageFromFrontend, MessageResponse } from "./platform/api.js"; import { platform } from "./platform/background.js"; import { ExtensionOperations } from "./taler-wallet-interaction-loader.js"; -import { BackgroundOperations, ExtendedPermissionsResponse } from "./wxApi.js"; +import { BackgroundOperations, WalletEvent } from "./wxApi.js"; +import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser"; /** * Currently active wallet instance. Might be unloaded and @@ -65,11 +71,6 @@ let currentWallet: Wallet | undefined; let currentDatabase: DbAccess<typeof WalletStoresV1> | undefined; -/** - * Last version of an outdated DB, if applicable. - */ -let outdatedDbVersion: number | undefined; - const walletInit: OpenedPromise<void> = openPromise<void>(); const logger = new Logger("wxBackend.ts"); @@ -91,6 +92,16 @@ async function resetDb(): Promise<void> { await reinitWallet(); } +//FIXME: maybe circular buffer +const notifications: WalletEvent[] = []; +async function getNotifications(): Promise<WalletEvent[]> { + return notifications; +} + +async function clearNotifications(): Promise<void> { + notifications.splice(0, notifications.length); +} + async function runGarbageCollector(): Promise<void> { const dbBeforeGc = currentDatabase; if (!dbBeforeGc) { @@ -111,46 +122,30 @@ async function runGarbageCollector(): Promise<void> { logger.info("imported"); } -function freeze(time: number): Promise<void> { - return new Promise((res, rej) => { - setTimeout(res, time); - }); -} - -async function sum(ns: Array<number>): Promise<number> { - return ns.reduce((prev, cur) => prev + cur, 0); -} - const extensionHandlers: ExtensionHandlerType = { - isInjectionEnabled, isAutoOpenEnabled, + isDomainTrusted, }; -async function isInjectionEnabled(): Promise<boolean> { +async function isAutoOpenEnabled(): Promise<boolean> { const settings = await platform.getSettingsFromStorage(); - return settings.injectTalerSupport === true; + return settings.autoOpen === true; } -async function isAutoOpenEnabled(): Promise<boolean> { +async function isDomainTrusted(): Promise<boolean> { const settings = await platform.getSettingsFromStorage(); - return settings.autoOpen === true; + return settings.injectTalerSupport === true; } const backendHandlers: BackendHandlerType = { - freeze, - sum, resetDb, runGarbageCollector, + getNotifications, + clearNotifications, + reinitWallet, setLoggingLevel, - containsHeaderListener, - toggleHeaderListener, }; -async function containsHeaderListener(): Promise<ExtendedPermissionsResponse> { - const result = platform.containsTalerHeaderListener(); - return { newValue: result }; -} - async function setLoggingLevel({ tag, level, @@ -165,10 +160,13 @@ async function setLoggingLevel({ setLogLevelFromString(tag, level); } } +let nextMessageIndex = 0; async function dispatch< Op extends WalletOperations | BackgroundOperations | ExtensionOperations, >(req: MessageFromFrontend<Op> & { id: string }): Promise<MessageResponse> { + nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100); + switch (req.channel) { case "background": { const handler = backendHandlers[req.operation] as (req: any) => any; @@ -231,19 +229,34 @@ async function dispatch< case "wallet": { const w = currentWallet; if (!w) { + const lastError: TalerErrorDetail = + walletInit.lastError instanceof TalerError + ? walletInit.lastError.errorDetail + : undefined; + return { type: "error", id: req.id, operation: req.operation, error: makeErrorDetail( TalerErrorCode.WALLET_CORE_NOT_AVAILABLE, - {}, - "wallet core not available", + { lastError }, + `wallet core not available${ + !lastError ? "" : `,last error: ${lastError.hint}` + }`, ), }; } - - return await w.handleCoreApiRequest(req.operation, req.id, req.payload); + //multiple client can create the same id, send the wallet an unique key + const newId = `${req.id}_${nextMessageIndex}`; + const resp = await w.handleCoreApiRequest( + req.operation, + newId, + req.payload, + ); + //return to the client the original id + resp.id = req.id; + return resp; } } @@ -262,21 +275,24 @@ async function dispatch< async function reinitWallet(): Promise<void> { if (currentWallet) { - currentWallet.stop(); + await currentWallet.client.call(WalletApiOperation.Shutdown, {}); currentWallet = undefined; } currentDatabase = undefined; // setBadgeText({ text: "" }); - let httpLib: HttpRequestLibrary; let cryptoWorker; let timer; + const httpFactory = (): HttpRequestLibrary => { + return new BrowserFetchHttpLib({ + // enableThrottling: false, + }); + }; + if (platform.useServiceWorkerAsBackgroundProcess()) { - httpLib = new ServiceWorkerHttpLib(); cryptoWorker = new SynchronousCryptoWorkerFactoryPlain(); timer = new SetTimeoutTimerAPI(); } else { - httpLib = new BrowserHttpLib(); // We could (should?) use the BrowserCryptoWorkerFactory here, // but right now we don't, to have less platform differences. // cryptoWorker = new BrowserCryptoWorkerFactory(); @@ -288,37 +304,49 @@ async function reinitWallet(): Promise<void> { logger.info("Setting up wallet"); const wallet = await Wallet.create( indexedDB as any, - httpLib as any, + httpFactory as any, timer, cryptoWorker, - { - features: { - allowHttp: settings.walletAllowHttp, - }, - }, ); try { - await wallet.handleCoreApiRequest("initWallet", "native-init", {}); + await wallet.handleCoreApiRequest("initWallet", "native-init", { + config: { + testing: { + emitObservabilityEvents: settings.showWalletActivity, + devModeActive: settings.advancedMode, + }, + features: { + allowHttp: settings.walletAllowHttp, + }, + }, + }); } catch (e) { logger.error("could not initialize wallet", e); walletInit.reject(e); return; } wallet.addNotificationListener((message) => { - logger.info("wallet -> ui", message); - platform.sendMessageToAllChannels(message); - }); + if (settings.showWalletActivity) { + notifications.push({ + notification: message, + when: AbsoluteTime.now(), + }); + } + + processWalletNotification(message); - platform.keepAlive(() => { - return wallet.runTaskLoop().catch((e) => { - logger.error("error during wallet task loop", e); + platform.sendMessageToAllChannels({ + type: "wallet", + notification: message, }); }); + // Useful for debugging in the background page. if (typeof window !== "undefined") { (window as any).talerWallet = wallet; } currentWallet = wallet; + updateIconBasedOnBalance(); return walletInit.resolve(); } @@ -355,66 +383,47 @@ export async function wxMain(): Promise<void> { } catch (e) { console.error(e); } +} - // platform.registerDeclarativeRedirect(); - // if (false) { - /** - * this is not working reliable on chrome, just - * intercepts queries after the user clicks the popups - * which doesn't make sense, keeping it to make more tests - */ - - logger.trace("check taler header listener"); - const enabled = platform.containsTalerHeaderListener() - if (!enabled) { - logger.info("header listener on") - const perm = await platform.getPermissionsApi().containsHostPermissions() - if (perm) { - logger.info("header listener allowed") - try { - platform.registerTalerHeaderListener(); - } catch (e) { - logger.error("could not register header listener", e); +async function updateIconBasedOnBalance() { + const balance = await currentWallet?.client.call( + WalletApiOperation.GetBalances, + {}, + ); + if (balance) { + let showAlert = false; + for (const b of balance.balances) { + if (b.flags.length > 0) { + console.log("b.flags", JSON.stringify(b.flags)) + showAlert = true; + break; } - } else { - logger.info("header listener requested") - await platform.getPermissionsApi().requestHostPermissions() } - } - // On platforms that support it, also listen to external - // modification of permissions. - platform.getPermissionsApi().addPermissionsListener((perm, lastError) => { - logger.info(`permission added: ${perm}`,) - if (lastError) { - logger.error( - `there was a problem trying to get permission ${perm}`, - lastError, - ); - return; + if (showAlert) { + platform.setAlertedIcon(); + } else { + platform.setNormalIcon(); } - platform.registerTalerHeaderListener(); - }); - - // } + } } - -async function toggleHeaderListener( - newVal: boolean, -): Promise<ExtendedPermissionsResponse> { - logger.trace("new extended permissions value", newVal); - if (newVal) { - try { - platform.registerTalerHeaderListener(); - return { newValue: true }; - } catch (e) { - logger.error("FAIL to toggle",e) - } - return { newValue: false } +/** + * All the actions triggered by notification that need to be + * run in the background. + * + * @param message + */ +async function processWalletNotification(message: WalletNotification) { + if ( + message.type === NotificationType.TransactionStateTransition && + (message.newTxState.minor === TransactionMinorState.KycRequired || + message.oldTxState.minor === TransactionMinorState.KycRequired || + message.newTxState.minor === TransactionMinorState.AmlRequired || + message.oldTxState.minor === TransactionMinorState.AmlRequired || + message.newTxState.minor === TransactionMinorState.BankConfirmTransfer || + message.oldTxState.minor === TransactionMinorState.BankConfirmTransfer) + ) { + await updateIconBasedOnBalance(); } - - const rem = await platform.getPermissionsApi().removeHostPermissions(); - logger.trace("permissions removed:", rem); - return { newValue: false }; } diff --git a/packages/taler-wallet-webextension/static/wallet.html b/packages/taler-wallet-webextension/static/wallet.html index c8baa7e4d..3025901d8 100644 --- a/packages/taler-wallet-webextension/static/wallet.html +++ b/packages/taler-wallet-webextension/static/wallet.html @@ -4,6 +4,7 @@ <head> <title>GNU Taler Wallet - WebExtension</title> <meta charset="utf-8" /> + <meta name="taler-support" content="uri,api" /> <link rel="stylesheet" type="text/css" href="/dist/walletEntryPoint.css" /> <link rel="stylesheet" type="text/css" href="/static/font/import.css" /> <link rel="icon" |