clash-verge-rev/src/services/notice-service.ts

354 lines
9.0 KiB
TypeScript

import i18n from "i18next";
import { ReactNode, isValidElement } from "react";
type NoticeType = "success" | "error" | "info";
export interface NoticeTranslationDescriptor {
key: string;
params?: Record<string, unknown>;
}
interface NoticeItem {
readonly id: number;
readonly type: NoticeType;
readonly duration: number;
readonly message?: ReactNode;
readonly i18n?: NoticeTranslationDescriptor;
timerId?: ReturnType<typeof setTimeout>;
}
type NoticeContent = unknown;
type NoticeExtra = unknown;
type NoticeShortcut = (
message: NoticeContent,
...extras: NoticeExtra[]
) => number;
type ShowNotice = ((
type: NoticeType,
message: NoticeContent,
...extras: NoticeExtra[]
) => number) & {
success: NoticeShortcut;
error: NoticeShortcut;
info: NoticeShortcut;
};
type NoticeSubscriber = () => void;
const DEFAULT_DURATIONS: Readonly<Record<NoticeType, number>> = {
success: 3000,
info: 5000,
error: 8000,
};
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/;
let nextId = 0;
let notices: NoticeItem[] = [];
const subscribers: Set<NoticeSubscriber> = new Set();
function notifySubscribers() {
subscribers.forEach((subscriber) => subscriber());
}
interface ParsedNoticeExtras {
params?: Record<string, unknown>;
raw?: unknown;
duration?: number;
}
function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
let params: Record<string, unknown> | undefined;
let raw: unknown;
let duration: number | undefined;
// Prioritize objects as translation params, then as raw payloads, while the first number wins as duration.
for (const extra of extras) {
if (extra === undefined) continue;
if (typeof extra === "number" && duration === undefined) {
duration = extra;
continue;
}
if (isPlainRecord(extra)) {
if (!params) {
params = extra;
continue;
}
if (!raw) {
raw = extra;
continue;
}
}
if (!raw) {
raw = extra;
continue;
}
if (!params && isPlainRecord(extra)) {
params = extra;
continue;
}
if (duration === undefined && typeof extra === "number") {
duration = extra;
}
}
return { params, raw, duration };
}
function resolveDuration(type: NoticeType, override?: number) {
return override ?? DEFAULT_DURATIONS[type];
}
function buildNotice(
id: number,
type: NoticeType,
duration: number,
payload: { message?: ReactNode; i18n?: NoticeTranslationDescriptor },
timerId?: ReturnType<typeof setTimeout>,
): NoticeItem {
return {
id,
type,
duration,
timerId,
...payload,
};
}
function isMaybeTranslationDescriptor(
message: unknown,
): message is NoticeTranslationDescriptor {
if (
typeof message === "object" &&
message !== null &&
!Array.isArray(message) &&
!isValidElement(message)
) {
return typeof (message as Record<string, unknown>).key === "string";
}
return false;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
value instanceof Error ||
isValidElement(value)
) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function createRawDescriptor(message: string): NoticeTranslationDescriptor {
return {
key: "shared.feedback.notices.raw",
params: { message },
};
}
function isLikelyTranslationKey(key: string) {
return TRANSLATION_KEY_PATTERN.test(key);
}
function shouldUseTranslationKey(
key: string,
params?: Record<string, unknown>,
) {
if (params && Object.keys(params).length > 0) return true;
if (isLikelyTranslationKey(key)) return true;
if (i18n.isInitialized) {
return i18n.exists(key);
}
return false;
}
function extractDisplayText(input: unknown): string | undefined {
if (input === null || input === undefined) return undefined;
if (typeof input === "string") return input;
if (typeof input === "number" || typeof input === "boolean") {
return String(input);
}
if (input instanceof Error) {
return input.message || input.name;
}
if (typeof input === "object" && input !== null) {
const maybeMessage = (input as { message?: unknown }).message;
if (typeof maybeMessage === "string") return maybeMessage;
}
try {
return JSON.stringify(input);
} catch {
return String(input);
}
}
function normalizeNoticeMessage(
message: NoticeContent,
params?: Record<string, unknown>,
raw?: unknown,
): { message?: ReactNode; i18n?: NoticeTranslationDescriptor } {
const rawText = raw !== undefined ? extractDisplayText(raw) : undefined;
if (isValidElement(message)) {
return { message };
}
if (isMaybeTranslationDescriptor(message)) {
const originalParams = message.params ?? {};
const mergedParams = Object.keys(params ?? {}).length
? { ...originalParams, ...params }
: { ...originalParams };
if (rawText !== undefined) {
return {
i18n: {
key: "shared.feedback.notices.prefixedRaw",
params: {
...mergedParams,
prefixKey: message.key,
prefixParams: originalParams,
message: rawText,
},
},
};
}
return {
i18n: {
key: message.key,
params: Object.keys(mergedParams).length ? mergedParams : undefined,
},
};
}
if (typeof message === "string") {
if (rawText !== undefined) {
if (shouldUseTranslationKey(message, params)) {
return {
i18n: {
key: "shared.feedback.notices.prefixedRaw",
params: {
...(params ?? {}),
prefixKey: message,
message: rawText,
},
},
};
}
// Prefer showing the original string while still surfacing the raw details below.
return {
i18n: {
key: "shared.feedback.notices.prefixedRaw",
params: {
...(params ?? {}),
prefix: message,
message: rawText,
},
},
};
}
if (shouldUseTranslationKey(message, params)) {
return {
i18n: {
key: message,
params: params && Object.keys(params).length ? params : undefined,
},
};
}
return { i18n: createRawDescriptor(message) };
}
if (rawText !== undefined) {
return { i18n: createRawDescriptor(rawText) };
}
const extracted = extractDisplayText(message);
if (extracted !== undefined) {
return { i18n: createRawDescriptor(extracted) };
}
return { i18n: createRawDescriptor("") };
}
const baseShowNotice = (
type: NoticeType,
message: NoticeContent,
...extras: NoticeExtra[]
): number => {
const id = nextId++;
const { params, raw, duration } = parseNoticeExtras(extras);
const effectiveDuration = resolveDuration(type, duration);
const timerId =
effectiveDuration > 0
? setTimeout(() => hideNotice(id), effectiveDuration)
: undefined;
const normalizedMessage = normalizeNoticeMessage(message, params, raw);
const notice = buildNotice(
id,
type,
effectiveDuration,
normalizedMessage,
timerId,
);
notices = [...notices, notice];
notifySubscribers();
return id;
};
/**
* Shows a global notice; `showNotice.success / error / info` are the usual entry points.
*
* - `message`: i18n key string, `{ key, params }`, ReactNode, Error/any value (message is extracted)
* - `extras` parsed left-to-right: first plain object is i18n params; next value is raw payload; first number overrides duration (ms, 0 = persistent; defaults: success 3000 / info 5000 / error 8000)
* - Returns a notice id for manual closing via `hideNotice(id)`
*
* @example showNotice.success("profiles.page.feedback.notifications.batchDeleted");
* @example showNotice.error(err); // pass an Error directly
* @example showNotice.error("shared.feedback.notifications.common.refreshFailed", err); // Simply pass an Error directly; but we recommend using { err } with i18n key and placeholders.
* @example showNotice.error("profiles.page.feedback.errors.invalidUrl", { url }, 4000);
*/
export const showNotice: ShowNotice = Object.assign(baseShowNotice, {
success: (message: NoticeContent, ...extras: NoticeExtra[]) =>
baseShowNotice("success", message, ...extras),
error: (message: NoticeContent, ...extras: NoticeExtra[]) =>
baseShowNotice("error", message, ...extras),
info: (message: NoticeContent, ...extras: NoticeExtra[]) =>
baseShowNotice("info", message, ...extras),
});
export function hideNotice(id: number) {
const notice = notices.find((candidate) => candidate.id === id);
if (notice?.timerId) {
clearTimeout(notice.timerId);
}
notices = notices.filter((candidate) => candidate.id !== id);
notifySubscribers();
}
export function subscribeNotices(subscriber: NoticeSubscriber) {
subscribers.add(subscriber);
return () => {
subscribers.delete(subscriber);
};
}
export function getSnapshotNotices() {
return notices;
}