diff options
Diffstat (limited to 'packages/web-util/src/hooks/useNotifications.ts')
-rw-r--r-- | packages/web-util/src/hooks/useNotifications.ts | 337 |
1 files changed, 337 insertions, 0 deletions
diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts new file mode 100644 index 000000000..103b88c86 --- /dev/null +++ b/packages/web-util/src/hooks/useNotifications.ts @@ -0,0 +1,337 @@ +import { + AbsoluteTime, + Duration, + OperationAlternative, + OperationFail, + OperationOk, + OperationResult, + TalerError, + TalerErrorCode, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; +import { ButtonHandler, OnOperationFailReturnType, OnOperationSuccesReturnType } from "../components/Button.js"; +import { + InternationalizationAPI, + memoryMap, + useTranslationContext, +} from "../index.browser.js"; + +export type NotificationMessage = ErrorNotification | InfoNotification; + +export interface ErrorNotification { + type: "error"; + title: TranslatedString; + ack?: boolean; + timeout?: boolean; + description?: TranslatedString; + debug?: any; + when: AbsoluteTime; +} +export interface InfoNotification { + type: "info"; + title: TranslatedString; + ack?: boolean; + timeout?: boolean; + when: AbsoluteTime; +} + +const storage = memoryMap<Map<string, NotificationMessage>>(); +const NOTIFICATION_KEY = "notification"; + +export const GLOBAL_NOTIFICATION_TIMEOUT = Duration.fromSpec({ + seconds: 5, +}); + +function updateInStorage(n: NotificationMessage) { + const h = hash(n); + const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); + const newState = new Map(mem); + newState.set(h, n); + storage.set(NOTIFICATION_KEY, newState); +} + +export function notify(notif: NotificationMessage): void { + const currentState: Map<string, NotificationMessage> = + storage.get(NOTIFICATION_KEY) ?? new Map(); + const newState = currentState.set(hash(notif), notif); + + if (GLOBAL_NOTIFICATION_TIMEOUT.d_ms !== "forever") { + setTimeout(() => { + notif.timeout = true; + updateInStorage(notif); + }, GLOBAL_NOTIFICATION_TIMEOUT.d_ms); + } + + storage.set(NOTIFICATION_KEY, newState); +} +export function notifyError( + title: TranslatedString, + description: TranslatedString | undefined, + debug?: any, +) { + notify({ + type: "error" as const, + title, + description, + debug, + when: AbsoluteTime.now(), + }); +} +export function notifyException(title: TranslatedString, ex: Error) { + notify({ + type: "error" as const, + title, + description: ex.message as TranslatedString, + debug: ex.stack, + when: AbsoluteTime.now(), + }); +} +export function notifyInfo(title: TranslatedString) { + notify({ + type: "info" as const, + title, + when: AbsoluteTime.now(), + }); +} + +export type Notification = { + message: NotificationMessage; + acknowledge: () => void; +}; + +export function useNotifications(): Notification[] { + const [, setLastUpdate] = useState<number>(); + const value = storage.get(NOTIFICATION_KEY) ?? new Map(); + + useEffect(() => { + return storage.onUpdate(NOTIFICATION_KEY, () => { + setLastUpdate(Date.now()) + // const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); + // setter(structuredClone(mem)); + }); + }); + + return Array.from(value.values()).map((message, idx) => { + return { + message, + acknowledge: () => { + message.ack = true; + updateInStorage(message); + }, + }; + }); +} + +function hashCode(str: string): string { + if (str.length === 0) return "0"; + let hash = 0; + let chr; + for (let i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return hash.toString(16); +} + +function hash(msg: NotificationMessage): string { + let str = (msg.type + ":" + msg.title) as string; + if (msg.type === "error") { + if (msg.description) { + str += ":" + msg.description; + } + if (msg.debug) { + str += ":" + msg.debug; + } + } + return hashCode(str); +} + +function errorMap<T extends OperationFail<unknown>>( + resp: T, + map: (d: T["case"]) => TranslatedString, +): void { + notify({ + type: "error", + title: map(resp.case), + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); +} + +export type ErrorNotificationHandler = ( + cb: (notify: typeof errorMap) => Promise<void>, +) => Promise<void>; + +/** + * @deprecated use useLocalNotificationHandler + * + * @returns + */ +export function useLocalNotification(): [ + Notification | undefined, + (n: NotificationMessage) => void, + ErrorNotificationHandler, +] { + const { i18n } = useTranslationContext(); + + const [value, setter] = useState<NotificationMessage>(); + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; + + async function errorHandling(cb: (notify: typeof errorMap) => Promise<void>) { + try { + return await cb(errorMap); + } catch (error: unknown) { + if (error instanceof TalerError) { + notify(buildUnifiedRequestErrorMessage(i18n, error)); + } else { + notifyError( + i18n.str`Operation failed, please report`, + (error instanceof Error + ? error.message + : JSON.stringify(error)) as TranslatedString, + ); + } + } + } + return [notif, setter, errorHandling]; +} + +type HandlerMaker = <T extends OperationResult<A, B>, A, B>( + onClick: () => Promise<T | undefined>, + onOperationSuccess: OnOperationSuccesReturnType<T>, + onOperationFail?: OnOperationFailReturnType<T>, + onOperationComplete?: () => void, +) => ButtonHandler<T, A, B>; + +export function useLocalNotificationHandler(): [ + Notification | undefined, + HandlerMaker, + (n: NotificationMessage) => void, +] { + const [value, setter] = useState<NotificationMessage>(); + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; + + function makeHandler<T extends OperationResult<A, B>, A, B>( + onClick: () => Promise<T | undefined>, + onOperationSuccess:OnOperationSuccesReturnType<T>, + onOperationFail?: OnOperationFailReturnType<T>, + onOperationComplete?: () => void, + ): ButtonHandler<T, A, B> { + return { + onClick, + onNotification: setter, + onOperationFail, + onOperationSuccess, + onOperationComplete, + }; + } + + return [notif, makeHandler, setter]; +} + +export function buildUnifiedRequestErrorMessage( + i18n: InternationalizationAPI, + cause: TalerError, +): ErrorNotification { + let result: ErrorNotification; + switch (cause.errorDetail.code) { + case TalerErrorCode.GENERIC_TIMEOUT: { + result = { + type: "error", + title: i18n.str`Request timeout`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { + result = { + type: "error", + title: i18n.str`Request cancelled`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { + result = { + type: "error", + title: i18n.str`Request timeout`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { + result = { + type: "error", + title: i18n.str`Request throttled`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { + result = { + type: "error", + title: i18n.str`Malformed response`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_NETWORK_ERROR: { + result = { + type: "error", + title: i18n.str`Network error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + result = { + type: "error", + title: i18n.str`Unexpected request error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + default: { + result = { + type: "error", + title: i18n.str`Unexpected error`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + } + return result; +} |