diff --git a/package.json b/package.json index cb93aa00a..6d48e6042 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "equicord", "private": "true", - "version": "1.14.15.2", + "version": "1.14.16.0", "description": "The other cutest Discord client mod", "homepage": "https://github.com/Equicord/Equicord#readme", "bugs": { diff --git a/src/main/csp/index.ts b/src/main/csp/index.ts index 92e96041c..c0d17b4a4 100644 --- a/src/main/csp/index.ts +++ b/src/main/csp/index.ts @@ -12,6 +12,7 @@ type PolicyMap = Record; export const ConnectSrc = ["connect-src"]; export const ImageSrc = [...ConnectSrc, "img-src"]; export const CssSrc = ["style-src", "font-src"]; +export const ImageAndMediaSrc = [...ImageSrc, "media-src"]; export const ImageAndCssSrc = [...ImageSrc, ...CssSrc]; export const ImageScriptsAndCssSrc = [...ImageAndCssSrc, "script-src", "worker-src"]; export const CSPSrc = ["style-src", "connect-src", "img-src", "frame-src", "font-src", "media-src", "worker-src"]; @@ -41,7 +42,6 @@ export const CspPolicies: PolicyMap = { "i.imgur.com": ImageSrc, // Imgur, used by some themes "i.ibb.co": ImageSrc, // ImgBB, used by some themes "i.pinimg.com": ImageSrc, // Pinterest, used by some themes - "*.tenor.com": ImageSrc, // Tenor, used by some themes "files.catbox.moe": ImageAndCssSrc, // Catbox, used by some themes "cdn.discordapp.com": ImageAndCssSrc, // Discord CDN, used by Vencord and some themes to load media @@ -64,6 +64,10 @@ export const CspPolicies: PolicyMap = { "dearrow-thumb.ajay.app": ImageSrc, // Dearrow Thumbnail CDN "usrbg.is-hardly.online": ImageSrc, // USRBG API "icons.duckduckgo.com": ImageSrc, // DuckDuckGo Favicon API (Reverse Image Search) + + // Tenor, used by TenorSearch plugin and some themes + "*.tenor.com": ImageAndMediaSrc, + "*.tenor.co": ImageAndMediaSrc, }; const findHeader = (headers: PolicyMap, headerName: Lowercase) => { diff --git a/src/plugins/betterFolders/index.tsx b/src/plugins/betterFolders/index.tsx index 6fade47bc..75de43cc7 100644 --- a/src/plugins/betterFolders/index.tsx +++ b/src/plugins/betterFolders/index.tsx @@ -219,8 +219,8 @@ export default definePlugin({ // If we are rendering the normal GuildsBar sidebar, we make Discord think the folder is always collapsed to show better icons (the mini guild icons) and avoid transitions { predicate: () => settings.store.keepIcons, - match: /(?<=let ?(?:\i,)*?{folderNode:\i,setNodeRef:\i,.+?expanded:(\i),.+?;)(?=let)/, - replace: (_, isExpanded) => `${isExpanded}=!!arguments[0]?.isBetterFolders&&${isExpanded};` + match: /let ?(?:\i,)*?{folderNode:\i,setNodeRef:\i,.+?expanded:(\i),.+?;(?=let)/, + replace: (m, isExpanded) => `${m}${isExpanded}=!!arguments[0]?.isBetterFolders&&${isExpanded};` }, // Disable expanding and collapsing folders transition in the normal GuildsBar sidebar { diff --git a/src/plugins/betterSettings/index.tsx b/src/plugins/betterSettings/index.tsx index b936162a2..1564c1c4a 100644 --- a/src/plugins/betterSettings/index.tsx +++ b/src/plugins/betterSettings/index.tsx @@ -110,8 +110,8 @@ export default definePlugin({ find: "this.renderArtisanalHack()", replacement: [ { - match: /class (\i)(?= extends \i\.PureComponent.+?static contextType=.+?jsx\)\(\1,\{mode:)/, - replace: "var $1=$self.Layer;class VencordPatchedOldFadeLayer", + match: /class (\i)( extends \i\.PureComponent.+?jsx\)\(\1,\{mode:)/, + replace: "var $1=$self.Layer;class VencordPatchedOldFadeLayer$2", predicate: () => settings.store.disableFade }, { // Lazy-load contents diff --git a/src/plugins/messageLogger/index.tsx b/src/plugins/messageLogger/index.tsx index ff47832b8..1fe4c5d23 100644 --- a/src/plugins/messageLogger/index.tsx +++ b/src/plugins/messageLogger/index.tsx @@ -20,9 +20,9 @@ import { getIntlMessage } from "@utils/discord"; import { Logger } from "@utils/Logger"; import { classes } from "@utils/misc"; import definePlugin, { OptionType } from "@utils/types"; -import { Message } from "@vencord/discord-types"; +import { Message, MessageAttachment } from "@vencord/discord-types"; import { findCssClassesLazy } from "@webpack"; -import { ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common"; +import { AuthenticationStore, ChannelStore, FluxDispatcher, Menu, MessageStore, Parser, SelectedChannelStore, Timestamp, UserStore, useStateFromStores } from "@webpack/common"; import overlayStyle from "./deleteStyleOverlay.css?managed"; import textStyle from "./deleteStyleText.css?managed"; @@ -36,6 +36,15 @@ interface MLMessage extends Message { diffViewDisabled?: boolean; } +interface MLAttachment extends MessageAttachment { + /** + * if the attachment was deleted + * + * a non-deleted {@link MLMessage|Message} can have deleted attachments + */ + deleted?: boolean; +} + const MessageClasses = findCssClassesLazy("edited", "communicationDisabled", "isSystemMessage"); // track messages where the user disabled diffs for this session @@ -418,7 +427,7 @@ export default definePlugin({ name: "MessageLogger", description: "Temporarily logs deleted and edited messages.", tags: ["Chat", "Utility"], - authors: [Devs.rushii, Devs.Ven, Devs.AutumnVN, Devs.Nickyux, Devs.Kyuuhachi, EquicordDevs.justjxke], + authors: [Devs.rushii, Devs.Ven, Devs.AutumnVN, Devs.Nickyux, Devs.Kyuuhachi, Devs.sadan, EquicordDevs.justjxke], dependencies: ["MessageUpdaterAPI"], isModified: true, settings, @@ -550,11 +559,29 @@ export default definePlugin({ }; }, - handleDelete( - cache: any, - data: { ids: string[]; id: string; mlDeleted?: boolean; }, - isBulk: boolean, - ) { + handleUpdateAttachments(newMessage: MLMessage): MLAttachment[] { + const oldMessage = MessageStore.getMessage(newMessage.channel_id, newMessage.id) as MLMessage | undefined; + // if oldMessage is undefined, this is a new message and we shouldn't touch the attachments + if (!oldMessage || this.shouldIgnore(newMessage, true)) { + return newMessage.attachments; + } + // not sure if it's ever actually null after an edit but discord does a null check here + if (!newMessage.attachments?.length) { + return oldMessage.attachments.map((a): MLAttachment => ({ ...a, deleted: true })); + } + const attachments: MLAttachment[] = []; + for (const oldAttachment of oldMessage.attachments) { + const wasDeleted = newMessage.attachments.every(a => a.id !== oldAttachment.id); + if (wasDeleted) { + attachments.push({ ...oldAttachment, deleted: true }); + } else { + attachments.push(oldAttachment); + } + } + return attachments; + }, + + handleDelete(cache: any, data: { ids: string[], id: string; mlDeleted?: boolean; }, isBulk: boolean) { try { if (cache == null || (!isBulk && !cache.has(data.id))) return cache; @@ -624,6 +651,24 @@ export default definePlugin({ } }, + // It is possible to replace a message in place by creating a new message with the same nonce as an existing one. + // This is not considered an edit since it's a new message. Thus it bypasses our edit logging and can be used to "delete" a message by replacing it with an empty one. + // This fixes that bypass + normalizeNonce(msg: Message) { + try { + if (!msg.nonce || msg.author.id === AuthenticationStore.getId()) return; + + const prevMsg = MessageStore.getMessage(msg.channel_id, msg.nonce); + if (!prevMsg || prevMsg.state !== "SENT") return; + + if (prevMsg.id !== msg.id) { + delete msg.nonce; + } + } catch (e) { + console.error("[MessageLogger] Error normalizing nonce"); + } + }, + EditMarker({ message, className, children, ...props }: any) { return ( settings.store.collapseDeleted, + predicate: () => settings.store.collapseDeleted }, - ], + + { + find: "this.truncateTop", + replacement: { + match: /receiveMessage\((\i)\)\{/, + replace: "$& $self.normalizeNonce($1);" + } + } + ] }); diff --git a/src/plugins/messageLogger/messageLogger.css b/src/plugins/messageLogger/messageLogger.css index 57f86f1b4..b080f049c 100644 --- a/src/plugins/messageLogger/messageLogger.css +++ b/src/plugins/messageLogger/messageLogger.css @@ -2,8 +2,10 @@ display: none; } -.messagelogger-deleted :is(.messagelogger-deleted-attachment, - .emoji, + +/* non-deleted messages can have deleted attachments */ +.messagelogger-deleted-attachment, +.messagelogger-deleted :is(.emoji, [data-type="sticker"], [class*="embedIframe"], [class*="embedSpotify"], @@ -107,4 +109,4 @@ .messagelogger-diff-disabled .messagelogger-diff-removed [class*="emojiContainer"]::after, .messagelogger-diff-disabled .messagelogger-diff-removed img.emoji::after { display: none !important; -} +} \ No newline at end of file diff --git a/src/plugins/replyTimestamp/style.css b/src/plugins/replyTimestamp/style.css index c7cf22495..d910134e4 100644 --- a/src/plugins/replyTimestamp/style.css +++ b/src/plugins/replyTimestamp/style.css @@ -1,4 +1,4 @@ .vc-reply-timestamp { - width: fit-content !important; - margin-right: 0.25em; -} + margin-inline-end: 0.5rem; + width: unset !important; +} \ No newline at end of file diff --git a/src/plugins/tenorGifSearch/index.tsx b/src/plugins/tenorGifSearch/index.tsx new file mode 100644 index 000000000..b691cedc4 --- /dev/null +++ b/src/plugins/tenorGifSearch/index.tsx @@ -0,0 +1,249 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import { isNonNullish } from "@utils/guards"; +import definePlugin from "@utils/types"; +import { FluxDispatcher, LocaleStore } from "@webpack/common"; + +// API key is taken from the GBoard app on iOS +const TENOR_KEY = "3Z0688EVWYKH"; + +let cachedCategories: TrendingCategories | null = null; + +interface TenorMedia { + url: string; + preview: string; + dims: [number, number]; +} +interface TenorResult { + id: string; + media: Array>; + itemurl: string; +} +interface TenorCategoryTag { + searchterm: string; + image: string; +} + +interface DiscordGif { + id: string; + title: string; + url: string; + src: string; + gif_src: string; + width: number; + height: number; + preview: string; +} + +interface TrendingCategories { + trendingCategories: Record<"name" | "src", string>[]; + trendingGIFPreview: { src: string; }; +} + +function toDiscordGif(item: TenorResult): DiscordGif | null { + const { gif, webm } = item.media[0]; + + return { + id: item.id, + title: "", // discord always returns a blank string + url: item.itemurl, + gif_src: gif.url, + src: webm.url, + width: webm.dims[0], + height: webm.dims[1], + preview: webm.preview + }; +} + +function mapToDiscordGifs(items: TenorResult[]) { + return items.map(toDiscordGif).filter(isNonNullish); +} + +async function tenorFetch(path: string, params: Record) { + const url = `https://api.tenor.com/v1${path}?` + new URLSearchParams({ + key: TENOR_KEY, + locale: LocaleStore.locale.replace("-", "_").toLowerCase(), + ...params + }); + + const res = await fetch(url); + if (!res.ok) + throw new Error(`GET ${path}: Tenor API request failed with status ${res.status}`); + + return res.json() as Promise; +} + +// function contributed by taep96 +async function fetchTenorResults(path: string, limit: number, extra: Record = {}) { + const pageSize = Math.min(limit, 50); + const items: TenorResult[] = []; + const seen = new Set(); + let pos = ""; + + while (items.length < limit) { + const params: Record = { + ...extra, + limit: String(Math.min(limit - items.length, pageSize)) + }; + if (pos) params.pos = pos; + + const { next, results: page } = await tenorFetch<{ next?: string; results: TenorResult[]; }>(path, params); + if (!page.length) break; + + const previousLength = items.length; + for (const item of page) { + if (seen.has(item.id)) continue; + seen.add(item.id); + + items.push(item); + if (items.length >= limit) break; + } + if (items.length === previousLength) break; + + if (!next || next === pos) break; + pos = next; + } + + return items; +} + +async function fetchCategories(): Promise { + return tenorFetch<{ tags?: TenorCategoryTag[]; }>("/categories", { type: "featured" }) + .then(({ tags }) => { + if (!tags?.length) return null; + + return { + trendingCategories: tags.map(t => ({ name: t.searchterm, src: t.image })), + trendingGIFPreview: { src: tags[0].image } + }; + }) + .catch(() => null); + +} + +export default definePlugin({ + name: "TenorGifSearch", + description: "Restore Tenor GIF search", + authors: [Devs.Lunascape], + + patches: [ + { + find: "renderHeaderContent()", + replacement: { + match: /placeholder:(\i),"aria-label":(\i)/, + replace: 'placeholder:$1?.replace(/Giphy|Klipy/gi,"Tenor"),"aria-label":$2?.replace(/Giphy|Klipy/gi,"Tenor")' + } + }, + { + find: '"GIF_PICKER_TRENDING_FETCH_SUCCESS",trendingCategories:', + replacement: [ + { + match: /let \i=Date\.now\(\);\i\([^)]+\),\i\.\i\.get\(\{url:\i\.\i\.GIFS_SEARCH,query:\{q:(\i),/, + replace: "return $self.handleSearchFetch($1);$&" + }, + { + match: /""!==(\i)&&null!=\1&&\i\.\i\.get\(\{url:\i\.\i\.GIFS_SUGGEST,/, + replace: "return $self.handleSuggestionsFetch($1);$&" + }, + { + match: /\i\.\i\.get\(\{url:\i\.\i\.GIFS_TRENDING,/, + replace: "return $self.handleTrendingFetch();$&" + }, + { + match: /let \i=Date\.now\(\);\i\([^)]+\),\i\.\i\.get\(\{url:\i\.\i\.GIFS_TRENDING_GIFS,/, + replace: "return $self.handleTrendingGifsFetch();$&" + }, + { + match: /\i\.\i\.post\(\{url:\i\.\i\.GIFS_SELECT,body:\{id:(\i),q:(\i),provider:\i\}/, + replace: "return $self.handleGifSelect($1,$2);$&" + } + ] + }, + { + find: '"IntegrationQueryStore"', + replacement: { + match: /(?<=search\((\i),(\i)\)\{)null==\i\.getResults\(\1,\2\)&&/, + replace: "return $self.tenorIntegrationSearch($1,$2);null==void 0&&" + } + } + ], + + async start() { + cachedCategories = await fetchCategories() ?? cachedCategories; + }, + + handleSearchFetch(query: string) { + // discord has a 100 result limit for normal search + fetchTenorResults("/search", 100, { q: query }) + .then(results => { + const items = mapToDiscordGifs(results); + FluxDispatcher.dispatch( + items.length + ? { type: "GIF_PICKER_QUERY_SUCCESS", query, items } + : { type: "GIF_PICKER_QUERY_FAILURE", query } + ); + }) + .catch(() => { + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY_FAILURE", query }); + }); + }, + + async handleSuggestionsFetch(query: string) { + if (!query) return; + + const { results } = await tenorFetch<{ results?: string[]; }>("/search_suggestions", { q: query, limit: "5" }); + + FluxDispatcher.dispatch({ type: "GIF_PICKER_SUGGESTIONS_SUCCESS", query, items: results }); + }, + + async handleTrendingFetch() { + if (!cachedCategories) { + cachedCategories = await fetchCategories(); + + if (!cachedCategories) return; + } + + FluxDispatcher.dispatch({ type: "GIF_PICKER_TRENDING_FETCH_SUCCESS", ...cachedCategories }); + }, + + handleGifSelect(id: string, query: string) { + tenorFetch("/registershare", { id, q: query }); + }, + + handleTrendingGifsFetch() { + fetchTenorResults("/trending", 50) + .then(results => { + const items = mapToDiscordGifs(results); + FluxDispatcher.dispatch( + items.length + ? { type: "GIF_PICKER_QUERY_SUCCESS", items } + : { type: "GIF_PICKER_QUERY_FAILURE" } + ); + }) + .catch(() => { + FluxDispatcher.dispatch({ type: "GIF_PICKER_QUERY_FAILURE" }); + }); + }, + + tenorIntegrationSearch(integration: string, query: string) { + FluxDispatcher.dispatch({ type: "INTEGRATION_QUERY", integration, query }); + + fetchTenorResults("/search", 20, { q: query }) + .then(results => { + const items = mapToDiscordGifs(results); + FluxDispatcher.dispatch( + items.length + ? { type: "INTEGRATION_QUERY_SUCCESS", integration, query, results: items } + : { type: "INTEGRATION_QUERY_FAILURE", integration, query } + ); + }) + .catch(() => { + FluxDispatcher.dispatch({ type: "INTEGRATION_QUERY_FAILURE", integration, query, results: [] }); + }); + } +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f1b28f5f5..9e4a8c82f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -666,6 +666,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "NightmareSan", id: 304239816466235392n }, + Lunascape: { + name: "Lunascape", + id: 383365021415243776n + } } satisfies Record); export const EquicordDevs = Object.freeze({