Merge remote-tracking branch 'upstream/dev' into dev
This commit is contained in:
commit
a0004a5b49
9 changed files with 345 additions and 28 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type PolicyMap = Record<string, string[]>;
|
|||
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<string>) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span
|
||||
|
|
@ -728,17 +773,21 @@ export default definePlugin({
|
|||
},
|
||||
|
||||
{
|
||||
// Updated message transformer(?)
|
||||
// Updated message transformer
|
||||
find: ".PREMIUM_REFERRAL&&(",
|
||||
replacement: [
|
||||
{
|
||||
// Pass through editHistory & deleted & original attachments to the "edited message" transformer
|
||||
match:
|
||||
/(?<=null!=\i\.edited_timestamp\)return )\i\(\i,\{reactions:(\i)\.reactions.{0,50}\}\)/,
|
||||
// Pass through editHistory & deleted to the "edited message" transformer
|
||||
match: /(?<=null!=\i\.edited_timestamp\)return )\i\(\i,\{reactions:(\i)\.reactions.{0,50}\}\)/,
|
||||
replace:
|
||||
"Object.assign($&,{ deleted:$1.deleted, editHistory:$1.editHistory, firstEditTimestamp:$1.firstEditTimestamp, diffViewDisabled:$1.diffViewDisabled })",
|
||||
},
|
||||
],
|
||||
// just mark deleted attachments as deleted on MESSAGE_UPDATE
|
||||
{
|
||||
match: /attachments:(\i)\.attachments\?\?\[\],/,
|
||||
replace: "attachments: $self.handleUpdateAttachments($1),"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
|
|
@ -751,8 +800,9 @@ export default definePlugin({
|
|||
match: /\.SPOILER,(?=\[\i\.\i\]:)(?<=item:(\i),.{0,200}?)/,
|
||||
replace: '$&"messagelogger-deleted-attachment": $1?.originalItem?.deleted,'
|
||||
},
|
||||
// dont allow deleting attachments from deleted messages
|
||||
{
|
||||
match: /(?<=function \i\(\i\)\{let\{[^}]*?item:(\i),\i:\i,)canRemoveItem:(\i)(?=,onRemoveItem:)/,
|
||||
match: /(?<=\{let\{[^}]*?item:(\i),autoPlayGif:\i,)canRemoveItem:(\i)(?=,onRemoveItem:)/,
|
||||
replace: "_canRemoveItem:$2 = arguments[0].canRemoveItem && !$1?.originalItem?.deleted",
|
||||
}
|
||||
]
|
||||
|
|
@ -838,7 +888,15 @@ export default definePlugin({
|
|||
replace: '$&$1.type==="MESSAGE_GROUP_DELETED"?$2=$self.DELETED_MESSAGE_COUNT:',
|
||||
},
|
||||
],
|
||||
predicate: () => settings.store.collapseDeleted,
|
||||
predicate: () => settings.store.collapseDeleted
|
||||
},
|
||||
],
|
||||
|
||||
{
|
||||
find: "this.truncateTop",
|
||||
replacement: {
|
||||
match: /receiveMessage\((\i)\)\{/,
|
||||
replace: "$& $self.normalizeNonce($1);"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.vc-reply-timestamp {
|
||||
width: fit-content !important;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
margin-inline-end: 0.5rem;
|
||||
width: unset !important;
|
||||
}
|
||||
249
src/plugins/tenorGifSearch/index.tsx
Normal file
249
src/plugins/tenorGifSearch/index.tsx
Normal file
|
|
@ -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<Record<string, TenorMedia>>;
|
||||
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<TResult>(path: string, params: Record<string, string>) {
|
||||
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<TResult>;
|
||||
}
|
||||
|
||||
// function contributed by taep96
|
||||
async function fetchTenorResults(path: string, limit: number, extra: Record<string, string> = {}) {
|
||||
const pageSize = Math.min(limit, 50);
|
||||
const items: TenorResult[] = [];
|
||||
const seen = new Set<string>();
|
||||
let pos = "";
|
||||
|
||||
while (items.length < limit) {
|
||||
const params: Record<string, string> = {
|
||||
...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<TrendingCategories | null> {
|
||||
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: [] });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -666,6 +666,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
|
|||
name: "NightmareSan",
|
||||
id: 304239816466235392n
|
||||
},
|
||||
Lunascape: {
|
||||
name: "Lunascape",
|
||||
id: 383365021415243776n
|
||||
}
|
||||
} satisfies Record<string, Dev>);
|
||||
|
||||
export const EquicordDevs = Object.freeze({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue