taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 884774261faa953b59770b28beb0d55321507c0a
parent 788605aa7965c464adf52e59e62c2c0254aa9311
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Mon,  1 Jun 2026 11:23:24 -0300

only one notification handler for all spa

Diffstat:
Mpackages/web-util/src/components/Header.tsx | 8++++----
Mpackages/web-util/src/components/NotificationBanner.tsx | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Dpackages/web-util/src/components/ToastBanner.tsx | 88-------------------------------------------------------------------------------
Mpackages/web-util/src/components/index.ts | 1-
Mpackages/web-util/src/context/index.ts | 2++
Apackages/web-util/src/context/notification.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/hooks/index.stories.ts | 1-
7 files changed, 240 insertions(+), 162 deletions(-)

diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx @@ -3,7 +3,7 @@ import { useState } from "preact/hooks"; import logo from "../assets/taler-logo-white.png"; import { LangSelector, - useNotifications, + // useNotifications, useTranslationContext, } from "../index.browser.js"; @@ -28,7 +28,7 @@ export function Header({ }: Props): VNode { const { i18n } = useTranslationContext(); const [open, setOpen] = useState(false); - const ns = useNotifications(); + // const ns = useNotifications(); return ( <Fragment> <header class="bg-primary w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400"> @@ -73,7 +73,7 @@ export function Header({ <span class="sr-only"> <i18n.Translate>Show notifications</i18n.Translate> </span> - {ns.length > 0 ? ( + {/* {ns.length > 0 ? ( <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" @@ -102,7 +102,7 @@ export function Header({ d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /> </svg> - )} + )} */} </a> )} {!profileURL ? undefined : ( diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx @@ -1,88 +1,113 @@ import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/compat"; +import { useRef, useState } from "preact/compat"; +import { Duration } from "../../../taler-util/src/time.js"; import { Notification, useCommonPreferences, + useNotificationContext, useTranslationContext, } from "../index.browser.js"; import { Attention } from "./Attention.js"; +import { composeRef, saveRef } from "./utils.js"; -export function LocalNotificationBanner({ - notification, -}: { - notification?: Notification; -}): VNode { +/** + * Toasts should be considered when displaying these types of information to the user: + * + * Low attention messages that do not require user action + * Singular status updates + * Confirmations + * Information that does not need to be followed up + * + * Do not use toasts if the information contains the following: + * + * High attention and critical information + * Time-sensitive information + * Requires user action or input + * Batch updates + * + * @returns + */ +export function ToastBanner(): VNode { const { i18n } = useTranslationContext(); + const { notification: ns } = useNotificationContext(); const [{ showDebugInfo }] = useCommonPreferences(); const [moreInfo, setMoreInfo] = useState(false); - if (!notification) return <Fragment />; + if (!ns || !ns.length) return <Fragment />; + const notification = ns[0]; switch (notification.message.type) { case "error": const desc = notification.message.description; return ( - <div class="relative"> - <div class="fixed top-0 left-0 right-0 z-20 w-full p-4"> - <Attention - type="danger" - title={notification.message.title} - onClose={() => { - notification.acknowledge(); - }} - > - {desc && - desc.length && - (moreInfo ? ( - desc.map((d) => { - return <div class="mt-2 text-sm text-red-700">{d}</div>; - }) - ) : ( - <div class="mt-2 text-sm text-red-700">{desc[0]}</div> - ))} - - <div class="flex justify-between"> - <div class="text-[grey]"> - {moreInfo || (desc && desc.length < 2) ? undefined : ( - <button onClick={() => setMoreInfo(true)} class="text-grey"> - <i18n.Translate>Show more info</i18n.Translate> - </button> - )} - </div> - </div> - {showDebugInfo && ( - <pre class="whitespace-break-spaces text-black"> - {JSON.stringify(notification.message.debug, undefined, 2)} - </pre> + <Attention + type="danger" + title={notification.message.title} + copy + onClose={() => { + notification.acknowledge(); + setMoreInfo(false); + }} + > + {desc && + desc.length && + (moreInfo ? ( + desc.map((d) => { + return <div class="mt-2 text-sm text-red-700">{d}</div>; + }) + ) : ( + <div class="mt-2 text-sm text-red-700">{desc[0]}</div> + ))} + <div class="flex justify-between"> + <div class="text-[grey]"> + {moreInfo || (desc && desc.length < 2) ? undefined : ( + <button onClick={() => setMoreInfo(true)} class="text-grey"> + <i18n.Translate>Show more info</i18n.Translate> + </button> )} - </Attention> + </div> </div> - </div> + + <pre + class="whitespace-break-spaces text-black" + style={{ display: showDebugInfo ? "block" : "none" }} + > + {JSON.stringify( + notification.message.debug, + function excludePrivate(key, value) { + if (key.startsWith("__")) return "..."; + return value; + }, + 2, + )} + </pre> + </Attention> ); case "info": return ( - <div class="relative"> - <div class="fixed top-0 left-0 right-0 z-20 w-full p-4"> - <Attention - type="success" - title={notification.message.title} - onClose={() => { - notification.acknowledge(); - }} - /> - </div> - </div> + <Attention + type="success" + title={notification.message.title} + onClose={() => { + notification.acknowledge(); + setMoreInfo(false); + }} + timeout={GLOBAL_TOAST_TIMEOUT} + /> ); } } -export function LocalNotificationBannerBulma({ - notification, -}: { - notification?: Notification; -}): VNode { +const GLOBAL_TOAST_TIMEOUT = Duration.fromSpec({ + seconds: 5, +}); + +export function ToastBannerBulma(): VNode { const { i18n } = useTranslationContext(); + const { notification: ns } = useNotificationContext(); const [{ showDebugInfo }] = useCommonPreferences(); - const [moreInfo, setMoreInfo] = useState(showDebugInfo); - if (!notification) return <Fragment />; + const [moreInfo, setMoreInfo] = useState(false); + const divHtml = useRef<HTMLDivElement>(); + if (!ns || !ns.length) return <Fragment />; + const notification = ns[0]; const msg = notification.message; switch (msg.type) { case "error": @@ -100,15 +125,28 @@ export function LocalNotificationBannerBulma({ > <div class="notification"> <div class="columns is-vcentered"> - <div class="column is-12"> + <div ref={composeRef(saveRef(divHtml))} class="column is-12"> <article class="message is-danger"> <div class="message-header"> <p>{msg.title}</p> - <button - class="delete " - aria-label="close" - onClick={() => notification.acknowledge()} - /> + <div> + <button + class="copy " + aria-label="copy" + onClick={(e) => { + e.preventDefault(); + + navigator.clipboard.writeText( + fromNodeToText(divHtml.current), + ); + }} + /> + <button + class="delete " + aria-label="close" + onClick={() => notification.acknowledge()} + /> + </div> </div> {msg.description && msg.description.length && ( <div class="message-body"> @@ -129,9 +167,23 @@ export function LocalNotificationBannerBulma({ <i18n.Translate>show more info</i18n.Translate> </a> )} - {showDebugInfo && msg.debug && ( - <pre> {JSON.stringify(msg.debug, undefined, 2)}</pre> - )} + {/* {showDebugInfo && msg.debug && ( */} + <pre + class="whitespace-break-spaces text-black" + style={{ + // display: showDebugInfo ? "block" : "none", + }} + > + {JSON.stringify( + msg.debug, + function excludePrivate(key, value) { + if (key.startsWith("__")) return "..."; + return value; + }, + 2, + )} + </pre> + {/* )} */} </div> )} </article> @@ -170,3 +222,25 @@ export function LocalNotificationBannerBulma({ ); } } + +function fromNodeToText(node: ChildNode | undefined) { + var i, result, text, child; + result = ""; + + if (node) + for (i = 0; i < node.childNodes.length; i++) { + child = node.childNodes[i]; + text = null; + if (child.nodeType === 1) { + text = fromNodeToText(child); + } else if (child.nodeType === 3) { + text = child.nodeValue; + } + if (text) { + result += "\n"; + result += text; + } + } + return result; +} + diff --git a/packages/web-util/src/components/ToastBanner.tsx b/packages/web-util/src/components/ToastBanner.tsx @@ -1,88 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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 { Fragment, VNode, h } from "preact"; -import { - Attention, - GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, - Notification, - useNotifications, -} from "../index.browser.js"; -import { Duration } from "@gnu-taler/taler-util"; - -/** - * Toasts should be considered when displaying these types of information to the user: - * - * Low attention messages that do not require user action - * Singular status updates - * Confirmations - * Information that does not need to be followed up - * - * Do not use toasts if the information contains the following: - * - * High attention and crtitical information - * Time-sensitive information - * Requires user action or input - * Batch updates - * - * @returns - */ -export function ToastBanner({ debug }: { debug?: boolean }): VNode { - const notifs = useNotifications(); - if (notifs.length === 0) return <Fragment />; - const show = notifs.filter((e) => !e.message.ack && !e.message.timeout); - if (show.length === 0) return <Fragment />; - return <AttentionByType msg={show[0]} debug={debug} />; -} - -function AttentionByType({ - msg, - debug, -}: { - debug?: boolean; - msg: Notification; -}) { - switch (msg.message.type) { - case "error": - return ( - <Attention - type="danger" - title={msg.message.title} - onClose={() => { - msg.acknowledge(); - }} - timeout={debug ? Duration.getForever() : GLOBAL_TOAST_TIMEOUT} - > - {msg.message.description && ( - <div class="mt-2 text-sm text-red-700"> - {msg.message.description} - </div> - )} - {!debug ? undefined : <pre>{msg.message.debug}</pre>} - </Attention> - ); - case "info": - return ( - <Attention - type="success" - title={msg.message.title} - onClose={() => { - msg.acknowledge(); - }} - timeout={GLOBAL_TOAST_TIMEOUT} - /> - ); - } -} diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts @@ -10,7 +10,6 @@ export * from "./Footer.js"; export * from "./Button.js"; export * from "./ShowInputErrorLabel.js"; export * from "./NotificationBanner.js"; -export * from "./ToastBanner.js"; export * from "./Time.js"; export * from "./RenderAmount.js"; export * from "./Pagination.js"; diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts @@ -9,5 +9,7 @@ export * from "./challenger-api.js"; export * from "./merchant-api.js"; export * from "./exchange-api.js"; export * from "./navigation.js"; +export * from "./notification.js"; +export * from "./activity.js"; export * from "./common-preferences.js"; export * from "./wallet-integration.js"; diff --git a/packages/web-util/src/context/notification.ts b/packages/web-util/src/context/notification.ts @@ -0,0 +1,92 @@ +/* + This file is part of GNU Taler + (C) 2026 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 { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; +import { + newSafeHandlerBuilder, + useNotificationHandler, + useTranslationContext, +} from "../index.browser.js"; + +type Notif = ReturnType<typeof useNotificationHandler>; +interface Type extends Notif { + actionHandler: ReturnType<typeof newSafeHandlerBuilder>; +} +function unhandled(): never { + throw Error( + "Missing NotificationProvider. The application is not properly configured.", + ); +} + +const initial: Type = { + notification: [], + actionHandler: unhandled, + showError: unhandled, + displayInfo: unhandled, + showSuccess: unhandled, + displayError: unhandled, + clear: unhandled, +}; +const Context = createContext<Type>(initial); + +interface Props { + children: ComponentChildren; +} + +// Outmost UI wrapper. +export const NotificationProvider = ({ children }: Props): VNode => { + const { i18n } = useTranslationContext(); + const { notification, ...nf } = useNotificationHandler(); + const actionHandler = newSafeHandlerBuilder({ + onError: (error) => { + nf.displayError( + i18n.str`Unexpected error.`, + error, + i18n.str`The runtime thrown an Error which was not properly handled. To report click the copy button and create an issue in https://bugs.taler.net.`, + ); + }, + onFail: (f) => { + nf.displayError( + i18n.str`The operation failed`, + f, + i18n.str`This handler also need a better error reporting. To report click the copy button and create an issue in https://bugs.taler.net.`, + ); + }, + // no need to show a toast for every succeed operation + // onSuccess: (body) => { + // displayInfo(i18n.str`operation succeeded: ${JSON.stringify(body)}`); + // }, + listeners: [ + (e) => { + if (e === "start") { + nf.clear(); + } + }, + ], + }); + + return h(Context.Provider, { + value: { + actionHandler, + ...nf, + notification, + }, + children, + }); +}; + +export const useNotificationContext = (): Type => useContext(Context); diff --git a/packages/web-util/src/hooks/index.stories.ts b/packages/web-util/src/hooks/index.stories.ts @@ -1,2 +1 @@ export * as a1 from "./useNotifications.stories.js"; -export * as a2 from "./useNotificationsDeprecated.stories.js";