mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-08 02:52:07 +08:00
415 lines
11 KiB
TypeScript
415 lines
11 KiB
TypeScript
import i18n from 'i18next'
|
|
import { ReactNode, isValidElement } from 'react'
|
|
|
|
type NoticeType = 'success' | 'error' | 'info'
|
|
|
|
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_-]+)+$/
|
|
const CODED_ERROR_PATTERN = /^CVR_ERROR:([A-Z0-9_]+)(?:\n([\s\S]*))?$/
|
|
const CODED_ERROR_TRANSLATION_KEYS: Readonly<Record<string, string>> = {
|
|
CLASH_CONFIG_UPDATE_FAILED:
|
|
'settings.feedback.errors.clash.configUpdateFailed',
|
|
CLASH_MODE_UPDATE_FAILED: 'settings.feedback.errors.clash.modeUpdateFailed',
|
|
CORE_CHANGE_FAILED: 'settings.feedback.notifications.clash.changeFailed',
|
|
CORE_RESTART_FAILED: 'settings.feedback.errors.clash.restartFailed',
|
|
CORE_START_FAILED: 'settings.feedback.errors.clash.startFailed',
|
|
CORE_STOP_FAILED: 'settings.feedback.errors.clash.stopFailed',
|
|
PROFILE_CREATE_FAILED: 'profiles.page.feedback.errors.createFailed',
|
|
PROFILE_DELETE_FAILED: 'profiles.page.feedback.errors.deleteFailed',
|
|
PROFILE_ENHANCE_FAILED: 'profiles.page.feedback.errors.enhanceFailed',
|
|
PROFILE_IMPORT_FAILED: 'profiles.page.feedback.errors.importFailed',
|
|
PROFILE_OPEN_FAILED: 'profiles.page.feedback.errors.openFailed',
|
|
PROFILE_READ_FAILED: 'profiles.page.feedback.errors.readFailed',
|
|
PROFILE_REORDER_FAILED: 'profiles.page.feedback.errors.reorderFailed',
|
|
PROFILE_SWITCH_FAILED: 'profiles.page.feedback.errors.switchFailed',
|
|
PROFILE_UPDATE_FAILED: 'profiles.page.feedback.errors.updateFailed',
|
|
SERVICE_INSTALL_FAILED: 'settings.feedback.errors.clashService.installFailed',
|
|
SERVICE_REINSTALL_FAILED:
|
|
'settings.feedback.errors.clashService.reinstallFailed',
|
|
SERVICE_REPAIR_FAILED: 'settings.feedback.errors.clashService.repairFailed',
|
|
SERVICE_SIDECAR_FAILED: 'settings.feedback.errors.clashService.sidecarFailed',
|
|
SERVICE_UNINSTALL_FAILED:
|
|
'settings.feedback.errors.clashService.uninstallFailed',
|
|
}
|
|
|
|
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 parseCodedError(input?: string) {
|
|
if (!input) return undefined
|
|
const match = input.match(CODED_ERROR_PATTERN)
|
|
if (!match) return undefined
|
|
|
|
return {
|
|
translationKey:
|
|
CODED_ERROR_TRANSLATION_KEYS[match[1]] ??
|
|
'shared.feedback.errors.operationFailed',
|
|
detail: match[2]?.trim(),
|
|
}
|
|
}
|
|
|
|
function normalizeNoticeMessage(
|
|
message: NoticeContent,
|
|
params?: Record<string, unknown>,
|
|
raw?: unknown,
|
|
): { message?: ReactNode; i18n?: NoticeTranslationDescriptor } {
|
|
const rawText = raw !== undefined ? extractDisplayText(raw) : undefined
|
|
const parsedRawError = parseCodedError(rawText)
|
|
const rawDetail = parsedRawError?.detail ?? rawText
|
|
|
|
if (isValidElement(message)) {
|
|
return { message }
|
|
}
|
|
|
|
if (isMaybeTranslationDescriptor(message)) {
|
|
const originalParams = message.params ?? {}
|
|
const mergedParams = Object.keys(params ?? {}).length
|
|
? { ...originalParams, ...params }
|
|
: { ...originalParams }
|
|
|
|
if (rawDetail !== undefined) {
|
|
return {
|
|
i18n: {
|
|
key: 'shared.feedback.notices.prefixedRaw',
|
|
params: {
|
|
...mergedParams,
|
|
prefixKey: message.key,
|
|
prefixParams: originalParams,
|
|
message: rawDetail,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return {
|
|
i18n: {
|
|
key: message.key,
|
|
params: Object.keys(mergedParams).length ? mergedParams : undefined,
|
|
},
|
|
}
|
|
}
|
|
|
|
const parsedMessageError = parseCodedError(extractDisplayText(message))
|
|
if (parsedMessageError) {
|
|
if (!parsedMessageError.detail) {
|
|
return {
|
|
i18n: {
|
|
key: parsedMessageError.translationKey,
|
|
},
|
|
}
|
|
}
|
|
return {
|
|
i18n: {
|
|
key: 'shared.feedback.notices.prefixedRaw',
|
|
params: {
|
|
prefixKey: parsedMessageError.translationKey,
|
|
message: parsedMessageError.detail,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
if (typeof message === 'string') {
|
|
if (rawDetail !== undefined) {
|
|
if (shouldUseTranslationKey(message, params)) {
|
|
return {
|
|
i18n: {
|
|
key: 'shared.feedback.notices.prefixedRaw',
|
|
params: {
|
|
...(params ?? {}),
|
|
prefixKey: message,
|
|
message: rawDetail,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
// Prefer showing the original string while still surfacing the raw details below.
|
|
return {
|
|
i18n: {
|
|
key: 'shared.feedback.notices.prefixedRaw',
|
|
params: {
|
|
...(params ?? {}),
|
|
prefix: message,
|
|
message: rawDetail,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
if (shouldUseTranslationKey(message, params)) {
|
|
return {
|
|
i18n: {
|
|
key: message,
|
|
params: params && Object.keys(params).length ? params : undefined,
|
|
},
|
|
}
|
|
}
|
|
return { i18n: createRawDescriptor(message) }
|
|
}
|
|
|
|
if (rawDetail !== undefined) {
|
|
return { i18n: createRawDescriptor(rawDetail) }
|
|
}
|
|
|
|
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
|
|
}
|