diff --git a/src/core/plugins/favouriteanything/Settings.tsx b/src/core/plugins/favouriteanything/Settings.tsx new file mode 100644 index 0000000..1041c20 --- /dev/null +++ b/src/core/plugins/favouriteanything/Settings.tsx @@ -0,0 +1,13 @@ +import { Forms } from "@vendetta/ui/components"; +const { FormSection, FormText } = Forms; + +export default () => ( + + + Adds the favourite button to ALL media in the image viewer, not just GIFs! + + + Images and videos can now be saved to your GIF favourites picker. Works by patching Discord's GIFFavButton to accept all media types. + + +) diff --git a/src/core/plugins/favouriteanything/index.ts b/src/core/plugins/favouriteanything/index.ts new file mode 100644 index 0000000..c57907e --- /dev/null +++ b/src/core/plugins/favouriteanything/index.ts @@ -0,0 +1,169 @@ +// src/core/plugins/favouriteanything/index.ts +import { defineCorePlugin } from ".."; +import { findByProps } from "@metro"; +import { before } from "@lib/api/patcher"; +import { logger } from "@lib/utils/logger"; + +type PatchCleanupFn = () => void; + +let origType: Function | null = null; +let memoWrapper: any = null; +let retryTimeout: ReturnType | null = null; +let unpatchAddFavorite: PatchCleanupFn | null = null; +let origUseFavoriteGIFsMobile: Function | null = null; +let favMobileModule: any = null; + +const VIDEO_EXT = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v", ".gifv"]; +const processed = new WeakSet(); + +function isVideo(url: string): boolean { + if (!url) return false; + const path = url.split("?")[0].toLowerCase(); + return VIDEO_EXT.some(e => path.endsWith(e)); +} + +function makeVideoThumbnail(url: string): string { + if (!url) return url; + let out = url.replace("cdn.discordapp.com", "media.discordapp.net"); + if (out.includes("media.discordapp.net") || out.includes("images-ext")) { + return out + (out.includes("?") ? "&" : "?") + "format=jpeg"; + } + return url; +} + +function findGIFFavButton(): any { + const modules = (globalThis as any).__bunny?.metro?.modules ?? (globalThis as any).modules; + if (!modules) return null; + + for (const id in modules) { + try { + const mod = modules[id]?.publicModule?.exports; + if (!mod) continue; + const def = mod.default; + if (def?.$$typeof?.toString().includes("memo") && def.type) { + if (def.type.displayName === "GIFFavButton" || def.type.name === "GIFFavButton") return def; + } + if (typeof mod === "function" && (mod.displayName === "GIFFavButton" || mod.name === "GIFFavButton")) return mod; + } catch {} + } + return null; +} + +function patchSource(source: any): any { + if (source.isGIFV) return source; + return { + ...source, + isGIFV: true, + embedURI: source.embedURI || source.sourceURI || source.uri, + videoURI: source.videoURI || source.uri, + embedProviderName: source.embedProviderName || "", + }; +} + +function applyPatch(): boolean { + memoWrapper = findGIFFavButton(); + if (!memoWrapper) return false; + + origType = memoWrapper.type; + memoWrapper.type = function PatchedGIFFavButton(props: any) { + const p = { ...props }; + if (p.source && !p.source.isGIFV) p.source = patchSource(p.source); + return (origType as Function)(p); + }; + (memoWrapper.type as any).displayName = "GIFFavButton"; + return true; +} + +function patchAddFavorite(): void { + const favModule = findByProps("addFavoriteGIF"); + if (!favModule) return; + + unpatchAddFavorite = before("addFavoriteGIF", favModule, (args: any[]) => { + const data = args[0]; + if (!data || typeof data !== "object") return; + const url = data.url || ""; + const src = data.src || ""; + + if (isVideo(url) || isVideo(src)) { + data.format = 2; + } else if (data.format === 2) { + data.format = 1; + } + }); +} + +function patchMobileFavorites(): void { + const mod = findByProps("useFavoriteGIFsMobile"); + if (!mod) return; + + favMobileModule = mod; + origUseFavoriteGIFsMobile = mod.useFavoriteGIFsMobile; + + let lastFavs: any = null; + mod.useFavoriteGIFsMobile = function (...args: any[]) { + const result = (origUseFavoriteGIFsMobile as Function).apply(this, args); + if (result?.favorites && Array.isArray(result.favorites) && result.favorites !== lastFavs) { + lastFavs = result.favorites; + for (const item of result.favorites) { + if (!item || processed.has(item)) continue; + processed.add(item); + if (isVideo(item.url) || isVideo(item.src)) { + item.src = makeVideoThumbnail(item.src || item.url); + } + } + } + return result; + }; +} + +export default defineCorePlugin({ + manifest: { + id: "bunny.favouriteanything", + version: "1.0.0", + type: "plugin", + spec: 3, + main: "", + display: { + name: "FavouriteAnything", + description: "Adds the favourite button to ALL media, not just GIFs", + authors: [{ name: "TheUnrealZaka" }], + }, + }, + + start() { + if (applyPatch()) { + logger.log("[FavouriteAnything] Patched GIFFavButton."); + } else { + let retries = 0; + const tryPatch = () => { + if (applyPatch()) { + logger.log("[FavouriteAnything] Patched GIFFavButton (after retry)."); + retryTimeout = null; + } else if (retries++ < 50) { + retryTimeout = setTimeout(tryPatch, 300); + } else { + logger.error("[FavouriteAnything] GIFFavButton not found after retries."); + } + }; + retryTimeout = setTimeout(tryPatch, 300); + } + + patchAddFavorite(); + patchMobileFavorites(); + }, + + stop() { + if (retryTimeout) { clearTimeout(retryTimeout); retryTimeout = null; } + if (memoWrapper && origType) { + memoWrapper.type = origType; + origType = null; + memoWrapper = null; + } + if (unpatchAddFavorite) { unpatchAddFavorite(); unpatchAddFavorite = null; } + if (favMobileModule && origUseFavoriteGIFsMobile) { + favMobileModule.useFavoriteGIFsMobile = origUseFavoriteGIFsMobile; + origUseFavoriteGIFsMobile = null; + favMobileModule = null; + } + }, +}); diff --git a/src/core/plugins/maxverstappen/Max Verstappen.mp3 b/src/core/plugins/maxverstappen/Max Verstappen.mp3 new file mode 100644 index 0000000..15053a3 Binary files /dev/null and b/src/core/plugins/maxverstappen/Max Verstappen.mp3 differ diff --git a/src/core/plugins/maxverstappen/index.ts b/src/core/plugins/maxverstappen/index.ts new file mode 100644 index 0000000..262cb31 --- /dev/null +++ b/src/core/plugins/maxverstappen/index.ts @@ -0,0 +1,145 @@ +import { defineCorePlugin } from ".."; +import { findByProps } from "@metro"; +import { logger } from "@lib/utils/logger"; + +const { ReactNative } = window as any; +const { DCDSoundManager } = ReactNative?.NativeModules || {}; + +const AUDIO_URL = "https://github.com/OTKUSteyler/GoonCord/raw/refs/heads/main/src/core/plugins/maxverstappen/Max%20Verstappen.mp3"; +const SOUND_ID = 6972; +const TRIGGER_CHANCE = 0.33; +const MAX_PATTERN = /\bmax\s*verst[ae]pp?en\b/i; + +let isPlaying = false; +let isPrepared = false; +let isPreparing = false; +let soundDuration = 30000; +let timeoutId: NodeJS.Timeout | null = null; +let onMessageCreate: ((e: any) => void) | null = null; +let onMessageSend: ((e: any) => void) | null = null; + +function shouldTrigger(): boolean { + return Math.random() < TRIGGER_CHANCE; +} + +function checkText(text: string): boolean { + if (!text) return false; + return MAX_PATTERN.test(text) && shouldTrigger(); +} + +function ensurePrepared(): Promise { + return new Promise((resolve) => { + if (isPrepared) return resolve(soundDuration); + if (isPreparing) return resolve(-1); + + isPreparing = true; + DCDSoundManager.prepare(AUDIO_URL, "music", SOUND_ID, (error: any, sound: any) => { + isPreparing = false; + if (error) { + logger.error("[MaxVerstappen] Failed to prepare:", error); + return resolve(-1); + } + isPrepared = true; + soundDuration = sound?.duration || 30000; + resolve(soundDuration); + }); + }); +} + +async function playAudio() { + if (!DCDSoundManager) return; + if (isPlaying) return; + + const duration = await ensurePrepared(); + if (duration === -1) return; + + isPlaying = true; + try { + await DCDSoundManager.play(SOUND_ID); + timeoutId = setTimeout(() => { + DCDSoundManager.stop(SOUND_ID); + isPlaying = false; + timeoutId = null; + }, soundDuration); + } catch (e) { + logger.error("[MaxVerstappen] Playback error:", e); + isPlaying = false; + } +} + +export default defineCorePlugin({ + manifest: { + id: "bunny.maxverstappen", + version: "1.0.0", + type: "plugin", + spec: 3, + main: "", + display: { + name: "Max Verstappen", + description: "Plays audio when Max Verstappen is mentioned (33% chance)", + authors: [{ name: "GoonCord Team" }], + }, + }, + + start() { + if (!DCDSoundManager) { + logger.error("[MaxVerstappen] DCDSoundManager not found"); + return; + } + + const FluxDispatcher = findByProps("dispatch", "subscribe"); + if (!FluxDispatcher) { + logger.error("[MaxVerstappen] FluxDispatcher not found"); + return; + } + + onMessageCreate = ({ message }: any) => { + try { + if (!message?.content) return; + if (checkText(message.content)) playAudio(); + } catch (e) { + logger.error("[MaxVerstappen] MESSAGE_CREATE error:", e); + } + }; + + onMessageSend = ({ message }: any) => { + try { + if (!message?.content) return; + if (checkText(message.content)) playAudio(); + } catch (e) { + logger.error("[MaxVerstappen] MESSAGE_SEND error:", e); + } + }; + + FluxDispatcher.subscribe("MESSAGE_CREATE", onMessageCreate); + FluxDispatcher.subscribe("MESSAGE_SEND", onMessageSend); + + logger.log("[MaxVerstappen] Enabled"); + }, + + stop() { + if (DCDSoundManager && isPlaying) { + DCDSoundManager.stop(SOUND_ID); + isPlaying = false; + } + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + const FluxDispatcher = findByProps("dispatch", "subscribe"); + if (FluxDispatcher) { + if (onMessageCreate) FluxDispatcher.unsubscribe("MESSAGE_CREATE", onMessageCreate); + if (onMessageSend) FluxDispatcher.unsubscribe("MESSAGE_SEND", onMessageSend); + } + + onMessageCreate = null; + onMessageSend = null; + isPrepared = false; + isPreparing = false; + soundDuration = 30000; + + logger.log("[MaxVerstappen] Disabled"); + }, +}); diff --git a/src/core/plugins/nodelete/index.ts b/src/core/plugins/nodelete/index.ts new file mode 100644 index 0000000..2f21c56 --- /dev/null +++ b/src/core/plugins/nodelete/index.ts @@ -0,0 +1,76 @@ +// src/core/plugins/NoDelete/index.ts +import { defineCorePlugin } from ".."; +import { findByStoreName } from "@metro"; +import { before } from "@lib/api/patcher"; +import { logger } from "@lib/utils/logger"; +import { FluxDispatcher } from "@metro/common"; + +type PatchCleanupFn = () => void; + +let patches: PatchCleanupFn[] = []; +let MessageStore: any = null; +let deleteable: string[] = []; + +function patchDispatcher(): PatchCleanupFn { + return before("dispatch", FluxDispatcher, (args: any[]) => { + try { + if (!MessageStore) MessageStore = findByStoreName("MessageStore"); + + const event = args[0]; + if (!event || event?.type !== "MESSAGE_DELETE") return; + if (!event?.id || !event?.channelId) return; + + if (deleteable.includes(event.id)) { + deleteable.splice(deleteable.indexOf(event.id), 1); + return; + } + + deleteable.push(event.id); + + args[0] = { + type: "MESSAGE_EDIT_FAILED_AUTOMOD", + messageData: { + type: 1, + message: { + channelId: event.channelId, + messageId: event.id, + }, + }, + errorResponseBody: { + code: 200000, + message: "[deleted]", + }, + }; + } catch (e) { + logger.error("NoDelete: dispatcher patch failed", e); + } + }); +} + +export default defineCorePlugin({ + manifest: { + id: "bunny.nodelete", + version: "1.0.0", + type: "plugin", + spec: 3, + main: "", + display: { + name: "NoDelete", + description: "Prevents messages from being deleted from your view", + authors: [{ name: "meqativ" }], + }, + }, + + start() { + patches = [patchDispatcher()].filter(Boolean); + logger.log("NoDelete: Enabled"); + }, + + stop() { + patches.forEach((p) => p?.()); + patches = []; + deleteable = []; + MessageStore = null; + logger.log("NoDelete: Disabled"); + }, +}); diff --git a/src/core/plugins/nodelete/settings.jsx b/src/core/plugins/nodelete/settings.jsx new file mode 100644 index 0000000..89c58b7 --- /dev/null +++ b/src/core/plugins/nodelete/settings.jsx @@ -0,0 +1,90 @@ +import { React, ReactNative } from "@vendetta/metro/common"; +import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; +import { showConfirmationAlert } from "@vendetta/ui/alerts"; +import { Forms } from "@vendetta/ui/components"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import { getTranslation } from "./translations.js"; +import ItemWithRemove from "../../common/ui/ItemWithRemove.jsx"; +import { findByStoreName, findByProps } from "@vendetta/metro"; +let UserStore, UncachedUserManager, Profiles; +export default (props) => { + UserStore ??= findByStoreName("UserStore"); + UncachedUserManager ??= findByProps("fetchProfile", "getUser", "setFlag"); + Profiles ??= findByProps("showUserProfile"); + async function openProfile(userId) { + const show = Profiles.showUserProfile; + + UserStore.getUser(userId) ? show({ userId }) : UncachedUserManager.getUser(userId).then(({ id }) => show({ userId: id })); + } + + useProxy(storage); + const [users, setUsers] = React.useState(storage["ignore"]["users"]); + + const handleRemoveUser = (userId) => { + const newArr = users.filter((id) => id !== userId); + storage["ignore"].users = newArr; + setUsers(newArr); + }; + const handleClearUsers = () => { + storage["ignore"].users = []; + setUsers([]); + }; + let uncached = 0; + + return ( + + + (storage.timestamps = v)} />} /> + (storage.ew = v)} />} /> + + + + + (storage["ignore"].bots = value)} />} /> + } + onPress={() => { + if (users.length !== 0) + showConfirmationAlert({ + title: getTranslation("settings.confirmClear.title"), + content: getTranslation("settings.confirmClear.description", true)?.make?.(users.length), + confirmText: getTranslation("settings.confirmClear.yes"), + cancelText: getTranslation("settings.confirmClear.no"), + confirmColor: "brand", + onConfirm: handleClearUsers, + }); + }} + /> + + {users.map((id) => { + const User = UserStore.getUser(id) ?? {}; + let pfp = User?.getAvatarURL?.(null,26)?.replace?.(/\.(gif|webp)/, ".png"); + if (!pfp) { + pfp = "https://cdn.discordapp.com/embed/avatars/1.png?size=48"; + User.username = `${id} Uncached`; + User.discriminator = "0"; + if (uncached === 0) User.username += ", press the avatar"; + uncached++; + } + + return ( + { + openProfile(id); + }} + onRemove={() => handleRemoveUser(id)} + label={User.username + (User.discriminator == 0 ? "" : `#${User.discriminator}`)} + labelRemove={getTranslation("settings.removeUserButton")} + /> + ); + })} + + + + + + ); +}; diff --git a/src/core/plugins/nodelete/translations.js b/src/core/plugins/nodelete/translations.js new file mode 100644 index 0000000..4144b7e --- /dev/null +++ b/src/core/plugins/nodelete/translations.js @@ -0,0 +1,158 @@ +function slavicNoun(number, $1, $2, $5) { + let n = Math.abs(number); + n %= 100; + if (n >= 5 && n <= 20) return $5; + + n %= 10; + if (n === 1) return $1; + if (n >= 2 && n <= 4) return $2; + return $5; +} +function latinNoun(number, $1, $x) { + return number === 1 ? $1 : $x; +} +export let massive = {}; +massive = { + settings: { + titles: { + settings: { + "en-GB": "Settings", + uk: "Налаштування", + ru: "Настройки", + }, + filters: { + "en-GB": "Filters", + uk: "Фільтри", + ru: "Фильтры", + }, + }, + showTimestamps: { + "en-GB": "Show the time of deletion", + uk: "Показувати час видалення", + ru: "Показывать время удаления", + }, + ewTimestampFormat: { + "en-GB": "Use 12-hour format", + uk: "Використовувати 12-годинний формат", + ru: "Использовать 12-часовой формат", + }, + youDeletedItWarning: { + "en-GB": "The messages YOU deleted - are not saved", + uk: "Повідомлення які видалили ВИ - не зберігаются", + ru: "Сообщения удаленные ВАМИ - не сохраняются", + }, + addUsersInfo: { + "en-GB": () => + `To add or remove users from the ignore list, follow these steps:\n` + + `1. open their profile\n` + + `2. press the •••\n` + + `3. press "${massive.optionLabels[0]["en-GB"]}"\n` + + `4. 🎉`, + uk: () => + `Щоб добавити когось до списку ігнорованих користувачів, виконайте ці дії:\n` + + `1. відкрите їх профіль\n` + + `2. натисніть •••\n` + + `3. натисніть "${massive.optionLabels[0]["uk"]}"\n` + + `4. 🎉`, + ru: () => + `Чтобы добавить кого-то в список игнорированных пользователей - следуйте этим шагам\n` + + `1. откройте их профиль\n` + + `2. нажмите •••\n` + + `3. нажмите "${massive.optionLabels[0]["ru"]}"\n` + + `4. 🎉`, + }, + ignoreBots: { + "en-GB": "Ignore bots", + uk: "Ігнорувати ботів", + ru: "Игнорировать ботов", + }, + clearUsersLabel: { + "en-GB": (amount) => + `You have ${amount} user${latinNoun(amount, "", "s")} in the ignored users list`, + uk: (amount) => + `Ви маєте ${amount} користувач${slavicNoun(amount, "а", "а", "ів")} у списку ігнорованих користувачів`, + ru: (amount) => + `Вы имеете ${amount} пользовател${slavicNoun(amount, "я", "я", "ей")} в списке игнорированных пользователей`, + }, + + confirmClear: { + title: { + "en-GB": "Hold on!", + uk: "Почекай-но!", + ru: "Положди-ка!", + }, + description: { + "en-GB": (amount) => + `This will remove ${amount} user${latinNoun(amount, "", "s")} from the ignored users list.\nDo you you really want to do that?`, + uk: (amount) => + `Це прибере ${amount} користувач${slavicNoun(amount, "а", "а", "ів")} зі списку ігнорованих користувачів.\nВи дійсно хочете продовжити?`, + ru: (amount) => + `Это уберет ${amount} пользовател${slavicNoun(amount, "я", "я", "ей")} из списка игнорированных пользователей.\nВы действительно хотите продолжить?`, + }, + yes: { + "en-GB": "Yes", + uk: "Так", + ru: "Да", + }, + no: { + "en-GB": "Cancel", + uk: "Відминити", + ru: "Отменить", + }, + }, + removeUserButton: { + "en-GB": "REMOVE", + uk: "ПРИБРАТИ", + ru: "УБРАТЬ", + }, + }, + optionLabels: [ + { + "en-GB": "Add to NoDelete ignored users list", + uk: "Добавити до списку ігнорованих користувачів у NoDelete", + ru: "Добавить в список игнорированных пользователей в NoDelete", + }, + { + "en-GB": "Remove from the NoDelete ignore list", + uk: "Прибрати з списку ігнорованих користувачів у NoDelete", + ru: "Убрать из списка игнорированных пользователей в NoDelete", + }, + ], + toastLabels: [ + { + "en-GB": "Added {user} to the ignored users list", + uk: "{user} добавлено до списку ігнорованих користувачів", + ru: "{user} добавлены в список игнорированных пользователей", + }, + { + "en-GB": "Removed {user} from the ignored users list", + uk: "{user} прибрано зі списку ігнорованих користувачів", + ru: "{user} убраны из списка игнорированных пользователей", + }, + ], + thisMessageWasDeleted: { + "en-GB": "This message was deleted", + uk: "Це повідомлення було видалено", + ru: "Это сообщение было удалено", + }, +}; + +export const locale = () => + vendetta.metro.findByStoreName("LocaleStore").locale; +const defaultLocale = "en-GB"; +export function getTranslation(find, fn) { + let value = massive; + + for (const key of find.split(".")) { + // biome-ignore lint/suspicious/noPrototypeBuiltins: meow + if (value?.hasOwnProperty(key)) { + value = value[key]; + } else { + value = massive; + } + } + if (value === massive) return find; + let localized = value[locale()] ?? value[defaultLocale] ?? find; + if (typeof localized === "function" && !fn) localized = localized(); + return fn ? { make: localized } : localized; +}