diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 885a6e20..13b3c948 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -4,6 +4,7 @@ import { failure, getNextOccurrence, getWalletInformation, + isErrorWithKey, success, } from '@/shared/helpers'; import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error'; @@ -250,6 +251,10 @@ export class Background { return; } } catch (e) { + if (isErrorWithKey(e)) { + this.logger.error(message.action, e); + return failure({ key: e.key, substitutions: e.substitutions }); + } if (e instanceof OpenPaymentsClientError) { this.logger.error(message.action, e.message, e.description); return failure( diff --git a/src/popup/lib/context.tsx b/src/popup/lib/context.tsx index a89a840e..a7148d77 100644 --- a/src/popup/lib/context.tsx +++ b/src/popup/lib/context.tsx @@ -1,6 +1,10 @@ import React, { type PropsWithChildren } from 'react'; import type { Browser } from 'webextension-polyfill'; -import { tFactory, type Translation } from '@/shared/helpers'; +import { + tFactory, + type ErrorWithKeyLike, + type Translation, +} from '@/shared/helpers'; import type { DeepNonNullable, PopupStore } from '@/shared/types'; import { BACKGROUND_TO_POPUP_CONNECTION_NAME as CONNECTION_NAME, @@ -153,7 +157,9 @@ export const BrowserContextProvider = ({ // #endregion // #region Translation -const TranslationContext = React.createContext((v: string) => v); +const TranslationContext = React.createContext( + (v: string | ErrorWithKeyLike) => (typeof v === 'string' ? v : v.key), +); export const useTranslation = () => React.useContext(TranslationContext); diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 83128f5a..871c4a7a 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -9,6 +9,11 @@ import { parse, toSeconds } from 'iso8601-duration'; import type { Browser } from 'webextension-polyfill'; import type { Storage, RepeatingInterval, AmountValue } from './types'; +export type TranslationKeys = + keyof typeof import('../_locales/en/messages.json'); + +export type ErrorKeys = Extract; + export const cn = (...inputs: CxOptions) => { return twMerge(cx(inputs)); }; @@ -53,6 +58,45 @@ export const getWalletInformation = async ( return json; }; +/** + * Error object with key and substitutions based on `_locales/[lang]/messages.json` + */ +export interface ErrorWithKeyLike { + key: Extract; + // Could be empty, but required for checking if an object follows this interface + substitutions: string[]; +} + +export class ErrorWithKey + extends Error + implements ErrorWithKeyLike +{ + constructor( + public readonly key: ErrorWithKeyLike['key'], + public readonly substitutions: ErrorWithKeyLike['substitutions'] = [], + ) { + super(key); + } +} + +/** + * Same as {@linkcode ErrorWithKey} but creates plain object instead of Error + * instance. + * Easier than creating object ourselves, but more performant than Error. + */ +export const errorWithKey = ( + key: ErrorWithKeyLike['key'], + substitutions: ErrorWithKeyLike['substitutions'] = [], +) => ({ key, substitutions }); + +export const isErrorWithKey = (err: any): err is ErrorWithKeyLike => { + if (!err || typeof err !== 'object') return false; + return ( + err instanceof ErrorWithKey || + (typeof err.key === 'string' && Array.isArray(err.substitutions)) + ); +}; + export const success = ( payload: TPayload, ): SuccessResponse => ({ @@ -60,9 +104,11 @@ export const success = ( payload, }); -export const failure = (message: string) => ({ - success: false, - message, +export const failure = (message: string | ErrorWithKeyLike) => ({ + success: false as const, + ...(typeof message === 'string' + ? { message } + : { error: message, message: message.key }), }); export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -199,19 +245,25 @@ export function bigIntMax(a: T, b: T): T { return BigInt(a) > BigInt(b) ? a : b; } -export type TranslationKeys = - keyof typeof import('../_locales/en/messages.json'); - export type Translation = ReturnType; export function tFactory(browser: Pick) { /** * Helper over calling cumbersome `this.browser.i18n.getMessage(key)` with * added benefit that it type-checks if key exists in message.json */ - return ( + function t( key: T, - substitutions?: string | string[], - ) => browser.i18n.getMessage(key, substitutions); + substitutions?: string[], + ): string; + function t(err: ErrorWithKeyLike): string; + function t(key: string | ErrorWithKeyLike, substitutions?: string[]): string { + if (typeof key === 'string') { + return browser.i18n.getMessage(key, substitutions); + } + const err = key; + return browser.i18n.getMessage(err.key, err.substitutions); + } + return t; } type Primitive = string | number | boolean | null | undefined; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 960d3a33..f5828f9a 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -4,6 +4,7 @@ import type { } from '@interledger/open-payments'; import type { Browser } from 'webextension-polyfill'; import type { AmountValue, Storage } from '@/shared/types'; +import type { ErrorWithKeyLike } from '@/shared/helpers'; import type { PopupState } from '@/popup/lib/context'; // #region MessageManager @@ -15,6 +16,7 @@ export interface SuccessResponse { export interface ErrorResponse { success: false; message: string; + error?: ErrorWithKeyLike; } export type Response =