From 647c824d9315094439c7db4d7817f260441d8e49 Mon Sep 17 00:00:00 2001 From: Vendicated Date: Wed, 15 Oct 2025 18:20:50 +0200 Subject: [PATCH] fix: update intl hash function https://github.com/discord/discord-intl/commit/e8598c6dde1d0f3479a8f216fda90265495e77ae --- src/plugins/showTimeoutDuration/index.tsx | 4 +- src/utils/intlHash.ts | 15 ++++++ src/utils/patches.ts | 63 +++++++++++++++++------ 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/plugins/showTimeoutDuration/index.tsx b/src/plugins/showTimeoutDuration/index.tsx index 6d13d9fc1..0be7513ec 100644 --- a/src/plugins/showTimeoutDuration/index.tsx +++ b/src/plugins/showTimeoutDuration/index.tsx @@ -17,8 +17,8 @@ import { findComponentLazy } from "@webpack"; import { ChannelStore, GuildMemberStore, Text, TooltipContainer } from "@webpack/common"; import { ReactNode } from "react"; -const countDownFilter = canonicalizeMatch("#{intl::MAX_AGE_NEVER}"); -const CountDown = findComponentLazy(m => m.prototype?.render?.toString().includes(countDownFilter)); +const countDownFilter = canonicalizeMatch(/#{intl::MAX_AGE_NEVER}/); +const CountDown = findComponentLazy(m => countDownFilter.test(m.prototype?.render?.toString())); const enum DisplayStyle { Tooltip = "tooltip", diff --git a/src/utils/intlHash.ts b/src/utils/intlHash.ts index e6b77d095..b66cd4d82 100644 --- a/src/utils/intlHash.ts +++ b/src/utils/intlHash.ts @@ -39,6 +39,21 @@ function numberToBytes(number: number | bigint) { * `@discord/intl-loader-core`, to be able to hash names at runtime. */ export function runtimeHashMessageKey(key: string): string { + const hash = h64(key, 0); + const bytes = numberToBytes(hash); + return [ + BASE64_TABLE[bytes[0] >> 2], + BASE64_TABLE[((bytes[0] & 0x03) << 4) | (bytes[1] >> 4)], + BASE64_TABLE[((bytes[1] & 0x0f) << 2) | (bytes[2] >> 6)], + BASE64_TABLE[bytes[2] & 0x3f], + BASE64_TABLE[bytes[3] >> 2], + BASE64_TABLE[((bytes[3] & 0x03) << 4) | (bytes[4] >> 4)], + ].join(""); +} + +// Old variant for compatibility +// TODO: remove once Discord shipped new one everywhere for a while +export function runtimeHashMessageKeyLegacy(key: string): string { const hash = h64(key, 0); const bytes = numberToBytes(hash); return [ diff --git a/src/utils/patches.ts b/src/utils/patches.ts index b212e6241..c149765d5 100644 --- a/src/utils/patches.ts +++ b/src/utils/patches.ts @@ -16,35 +16,66 @@ * along with this program. If not, see . */ -import { runtimeHashMessageKey } from "./intlHash"; +import { runtimeHashMessageKey, runtimeHashMessageKeyLegacy } from "./intlHash"; import { Patch, PatchReplacement, ReplaceFn } from "./types"; -export function canonicalizeMatch(match: T): T { +// TODO: remove legacy hashing function once Discord ships new one everywhere for a while + +// @ts-expect-error "RegExp.escape" is very new and not yet in DOM types +const escapeRegex = RegExp.escape || ((s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + +function getReplacement(isString: boolean, hashed: string) { + const hasSpecialChars = !Number.isNaN(Number(hashed[0])) || hashed.includes("+") || hashed.includes("/"); + + if (hasSpecialChars) { + return isString + ? `["${hashed}"]` + : String.raw`(?:\["${hashed}"\])`.replaceAll("+", "\\+"); + } + + return isString ? `.${hashed}` : String.raw`(?:\.${hashed})`; +} + +function getCompatReplacement(key: string) { + const hashed = getReplacement(false, runtimeHashMessageKey(key)); + const legacyHashed = getReplacement(false, runtimeHashMessageKeyLegacy(key)); + + return String.raw`(?:${hashed}|${legacyHashed})`; +} + +function canonicalizeMatchCompatString(str: string) { + let result = ""; + let lastIndex = 0; + const re = /#{intl::([\w$+/]*)(?:::(\w+))?}/g; + for (const match of str.matchAll(re)) { + result += escapeRegex(str.slice(lastIndex, match.index)); + result += getCompatReplacement(match[1]); + lastIndex = (match.index ?? 0) + match[0].length; + } + result += escapeRegex(str.slice(lastIndex)); + return new RegExp(result); +} + +export function canonicalizeMatch(match: T): T extends RegExp ? RegExp : string | RegExp { + if (typeof match === "string") { + return canonicalizeMatchCompatString(match) as any; + } + let partialCanon = typeof match === "string" ? match : match.source; partialCanon = partialCanon.replaceAll(/#{intl::([\w$+/]*)(?:::(\w+))?}/g, (_, key, modifier) => { - const hashed = modifier === "raw" ? key : runtimeHashMessageKey(key); - - const isString = typeof match === "string"; - const hasSpecialChars = !Number.isNaN(Number(hashed[0])) || hashed.includes("+") || hashed.includes("/"); - - if (hasSpecialChars) { - return isString - ? `["${hashed}"]` - : String.raw`(?:\["${hashed}"\])`.replaceAll("+", "\\+"); - } - - return isString ? `.${hashed}` : String.raw`(?:\.${hashed})`; + if (modifier === "raw") return getReplacement(false, key); + return getCompatReplacement(key); }); if (typeof match === "string") { - return partialCanon as T; + return partialCanon as any; } const canonSource = partialCanon.replaceAll("\\i", String.raw`(?:[A-Za-z_$][\w$]*)`); const canonRegex = new RegExp(canonSource, match.flags); canonRegex.toString = match.toString.bind(match); - return canonRegex as T; + return canonRegex as any; } export function canonicalizeReplace(replace: T, pluginPath: string): T {