diff --git a/package.json b/package.json index ea5f601b8..60d5fcad6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.13.3", + "version": "1.13.4", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { @@ -121,4 +121,4 @@ "engines": { "node": ">=18" } -} \ No newline at end of file +} diff --git a/src/equicordplugins/noAppsAllowed/index.tsx b/src/equicordplugins/noAppsAllowed/index.tsx index ee0e19a18..a894055bf 100644 --- a/src/equicordplugins/noAppsAllowed/index.tsx +++ b/src/equicordplugins/noAppsAllowed/index.tsx @@ -16,8 +16,8 @@ export default definePlugin({ { find: "#{intl::APP_TAG::hash}\":", replacement: { - match: /(#{intl::APP_TAG::hash}"):".*?"/, - replace: '$1:"BOT"' + match: /(9RNkeF":\[)"APP"/, + replace: '$1"BOT"' } } ], diff --git a/src/plugins/showTimeoutDuration/index.tsx b/src/plugins/showTimeoutDuration/index.tsx index ea4def074..e0f614c51 100644 --- a/src/plugins/showTimeoutDuration/index.tsx +++ b/src/plugins/showTimeoutDuration/index.tsx @@ -18,8 +18,8 @@ import { findComponentLazy } from "@webpack"; import { ChannelStore, GuildMemberStore, 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 => m.prototype?.render && countDownFilter.test(m.prototype.render.toString())); const enum DisplayStyle { Tooltip = "tooltip", diff --git a/src/utils/discord.tsx b/src/utils/discord.tsx index a2b95ac15..1242e03cf 100644 --- a/src/utils/discord.tsx +++ b/src/utils/discord.tsx @@ -21,19 +21,21 @@ import { Channel, CloudUpload, Guild, GuildFeatures, Message, User } from "@venc import { ChannelActionCreators, ChannelStore, ComponentDispatch, Constants, FluxDispatcher, GuildStore, i18n, IconUtils, InviteActions, MessageActions, RestAPI, SelectedChannelStore, SelectedGuildStore, UserProfileActions, UserProfileStore, UserSettingsActionCreators, UserUtils } from "@webpack/common"; import { Except } from "type-fest"; -import { runtimeHashMessageKey } from "./intlHash"; +import { runtimeHashMessageKey, runtimeHashMessageKeyLegacy } from "./intlHash"; import { Logger } from "./Logger"; import { MediaModalItem, MediaModalProps, openMediaModal } from "./modal"; const IntlManagerLogger = new Logger("IntlManager"); +// TODO: remove legacy hashing function once Discord ships new one everywhere for a while + /** * Get an internationalized message from a non hashed key * @param key The plain message key * @param values The values to interpolate, if it's a rich message */ export function getIntlMessage(key: string, values?: Record): any { - return getIntlMessageFromHash(runtimeHashMessageKey(key), values, key); + return getIntlMessageFromHash(runtimeHashMessageKey(key), values, key) || getIntlMessageFromHash(runtimeHashMessageKeyLegacy(key), values, key); } /** 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 bcced95d2..0bcd2ec12 100644 --- a/src/utils/patches.ts +++ b/src/utils/patches.ts @@ -16,37 +16,65 @@ * 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, "\\$&")); + +export 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 (modifier === "hash") return hashed; - - 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 {