diff --git a/.github/workflows/codeberg-mirror.yml b/.github/workflows/codeberg-mirror.yml index 647a43135..1b2266ee7 100644 --- a/.github/workflows/codeberg-mirror.yml +++ b/.github/workflows/codeberg-mirror.yml @@ -18,5 +18,5 @@ jobs: fetch-depth: 0 - uses: pixta-dev/repository-mirroring-action@674e65a7d483ca28dafaacba0d07351bdcc8bd75 # v1.1.1 with: - target_repo_url: "git@codeberg.org:Ven/cord.git" + target_repo_url: "git@codeberg.org:Vee/cord.git" ssh_private_key: ${{ secrets.CODEBERG_SSH_PRIVATE_KEY }} diff --git a/package.json b/package.json index b59fbafe7..076b2999d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vencord", "private": "true", - "version": "1.6.5", + "version": "1.6.7", "description": "The cutest Discord client mod", "homepage": "https://github.com/Vendicated/Vencord#readme", "bugs": { diff --git a/src/Vencord.ts b/src/Vencord.ts index a106a0b7d..29e965fa0 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -44,7 +44,7 @@ async function syncSettings() { // pre-check for local shared settings if ( Settings.cloud.authenticated && - await dsGet("Vencord_cloudSecret") === null // this has been enabled due to local settings share or some other bug + !await dsGet("Vencord_cloudSecret") // this has been enabled due to local settings share or some other bug ) { // show a notification letting them know and tell them how to fix it showNotification({ @@ -145,4 +145,3 @@ document.addEventListener("DOMContentLoaded", () => { })); } }, { once: true }); - diff --git a/src/components/VencordSettings/ThemesTab.tsx b/src/components/VencordSettings/ThemesTab.tsx index 2808494a1..8abaaba4f 100644 --- a/src/components/VencordSettings/ThemesTab.tsx +++ b/src/components/VencordSettings/ThemesTab.tsx @@ -21,9 +21,11 @@ import { classNameFactory } from "@api/Styles"; import { Flex } from "@components/Flex"; import { DeleteIcon } from "@components/Icons"; import { Link } from "@components/Link"; +import PluginModal from "@components/PluginSettings/PluginModal"; import { openInviteModal } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; +import { openModal } from "@utils/modal"; import { showItemInFolder } from "@utils/native"; import { useAwaiter } from "@utils/react"; import { findByPropsLazy, findLazy } from "@webpack"; @@ -248,6 +250,21 @@ function ThemesTab() { > Edit QuickCSS + + {Vencord.Settings.plugins.ClientTheme.enabled && ( + + )} diff --git a/src/components/VencordSettings/UpdaterTab.tsx b/src/components/VencordSettings/UpdaterTab.tsx index 81433960f..0a5d1f149 100644 --- a/src/components/VencordSettings/UpdaterTab.tsx +++ b/src/components/VencordSettings/UpdaterTab.tsx @@ -81,9 +81,12 @@ function HashLink({ repo, hash, disabled = false }: { repo: string, hash: string function Changes({ updates, repo, repoPending }: CommonProps & { updates: typeof changes; }) { return ( - + {updates.map(({ hash, author, message }) => ( -
+
) : ( - {isOutdated ? `There are ${updates.length} Updates` : "Up to Date!"} + {isOutdated ? (updates.length === 1 ? "There is 1 Update" : `There are ${updates.length} Updates`) : "Up to Date!"} )} diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index 6254bc826..47d400eb6 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -139,8 +139,15 @@ export function initIpc(mainWindow: BrowserWindow) { } ipcMain.handle(IpcEvents.OPEN_MONACO_EDITOR, async () => { + const title = "Vencord QuickCSS Editor"; + const existingWindow = BrowserWindow.getAllWindows().find(w => w.title === title); + if (existingWindow && !existingWindow.isDestroyed()) { + existingWindow.focus(); + return; + } + const win = new BrowserWindow({ - title: "Vencord QuickCSS Editor", + title, autoHideMenuBar: true, darkTheme: true, webPreferences: { diff --git a/src/main/updater/git.ts b/src/main/updater/git.ts index e96026443..2ff3ba512 100644 --- a/src/main/updater/git.ts +++ b/src/main/updater/git.ts @@ -73,6 +73,8 @@ async function build() { const command = isFlatpak ? "flatpak-spawn" : "node"; const args = isFlatpak ? ["--host", "node", "scripts/build/build.mjs"] : ["scripts/build/build.mjs"]; + if (IS_DEV) args.push("--dev"); + const res = await execFile(command, args, opts); return !res.stderr.includes("Build failed"); diff --git a/src/plugins/anonymiseFileNames/index.ts b/src/plugins/anonymiseFileNames/index.ts deleted file mode 100644 index 9e69d7a93..000000000 --- a/src/plugins/anonymiseFileNames/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2022 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -import { Settings } from "@api/Settings"; -import { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; - -const enum Methods { - Random, - Consistent, - Timestamp, -} - -const tarExtMatcher = /\.tar\.\w+$/; - -export default definePlugin({ - name: "AnonymiseFileNames", - authors: [Devs.obscurity], - description: "Anonymise uploaded file names", - patches: [ - { - find: "instantBatchUpload:function", - replacement: { - match: /uploadFiles:(.{1,2}),/, - replace: - "uploadFiles:(...args)=>(args[0].uploads.forEach(f=>f.filename=$self.anonymise(f.filename)),$1(...args)),", - }, - }, - ], - - options: { - method: { - description: "Anonymising method", - type: OptionType.SELECT, - options: [ - { label: "Random Characters", value: Methods.Random, default: true }, - { label: "Consistent", value: Methods.Consistent }, - { label: "Timestamp (4chan-like)", value: Methods.Timestamp }, - ], - }, - randomisedLength: { - description: "Random characters length", - type: OptionType.NUMBER, - default: 7, - disabled: () => Settings.plugins.AnonymiseFileNames.method !== Methods.Random, - }, - consistent: { - description: "Consistent filename", - type: OptionType.STRING, - default: "image", - disabled: () => Settings.plugins.AnonymiseFileNames.method !== Methods.Consistent, - }, - }, - - anonymise(file: string) { - let name = "image"; - const tarMatch = tarExtMatcher.exec(file); - const extIdx = tarMatch?.index ?? file.lastIndexOf("."); - const ext = extIdx !== -1 ? file.slice(extIdx) : ""; - - switch (Settings.plugins.AnonymiseFileNames.method) { - case Methods.Random: - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - name = Array.from( - { length: Settings.plugins.AnonymiseFileNames.randomisedLength }, - () => chars[Math.floor(Math.random() * chars.length)] - ).join(""); - break; - case Methods.Consistent: - name = Settings.plugins.AnonymiseFileNames.consistent; - break; - case Methods.Timestamp: - // UNIX timestamp in nanos, i could not find a better dependency-less way - name = `${Math.floor(Date.now() / 1000)}${Math.floor(window.performance.now())}`; - break; - } - return name + ext; - }, -}); diff --git a/src/plugins/anonymiseFileNames/index.tsx b/src/plugins/anonymiseFileNames/index.tsx new file mode 100644 index 000000000..845aa756d --- /dev/null +++ b/src/plugins/anonymiseFileNames/index.tsx @@ -0,0 +1,130 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { Upload } from "@api/MessageEvents"; +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { findByCodeLazy, findByPropsLazy } from "@webpack"; + +type AnonUpload = Upload & { anonymise?: boolean; }; + +const ActionBarIcon = findByCodeLazy(".actionBarIcon)"); +const UploadDraft = findByPropsLazy("popFirstFile", "update"); + +const enum Methods { + Random, + Consistent, + Timestamp, +} + +const tarExtMatcher = /\.tar\.\w+$/; + +const settings = definePluginSettings({ + anonymiseByDefault: { + description: "Whether to anonymise file names by default", + type: OptionType.BOOLEAN, + default: true, + }, + method: { + description: "Anonymising method", + type: OptionType.SELECT, + options: [ + { label: "Random Characters", value: Methods.Random, default: true }, + { label: "Consistent", value: Methods.Consistent }, + { label: "Timestamp", value: Methods.Timestamp }, + ], + }, + randomisedLength: { + description: "Random characters length", + type: OptionType.NUMBER, + default: 7, + disabled: () => settings.store.method !== Methods.Random, + }, + consistent: { + description: "Consistent filename", + type: OptionType.STRING, + default: "image", + disabled: () => settings.store.method !== Methods.Consistent, + }, +}); + +export default definePlugin({ + name: "AnonymiseFileNames", + authors: [Devs.obscurity], + description: "Anonymise uploaded file names", + patches: [ + { + find: "instantBatchUpload:function", + replacement: { + match: /uploadFiles:(.{1,2}),/, + replace: + "uploadFiles:(...args)=>(args[0].uploads.forEach(f=>f.filename=$self.anonymise(f)),$1(...args)),", + }, + }, + { + find: ".Messages.ATTACHMENT_UTILITIES_SPOILER", + replacement: { + match: /(?<=children:\[)(?=.{10,80}tooltip:\i\.\i\.Messages\.ATTACHMENT_UTILITIES_SPOILER)/, + replace: "arguments[0].canEdit!==false?$self.renderIcon(arguments[0]):null," + }, + }, + ], + settings, + + renderIcon: ErrorBoundary.wrap(({ upload, channelId, draftType }: { upload: AnonUpload; draftType: unknown; channelId: string; }) => { + const anonymise = upload.anonymise ?? settings.store.anonymiseByDefault; + return ( + { + upload.anonymise = !anonymise; + UploadDraft.update(channelId, upload.id, draftType, {}); // dummy update so component rerenders + }} + > + {anonymise + ? + : + } + + ); + }, { noop: true }), + + anonymise(upload: AnonUpload) { + if ((upload.anonymise ?? settings.store.anonymiseByDefault) === false) return upload.filename; + + const file = upload.filename; + const tarMatch = tarExtMatcher.exec(file); + const extIdx = tarMatch?.index ?? file.lastIndexOf("."); + const ext = extIdx !== -1 ? file.slice(extIdx) : ""; + + switch (settings.store.method) { + case Methods.Random: + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return Array.from( + { length: settings.store.randomisedLength }, + () => chars[Math.floor(Math.random() * chars.length)] + ).join("") + ext; + case Methods.Consistent: + return settings.store.consistent + ext; + case Methods.Timestamp: + return Date.now() + ext; + } + }, +}); diff --git a/src/plugins/betterGifPicker/index.ts b/src/plugins/betterGifPicker/index.ts new file mode 100644 index 000000000..09bb570d7 --- /dev/null +++ b/src/plugins/betterGifPicker/index.ts @@ -0,0 +1,23 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "BetterGifPicker", + description: "Makes the gif picker open the favourite category by default", + authors: [Devs.Samwich], + patches: [ + { + find: ".GIFPickerResultTypes.SEARCH", + replacement: [{ + match: "this.state={resultType:null}", + replace: 'this.state={resultType:"Favorites"}' + }] + } + ] +}); diff --git a/src/plugins/biggerStreamPreview/index.tsx b/src/plugins/biggerStreamPreview/index.tsx index acad564da..40bbe30a8 100644 --- a/src/plugins/biggerStreamPreview/index.tsx +++ b/src/plugins/biggerStreamPreview/index.tsx @@ -82,7 +82,7 @@ export const streamContextPatch: NavContextMenuPatchCallback = (children, { stre }; export const userContextPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => { - return addViewStreamContext(children, { userId: user.id }); + if (user) return addViewStreamContext(children, { userId: user.id }); }; export default definePlugin({ diff --git a/src/plugins/clearURLs/defaultRules.ts b/src/plugins/clearURLs/defaultRules.ts index 0633b717d..95a59c037 100644 --- a/src/plugins/clearURLs/defaultRules.ts +++ b/src/plugins/clearURLs/defaultRules.ts @@ -153,5 +153,6 @@ export const defaultRules = [ "utm_term", "si@open.spotify.com", "igshid", + "igsh", "share_id@reddit.com", ]; diff --git a/src/plugins/clientTheme/clientTheme.css b/src/plugins/clientTheme/clientTheme.css index 023f88bd2..64aefdcf5 100644 --- a/src/plugins/clientTheme/clientTheme.css +++ b/src/plugins/clientTheme/clientTheme.css @@ -19,6 +19,16 @@ border: thin solid var(--background-modifier-accent) !important; } -.client-theme-warning { +.client-theme-warning * { color: var(--text-danger); } + +.client-theme-contrast-warning { + background-color: var(--background-primary); + padding: 0.5rem; + border-radius: .5rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} diff --git a/src/plugins/clientTheme/index.tsx b/src/plugins/clientTheme/index.tsx index d75929961..5d8cd4dc0 100644 --- a/src/plugins/clientTheme/index.tsx +++ b/src/plugins/clientTheme/index.tsx @@ -8,19 +8,19 @@ import "./clientTheme.css"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; -import { getTheme, Theme } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; import definePlugin, { OptionType, StartAt } from "@utils/types"; -import { findComponentByCodeLazy } from "@webpack"; -import { Button, Forms } from "@webpack/common"; +import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack"; +import { Button, Forms, lodash as _, useStateFromStores } from "@webpack/common"; const ColorPicker = findComponentByCodeLazy(".Messages.USER_SETTINGS_PROFILE_COLOR_SELECT_COLOR", ".BACKGROUND_PRIMARY)"); const colorPresets = [ "#1E1514", "#172019", "#13171B", "#1C1C28", "#402D2D", "#3A483D", "#344242", "#313D4B", "#2D2F47", "#322B42", - "#3C2E42", "#422938" + "#3C2E42", "#422938", "#b6908f", "#bfa088", "#d3c77d", + "#86ac86", "#88aab3", "#8693b5", "#8a89ba", "#ad94bb", ]; function onPickColor(color: number) { @@ -30,9 +30,35 @@ function onPickColor(color: number) { updateColorVars(hexColor); } +const { saveClientTheme } = findByPropsLazy("saveClientTheme"); + +function setTheme(theme: string) { + saveClientTheme({ theme }); +} + +const ThemeStore = findStoreLazy("ThemeStore"); +const NitroThemeStore = findStoreLazy("ClientThemesBackgroundStore"); + function ThemeSettings() { - const lightnessWarning = hexToLightness(settings.store.color) > 45; - const lightModeWarning = getTheme() === Theme.Light; + const theme = useStateFromStores([ThemeStore], () => ThemeStore.theme); + const isLightTheme = theme === "light"; + const oppositeTheme = isLightTheme ? "dark" : "light"; + + const nitroTheme = useStateFromStores([NitroThemeStore], () => NitroThemeStore.gradientPreset); + const nitroThemeEnabled = nitroTheme !== undefined; + + const selectedLuminance = relativeLuminance(settings.store.color); + + let contrastWarning = false, fixableContrast = true; + if ((isLightTheme && selectedLuminance < 0.26) || !isLightTheme && selectedLuminance > 0.12) + contrastWarning = true; + if (selectedLuminance < 0.26 && selectedLuminance > 0.12) + fixableContrast = false; + // light mode with values greater than 65 leads to background colors getting crushed together and poor text contrast for muted channels + if (isLightTheme && selectedLuminance > 0.65) { + contrastWarning = true; + fixableContrast = false; + } return (
@@ -48,15 +74,18 @@ function ThemeSettings() { suggestedColors={colorPresets} />
- {lightnessWarning || lightModeWarning - ?
- - Your theme won't look good: - {lightnessWarning && Selected color is very light} - {lightModeWarning && Light mode isn't supported} + {(contrastWarning || nitroThemeEnabled) && (<> + +
+
+ Warning, your theme won't look good: + {contrastWarning && Selected color won't contrast well with text} + {nitroThemeEnabled && Nitro themes aren't supported} +
+ {(contrastWarning && fixableContrast) && } + {(nitroThemeEnabled) && }
- : null - } + )}
); } @@ -87,9 +116,12 @@ export default definePlugin({ settings, startAt: StartAt.DOMContentLoaded, - start() { + async start() { updateColorVars(settings.store.color); - generateColorOffsets(); + + const styles = await getStyles(); + generateColorOffsets(styles); + generateLightModeFixes(styles); }, stop() { @@ -98,56 +130,86 @@ export default definePlugin({ } }); -const variableRegex = /(--primary-[5-9]\d{2}-hsl):.*?(\S*)%;/g; +const variableRegex = /(--primary-\d{3}-hsl):.*?(\S*)%;/g; +const lightVariableRegex = /^--primary-[1-5]\d{2}-hsl/g; +const darkVariableRegex = /^--primary-[5-9]\d{2}-hsl/g; -async function generateColorOffsets() { - - const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]'); - const variableLightness = {} as Record; - - // Search all stylesheets for color variables - for (const styleLinkNode of styleLinkNodes) { - const cssLink = styleLinkNode.getAttribute("href"); - if (!cssLink) continue; - - const res = await fetch(cssLink); - const cssString = await res.text(); - - // Get lightness values of --primary variables >=500 - let variableMatch = variableRegex.exec(cssString); - while (variableMatch !== null) { - const [, variable, lightness] = variableMatch; - variableLightness[variable] = parseFloat(lightness); - variableMatch = variableRegex.exec(cssString); - } - } - - // Generate offsets - const lightnessOffsets = Object.entries(variableLightness) +// generates variables per theme by: +// - matching regex (so we can limit what variables are included in light/dark theme, otherwise text becomes unreadable) +// - offset from specified center (light/dark theme get different offsets because light uses 100 for background-primary, while dark uses 600) +function genThemeSpecificOffsets(variableLightness: Record, regex: RegExp, centerVariable: string): string { + return Object.entries(variableLightness).filter(([key]) => key.search(regex) > -1) .map(([key, lightness]) => { - const lightnessOffset = lightness - variableLightness["--primary-600-hsl"]; + const lightnessOffset = lightness - variableLightness[centerVariable]; const plusOrMinus = lightnessOffset >= 0 ? "+" : "-"; return `${key}: var(--theme-h) var(--theme-s) calc(var(--theme-l) ${plusOrMinus} ${Math.abs(lightnessOffset).toFixed(2)}%);`; }) .join("\n"); +} - const style = document.createElement("style"); - style.setAttribute("id", "clientThemeOffsets"); - style.textContent = `:root:root { - ${lightnessOffsets} - }`; - document.head.appendChild(style); + +function generateColorOffsets(styles) { + const variableLightness = {} as Record; + + // Get lightness values of --primary variables + let variableMatch = variableRegex.exec(styles); + while (variableMatch !== null) { + const [, variable, lightness] = variableMatch; + variableLightness[variable] = parseFloat(lightness); + variableMatch = variableRegex.exec(styles); + } + + createStyleSheet("clientThemeOffsets", [ + `.theme-light {\n ${genThemeSpecificOffsets(variableLightness, lightVariableRegex, "--primary-345-hsl")} \n}`, + `.theme-dark {\n ${genThemeSpecificOffsets(variableLightness, darkVariableRegex, "--primary-600-hsl")} \n}`, + ].join("\n\n")); +} + +function generateLightModeFixes(styles) { + const groupLightUsesW500Regex = /\.theme-light[^{]*\{[^}]*var\(--white-500\)[^}]*}/gm; + // get light capturing groups that mention --white-500 + const relevantStyles = [...styles.matchAll(groupLightUsesW500Regex)].flat(); + + const groupBackgroundRegex = /^([^{]*)\{background:var\(--white-500\)/m; + const groupBackgroundColorRegex = /^([^{]*)\{background-color:var\(--white-500\)/m; + // find all capturing groups that assign background or background-color directly to w500 + const backgroundGroups = mapReject(relevantStyles, entry => captureOne(entry, groupBackgroundRegex)).join(",\n"); + const backgroundColorGroups = mapReject(relevantStyles, entry => captureOne(entry, groupBackgroundColorRegex)).join(",\n"); + // create css to reassign them to --primary-100 + const reassignBackgrounds = `${backgroundGroups} {\n background: var(--primary-100) \n}`; + const reassignBackgroundColors = `${backgroundColorGroups} {\n background-color: var(--primary-100) \n}`; + + const groupBgVarRegex = /\.theme-light\{([^}]*--[^:}]*(?:background|bg)[^:}]*:var\(--white-500\)[^}]*)\}/m; + const bgVarRegex = /^(--[^:]*(?:background|bg)[^:]*):var\(--white-500\)/m; + // get all global variables used for backgrounds + const lightVars = mapReject(relevantStyles, style => captureOne(style, groupBgVarRegex)) // get the insides of capture groups that have at least one background var with w500 + .map(str => str.split(";")).flat(); // captureGroupInsides[] -> cssRule[] + const lightBgVars = mapReject(lightVars, variable => captureOne(variable, bgVarRegex)); // remove vars that aren't for backgrounds or w500 + // create css to reassign every var + const reassignVariables = `.theme-light {\n ${lightBgVars.map(variable => `${variable}: var(--primary-100);`).join("\n")} \n}`; + + createStyleSheet("clientThemeLightModeFixes", [ + reassignBackgrounds, + reassignBackgroundColors, + reassignVariables, + ].join("\n\n")); +} + +function captureOne(str, regex) { + const result = str.match(regex); + return (result === null) ? null : result[1]; +} + +function mapReject(arr, mapFunc, rejectFunc = _.isNull) { + return _.reject(arr.map(mapFunc), rejectFunc); } function updateColorVars(color: string) { const { hue, saturation, lightness } = hexToHSL(color); let style = document.getElementById("clientThemeVars"); - if (!style) { - style = document.createElement("style"); - style.setAttribute("id", "clientThemeVars"); - document.head.appendChild(style); - } + if (!style) + style = createStyleSheet("clientThemeVars"); style.textContent = `:root { --theme-h: ${hue}; @@ -156,6 +218,28 @@ function updateColorVars(color: string) { }`; } +function createStyleSheet(id, content = "") { + const style = document.createElement("style"); + style.setAttribute("id", id); + style.textContent = content.split("\n").map(line => line.trim()).join("\n"); + document.body.appendChild(style); + return style; +} + +// returns all of discord's native styles in a single string +async function getStyles(): Promise { + let out = ""; + const styleLinkNodes = document.querySelectorAll('link[rel="stylesheet"]'); + for (const styleLinkNode of styleLinkNodes) { + const cssLink = styleLinkNode.getAttribute("href"); + if (!cssLink) continue; + + const res = await fetch(cssLink); + out += await res.text(); + } + return out; +} + // https://css-tricks.com/converting-color-spaces-in-javascript/ function hexToHSL(hexCode: string) { // Hex => RGB normalized to 0-1 @@ -198,17 +282,14 @@ function hexToHSL(hexCode: string) { return { hue, saturation, lightness }; } -// Minimized math just for lightness, lowers lag when changing colors -function hexToLightness(hexCode: string) { - // Hex => RGB normalized to 0-1 - const r = parseInt(hexCode.substring(0, 2), 16) / 255; - const g = parseInt(hexCode.substring(2, 4), 16) / 255; - const b = parseInt(hexCode.substring(4, 6), 16) / 255; +// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +function relativeLuminance(hexCode: string) { + const normalize = (x: number) => + x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; - const cMax = Math.max(r, g, b); - const cMin = Math.min(r, g, b); + const r = normalize(parseInt(hexCode.substring(0, 2), 16) / 255); + const g = normalize(parseInt(hexCode.substring(2, 4), 16) / 255); + const b = normalize(parseInt(hexCode.substring(4, 6), 16) / 255); - const lightness = 100 * ((cMax + cMin) / 2); - - return lightness; + return r * 0.2126 + g * 0.7152 + b * 0.0722; } diff --git a/src/plugins/copyUserURLs/index.tsx b/src/plugins/copyUserURLs/index.tsx index e3c336fb2..9f69674cf 100644 --- a/src/plugins/copyUserURLs/index.tsx +++ b/src/plugins/copyUserURLs/index.tsx @@ -30,6 +30,8 @@ interface UserContextProps { } const UserContextMenuPatch: NavContextMenuPatchCallback = (children, { user }: UserContextProps) => () => { + if (!user) return; + children.push( - - - You can also access Decor decorations from the { - e.preventDefault(); - closeAllModals(); - FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Profile Customization" }); - }} - >Profiles page. - -
; - } - } -}); export default definePlugin({ name: "Decor", description: "Create and use your own custom avatar decorations, or pick your favorite from the presets.", diff --git a/src/plugins/decor/settings.tsx b/src/plugins/decor/settings.tsx new file mode 100644 index 000000000..0d3628cc6 --- /dev/null +++ b/src/plugins/decor/settings.tsx @@ -0,0 +1,47 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { Link } from "@components/Link"; +import { Margins } from "@utils/margins"; +import { classes } from "@utils/misc"; +import { closeAllModals } from "@utils/modal"; +import { OptionType } from "@utils/types"; +import { FluxDispatcher, Forms } from "@webpack/common"; + +import DecorSection from "./ui/components/DecorSection"; + +export const settings = definePluginSettings({ + changeDecoration: { + type: OptionType.COMPONENT, + description: "Change your avatar decoration", + component() { + if (!Vencord.Plugins.plugins.Decor.started) return + Enable Decor and restart your client to change your avatar decoration. + ; + + return
+ + + You can also access Decor decorations from the { + e.preventDefault(); + closeAllModals(); + FluxDispatcher.dispatch({ type: "USER_SETTINGS_MODAL_SET_SECTION", section: "Profile Customization" }); + }} + >Profiles page. + +
; + } + }, + agreedToGuidelines: { + type: OptionType.BOOLEAN, + description: "Agreed to guidelines", + hidden: true, + default: false + } +}); diff --git a/src/plugins/decor/ui/components/index.ts b/src/plugins/decor/ui/components/index.ts index 8f39a10ee..8fe41fc92 100644 --- a/src/plugins/decor/ui/components/index.ts +++ b/src/plugins/decor/ui/components/index.ts @@ -19,7 +19,7 @@ export let DecorationGridItem: DecorationGridItemComponent; export const setDecorationGridItem = v => DecorationGridItem = v; export const AvatarDecorationModalPreview = LazyComponentWebpack(() => { - const component = findComponentByCode("AvatarDecorationModalPreview"); + const component = findComponentByCode(".shopPreviewBanner"); return React.memo(component); }); diff --git a/src/plugins/decor/ui/index.ts b/src/plugins/decor/ui/index.ts index 52b169d77..0ead602e2 100644 --- a/src/plugins/decor/ui/index.ts +++ b/src/plugins/decor/ui/index.ts @@ -5,9 +5,10 @@ */ import { classNameFactory } from "@api/Styles"; -import { extractAndLoadChunksLazy } from "@webpack"; +import { extractAndLoadChunksLazy, findByPropsLazy } from "@webpack"; export const cl = classNameFactory("vc-decor-"); +export const DecorationModalStyles = findByPropsLazy("modalFooterShopButton"); export const requireAvatarDecorationModal = extractAndLoadChunksLazy(["openAvatarDecorationModal:"]); export const requireCreateStickerModal = extractAndLoadChunksLazy(["stickerInspected]:"]); diff --git a/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx b/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx index bed007174..5fbe165ce 100644 --- a/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx +++ b/src/plugins/decor/ui/modals/ChangeDecorationModal.tsx @@ -4,12 +4,13 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import ErrorBoundary from "@components/ErrorBoundary"; import { Flex } from "@components/Flex"; import { openInviteModal } from "@utils/discord"; import { Margins } from "@utils/margins"; import { classes } from "@utils/misc"; -import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; +import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { findComponentByCodeLazy } from "@webpack"; import { Alerts, Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Parser, Text, Tooltip, useEffect, UserStore, UserUtils, useState } from "@webpack/common"; import { User } from "discord-types/general"; @@ -18,16 +19,17 @@ import { GUILD_ID, INVITE_KEY } from "../../lib/constants"; import { useAuthorizationStore } from "../../lib/stores/AuthorizationStore"; import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore"; import { decorationToAvatarDecoration } from "../../lib/utils/decoration"; -import { cl, requireAvatarDecorationModal } from "../"; +import { settings } from "../../settings"; +import { cl, DecorationModalStyles, requireAvatarDecorationModal } from "../"; import { AvatarDecorationModalPreview } from "../components"; import DecorationGridCreate from "../components/DecorationGridCreate"; import DecorationGridNone from "../components/DecorationGridNone"; import DecorDecorationGridDecoration from "../components/DecorDecorationGridDecoration"; import SectionedGridList from "../components/SectionedGridList"; import { openCreateDecorationModal } from "./CreateDecorationModal"; +import { openGuidelinesModal } from "./GuidelinesModal"; const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers"); -const DecorationModalStyles = findByPropsLazy("modalFooterShopButton"); function usePresets() { const [presets, setPresets] = useState([]); @@ -83,7 +85,7 @@ function SectionHeader({ section }: { section: Section; }) {
; } -export default function ChangeDecorationModal(props: any) { +function ChangeDecorationModal(props: ModalProps) { // undefined = not trying, null = none, Decoration = selected const [tryingDecoration, setTryingDecoration] = useState(undefined); const isTryingDecoration = typeof tryingDecoration !== "undefined"; @@ -116,6 +118,7 @@ export default function ChangeDecorationModal(props: any) { const data = [ { title: "Your Decorations", + subtitle: "You can delete your own decorations by right clicking on them.", sectionKey: "ownDecorations", items: ["none", ...ownDecorations, "create"] }, @@ -148,60 +151,62 @@ export default function ChangeDecorationModal(props: any) { className={cl("change-decoration-modal-content")} scrollbarType="none" > - { - if (typeof item === "string") { - switch (item) { - case "none": - return setTryingDecoration(null)} - />; - case "create": - return - {tooltipProps => + { + if (typeof item === "string") { + switch (item) { + case "none": + return setTryingDecoration(null)} + />; + case "create": + return + {tooltipProps => { }} + />} + ; + } + } else { + return + {tooltipProps => ( + { }} - />} - ; + className={cl("change-decoration-modal-decoration")} + onSelect={item.reviewed !== false ? () => setTryingDecoration(item) : () => { }} + isSelected={activeSelectedDecoration?.hash === item.hash} + decoration={item} + /> + )} + ; } - } else { - return - {tooltipProps => ( - setTryingDecoration(item) : () => { }} - isSelected={activeSelectedDecoration?.hash === item.hash} - decoration={item} - /> - )} - ; - } - }} - getItemKey={item => typeof item === "string" ? item : item.hash} - getSectionKey={section => section.sectionKey} - renderSectionHeader={section => } - sections={data} - /> -
- typeof item === "string" ? item : item.hash} + getSectionKey={section => section.sectionKey} + renderSectionHeader={section => } + sections={data} /> - {isActiveDecorationPreset && Part of the {activeDecorationPreset.name} Preset} - {typeof activeSelectedDecoration === "object" && - - {activeSelectedDecoration?.alt} - - } - {activeDecorationHasAuthor && Created by {Parser.parse(`<@${activeSelectedDecoration.authorId}>`)}} -
+
+ + {isActiveDecorationPreset && Part of the {activeDecorationPreset.name} Preset} + {typeof activeSelectedDecoration === "object" && + + {activeSelectedDecoration?.alt} + + } + {activeDecorationHasAuthor && Created by {Parser.parse(`<@${activeSelectedDecoration.authorId}>`)}} +
+
diff --git a/src/plugins/decor/ui/modals/CreateDecorationModal.tsx b/src/plugins/decor/ui/modals/CreateDecorationModal.tsx index a5937b0dd..0dcf855ef 100644 --- a/src/plugins/decor/ui/modals/CreateDecorationModal.tsx +++ b/src/plugins/decor/ui/modals/CreateDecorationModal.tsx @@ -4,23 +4,23 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import ErrorBoundary from "@components/ErrorBoundary"; import { Link } from "@components/Link"; import { openInviteModal } from "@utils/discord"; import { Margins } from "@utils/margins"; -import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { closeAllModals, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; import { Button, FluxDispatcher, Forms, GuildStore, NavigationRouter, Text, TextInput, useEffect, useMemo, UserStore, useState } from "@webpack/common"; import { GUILD_ID, INVITE_KEY, RAW_SKU_ID } from "../../lib/constants"; import { useCurrentUserDecorationsStore } from "../../lib/stores/CurrentUserDecorationsStore"; -import { cl, requireAvatarDecorationModal, requireCreateStickerModal } from "../"; +import { cl, DecorationModalStyles, requireAvatarDecorationModal, requireCreateStickerModal } from "../"; import { AvatarDecorationModalPreview } from "../components"; - -const DecorationModalStyles = findByPropsLazy("modalFooterShopButton"); - const FileUpload = findComponentByCodeLazy("fileUploadInput,"); +const { default: HelpMessage, HelpMessageTypes } = findByPropsLazy("HelpMessageTypes"); + function useObjectURL(object: Blob | MediaSource | null) { const [url, setUrl] = useState(null); @@ -39,7 +39,7 @@ function useObjectURL(object: Blob | MediaSource | null) { return url; } -export default function CreateDecorationModal(props) { +function CreateDecorationModal(props: ModalProps) { const [name, setName] = useState(""); const [file, setFile] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -75,65 +75,69 @@ export default function CreateDecorationModal(props) { className={cl("create-decoration-modal-content")} scrollbarType="none" > -
-
- {error !== null && {error.message}} - - + + Make sure your decoration does not violate + the guidelines + before submitting it. + +
+
+ {error !== null && {error.message}} + + + + File should be APNG or PNG. + + + + + + This name will be used when referring to this decoration. + + +
+
+ - - File should be APNG or PNG. - - - - - - This name will be used when referring to this decoration. - - +
-
- -
-
- - Make sure your decoration does not violate - the guidelines - before creating your decoration. -
You can receive updates on your decoration's review by joining { - e.preventDefault(); - if (!GuildStore.getGuild(GUILD_ID)) { - const inviteAccepted = await openInviteModal(INVITE_KEY); - if (inviteAccepted) { + +
You can receive updates on your decoration's review by joining { + e.preventDefault(); + if (!GuildStore.getGuild(GUILD_ID)) { + const inviteAccepted = await openInviteModal(INVITE_KEY); + if (inviteAccepted) { + closeAllModals(); + FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); + } + } else { closeAllModals(); FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); + NavigationRouter.transitionToGuild(GUILD_ID); } - } else { - closeAllModals(); - FluxDispatcher.dispatch({ type: "LAYER_POP_ALL" }); - NavigationRouter.transitionToGuild(GUILD_ID); - } - }} - > - Decor's Discord server - . -
+ }} + > + Decor's Discord server + . +
+ + + + ; +} + +export const openGuidelinesModal = () => + requireAvatarDecorationModal().then(() => openModal(props => )); diff --git a/src/plugins/decor/ui/styles.css b/src/plugins/decor/ui/styles.css index ff10c82fa..9730efb7e 100644 --- a/src/plugins/decor/ui/styles.css +++ b/src/plugins/decor/ui/styles.css @@ -8,7 +8,7 @@ display: flex; border-radius: 5px 5px 0 0; padding: 0 16px; - gap: 4px + gap: 4px; } .vc-decor-change-decoration-modal-preview { @@ -72,7 +72,7 @@ .vc-decor-sectioned-grid-list-grid { display: flex; flex-wrap: wrap; - gap: 8px + gap: 8px; } .vc-decor-section-remove-margin { diff --git a/src/plugins/fixCodeblockGap/index.ts b/src/plugins/fixCodeblockGap/index.ts new file mode 100644 index 000000000..133409959 --- /dev/null +++ b/src/plugins/fixCodeblockGap/index.ts @@ -0,0 +1,35 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "FixCodeblockGap", + description: "Removes the gap between codeblocks and text below it", + authors: [Devs.Grzesiek11], + patches: [ + { + find: ".default.Messages.DELETED_ROLE_PLACEHOLDER", + replacement: { + match: String.raw`/^${"```"}(?:([a-z0-9_+\-.#]+?)\n)?\n*([^\n][^]*?)\n*${"```"}`, + replace: "$&\\n?", + }, + }, + ], +}); diff --git a/src/plugins/permissionsViewer/index.tsx b/src/plugins/permissionsViewer/index.tsx index 985b23842..9e0131e64 100644 --- a/src/plugins/permissionsViewer/index.tsx +++ b/src/plugins/permissionsViewer/index.tsx @@ -126,7 +126,9 @@ function MenuItem(guildId: string, id?: string, type?: MenuItemParentType) { function makeContextMenuPatch(childId: string | string[], type?: MenuItemParentType): NavContextMenuPatchCallback { return (children, props) => () => { - if (!props || (type === MenuItemParentType.User && !props.user) || (type === MenuItemParentType.Guild && !props.guild)) return children; + if (!props) return; + if ((type === MenuItemParentType.User && !props.user) || (type === MenuItemParentType.Guild && !props.guild) || (type === MenuItemParentType.Channel && (!props.channel || !props.guild))) + return children; const group = findGroupChildrenByChildId(childId, children); diff --git a/src/plugins/reverseImageSearch/index.tsx b/src/plugins/reverseImageSearch/index.tsx index 811e7ff08..6c5f3e729 100644 --- a/src/plugins/reverseImageSearch/index.tsx +++ b/src/plugins/reverseImageSearch/index.tsx @@ -36,62 +36,68 @@ function search(src: string, engine: string) { open(engine + encodeURIComponent(src), "_blank"); } -const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { - if (!props) return; - const { reverseImageSearchType, itemHref, itemSrc } = props; +function makeSearchItem(src: string) { + return ( + + {Object.keys(Engines).map((engine, i) => { + const key = "search-image-" + engine; + return ( + + = 3 // Do not round Google, Yandex & SauceNAO + ? "50%" + : void 0 + }} + aria-hidden="true" + height={16} + width={16} + src={new URL("/favicon.ico", Engines[engine]).toString().replace("lens.", "")} + /> + {engine} + + } + action={() => search(src, Engines[engine])} + /> + ); + })} + + + All + + } + action={() => Object.values(Engines).forEach(e => search(src, e))} + /> + + ); +} - if (!reverseImageSearchType || reverseImageSearchType !== "img") return; +const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { + if (props?.reverseImageSearchType !== "img") return; - const src = itemHref ?? itemSrc; + const src = props.itemHref ?? props.itemSrc; const group = findGroupChildrenByChildId("copy-link", children); - if (group) { - group.push(( - - {Object.keys(Engines).map((engine, i) => { - const key = "search-image-" + engine; - return ( - - = 3 // Do not round Google, Yandex & SauceNAO - ? "50%" - : void 0 - }} - aria-hidden="true" - height={16} - width={16} - src={new URL("/favicon.ico", Engines[engine]).toString().replace("lens.", "")} - /> - {engine} - - } - action={() => search(src, Engines[engine])} - /> - ); - })} - - - All - - } - action={() => Object.values(Engines).forEach(e => search(src, e))} - /> - - )); - } + group?.push(makeSearchItem(src)); +}; + +const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { + if (!props?.src) return; + + const group = findGroupChildrenByChildId("copy-native-link", children) ?? children; + group.push(makeSearchItem(props.src)); }; export default definePlugin({ @@ -111,10 +117,12 @@ export default definePlugin({ ], start() { - addContextMenuPatch("message", imageContextMenuPatch); + addContextMenuPatch("message", messageContextMenuPatch); + addContextMenuPatch("image-context", imageContextMenuPatch); }, stop() { - removeContextMenuPatch("message", imageContextMenuPatch); + removeContextMenuPatch("message", messageContextMenuPatch); + removeContextMenuPatch("image-context", imageContextMenuPatch); } }); diff --git a/src/plugins/reviewDB/auth.tsx b/src/plugins/reviewDB/auth.tsx new file mode 100644 index 000000000..e7a369217 --- /dev/null +++ b/src/plugins/reviewDB/auth.tsx @@ -0,0 +1,78 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DataStore } from "@api/index"; +import { Logger } from "@utils/Logger"; +import { openModal } from "@utils/modal"; +import { findByPropsLazy } from "@webpack"; +import { showToast, Toasts, UserStore } from "@webpack/common"; + +import { ReviewDBAuth } from "./entities"; + +const DATA_STORE_KEY = "rdb-auth"; + +const { OAuth2AuthorizeModal } = findByPropsLazy("OAuth2AuthorizeModal"); + +export let Auth: ReviewDBAuth = {}; + +export async function initAuth() { + Auth = await getAuth() ?? {}; +} + +export async function getAuth(): Promise { + const auth = await DataStore.get(DATA_STORE_KEY); + return auth?.[UserStore.getCurrentUser()?.id]; +} + +export async function getToken() { + const auth = await getAuth(); + return auth?.token; +} + +export async function updateAuth(newAuth: ReviewDBAuth) { + return DataStore.update(DATA_STORE_KEY, auth => { + auth ??= {}; + Auth = auth[UserStore.getCurrentUser().id] ??= {}; + + if (newAuth.token) Auth.token = newAuth.token; + if (newAuth.user) Auth.user = newAuth.user; + + return auth; + }); +} + +export function authorize(callback?: any) { + openModal(props => + { + try { + const url = new URL(response.location); + url.searchParams.append("clientMod", "vencord"); + const res = await fetch(url, { + headers: new Headers({ Accept: "application/json" }) + }); + const { token, success } = await res.json(); + if (success) { + updateAuth({ token }); + showToast("Successfully logged in!", Toasts.Type.SUCCESS); + callback?.(); + } else if (res.status === 1) { + showToast("An Error occurred while logging in.", Toasts.Type.FAILURE); + } + } catch (e) { + new Logger("ReviewDB").error("Failed to authorize", e); + } + }} + /> + ); +} diff --git a/src/plugins/reviewDB/components/BlockedUserModal.tsx b/src/plugins/reviewDB/components/BlockedUserModal.tsx new file mode 100644 index 000000000..43c81eb52 --- /dev/null +++ b/src/plugins/reviewDB/components/BlockedUserModal.tsx @@ -0,0 +1,99 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, openModal } from "@utils/modal"; +import { useAwaiter } from "@utils/react"; +import { Forms, Tooltip, useState } from "@webpack/common"; + +import { Auth } from "../auth"; +import { ReviewDBUser } from "../entities"; +import { fetchBlocks, unblockUser } from "../reviewDbApi"; +import { cl } from "../utils"; + +function UnblockButton(props: { onClick?(): void; }) { + return ( + + {tooltipProps => ( +
+ + + +
+ )} +
+ ); +} + +function BlockedUser({ user, isBusy, setIsBusy }: { user: ReviewDBUser; isBusy: boolean; setIsBusy(v: boolean): void; }) { + const [gone, setGone] = useState(false); + if (gone) return null; + + return ( +
+ + {user.username} + { + setIsBusy(true); + try { + await unblockUser(user.discordID); + setGone(true); + } finally { + setIsBusy(false); + } + }} + /> +
+ ); +} + +function Modal() { + const [isBusy, setIsBusy] = useState(false); + const [blocks, error, pending] = useAwaiter(fetchBlocks, { + onError: e => new Logger("ReviewDB").error("Failed to fetch blocks", e), + fallbackValue: [], + }); + + if (pending) + return null; + if (error) + return Failed to fetch blocks: ${String(error)}; + if (!blocks.length) + return No blocked users.; + + return ( + <> + {blocks.map(b => ( + + ))} + + ); +} + +export function openBlockModal() { + openModal(modalProps => ( + + + Blocked Users + + + + {Auth.token ? : You are not logged into ReviewDB!} + + + )); +} diff --git a/src/plugins/reviewDB/components/MessageButton.tsx b/src/plugins/reviewDB/components/MessageButton.tsx new file mode 100644 index 000000000..9b0b4be1a --- /dev/null +++ b/src/plugins/reviewDB/components/MessageButton.tsx @@ -0,0 +1,85 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { DeleteIcon } from "@components/Icons"; +import { classes } from "@utils/misc"; +import { findByPropsLazy } from "@webpack"; +import { Tooltip } from "@webpack/common"; + +const iconClasses = findByPropsLazy("button", "wrapper", "disabled", "separator"); + +export function DeleteButton({ onClick }: { onClick(): void; }) { + return ( + + {props => ( +
+ +
+ )} +
+ ); +} + +export function ReportButton({ onClick }: { onClick(): void; }) { + return ( + + {props => ( +
+ + + +
+ )} +
+ ); +} + +export function BlockButton({ onClick, isBlocked }: { onClick(): void; isBlocked: boolean; }) { + return ( + + {props => ( +
+ + {isBlocked + ? + : + } + +
+ )} +
+ ); +} diff --git a/src/plugins/reviewDB/components/ReviewBadge.tsx b/src/plugins/reviewDB/components/ReviewBadge.tsx new file mode 100644 index 000000000..665b9bb09 --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewBadge.tsx @@ -0,0 +1,50 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { MaskedLink, React, Tooltip } from "@webpack/common"; +import { HTMLAttributes } from "react"; + +import { Badge } from "../entities"; +import { cl } from "../utils"; + +export default function ReviewBadge(badge: Badge & { onClick?(): void; }) { + const Wrapper = badge.redirectURL + ? MaskedLink + : (props: HTMLAttributes) => ( + {props.children} + ); + + return ( + + {({ onMouseEnter, onMouseLeave }) => ( + + {badge.description} + + )} + + ); +} diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx new file mode 100644 index 000000000..13b0f167f --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -0,0 +1,188 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { openUserProfile } from "@utils/discord"; +import { classes } from "@utils/misc"; +import { LazyComponent } from "@utils/react"; +import { filters, findBulk } from "@webpack"; +import { Alerts, moment, Parser, showToast, Timestamp } from "@webpack/common"; + +import { Auth, getToken } from "../auth"; +import { Review, ReviewType } from "../entities"; +import { blockUser, deleteReview, reportReview, unblockUser } from "../reviewDbApi"; +import { settings } from "../settings"; +import { canBlockReviewAuthor, canDeleteReview, canReportReview, cl } from "../utils"; +import { openBlockModal } from "./BlockedUserModal"; +import { BlockButton, DeleteButton, ReportButton } from "./MessageButton"; +import ReviewBadge from "./ReviewBadge"; + +export default LazyComponent(() => { + // this is terrible, blame mantika + const p = filters.byProps; + const [ + { cozyMessage, buttons, message, buttonsInner, groupStart }, + { container, isHeader }, + { avatar, clickable, username, wrapper, cozy }, + buttonClasses, + botTag + ] = findBulk( + p("cozyMessage"), + p("container", "isHeader"), + p("avatar", "zalgo"), + p("button", "wrapper", "selected"), + p("botTag", "botTagRegular") + ); + + const dateFormat = new Intl.DateTimeFormat(); + + return function ReviewComponent({ review, refetch, profileId }: { review: Review; refetch(): void; profileId: string; }) { + function openModal() { + openUserProfile(review.sender.discordID); + } + + function delReview() { + Alerts.show({ + title: "Are you sure?", + body: "Do you really want to delete this review?", + confirmText: "Delete", + cancelText: "Nevermind", + onConfirm: async () => { + if (!(await getToken())) { + return showToast("You must be logged in to delete reviews."); + } else { + deleteReview(review.id).then(res => { + if (res.success) { + refetch(); + } + showToast(res.message); + }); + } + } + }); + } + + function reportRev() { + Alerts.show({ + title: "Are you sure?", + body: "Do you really you want to report this review?", + confirmText: "Report", + cancelText: "Nevermind", + // confirmColor: "red", this just adds a class name and breaks the submit button guh + onConfirm: async () => { + if (!(await getToken())) { + return showToast("You must be logged in to report reviews."); + } else { + reportReview(review.id); + } + } + }); + } + + const isAuthorBlocked = Auth?.user?.blockedUsers?.includes(review.sender.discordID) ?? false; + + function blockReviewSender() { + if (isAuthorBlocked) + return unblockUser(review.sender.discordID); + + Alerts.show({ + title: "Are you sure?", + body: "Do you really you want to block this user? They will be unable to leave further reviews on your profile. You can unblock users in the plugin settings.", + confirmText: "Block", + cancelText: "Nevermind", + // confirmColor: "red", this just adds a class name and breaks the submit button guh + onConfirm: async () => { + if (!(await getToken())) { + return showToast("You must be logged in to block users."); + } else { + blockUser(review.sender.discordID); + } + } + }); + } + + return ( +
+ + +
+ openModal()} + > + {review.sender.username} + + + {review.type === ReviewType.System && ( + + + System + + + )} +
+ {isAuthorBlocked && ( + openBlockModal()} + /> + )} + {review.sender.badges.map(badge => )} + + { + !settings.store.hideTimestamps && review.type !== ReviewType.System && ( + + {dateFormat.format(review.timestamp * 1000)} + ) + } + +
+ {Parser.parseGuildEventDescription(review.comment)} +
+ + {review.id !== 0 && ( +
+
+ {canReportReview(review) && } + {canBlockReviewAuthor(profileId, review) && } + {canDeleteReview(profileId, review) && } +
+
+ )} +
+ ); + }; +}); diff --git a/src/plugins/reviewDB/components/ReviewModal.tsx b/src/plugins/reviewDB/components/ReviewModal.tsx new file mode 100644 index 000000000..9669a2b32 --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewModal.tsx @@ -0,0 +1,105 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import ErrorBoundary from "@components/ErrorBoundary"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import { useForceUpdater } from "@utils/react"; +import { Paginator, Text, useRef, useState } from "@webpack/common"; + +import { Auth } from "../auth"; +import { Response, REVIEWS_PER_PAGE } from "../reviewDbApi"; +import { cl } from "../utils"; +import ReviewComponent from "./ReviewComponent"; +import ReviewsView, { ReviewsInputComponent } from "./ReviewsView"; + +function Modal({ modalProps, discordId, name }: { modalProps: any; discordId: string; name: string; }) { + const [data, setData] = useState(); + const [signal, refetch] = useForceUpdater(true); + const [page, setPage] = useState(1); + + const ref = useRef(null); + + const reviewCount = data?.reviewCount; + const ownReview = data?.reviews.find(r => r.sender.discordID === Auth.user?.discordID); + + return ( + + + + + {name}'s Reviews + {!!reviewCount && ({reviewCount} Reviews)} + + + + + +
+ ref.current?.scrollTo({ top: 0, behavior: "smooth" })} + hideOwnReview + /> +
+
+ + +
+ {ownReview && ( + + )} + + + {!!reviewCount && ( + + )} +
+
+
+
+ ); +} + +export function openReviewsModal(discordId: string, name: string) { + openModal(props => ( + + )); +} diff --git a/src/plugins/reviewDB/components/ReviewsView.tsx b/src/plugins/reviewDB/components/ReviewsView.tsx new file mode 100644 index 000000000..cfd5477db --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewsView.tsx @@ -0,0 +1,199 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { LazyComponent, useAwaiter, useForceUpdater } from "@utils/react"; +import { find, findByPropsLazy } from "@webpack"; +import { Forms, React, RelationshipStore, showToast, useRef, UserStore } from "@webpack/common"; + +import { Auth, authorize } from "../auth"; +import { Review } from "../entities"; +import { addReview, getReviews, Response, REVIEWS_PER_PAGE } from "../reviewDbApi"; +import { settings } from "../settings"; +import { cl } from "../utils"; +import ReviewComponent from "./ReviewComponent"; + + +const { Editor, Transforms } = findByPropsLazy("Editor", "Transforms"); +const { ChatInputTypes } = findByPropsLazy("ChatInputTypes"); + +const InputComponent = LazyComponent(() => find(m => m.default?.type?.render?.toString().includes("default.CHANNEL_TEXT_AREA")).default); + +interface UserProps { + discordId: string; + name: string; +} + +interface Props extends UserProps { + onFetchReviews(data: Response): void; + refetchSignal?: unknown; + showInput?: boolean; + page?: number; + scrollToTop?(): void; + hideOwnReview?: boolean; +} + +export default function ReviewsView({ + discordId, + name, + onFetchReviews, + refetchSignal, + scrollToTop, + page = 1, + showInput = false, + hideOwnReview = false, +}: Props) { + const [signal, refetch] = useForceUpdater(true); + + const [reviewData] = useAwaiter(() => getReviews(discordId, (page - 1) * REVIEWS_PER_PAGE), { + fallbackValue: null, + deps: [refetchSignal, signal, page], + onSuccess: data => { + if (settings.store.hideBlockedUsers) + data!.reviews = data!.reviews?.filter(r => !RelationshipStore.isBlocked(r.sender.discordID)); + + scrollToTop?.(); + onFetchReviews(data!); + } + }); + + if (!reviewData) return null; + + return ( + <> + + + {showInput && ( + r.sender.discordID === UserStore.getCurrentUser().id)} + /> + )} + + ); +} + +function ReviewList({ refetch, reviews, hideOwnReview, profileId }: { refetch(): void; reviews: Review[]; hideOwnReview: boolean; profileId: string; }) { + const myId = UserStore.getCurrentUser().id; + + return ( +
+ {reviews?.map(review => + (review.sender.discordID !== myId || !hideOwnReview) && + + )} + + {reviews?.length === 0 && ( + + Looks like nobody reviewed this user yet. You could be the first! + + )} +
+ ); +} + + +export function ReviewsInputComponent({ discordId, isAuthor, refetch, name }: { discordId: string, name: string; isAuthor: boolean; refetch(): void; }) { + const { token } = Auth; + const editorRef = useRef(null); + const inputType = ChatInputTypes.FORM; + inputType.disableAutoFocus = true; + + const channel = { + flags_: 256, + guild_id_: null, + id: "0", + getGuildId: () => null, + isPrivate: () => true, + isActiveThread: () => false, + isArchivedLockedThread: () => false, + isDM: () => true, + roles: { "0": { permissions: 0n } }, + getRecipientId: () => "0", + hasFlag: () => false, + }; + + return ( + <> +
{ + if (!token) { + showToast("Opening authorization window..."); + authorize(); + } + }}> + editorRef.current = ref} + textValue="" + onSubmit={ + async res => { + const response = await addReview({ + userid: discordId, + comment: res.value, + }); + + if (response?.success) { + refetch(); + + const slateEditor = editorRef.current.ref.current.getSlateEditor(); + + // clear editor + Transforms.delete(slateEditor, { + at: { + anchor: Editor.start(slateEditor, []), + focus: Editor.end(slateEditor, []), + } + }); + } else if (response?.message) { + showToast(response.message); + } + + // even tho we need to return this, it doesnt do anything + return { + shouldClear: false, + shouldRefocus: true, + }; + } + } + /> +
+ + + ); +} diff --git a/src/plugins/reviewDB/entities.ts b/src/plugins/reviewDB/entities.ts new file mode 100644 index 000000000..a871d90f2 --- /dev/null +++ b/src/plugins/reviewDB/entities.ts @@ -0,0 +1,100 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +export const enum UserType { + Banned = -1, + Normal = 0, + Admin = 1 +} + +export const enum ReviewType { + User = 0, + Server = 1, + Support = 2, + System = 3 +} + +export const enum NotificationType { + Info = 0, + Ban = 1, + Unban = 2, + Warning = 3 +} + +export interface ReviewDBAuth { + token?: string; + user?: ReviewDBCurrentUser; +} + +export interface Badge { + name: string; + description: string; + icon: string; + redirectURL?: string; + type: number; +} + +export interface BanInfo { + id: string; + discordID: string; + reviewID: number; + reviewContent: string; + banEndDate: number; +} + +export interface Notification { + id: number; + title: string; + content: string; + type: NotificationType; +} + +export interface ReviewDBUser { + ID: number; + discordID: string; + username: string; + type: UserType; + profilePhoto: string; + badges: any[]; +} + +export interface ReviewDBCurrentUser extends ReviewDBUser { + warningCount: number; + clientMod: string; + banInfo: BanInfo | null; + notification: Notification | null; + lastReviewID: number; + blockedUsers?: string[]; +} + +export interface ReviewAuthor { + id: number, + discordID: string, + username: string, + profilePhoto: string, + badges: Badge[]; +} + +export interface Review { + comment: string, + id: number, + star: number, + sender: ReviewAuthor, + timestamp: number; + type?: ReviewType; +} diff --git a/src/plugins/reviewDB/index.tsx b/src/plugins/reviewDB/index.tsx new file mode 100644 index 000000000..d8357faf2 --- /dev/null +++ b/src/plugins/reviewDB/index.tsx @@ -0,0 +1,157 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import "./style.css"; + +import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import ErrorBoundary from "@components/ErrorBoundary"; +import ExpandableHeader from "@components/ExpandableHeader"; +import { OpenExternalIcon } from "@components/Icons"; +import { Devs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import definePlugin from "@utils/types"; +import { Alerts, Menu, Parser, showToast, useState } from "@webpack/common"; +import { Guild, User } from "discord-types/general"; + +import { Auth, initAuth, updateAuth } from "./auth"; +import { openReviewsModal } from "./components/ReviewModal"; +import ReviewsView from "./components/ReviewsView"; +import { NotificationType } from "./entities"; +import { getCurrentUserInfo, readNotification } from "./reviewDbApi"; +import { settings } from "./settings"; + +const guildPopoutPatch: NavContextMenuPatchCallback = (children, props: { guild: Guild, onClose(): void; }) => () => { + children.push( + openReviewsModal(props.guild.id, props.guild.name)} + /> + ); +}; + +export default definePlugin({ + name: "ReviewDB", + description: "Review other users (Adds a new settings to profiles)", + authors: [Devs.mantikafasi, Devs.Ven], + + settings, + + patches: [ + { + find: "showBorder:null", + replacement: { + match: /user:(\i),setNote:\i,canDM.+?\}\)/, + replace: "$&,$self.getReviewsComponent($1)" + } + } + ], + + flux: { + CONNECTION_OPEN: initAuth, + }, + + async start() { + addContextMenuPatch("guild-header-popout", guildPopoutPatch); + + const s = settings.store; + const { lastReviewId, notifyReviews } = s; + + const legacy = s as any as { token?: string; }; + if (legacy.token) { + await updateAuth({ token: legacy.token }); + legacy.token = undefined; + new Logger("ReviewDB").info("Migrated legacy settings"); + } + + await initAuth(); + + setTimeout(async () => { + if (!Auth.token) return; + + const user = await getCurrentUserInfo(Auth.token); + updateAuth({ user }); + + if (notifyReviews) { + if (lastReviewId && lastReviewId < user.lastReviewID) { + s.lastReviewId = user.lastReviewID; + if (user.lastReviewID !== 0) + showToast("You have new reviews on your profile!"); + } + } + + if (user.notification) { + const props = user.notification.type === NotificationType.Ban ? { + cancelText: "Appeal", + confirmText: "Ok", + onCancel: async () => + VencordNative.native.openExternal( + "https://reviewdb.mantikafasi.dev/api/redirect?" + + new URLSearchParams({ + token: Auth.token!, + page: "dashboard/appeal" + }) + ) + } : {}; + + Alerts.show({ + title: user.notification.title, + body: ( + Parser.parse( + user.notification.content, + false + ) + ), + ...props + }); + + readNotification(user.notification.id); + } + }, 4000); + }, + + stop() { + removeContextMenuPatch("guild-header-popout", guildPopoutPatch); + }, + + getReviewsComponent: ErrorBoundary.wrap((user: User) => { + const [reviewCount, setReviewCount] = useState(); + + return ( + openReviewsModal(user.id, user.username)} + moreTooltipText={ + reviewCount && reviewCount > 50 + ? `View all ${reviewCount} reviews` + : "Open Review Modal" + } + onDropDownClick={state => settings.store.reviewsDropdownState = !state} + defaultState={settings.store.reviewsDropdownState} + > + setReviewCount(r.reviewCount)} + showInput + /> + + ); + }, { message: "Failed to render Reviews" }) +}); diff --git a/src/plugins/reviewDB/reviewDbApi.ts b/src/plugins/reviewDB/reviewDbApi.ts new file mode 100644 index 000000000..657e9c475 --- /dev/null +++ b/src/plugins/reviewDB/reviewDbApi.ts @@ -0,0 +1,198 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { showToast, Toasts } from "@webpack/common"; + +import { Auth, authorize, getToken, updateAuth } from "./auth"; +import { Review, ReviewDBCurrentUser, ReviewDBUser, ReviewType } from "./entities"; +import { settings } from "./settings"; + +const API_URL = "https://manti.vendicated.dev/api/reviewdb"; + +export const REVIEWS_PER_PAGE = 50; + +export interface Response { + success: boolean, + message: string; + reviews: Review[]; + updated: boolean; + hasNextPage: boolean; + reviewCount: number; +} + +const WarningFlag = 0b00000010; + +export async function getReviews(id: string, offset = 0): Promise { + let flags = 0; + if (!settings.store.showWarning) flags |= WarningFlag; + + const params = new URLSearchParams({ + flags: String(flags), + offset: String(offset) + }); + const req = await fetch(`${API_URL}/users/${id}/reviews?${params}`); + + const res = (req.status === 200) + ? await req.json() as Response + : { + success: false, + message: req.status === 429 ? "You are sending requests too fast. Wait a few seconds and try again." : "An Error occured while fetching reviews. Please try again later.", + reviews: [], + updated: false, + hasNextPage: false, + reviewCount: 0 + }; + + if (!res.success) { + showToast(res.message, Toasts.Type.FAILURE); + return { + ...res, + reviews: [ + { + id: 0, + comment: res.message, + star: 0, + timestamp: 0, + type: ReviewType.System, + sender: { + id: 0, + username: "ReviewDB", + profilePhoto: "https://cdn.discordapp.com/avatars/1134864775000629298/3f87ad315b32ee464d84f1270c8d1b37.png?size=256&format=webp&quality=lossless", + discordID: "1134864775000629298", + badges: [] + } + } + ] + }; + } + + return res; +} + +export async function addReview(review: any): Promise { + review.token = await getToken(); + + if (!review.token) { + showToast("Please authorize to add a review."); + authorize(); + return null; + } + + return fetch(API_URL + `/users/${review.userid}/reviews`, { + method: "PUT", + body: JSON.stringify(review), + headers: { + "Content-Type": "application/json", + } + }) + .then(r => r.json()) + .then(res => { + showToast(res.message); + return res ?? null; + }); +} + +export async function deleteReview(id: number): Promise { + return fetch(API_URL + `/users/${id}/reviews`, { + method: "DELETE", + headers: new Headers({ + "Content-Type": "application/json", + Accept: "application/json", + }), + body: JSON.stringify({ + token: await getToken(), + reviewid: id + }) + }).then(r => r.json()); +} + +export async function reportReview(id: number) { + const res = await fetch(API_URL + "/reports", { + method: "PUT", + headers: new Headers({ + "Content-Type": "application/json", + Accept: "application/json", + }), + body: JSON.stringify({ + reviewid: id, + token: await getToken() + }) + }).then(r => r.json()) as Response; + + showToast(res.message); +} + +async function patchBlock(action: "block" | "unblock", userId: string) { + const res = await fetch(API_URL + "/blocks", { + method: "PATCH", + headers: new Headers({ + "Content-Type": "application/json", + Accept: "application/json", + Authorization: await getToken() || "" + }), + body: JSON.stringify({ + action: action, + discordId: userId + }) + }); + + if (!res.ok) { + showToast(`Failed to ${action} user`, Toasts.Type.FAILURE); + } else { + showToast(`Successfully ${action}ed user`, Toasts.Type.SUCCESS); + + if (Auth?.user?.blockedUsers) { + const newBlockedUsers = action === "block" + ? [...Auth.user.blockedUsers, userId] + : Auth.user.blockedUsers.filter(id => id !== userId); + updateAuth({ user: { ...Auth.user, blockedUsers: newBlockedUsers } }); + } + } +} + +export const blockUser = (userId: string) => patchBlock("block", userId); +export const unblockUser = (userId: string) => patchBlock("unblock", userId); + +export async function fetchBlocks(): Promise { + const res = await fetch(API_URL + "/blocks", { + method: "GET", + headers: new Headers({ + Accept: "application/json", + Authorization: await getToken() || "" + }) + }); + + if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`); + return res.json(); +} + +export function getCurrentUserInfo(token: string): Promise { + return fetch(API_URL + "/users", { + body: JSON.stringify({ token }), + method: "POST", + }).then(r => r.json()); +} + +export async function readNotification(id: number) { + return fetch(API_URL + `/notifications?id=${id}`, { + method: "PATCH", + headers: { + "Authorization": await getToken() || "", + }, + }); +} diff --git a/src/plugins/reviewDB/settings.tsx b/src/plugins/reviewDB/settings.tsx new file mode 100644 index 000000000..efcb80588 --- /dev/null +++ b/src/plugins/reviewDB/settings.tsx @@ -0,0 +1,93 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; +import { Button } from "@webpack/common"; + +import { authorize, getToken } from "./auth"; +import { openBlockModal } from "./components/BlockedUserModal"; + +export const settings = definePluginSettings({ + authorize: { + type: OptionType.COMPONENT, + description: "Authorize with ReviewDB", + component: () => ( + + ) + }, + notifyReviews: { + type: OptionType.BOOLEAN, + description: "Notify about new reviews on startup", + default: true, + }, + showWarning: { + type: OptionType.BOOLEAN, + description: "Display warning to be respectful at the top of the reviews list", + default: true, + }, + hideTimestamps: { + type: OptionType.BOOLEAN, + description: "Hide timestamps on reviews", + default: false, + }, + hideBlockedUsers: { + type: OptionType.BOOLEAN, + description: "Hide reviews from blocked users", + default: true, + }, + manageBlocks: { + type: OptionType.COMPONENT, + description: "Manage Blocked Users", + component: () => ( + + ) + }, + website: { + type: OptionType.COMPONENT, + description: "ReviewDB website", + component: () => ( + + ) + }, + supportServer: { + type: OptionType.COMPONENT, + description: "ReviewDB Support Server", + component: () => ( + + ) + } +}).withPrivateSettings<{ + lastReviewId?: number; + reviewsDropdownState?: boolean; +}>(); diff --git a/src/plugins/reviewDB/style.css b/src/plugins/reviewDB/style.css new file mode 100644 index 000000000..a812ecaf2 --- /dev/null +++ b/src/plugins/reviewDB/style.css @@ -0,0 +1,121 @@ +[class|="section"]:not([class|="lastSection"]) + .vc-rdb-view { + margin-top: 12px; +} + +.vc-rdb-badge { + vertical-align: middle; + margin-left: 4px; +} + +.vc-rdb-input { + margin-top: 6px; + margin-bottom: 12px; + resize: none; + overflow: hidden; + background: transparent; + border: 1px solid var(--profile-message-input-border-color); +} + +.vc-rdb-modal-footer > div { + width: 100%; + margin: 6px 16px; +} + +/* When input becomes disabled(while sending review), input adds unneccesary padding to left, this prevents it */ +.vc-rdb-input > div > div { + padding-left: 0 !important; +} + +.vc-rdb-placeholder { + margin-bottom: 4px; + font-weight: bold; + font-style: italic; + color: var(--text-muted); +} + +.vc-rdb-input * { + font-size: 14px; +} + +.vc-rdb-modal-footer { + padding: 0; +} + +.vc-rdb-modal-footer .vc-rdb-input { + margin-bottom: 0; + background: var(--input-background); +} + +.vc-rdb-modal-footer [class|="pageControlContainer"] { + margin-top: 0; +} + +.vc-rdb-modal-header { + flex-grow: 1; +} + +.vc-rdb-modal-reviews { + margin-top: 16px; +} + +.vc-rdb-review { + margin-top: 8px; + margin-bottom: 8px; +} + +.vc-rdb-review-comment img { + vertical-align: text-top; +} + +.vc-rdb-review-comment { + overflow-y: hidden; + margin-top: 1px; + margin-bottom: 8px; + color: var(--text-normal); + font-size: 15px; +} + +.vc-rdb-blocked-badge { + cursor: pointer; +} + +.vc-rdb-block-modal-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.vc-rdb-block-modal { + padding: 1em; + display: grid; + gap: 0.75em; +} + +.vc-rdb-block-modal-row { + display: flex; + height: 2em; + gap: 0.5em; + align-items: center; +} + +.vc-rdb-block-modal-row img { + border-radius: 50%; + height: 2em; + width: 2em; +} + +.vc-rdb-block-modal img::before { + content: ""; + display: block; + width: 100%; + height: 100%; + background-color: var(--background-modifier-accent); +} + +.vc-rdb-block-modal-username { + flex-grow: 1; +} + +.vc-rdb-block-modal-unblock { + cursor: pointer; +} diff --git a/src/plugins/reviewDB/utils.tsx b/src/plugins/reviewDB/utils.tsx new file mode 100644 index 000000000..eeaca1204 --- /dev/null +++ b/src/plugins/reviewDB/utils.tsx @@ -0,0 +1,43 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { classNameFactory } from "@api/Styles"; +import { UserStore } from "@webpack/common"; + +import { Auth } from "./auth"; +import { Review, UserType } from "./entities"; + +export const cl = classNameFactory("vc-rdb-"); + +export function canDeleteReview(profileId: string, review: Review) { + const myId = UserStore.getCurrentUser().id; + return ( + myId === profileId + || review.sender.discordID === myId + || Auth.user?.type === UserType.Admin + ); +} + +export function canBlockReviewAuthor(profileId: string, review: Review) { + const myId = UserStore.getCurrentUser().id; + return profileId === myId && review.sender.discordID !== myId; +} + +export function canReportReview(review: Review) { + return review.sender.discordID !== UserStore.getCurrentUser().id; +} diff --git a/src/plugins/roleColorEverywhere/index.tsx b/src/plugins/roleColorEverywhere/index.tsx index d718f4027..968027163 100644 --- a/src/plugins/roleColorEverywhere/index.tsx +++ b/src/plugins/roleColorEverywhere/index.tsx @@ -72,10 +72,6 @@ export default definePlugin({ { find: 'tutorialId:"whos-online', replacement: [ - { - match: /\i.roleIcon,\.\.\.\i/, - replace: "$&,color:$self.roleGroupColor(arguments[0])" - }, { match: /null,\i," — ",\i\]/, replace: "null,$self.roleGroupColor(arguments[0])]" @@ -83,6 +79,16 @@ export default definePlugin({ ], predicate: () => settings.store.memberList, }, + { + find: ".Messages.THREAD_BROWSER_PRIVATE", + replacement: [ + { + match: /children:\[\i," — ",\i\]/, + replace: "children:[$self.roleGroupColor(arguments[0])]" + }, + ], + predicate: () => settings.store.memberList, + }, { find: "renderPrioritySpeaker", replacement: [ diff --git a/src/plugins/sendTimestamps/index.tsx b/src/plugins/sendTimestamps/index.tsx index 7904545c8..6d488add6 100644 --- a/src/plugins/sendTimestamps/index.tsx +++ b/src/plugins/sendTimestamps/index.tsx @@ -19,14 +19,23 @@ import "./styles.css"; import { addPreSendListener, removePreSendListener } from "@api/MessageEvents"; +import { definePluginSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; import { Devs } from "@utils/constants"; import { getTheme, insertTextIntoChatInputBox, Theme } from "@utils/discord"; import { Margins } from "@utils/margins"; import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; -import definePlugin from "@utils/types"; +import definePlugin, { OptionType } from "@utils/types"; import { Button, ButtonLooks, ButtonWrapperClasses, Forms, Parser, Select, Tooltip, useMemo, useState } from "@webpack/common"; +const settings = definePluginSettings({ + replaceMessageContents: { + description: "Replace timestamps in message contents", + type: OptionType.BOOLEAN, + default: true, + }, +}); + function parseTime(time: string) { const cleanTime = time.slice(1, -1).replace(/(\d)(AM|PM)$/i, "$1 $2"); @@ -116,9 +125,11 @@ function PickerModal({ rootProps, close }: { rootProps: ModalProps, close(): voi export default definePlugin({ name: "SendTimestamps", description: "Send timestamps easily via chat box button & text shortcuts. Read the extended description!", - authors: [Devs.Ven, Devs.Tyler], + authors: [Devs.Ven, Devs.Tyler, Devs.Grzesiek11], dependencies: ["MessageEventsAPI"], + settings: settings, + patches: [ { find: "ChannelTextAreaButtons", @@ -131,7 +142,9 @@ export default definePlugin({ start() { this.listener = addPreSendListener((_, msg) => { - msg.content = msg.content.replace(/`\d{1,2}:\d{2} ?(?:AM|PM)?`/gi, parseTime); + if (settings.store.replaceMessageContents) { + msg.content = msg.content.replace(/`\d{1,2}:\d{2} ?(?:AM|PM)?`/gi, parseTime); + } }); }, diff --git a/src/plugins/textReplace/index.tsx b/src/plugins/textReplace/index.tsx index 5d66d2265..416ce83fb 100644 --- a/src/plugins/textReplace/index.tsx +++ b/src/plugins/textReplace/index.tsx @@ -213,7 +213,7 @@ function applyRules(content: string): string { if (stringRules) { for (const rule of stringRules) { - if (!rule.find || !rule.replace) continue; + if (!rule.find) continue; if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue; content = ` ${content} `.replaceAll(rule.find, rule.replace.replaceAll("\\n", "\n")).replace(/^\s|\s$/g, ""); @@ -222,7 +222,7 @@ function applyRules(content: string): string { if (regexRules) { for (const rule of regexRules) { - if (!rule.find || !rule.replace) continue; + if (!rule.find) continue; if (rule.onlyIfIncludes && !content.includes(rule.onlyIfIncludes)) continue; try { diff --git a/src/plugins/vencordToolbox/index.tsx b/src/plugins/vencordToolbox/index.tsx index 2b0ce14e6..0a805a0d2 100644 --- a/src/plugins/vencordToolbox/index.tsx +++ b/src/plugins/vencordToolbox/index.tsx @@ -33,7 +33,7 @@ function VencordPopout(onClose: () => void) { const pluginEntries = [] as ReactNode[]; for (const plugin of Object.values(Vencord.Plugins.plugins)) { - if (plugin.toolboxActions) { + if (plugin.toolboxActions && Vencord.Plugins.isPluginEnabled(plugin.name)) { pluginEntries.push( () => { - if ((name === "Guild" && !props.guild) || (name === "User" && !props.user)) return; + const value = props[name.toLowerCase()]; + if (!value) return; + if (props.label === i18n.Messages.CHANNEL_ACTIONS_MENU_LABEL) return; // random shit like notification settings + const lastChild = children.at(-1); if (lastChild?.key === "developer-actions") { const p = lastChild.props; if (!Array.isArray(p.children)) p.children = [p.children]; - ({ children } = p); + + children = p.children; } children.splice(-1, 0, openViewRawModal(JSON.stringify(props[name.toLowerCase()], null, 4), name)} + action={() => openViewRawModal(JSON.stringify(value, null, 4), name)} icon={CopyIcon} /> ); diff --git a/src/plugins/webContextMenus.web/index.ts b/src/plugins/webContextMenus.web/index.ts index eb076dfd0..bb98c61d7 100644 --- a/src/plugins/webContextMenus.web/index.ts +++ b/src/plugins/webContextMenus.web/index.ts @@ -169,10 +169,25 @@ export default definePlugin({ match: /let\{text:\i=""/, replace: "return [null,null];$&" } + }, + + // Add back "Show My Camera" context menu + { + find: '.default("MediaEngineWebRTC");', + replacement: { + match: /supports\(\i\)\{switch\(\i\)\{case (\i).Features/, + replace: "$&.DISABLE_VIDEO:return true;case $1.Features" + } } ], async copyImage(url: string) { + if (IS_VESKTOP && VesktopNative.clipboard) { + const data = await fetch(url).then(r => r.arrayBuffer()); + VesktopNative.clipboard.copyImage(data, url); + return; + } + // Clipboard only supports image/png, jpeg and similar won't work. Thus, we need to convert it to png // via canvas first const img = new Image(); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e61019e87..c7265a02e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -403,6 +403,14 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "maisy", id: 257109471589957632n, }, + Grzesiek11: { + name: "Grzesiek11", + id: 368475654662127616n, + }, + Samwich: { + name: "Samwich", + id: 976176454511509554n, + }, } satisfies Record); // iife so #__PURE__ works correctly