mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-18 00:11:08 +08:00
354 lines
9.0 KiB
TypeScript
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;
|
|
}
|