commit b69bcd14426292a8c1373312c0e2d68ee7d85e3e parent 6bfa209354131942906566b156a9084074bf1090 Author: Martin Schanzenbach <schanzen@gnunet.org> Date: Wed, 15 Oct 2025 13:29:58 +0200 Add contacts handling. Issue #10501 The feature remains behind a settings switch as it is incomplete. Diffstat:
24 files changed, 1355 insertions(+), 5 deletions(-)
diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -25,6 +25,7 @@ import { AbsoluteTime } from "./time.js"; import { TransactionState } from "./types-taler-wallet-transactions.js"; import { + ContactEntry, ExchangeEntryState, TalerErrorDetail, TransactionIdStr, @@ -34,6 +35,8 @@ export enum NotificationType { BalanceChange = "balance-change", BankAccountChange = "bank-account-change", BackupOperationError = "backup-error", + ContactAdded = "contact-added", + ContactDeleted = "contact-deleted", TransactionStateTransition = "transaction-state-transition", ExchangeStateTransition = "exchange-state-transition", Idle = "idle", @@ -117,6 +120,30 @@ export interface ExchangeStateTransitionNotification { } /** + * Notification emitted when a contact is added + */ +export interface ContactAddedNotification { + type: NotificationType.ContactAdded; + + /** + * The contact that was added + */ + contact: ContactEntry; +} + +/** + * Notification emitted when a contact is deleted + */ +export interface ContactDeletedNotification { + type: NotificationType.ContactDeleted; + + /** + * The contact that was deleted + */ + contact: ContactEntry; +} + +/** * Transaction emitted when a bank account changes. */ export interface BankAccountChangeNotification { @@ -289,6 +316,8 @@ export type WalletNotification = | BalanceChangeNotification | BankAccountChangeNotification | BackupOperationErrorNotification + | ContactAddedNotification + | ContactDeletedNotification | ExchangeStateTransitionNotification | TransactionStateTransitionNotification | TaskProgressNotification diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -50,7 +50,8 @@ export type TalerUri = | WithdrawUriResult | WithdrawExchangeUri | AddExchangeUri - | WithdrawalTransferResultUri; + | WithdrawalTransferResultUri + | AddContactUri; declare const __action_str: unique symbol; export type TalerUriString = string & { [__action_str]: true }; @@ -107,6 +108,7 @@ export namespace TalerUris { export type URI = TalerUri; const supported_targets: Record<TalerUriAction, true> = { + "add-contact": true, "add-exchange": true, "dev-experiment": true, pay: true, @@ -232,6 +234,20 @@ export namespace TalerUris { exchangeBaseUrl, }; } + export function createTalerAddContact( + alias: string, + aliasType: string, + mailboxUri: string, + sourceBaseUrl: string, + ): AddContactUri { + return { + type: TalerUriAction.AddContact, + alias: alias, + aliasType: aliasType, + mailboxUri: mailboxUri, + sourceBaseUrl: sourceBaseUrl, + }; + } export function createTalerWithdrawalTransferResult( ref: string, opts: { @@ -273,6 +289,10 @@ export namespace TalerUris { if (p.status) result.push(["status", p.status]); return result; } + case TalerUriAction.AddContact: { + if (p.sourceBaseUrl) result.push(["sourceBaseUrl", p.sourceBaseUrl]); + return result; + } case TalerUriAction.Refund: case TalerUriAction.PayPush: case TalerUriAction.PayPull: @@ -310,6 +330,7 @@ export namespace TalerUris { case TalerUriAction.Restore: case TalerUriAction.DevExperiment: case TalerUriAction.WithdrawalTransferResult: + case TalerUriAction.AddContact: return TALER_PREFIX; default: assertUnreachable(p); @@ -349,6 +370,8 @@ export namespace TalerUris { return `/${p.devExperimentId}`; case TalerUriAction.WithdrawalTransferResult: return `/`; + case TalerUriAction.AddContact: + return `/${p.aliasType}/${p.alias}/${p.mailboxUri}` default: assertUnreachable(p); } @@ -802,6 +825,34 @@ export namespace TalerUris { }), ); } + case TalerUriAction.AddContact: { + // check number of segments + if (cs.length !== 3) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + const exchange = Paytos.parseHostPortPath2( + cs[0], + cs.slice(1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: exchange, + }, + ); + } + + return opFixedSuccess<URI>( + createTalerAddContact(cs[0], cs[1], cs[2], params["sourceBaseUrl"]), + ); + } default: { assertUnreachable(uriType); } @@ -889,6 +940,14 @@ export interface WithdrawalTransferResultUri { status?: "success" | "aborted"; } +export interface AddContactUri { + type: TalerUriAction.AddContact; + alias: string; + aliasType: string; + mailboxUri: string; + sourceBaseUrl: string; +} + /** * Parse a taler[+http]://withdraw URI. * Return undefined if not passed a valid URI. @@ -982,6 +1041,33 @@ export function parseAddExchangeUriWithError(s: string) { } /** + * Parse a taler[+http]://add-contact URI. + * Return undefined if not passed a valid URI. + */ +export function parseAddContactUriWithError(s: string) { + const pi = parseProtoInfoWithError(s, "add-contact"); + if (pi.tag === "error") { + return pi; + } + const parts = pi.value.rest.split("/"); + + if (parts.length !== 3) { + return Result.error(TalerErrorCode.WALLET_TALER_URI_MALFORMED); + } + const q = new URLSearchParams(parts[2] ?? ""); + const sourceBaseUrl = q.get("sourceBaseUrl") ?? ""; + + const result: AddContactUri = { + type: TalerUriAction.AddContact, + aliasType: parts[0], + alias: parts[1], + mailboxUri: parts[2], + sourceBaseUrl: sourceBaseUrl, + }; + return Result.of(result); +} + +/** * * @deprecated use parseWithdrawUriWithError */ @@ -991,6 +1077,16 @@ export function parseAddExchangeUri(s: string): AddExchangeUri | undefined { return r.value; } +/** + * + * @deprecated use parseWithdrawUriWithError + */ +export function parseAddContactUri(s: string): AddContactUri | undefined { + const r = parseAddContactUriWithError(s); + if (r.tag === "error") return undefined; + return r.value; +} + export enum TalerUriAction { /** * https://lsd.gnunet.org/lsd0006/#section-5.1 @@ -1036,6 +1132,11 @@ export enum TalerUriAction { * https://lsd.gnunet.org/lsd0006/#section-5.11 */ WithdrawalTransferResult = "withdrawal-transfer-result", + /** + * FIXME: LSD + * Add a contact to the wallet + */ + AddContact = "add-contact" } interface TalerUriProtoInfo { @@ -1115,6 +1216,7 @@ const parsers: { [A in TalerUriAction]: Parser } = { [TalerUriAction.DevExperiment]: parseDevExperimentUri, [TalerUriAction.WithdrawExchange]: parseWithdrawExchangeUri, [TalerUriAction.AddExchange]: parseAddExchangeUri, + [TalerUriAction.AddContact]: parseAddContactUri, [TalerUriAction.WithdrawalTransferResult]: () => { throw new Error("not supported"); }, @@ -1176,6 +1278,9 @@ export function stringifyTalerUri(uri: TalerUri): string { case TalerUriAction.AddExchange: { return stringifyAddExchange(uri); } + case TalerUriAction.AddContact: { + return stringifyAddContact(uri); + } case TalerUriAction.WithdrawalTransferResult: { throw Error("not supported"); } @@ -1582,6 +1687,25 @@ export function stringifyAddExchange({ * @param param0 * @returns */ +export function stringifyAddContact({ + alias, + aliasType, + mailboxUri, + sourceBaseUrl, +}: Omit<AddContactUri, "type">): string { + const baseUri = `taler://add-contact/${aliasType}/${alias}/${mailboxUri}`; + if (sourceBaseUrl) { + return baseUri + `?sourceBaseUrl=${sourceBaseUrl}`; + } else { + return baseUri; + } +} + +/** + * @deprecated + * @param param0 + * @returns + */ export function stringifyDevExperimentUri({ devExperimentId, }: Omit<DevExperimentUri, "type">): string { diff --git a/packages/taler-util/src/taleruris.test.ts b/packages/taler-util/src/taleruris.test.ts @@ -586,6 +586,17 @@ test("taler-new add exchange URI (stringify)", (t) => { t.deepEqual(url, "taler://add-exchange/exchange.demo.taler.net/"); }); +test("taler-new add contact URI (stringify)", (t) => { + const url = TalerUris.toString({ + type: TalerUriAction.AddContact, + alias: "bob@example.com", + aliasType: "email", + mailboxUri: "https://mailbox.example.com/ABCDEFG", + sourceBaseUrl: "https://taldir.example.com", + }); + t.deepEqual(url, "taler://add-exchange/exchange.demo.taler.net/"); +}); + /** * wrong uris */ diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1358,6 +1358,37 @@ export interface ExchangeDetailedResponse { exchange: ExchangeFullDetails; } +export interface AddContactRequest { + contact: ContactEntry; +} + +export interface DeleteContactRequest { + contact: ContactEntry; +} + +export interface ContactListResponse { + contacts: ContactEntry[]; +} + +export const codecForContactListItem = (): Codec<ContactEntry> => + buildCodecForObject<ContactEntry>() + .property("alias", codecForString()) + .property("aliasType", codecForString()) + .property("mailboxUri", codecForString()) + .property("source", codecForString()) + .build("ContactListItem"); + +export const codecForAddContactRequest = (): Codec<AddContactRequest> => + buildCodecForObject<AddContactRequest>() + .property("contact", codecForContactListItem()) + .build("AddContactRequest"); + +export const codecForDeleteContactRequest = (): Codec<DeleteContactRequest> => + buildCodecForObject<DeleteContactRequest>() + .property("contact", codecForContactListItem()) + .build("DeleteContactRequest"); + + export interface WalletCoreVersion { implementationSemver: string; implementationGitHash: string; @@ -1691,6 +1722,29 @@ export interface ExchangeListItem { unavailableReason?: TalerErrorDetail; } +export interface ContactEntry { + /** + * Contact alias + */ + alias: string, + + /** + * Alias type + */ + aliasType: string, + + /** + * mailbox URI + */ + mailboxUri: string, + + /** + * The source of this contact + * may be a URI + */ + source: string, +} + const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> => buildCodecForObject<AuditorDenomSig>() .property("denom_pub_h", codecForString()) diff --git a/packages/taler-wallet-core/src/contacts.ts b/packages/taler-wallet-core/src/contacts.ts @@ -0,0 +1,174 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + (C) 2025 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/> + */ + +/** + * @fileoverview + * Implementation of contacts management in wallet-core. + * The details of contacts management are specified in DD48. + */ + +import { + EmptyObject, + AddContactRequest, + DeleteContactRequest, + ContactListResponse, + ContactEntry, + Logger, + NotificationType, +} from "@gnu-taler/taler-util"; +import { + ContactRecord, + WalletDbReadOnlyTransaction +} from "./db.js" +import { + WalletExecutionContext, +} from "./wallet.js"; + + +const logger = new Logger("contacts.ts"); + +async function makeContactListItem( + wex: WalletExecutionContext, + tx: WalletDbReadOnlyTransaction< + ["contacts"] + >, + r: ContactRecord, + //lastError: TalerErrorDetail | undefined, +): Promise<ContactEntry> { + //const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError + // ? { + // error: lastError, + // } + // : undefined; + + const listItem: ContactEntry = { + alias: r.alias, + aliasType: r.aliasType, + mailboxUri: r.mailboxUri, + source: r.source, + }; + //switch (listItem.exchangeUpdateStatus) { + // case ExchangeUpdateStatus.UnavailableUpdate: + // if (r.unavailableReason) { + // listItem.unavailableReason = r.unavailableReason; + // } + // break; + //} + return listItem; +} + + +/** + * Add contact to the database. + */ +export async function addContact( + wex: WalletExecutionContext, + req: AddContactRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { + storeNames: [ + "contacts", + ], + }, + async (tx) => { + tx.contacts.put (req.contact); + tx.notify({ + type: NotificationType.ContactAdded, + contact: req.contact, + }); + }, + ); + return { }; +} + +/** + * Delete contact from the database. + */ +export async function deleteContact( + wex: WalletExecutionContext, + req: DeleteContactRequest, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx( + { + storeNames: [ + "contacts", + ], + }, + async (tx) => { + tx.contacts.delete ([req.contact.alias, req.contact.aliasType]); + tx.notify({ + type: NotificationType.ContactDeleted, + contact: req.contact, + }); + }, + ); + return { }; +} + +/** + * Get contacts from the database. + */ +export async function listContacts( + wex: WalletExecutionContext, + req: EmptyObject, +): Promise<ContactListResponse> { + const contacts: ContactEntry[] = []; + await wex.db.runReadOnlyTx( + { + storeNames: [ + "contacts", + ], + }, + async (tx) => { + const contactsRecords = await tx.contacts.iter().toArray(); + for (const contactRec of contactsRecords) { + //const taskId = constructTaskIdentifier({ + // tag: PendingTaskType.FriendUpdate, + // fiendAlias: friendRec.alias, + //}); + //const opRetryRecord = await tx.operationRetries.get(taskId); + // FIXME maybe string filter here? + //if (req.filterByScope) { + // const inScope = await checkExchangeInScopeTx( + // tx, + // exchangeRec.baseUrl, + // req.filterByScope, + // ); + // if (!inScope) { + // continue; + // } + //} + const li = await makeContactListItem( + wex, + tx, + contactRec, + //opRetryRecord?.lastError, + ); + // FIXME aliases can expire, we probably want this. + //if (req.filterByFriendEntryStatus) { + // if (req.filterByFriendEntryStatus !== li.exchangeEntryStatus) { + // continue; + // } + //} + contacts.push(li); + } + }, + ); + return { contacts: contacts }; +} + diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -2906,6 +2906,28 @@ export interface DonationSummaryRecord { } /** + * Record for contacts + */ +export interface ContactRecord { + /** + * The mailbox URI of this contact + */ + mailboxUri: string; + /** + * The alias of this contact + */ + alias: string; + /** + * The type of the alias + */ + aliasType: string; + /** + * The source of this alias + */ + source: string; +} + +/** * Schema definition for the IndexedDB * wallet database. */ @@ -3153,6 +3175,12 @@ export const WalletStoresV1 = { ]), }, ), + contacts: describeStoreV2({ + recordCodec: passthroughCodec<ContactRecord>(), + storeName: "contacts", + keyPath: ["alias", "aliasType"], + indexes: {}, + }), refreshGroups: describeStore( "refreshGroups", describeContents<RefreshGroupRecord>({ diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -94,6 +94,7 @@ import { GetDonauResponse, GetDonauStatementsRequest, GetDonauStatementsResponse, + ContactListResponse, GetExchangeDetailedInfoRequest, GetExchangeEntryByUrlRequest, GetExchangeEntryByUrlResponse, @@ -183,6 +184,8 @@ import { WithdrawTestBalanceRequest, WithdrawUriInfoResponse, WithdrawalDetailsForAmount, + AddContactRequest, + DeleteContactRequest, } from "@gnu-taler/taler-util"; import { AddBackupProviderRequest, @@ -238,6 +241,9 @@ export enum WalletApiOperation { AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal", GetExchangeTos = "getExchangeTos", GetExchangeDetailedInfo = "getExchangeDetailedInfo", + AddContact = "addContact", + DeleteContact = "deleteContact", + GetContacts = "getContacts", RetryPendingNow = "retryPendingNow", AbortTransaction = "abortTransaction", FailTransaction = "failTransaction", @@ -435,6 +441,36 @@ export type GetDonauStatementsOp = { response: GetDonauStatementsResponse; }; +// group: Contacts + +/** + * add contact. + */ +export type AddContactOp = { + op: WalletApiOperation.AddContact; + request: AddContactRequest; + response: EmptyObject; +}; + +/** + * delete contact. + */ +export type DeleteContactOp = { + op: WalletApiOperation.DeleteContact; + request: DeleteContactRequest; + response: EmptyObject; +}; + +/** + * Get contacts. + */ +export type GetContactsOp = { + op: WalletApiOperation.GetContacts; + request: EmptyObject; + response: ContactListResponse; +}; + + // group: Basic Wallet Information /** @@ -1610,6 +1646,9 @@ export type WalletOperations = { [WalletApiOperation.SetDonau]: SetDonauOp; [WalletApiOperation.GetDonau]: GetDonauOp; [WalletApiOperation.GetDonauStatements]: GetDonauStatementsOp; + [WalletApiOperation.AddContact]: AddContactOp; + [WalletApiOperation.DeleteContact]: DeleteContactOp; + [WalletApiOperation.GetContacts]: GetContactsOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -219,6 +219,8 @@ import { codecForRunFixupRequest, codecForSetCoinSuspendedRequest, codecForSetDonauRequest, + codecForAddContactRequest, + codecForDeleteContactRequest, codecForSetWalletDeviceIdRequest, codecForSharePaymentRequest, codecForStartExchangeWalletKycRequest, @@ -312,6 +314,7 @@ import { handleGetDonauStatements, handleSetDonau, } from "./donau.js"; +import { listContacts, addContact, deleteContact } from "./contacts.js"; import { ReadyExchangeSummary, acceptExchangeTermsOfService, @@ -1904,6 +1907,18 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForSetDonauRequest(), handler: handleSetDonau, }, + [WalletApiOperation.AddContact]: { + codec: codecForAddContactRequest(), + handler: addContact, + }, + [WalletApiOperation.DeleteContact]: { + codec: codecForDeleteContactRequest(), + handler: deleteContact, + }, + [WalletApiOperation.GetContacts]: { + codec: codecForEmptyObject(), + handler: listContacts, + }, [WalletApiOperation.TestingGetDbStats]: { codec: codecForEmptyObject(), handler: async (wex) => { diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx @@ -118,6 +118,10 @@ export const Pages = { balanceDeposit: pageDefinition<{ scope: CrockEncodedString; }>("/balance/deposit/:scope"), + contacts: pageDefinition<{ tid?: string }>( + "/contacts/:tid?", + ), + contactsAdd: "/contacts/add/", sendCash: pageDefinition<{ scope: CrockEncodedString; amount?: string }>( "/destination/send/:scope/:amount?", ), @@ -164,6 +168,7 @@ export const Pages = { }>("/cta/deposit/:scope/:account"), ctaExperiment: "/cta/experiment", ctaAddExchange: "/cta/add/exchange", + ctaAddContact: "/cts/add/contact", ctaInvoiceCreate: pageDefinition<{ scope: CrockEncodedString; }>("/cta/invoice/create/:scope/:amount?"), @@ -198,6 +203,7 @@ const talerUriActionToPageName: { [TalerUriAction.WithdrawExchange]: "ctaWithdrawManual", [TalerUriAction.DevExperiment]: "ctaExperiment", [TalerUriAction.AddExchange]: "ctaAddExchange", + [TalerUriAction.AddContact]: "ctaAddContact", [TalerUriAction.WithdrawalTransferResult]: "ctaWithdrawTransferResult", }; @@ -269,7 +275,7 @@ export function PopupNavBar({ path }: { path?: PopupNavBarOptions }): VNode { </NavigationHeader> ); } -export type WalletNavBarOptions = "balance" | "backup" | "dev"; +export type WalletNavBarOptions = "balance" | "backup" | "dev" | "contacts"; export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode { const { i18n } = useTranslationContext(); @@ -314,6 +320,11 @@ export function WalletNavBar({ path }: { path?: WalletNavBarOptions }): VNode { <i18n.Translate>Dev tools</i18n.Translate> </a> </EnabledBySettings> + <EnabledBySettings name="p2p_aliases"> + <a href={`#${Pages.contacts({})}`} class={path === "contacts" ? "active" : ""}> + <i18n.Translate>Contacts</i18n.Translate> + </a> + </EnabledBySettings> <div style={{ display: "flex", paddingTop: 4, justifyContent: "right" }} diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -850,6 +850,10 @@ export function ObservabilityEventsTable(): VNode { })`; case NotificationType.BankAccountChange: return i18n.str`Bank account info changed`; + case NotificationType.ContactAdded: + return i18n.str`Contact added`; + case NotificationType.ContactDeleted: + return i18n.str`Contact deleted`; default: { assertUnreachable(not.type); } diff --git a/packages/taler-wallet-webextension/src/hooks/useSelectedContact.ts b/packages/taler-wallet-webextension/src/hooks/useSelectedContact.ts @@ -0,0 +1,120 @@ +/* + This file is part of GNU Taler + (C) 2025 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 { + ContactEntry, + ScopeInfo +} from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; +import { useAlertContext } from "../context/alert.js"; +import { ButtonHandler } from "../mui/handlers.js"; + +type State = State.Ready | State.NoContactFound | State.Selecting; + +export namespace State { + export interface NoContactFound { + status: "no-contact-found"; + error: undefined; + } + export interface Ready { + status: "ready"; + doSelect: ButtonHandler; + selected: ContactEntry; + } + export interface Selecting { + status: "selecting-contact"; + error: undefined; + onSelection: (alias: string) => Promise<void>; + onCancel: () => Promise<void>; + list: ContactEntry[]; + initialValue: string; + } +} + +interface Props { + scope: ScopeInfo; + //list of contacts + list: ContactEntry[]; +} + +export function useSelectedContact({ + list, +}: Props): State { + const [isSelecting, setIsSelecting] = useState(false); + const [selectedContact, setSelectedContact] = useState<string | undefined>( + undefined, + ); + const { pushAlertOnError } = useAlertContext(); + + if (!list.length) { + return { + status: "no-contact-found", + error: undefined, + }; + } + + if (isSelecting) { + const currentContact = + selectedContact ?? + list[0].alias; + return { + status: "selecting-contact", + error: undefined, + list: list, + initialValue: currentContact, + onSelection: async (alias: string) => { + setIsSelecting(false); + setSelectedContact(alias); + }, + onCancel: async () => { + setIsSelecting(false); + }, + }; + } + + { + const found = !selectedContact + ? undefined + : list.find((e) => e.alias === selectedContact); + if (found) + return { + status: "ready", + doSelect: { + onClick: pushAlertOnError(async () => setIsSelecting(true)), + }, + selected: found, + }; + } + { + const found = false + if (found) + return { + status: "ready", + doSelect: { + onClick: pushAlertOnError(async () => setIsSelecting(true)), + }, + selected: found, + }; + } + + return { + status: "ready", + doSelect: { + onClick: pushAlertOnError(async () => setIsSelecting(true)), + }, + selected: list[0], + }; +} diff --git a/packages/taler-wallet-webextension/src/hooks/useSettings.ts b/packages/taler-wallet-webextension/src/hooks/useSettings.ts @@ -29,6 +29,7 @@ export const codecForSettings = (): Codec<Settings> => .property("autoOpen", codecForBoolean()) .property("advancedMode", codecForBoolean()) .property("backup", codecForBoolean()) + .property("p2p_aliases", codecForBoolean()) .property("langSelector", codecForBoolean()) .property("showJsonOnError", codecForBoolean()) .property("extendedAccountTypes", codecForBoolean()) diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts @@ -118,6 +118,7 @@ export interface Settings extends WebexWalletConfig { autoOpen: boolean; advancedMode: boolean; backup: boolean; + p2p_aliases: boolean; langSelector: boolean; showJsonOnError: boolean; extendedAccountTypes: boolean; @@ -133,6 +134,7 @@ export const defaultSettings: Settings = { autoOpen: true, advancedMode: false, backup: false, + p2p_aliases: false, langSelector: false, showRefeshTransactions: false, suspendIndividualTransaction: false, diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -231,6 +231,13 @@ function openWalletURIFromPopup(uri: TalerUri): void { )}`, ); break; + case TalerUriAction.AddContact: + url = chrome.runtime.getURL( + `static/wallet.html#/cta/add/contact?talerUri=${encodeCrockForURI( + talerUri, + )}`, + ); + break; case TalerUriAction.DevExperiment: logger.warn(`taler://dev-experiment URIs are not allowed in headers`); return; diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx @@ -88,7 +88,17 @@ function ContentByUriType({ </Button> </div> ); - + case TalerUriAction.AddContact: + return ( + <div> + <p> + <i18n.Translate>This page has an add contact action.</i18n.Translate> + </p> + <Button variant="contained" color="success" onClick={onConfirm}> + <i18n.Translate>Open add contact page</i18n.Translate> + </Button> + </div> + ); case TalerUriAction.DevExperiment: case TalerUriAction.PayPull: case TalerUriAction.PayPush: diff --git a/packages/taler-wallet-webextension/src/wallet/AddContact/index.ts b/packages/taler-wallet-webextension/src/wallet/AddContact/index.ts @@ -0,0 +1,78 @@ +/* + 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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { ContactEntry, OperationFail, OperationOk } from "@gnu-taler/taler-util"; +import { ErrorAlertView } from "../../components/CurrentAlerts.js"; +import { ErrorAlert } from "../../context/alert.js"; +import { TextFieldHandler } from "../../mui/handlers.js"; +import { StateViewMap, compose } from "../../utils/index.js"; +import { useComponentState } from "./state.js"; +import { ConfirmAddContactView, VerifyContactView } from "./views.js"; +import { useBackendContext } from "../../context/backend.js"; + +export interface Props { + contact?: ContactEntry; + onBack: () => Promise<void>; + noDebounce?: boolean; +} + +export type State = + | State.LoadingUriError + | State.Confirm + | State.Verify; + +export namespace State { + export interface LoadingUriError { + status: "error"; + error: ErrorAlert; + } + + export interface BaseInfo { + error: undefined; + } + export interface Confirm extends BaseInfo { + status: "confirm"; + contact: ContactEntry; + onCancel: () => Promise<void>; + onConfirm: () => Promise<void>; + error: undefined; + } + export interface Verify extends BaseInfo { + status: "verify"; + error: undefined; + + onCancel: () => Promise<void>; + onAccept: () => Promise<void>; + + alias: TextFieldHandler, + aliasType: TextFieldHandler, + mailboxUri: TextFieldHandler, + result: OperationOk<"ok"> + | OperationFail<"already-added"> + } +} +const viewMapping: StateViewMap<State> = { + error: ErrorAlertView, + confirm: ConfirmAddContactView, + verify: VerifyContactView, +}; + +export const AddContact = compose( + "AddContact", + (p: Props) => useComponentState(p), + viewMapping, +); diff --git a/packages/taler-wallet-webextension/src/wallet/AddContact/state.ts b/packages/taler-wallet-webextension/src/wallet/AddContact/state.ts @@ -0,0 +1,92 @@ +/* + 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 { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { ContactEntry, opFixedSuccess, opKnownFailure } from "@gnu-taler/taler-util"; +import { useCallback, useState } from "preact/hooks"; +import { useBackendContext } from "../../context/backend.js"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { withSafe } from "../../mui/handlers.js"; +import { RecursiveState } from "../../utils/index.js"; +import { Props, State } from "./index.js"; + +export function useComponentState({ onBack, contact, noDebounce }: Props): RecursiveState<State> { + const [alias, setAlias] = useState<string>(); + const [aliasType, setAliasType] = useState<string>(); + const [mailboxUri, setMailboxUri] = useState<string>(); + const [contactInState, setContactInState] = useState<ContactEntry>(); + const api = useBackendContext(); + const hook = useAsyncAsHook(() => + api.wallet.call(WalletApiOperation.GetContacts, {}), + ); + const walletContacts = !hook ? [] : hook.hasError ? [] : hook.response.contacts + + if (!contactInState) { + return (): State => { + const found = (!contact) ? -1 : walletContacts.findIndex((e) => (e.alias == contact.alias) && (e.aliasType == contact.aliasType)); + const result = (found !== -1) ? + opKnownFailure("already-added"as const) : + opFixedSuccess("ok"as const); + const [inputError, setInputError] = useState<string>() + + return { + status: "verify", + error: undefined, + onCancel: onBack, + onAccept: async () => { + if (!result || result.type !== "ok") return; + setContactInState(contact) + }, + alias: { + value: alias ?? "", + error: inputError, + onInput: withSafe(async (x) => {setAlias(x)}, (e) => { + setInputError(e.message) + }) + }, + aliasType: { + value: aliasType ?? "", + error: inputError, + onInput: withSafe(setAliasType, (e) => { + setInputError(e.message) + }) + }, + mailboxUri: { + value: mailboxUri ?? "", + error: inputError, + onInput: withSafe(setMailboxUri, (e) => { + setInputError(e.message) + }) + }, + result, + }; + + } + } + + async function addContactInternal(): Promise<void> { + if (!contactInState) return; + await api.wallet.call(WalletApiOperation.AddContact, { contact: contactInState }); + onBack(); + } + return { + status: "confirm", + error: undefined, + onCancel: onBack, + onConfirm: addContactInternal, + contact: contactInState ?? contact, + } +} diff --git a/packages/taler-wallet-webextension/src/wallet/AddContact/stories.tsx b/packages/taler-wallet-webextension/src/wallet/AddContact/stories.tsx @@ -0,0 +1,27 @@ +/* + 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) + */ + + +export default { + title: "example", +}; + +// export const Ready = tests.createExample(ReadyView, {}); diff --git a/packages/taler-wallet-webextension/src/wallet/AddContact/views.tsx b/packages/taler-wallet-webextension/src/wallet/AddContact/views.tsx @@ -0,0 +1,209 @@ +/* + 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; +import { + Input, + LightText, + SubTitle, + Title, + WarningBox, +} from "../../components/styled/index.js"; +import { Button } from "../../mui/Button.js"; +import { State } from "./index.js"; +import { assertUnreachable } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { Loading } from "../../components/Loading.js"; +import { useBackendContext } from "../../context/backend.js"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; + +export function VerifyContactView({ + alias, + aliasType, + mailboxUri, + onCancel, + onAccept, + result, +}: State.Verify): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <section> + <Title> + <i18n.Translate>Add new contact</i18n.Translate> + </Title> + {!result && ( + <LightText> + <i18n.Translate> + Enter the details of a contact you trust. + </i18n.Translate> + </LightText> + )} + {(() => { + if (!result) return; + if (result.type == "ok") { + return ( + <LightText> + <i18n.Translate> + Contact info valid. + </i18n.Translate> + </LightText> + ); + } + switch (result.case) { + case "already-added": { + return ( + <WarningBox> + <i18n.Translate> + This contact is already in your list. + </i18n.Translate> + </WarningBox> + ); + } + default: { + assertUnreachable(result); + } + } + })()} + <p> + <Input invalid={result && result.type !== "ok"}> + <label>Alias</label> + <input + type="text" + placeholder="john.doe@id.example" + value={alias.value} + onInput={(e) => { + if (alias.onInput) { + alias.onInput(e.currentTarget.value); + } + }} + /> + </Input> + <Input invalid={result && result.type !== "ok"}> + <label>Alias type</label> + <input + type="text" + placeholder="email" + value={aliasType.value} + onInput={(e) => { + if (aliasType.onInput) { + aliasType.onInput(e.currentTarget.value); + } + }} + /> + </Input> + <Input invalid={result && result.type !== "ok"}> + <label>Mailbox</label> + <input + type="text" + placeholder="http://" + value={mailboxUri.value} + onInput={(e) => { + if (mailboxUri.onInput) { + mailboxUri.onInput(e.currentTarget.value); + } + }} + /> + </Input> + </p> + </section> + <footer> + <Button variant="contained" color="secondary" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </Button> + <Button + variant="contained" + disabled={!result || result.type !== "ok"} + onClick={onAccept} + > + <i18n.Translate>Next</i18n.Translate> + </Button> + </footer> + </Fragment> + ); +} + +export function ConfirmAddContactView({ + contact, + onCancel, + onConfirm, +}: State.Confirm): VNode { + const { i18n } = useTranslationContext(); + + const api = useBackendContext(); + + const state = useAsyncAsHook(async () => { + const b = await api.wallet.call(WalletApiOperation.GetContacts, {}); + const contacts = b.contacts; + return { contacts: contacts }; + }, []); + + if (!state) { + return <Loading />; + } + async function onAddContact(): Promise<void> { + api.wallet.call(WalletApiOperation.AddContact, { contact: contact }).then(); + onConfirm(); + } + + return ( + <Fragment> + <section> + <Title> + <i18n.Translate>Review contact</i18n.Translate> + </Title> + <div> + <i18n.Translate>Alias</i18n.Translate>: + {contact.alias} + </div> + <div> + <i18n.Translate>Type</i18n.Translate>: + {contact.aliasType} + </div> + <div> + <i18n.Translate>Source</i18n.Translate>: + {contact.source} + </div> + <div> + <i18n.Translate>Mailbox URI</i18n.Translate>: + {contact.mailboxUri} + </div> + </section> + + <footer> + <Button + key="cancel" + variant="contained" + color="secondary" + onClick={onCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </Button> + <Button + key="add" + variant="contained" + color="success" + onClick={onAddContact} + > + <i18n.Translate>Add contact</i18n.Translate> + </Button> + </footer> + </Fragment> + ); +} diff --git a/packages/taler-wallet-webextension/src/wallet/Application.tsx b/packages/taler-wallet-webextension/src/wallet/Application.tsx @@ -82,6 +82,8 @@ import CloseIcon from "../svg/close_24px.inline.svg"; import { AddBackupProviderPage } from "./AddBackupProvider/index.js"; import { AddExchange } from "./AddExchange/index.js"; import { ConfirmAddExchangeView } from "./AddExchange/views.js"; +import { AddContact } from "./AddContact/index.js"; +import { ConfirmAddContactView } from "./AddContact/views.js"; import { BackupPage } from "./BackupPage.js"; import { DepositPage } from "./DepositPage/index.js"; import { DestinationSelectionPage } from "./DestinationSelection/index.js"; @@ -92,6 +94,7 @@ import { NotificationsPage } from "./Notifications/index.js"; import { ProviderDetailPage } from "./ProviderDetailPage.js"; import { QrReaderPage } from "./QrReader.js"; import { SettingsPage } from "./Settings.js"; +import { ContactsPage } from "./Contacts.js"; import { SupportedBanksForAccount } from "./SupportedBanksForAccount.js"; import { TransactionPage } from "./Transaction.js"; import { WelcomePage } from "./Welcome.js"; @@ -154,6 +157,17 @@ export function Application(): VNode { )} /> <Route + path={Pages.contacts.pattern} + component={({ tid }: { tid?: string }) => ( + <WalletTemplate + goToTransaction={redirectToTxInfo} + goToURL={redirectToURL} + > + <ContactsPage tid={tid} /> + </WalletTemplate> + )} + /> + <Route path={Pages.notifications} component={() => ( <WalletTemplate goToURL={redirectToURL}> @@ -165,13 +179,22 @@ export function Application(): VNode { * SETTINGS */} <Route - path={Pages.settingsExchangeAdd.pattern} + path={Pages.settingsExchangeAdd.pattern} component={() => ( <WalletTemplate goToURL={redirectToURL}> - <AddExchange onBack={() => redirectTo(Pages.balance)} /> + <AddExchange onBack={() => redirectTo(Pages.balance)} /> </WalletTemplate> )} /> + <Route + path={Pages.contactsAdd} + component={() => ( + <WalletTemplate goToURL={redirectToURL}> + <AddContact onBack={() => redirectTo(Pages.contacts.pattern)} /> + </WalletTemplate> + )} + /> + <Route path={Pages.balanceHistory.pattern} @@ -822,6 +845,38 @@ export function Application(): VNode { }} /> <Route + path={Pages.ctaAddContact} + component={({ talerUri }: { talerUri: string }) => { + const tUri = parseTalerUri( + decodeCrockFromURI(talerUri).toLowerCase(), + ); + const contact = + tUri?.type === TalerUriAction.AddContact + ? { + alias: tUri.alias, + aliasType: tUri.aliasType, + mailboxUri: tUri.mailboxUri, + source: tUri.sourceBaseUrl, + } + : undefined; + if (!contact) { + redirectTo(Pages.balanceHistory({})); + return <div>invalid url {talerUri}</div>; + } + return ( + <CallToActionTemplate title={i18n.str`Add contact`}> + <ConfirmAddContactView + contact={contact} + status="confirm" + error={undefined} + onCancel={() => redirectTo(Pages.balanceHistory({}))} + onConfirm={() => redirectTo(Pages.contacts({}))} + /> + </CallToActionTemplate> + ); + }} + /> + <Route path={Pages.paytoBanks.pattern} component={({ payto }: { payto: string }) => { const pUri = parsePaytoUri( diff --git a/packages/taler-wallet-webextension/src/wallet/Contacts.tsx b/packages/taler-wallet-webextension/src/wallet/Contacts.tsx @@ -0,0 +1,228 @@ +/* + This file is part of GNU Taler + (C) 2025 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 { + ScopeInfo, + ContactEntry, + NotificationType, + Transaction, + TransactionIdStr, + TransactionType, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Link } from "preact-router"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ErrorAlertView } from "../components/CurrentAlerts.js"; +import { Loading } from "../components/Loading.js"; +import { + BoldLight, + Centered, + RowBorderGray, + SmallText, + SmallLightText, +} from "../components/styled/index.js"; +import { alertFromError, useAlertContext } from "../context/alert.js"; +import { useBackendContext } from "../context/backend.js"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook.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"; +import { TextFieldHandler } from "../mui/handlers.js"; + +interface Props { + tid?: string; + scope?: ScopeInfo; + search?: boolean; +} + +export function ContactsPage({ + tid, + scope, + search: showSearch, +}: Props): VNode { + const transactionId = tid as TransactionIdStr; //FIXME: validate + const { i18n } = useTranslationContext(); + const api = useBackendContext(); + const [search, setSearch] = useState<string>(); + + const state = useAsyncAsHook(async () => { + const b = await api.wallet.call(WalletApiOperation.GetContacts, {}); + const contacts = b.contacts; + const filteredContacts = contacts.filter(c => (!search) ? true : ( + c.alias.toLowerCase().includes(search.toLowerCase()) || + c.aliasType.toLowerCase().includes(search.toLowerCase()) || + c.mailboxUri.toLowerCase().includes(search.toLowerCase()) || + c.source.toLowerCase().includes(search.toLowerCase())) + ); + const tx = tid? + await api.wallet.call(WalletApiOperation.GetTransactionById, {transactionId}) + : undefined; + return { + contacts: filteredContacts, + transaction: tx + }; + }, [search, tid]); + + const { pushAlertOnError } = useAlertContext(); + + if (!state) { + return <Loading />; + } + + useEffect(() => { + return api.listener.onUpdateNotification( + [NotificationType.ContactAdded, NotificationType.ContactDeleted], + state?.retry, + ); + }); + if (state.hasError) { + return ( + <ErrorAlertView + error={alertFromError( + i18n, + i18n.str`Could not load the list of contacts`, + state, + )} + /> + ); + } + + const onAddContact = async (c: ContactEntry) => { + api.wallet.call(WalletApiOperation.AddContact, { contact: c }).then(); + }; + const onDeleteContact = async (c: ContactEntry) => { + api.wallet.call(WalletApiOperation.DeleteContact, { contact: c }).then(); + }; + return ( + <ContactsView + search={{ + value: search ?? "", + onInput: pushAlertOnError(async (d: string) => { + setSearch(d); + }), + }} + contacts={state.response.contacts} + transaction={state.response.transaction} + onAddContact={onAddContact} + onDeleteContact={onDeleteContact} + /> + ); +} + +interface ContactProps { + contact: ContactEntry; + transaction?: Transaction; + onDeleteContact: (c: ContactEntry) => Promise<void>; +} + + +function ContactLayout(props: ContactProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <Paper style={{ padding: 8 }}> + <p> + <span>{props.contact.alias}</span> + <SmallText style={{ marginTop: 5 }}> + <i18n.Translate>Type</i18n.Translate>: {props.contact.aliasType} + </SmallText> + <SmallText style={{ marginTop: 5 }}> + <i18n.Translate>Source</i18n.Translate>: {props.contact.source} + </SmallText> + <SmallLightText style={{ marginTop: 5, marginBotton: 5 }}> + <i18n.Translate>Mailbox</i18n.Translate>: {props.contact.mailboxUri} + </SmallLightText> + </p> + {!props.transaction && ( + <Button variant="contained" onClick={() => { return props.onDeleteContact(props.contact)}} color="error"> + <i18n.Translate>Delete</i18n.Translate> + </Button> + )} + {(props.transaction && (props.transaction.type == TransactionType.PeerPushDebit)) && ( + <Button variant="contained" color="warning"> + <i18n.Translate>Send Cash</i18n.Translate> + </Button> + )} + {(props.transaction && (props.transaction.type == TransactionType.PeerPullCredit)) && ( + <Button variant="contained" color="success"> + <i18n.Translate>Request Cash</i18n.Translate> + </Button> + )} + </Paper> + ); +} + +export function ContactsView({ + search, + contacts, + transaction, + onAddContact, + onDeleteContact, +}: { + search: TextFieldHandler; + contacts: ContactEntry[]; + transaction?: Transaction; + onAddContact: (c: ContactEntry) => Promise<void>; + onDeleteContact: (c: ContactEntry) => Promise<void>; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <Fragment> + <section> + <Centered style={{ margin: 10 }}> + <a href="/contacts/add"> + <i18n.Translate>Add new contact</i18n.Translate> + </a> + </Centered> + {!contacts.length && ( + <Centered style={{ marginTop: 20 }}> + <BoldLight> + <i18n.Translate>No contacts yet.</i18n.Translate> + </BoldLight> + </Centered> + )} + {contacts.length && ( + <TextField + label="Search" + variant="filled" + error={search.error} + required + fullWidth + value={search.value} + onChange={search.onInput} + /> + )} + <Grid item container columns={1} spacing={1} style={{ marginTop: 20 }}> + { + contacts.map((c, i) => ( + <Grid item xs={1}> + <ContactLayout + contact={c} + transaction={transaction} + onDeleteContact={onDeleteContact} + /> + </Grid> + )) + } + </Grid> + </section> + </Fragment> + ); +} + + diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -278,6 +278,8 @@ function translateTalerUriError( return i18n.str`This URI requires the experiment id`; case TalerUriAction.AddExchange: return i18n.str`This URI requires the exchange host`; + case TalerUriAction.AddContact: + return i18n.str`This URI requires the alias type, the alias and the mailbox URI separated by /`; case TalerUriAction.WithdrawExchange: return i18n.str`This URI requires the exchange host`; case TalerUriAction.WithdrawalTransferResult: @@ -306,6 +308,8 @@ function translateTalerUriError( return i18n.str`The merchant host is invalid`; case TalerUriAction.AddExchange: return i18n.str`The exchange host is invalid`; + case TalerUriAction.AddContact: + return i18n.str`The contact is invalid`; case TalerUriAction.WithdrawExchange: switch (result.body.pos) { case 0: @@ -326,6 +330,7 @@ function translateTalerUriError( case TalerUriAction.PayPull: case TalerUriAction.PayTemplate: case TalerUriAction.AddExchange: + case TalerUriAction.AddContact: return i18n.str`A parameter is invalid`; } } @@ -512,6 +517,8 @@ export function QrReaderPage({ onDetected }: Props): VNode { return ( <i18n.Translate>Notify transaction</i18n.Translate> ); + case TalerUriAction.AddContact: + return <i18n.Translate>Add contact</i18n.Translate>; default: { assertUnreachable(talerUri); } @@ -560,6 +567,7 @@ async function testValidUri( switch (uri.type) { case TalerUriAction.Restore: case TalerUriAction.DevExperiment: + case TalerUriAction.AddContact: case TalerUriAction.WithdrawalTransferResult: { return undefined; } diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.tsx @@ -228,6 +228,10 @@ function AdvanceSettings(): VNode { label: i18n.str`Show backup feature`, description: i18n.str`Backup integration still in beta.`, }, + p2p_aliases: { + label: i18n.str`Show P2P alias feature`, + description: i18n.str`P2P aliases integration still in beta.`, + }, suspendIndividualTransaction: { label: i18n.str`Show suspend/resume transaction`, description: i18n.str`Prevent transaction from doing network request.`, diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -987,6 +987,16 @@ export function TransactionView({ /> } /> + <EnabledBySettings name="p2p_aliases"> + <Part + title={i18n.str`Social`} + text={ + <a href={`#${Pages.contacts({tid: transaction.transactionId})}`} > + <i18n.Translate>Forward request to a contact</i18n.Translate> + </a> + } + /> + </EnabledBySettings> </TransactionTemplate> ); } @@ -1083,6 +1093,16 @@ export function TransactionView({ /> } /> + <EnabledBySettings name="p2p_aliases"> + <Part + title={i18n.str`Social`} + text={ + <a href={`#${Pages.contacts({tid: transaction.transactionId})}`} > + <i18n.Translate>Forward request to a contact</i18n.Translate> + </a> + } + /> + </EnabledBySettings> </TransactionTemplate> ); }