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:
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";