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 {