Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
thororen1234 2026-06-15 19:46:24 -04:00
commit 2c6cd72a53
No known key found for this signature in database
39 changed files with 545 additions and 206 deletions

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classes } from "@utils/misc";
import { findCssClassesLazy } from "@webpack";
import { Parser } from "@webpack/common";
@ -12,9 +13,9 @@ const CodeContainerClasses = findCssClassesLazy("markup", "codeContainer");
/**
* Renders code in a Discord codeblock
*/
export function CodeBlock(props: { content?: string, lang: string; }) {
export function CodeBlock({ className, ...props }: { content?: string, lang: string; className?: string; }) {
return (
<div className={CodeContainerClasses.markup}>
<div className={classes(CodeContainerClasses.markup, className)}>
{Parser.defaultRules.codeBlock.react(props, null, {})}
</div>
);
@ -23,9 +24,9 @@ export function CodeBlock(props: { content?: string, lang: string; }) {
/**
* Renders inline code like `this`
*/
export function InlineCode({ children }: { children: React.ReactNode; }) {
export function InlineCode({ children, className }: { children: React.ReactNode; className?: string; }) {
return (
<span className={CodeContainerClasses.markup}>
<span className={classes(CodeContainerClasses.markup, className)}>
<code className="inline">{children}</code>
</span>
);

View file

@ -81,6 +81,31 @@ export function CopyIcon(props: IconProps) {
);
}
/**
* Discord's ID icon, as seen in Developer Mode "Copy ID" context menu items
*/
export function CopyIdIcon(props: IconProps) {
return (
<Icon
{...props}
className={classes(props.className, "vc-copy-id-icon")}
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M15.3 14.48c-.46.45-1.08.67-1.86.67h-1.39V9.2h1.39c.78 0 1.4.22 1.86.67.46.45.68 1.22.68 2.31 0 1.1-.22 1.86-.68 2.31Z"
/>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm1 15h2.04V7.34H6V17Zm4-9.66V17h3.44c1.46 0 2.6-.42 3.38-1.25.8-.83 1.2-2.02 1.2-3.58s-.4-2.75-1.2-3.58c-.79-.83-1.92-1.25-3.38-1.25H10Z"
/>
</Icon>
);
}
/**
* Discord's open external icon, as seen in the user profile connections
*/

View file

@ -41,7 +41,7 @@ export function BooleanSetting({ setting, pluginSettings, definedSettings, id, o
}
return (
<SettingsSection tag="label" name={id} description={setting.description} error={error} inlineSetting>
<SettingsSection tag="label" name={setting.displayName} id={id} description={setting.description} error={error} inlineSetting>
<Switch
checked={state}
onChange={handleChange}

View file

@ -35,19 +35,20 @@ export function resolveError(isValidResult: boolean | string) {
}
interface SettingsSectionProps extends PropsWithChildren {
name: string;
name?: string;
id: string;
description: string;
error?: string | null;
inlineSetting?: boolean;
tag?: "label" | "div";
}
export function SettingsSection({ tag: Tag = "div", name, description, error, inlineSetting, children }: SettingsSectionProps) {
export function SettingsSection({ tag: Tag = "div", name, id, description, error, inlineSetting, children }: SettingsSectionProps) {
return (
<Tag className={cl("section")}>
<div className={classes(cl("content"), inlineSetting && cl("inline"))}>
<div className={cl("label")}>
{name && <BaseText className={cl("title")} size="md" weight="medium">{wordsToTitle(wordsFromCamel(name))}</BaseText>}
<BaseText className={cl("title")} size="md" weight="medium">{name ?? wordsToTitle(wordsFromCamel(id))}</BaseText>
{description && <BaseText className={cl("description")} size="sm">{description}</BaseText>}
</div>
{children}

View file

@ -50,7 +50,7 @@ export function NumberSetting({ setting, pluginSettings, definedSettings, id, on
}
return (
<SettingsSection name={id} description={setting.description} error={error}>
<SettingsSection name={setting.displayName} id={id} description={setting.description} error={error}>
<TextInput
type="number"
pattern="-?[0-9]+"

View file

@ -40,7 +40,7 @@ export function SelectSetting({ setting, pluginSettings, definedSettings, onChan
}
return (
<SettingsSection name={id} description={setting.description} error={error}>
<SettingsSection name={setting.displayName} id={id} description={setting.description} error={error}>
<Select
placeholder={setting.placeholder ?? "Select an option"}
options={setting.options}

View file

@ -38,7 +38,7 @@ export function SliderSetting({ setting, pluginSettings, definedSettings, id, on
}
return (
<SettingsSection name={id} description={setting.description} error={error}>
<SettingsSection name={setting.displayName} id={id} description={setting.description} error={error}>
<Slider
markers={setting.markers}
minValue={setting.markers[0]}

View file

@ -38,7 +38,7 @@ export function TextSetting({ setting, pluginSettings, definedSettings, id, onCh
}
return (
<SettingsSection name={id} description={setting.description} error={error}>
<SettingsSection name={setting.displayName} id={id} description={setting.description} error={error}>
{setting.multiline
? <TextArea
placeholder={setting.placeholder ?? "Enter a value"}

View file

@ -88,6 +88,22 @@ export default definePlugin({
replace: "$1!0"
}
},
{
// Gradient roles in chat
find: "=!1,contentOnly:",
replacement: {
match: /animate:\i/,
replace: "animate:!0"
}
},
{
// Gradient roles in member list
find: '="left",className:',
replacement: {
match: /,animateGradient:/,
replace: ",animateGradient:!0,_oldAnimateGradient:"
}
},
{
// Nameplates
find: ".MINI_PREVIEW,[",

View file

@ -20,6 +20,7 @@ export interface TrackData {
artist?: string;
appleMusicLink?: string;
appleMusicArtistLink?: string;
songLink?: string;
albumArtwork?: string;
@ -35,6 +36,12 @@ const enum AssetImageType {
Disabled = "Disabled"
}
const enum LinkType {
Album = "Album",
Artist = "Artist",
Disabled = "Disabled"
}
const applicationId = "1239490006054207550";
let updateInterval: NodeJS.Timeout | undefined;
@ -107,6 +114,24 @@ const settings = definePluginSettings({
description: "Activity state format string",
default: "{artist} · {album}"
},
detailsLink: {
type: OptionType.SELECT,
description: "Activity details link",
options: [
{ label: "Album", value: LinkType.Album, default: true },
{ label: "Artist", value: LinkType.Artist },
{ label: "Disabled", value: LinkType.Disabled }
],
},
stateLink: {
type: OptionType.SELECT,
description: "Activity state link",
options: [
{ label: "Album", value: LinkType.Album },
{ label: "Artist", value: LinkType.Artist, default: true },
{ label: "Disabled", value: LinkType.Disabled }
],
},
largeImageType: {
type: OptionType.SELECT,
description: "Activity assets large image type",
@ -121,6 +146,15 @@ const settings = definePluginSettings({
description: "Activity assets large text format string",
default: "{album}"
},
largeImageLink: {
type: OptionType.SELECT,
description: "Activity assets large image link",
options: [
{ label: "Album", value: LinkType.Album, default: true },
{ label: "Artist", value: LinkType.Artist },
{ label: "Disabled", value: LinkType.Disabled }
],
},
smallImageType: {
type: OptionType.SELECT,
description: "Activity assets small image type",
@ -135,6 +169,15 @@ const settings = definePluginSettings({
description: "Activity assets small text format string",
default: "{artist}"
},
smallImageLink: {
type: OptionType.SELECT,
description: "Activity assets small image link",
options: [
{ label: "Album", value: LinkType.Album },
{ label: "Artist", value: LinkType.Artist, default: true },
{ label: "Disabled", value: LinkType.Disabled }
],
},
});
function customFormat(formatStr: string, data: TrackData) {
@ -144,6 +187,14 @@ function customFormat(formatStr: string, data: TrackData) {
.replaceAll("{artist}", data.artist ?? "");
}
function getLink(type: LinkType, data: TrackData) {
return type === LinkType.Album
? data.appleMusicLink
: type === LinkType.Artist
? data.appleMusicArtistLink
: undefined;
}
function getImageAsset(type: AssetImageType, data: TrackData) {
const source = type === AssetImageType.Album
? data.albumArtwork
@ -204,11 +255,13 @@ export default definePlugin({
if (settings.store.largeImageType !== AssetImageType.Disabled) {
assets.large_image = largeImageAsset;
if (!isRadio) assets.large_text = customFormat(settings.store.largeTextString, trackData);
assets.large_url = getLink(settings.store.largeImageLink, trackData);
}
if (settings.store.smallImageType !== AssetImageType.Disabled) {
assets.small_image = smallImageAsset;
if (!isRadio) assets.small_text = customFormat(settings.store.smallTextString, trackData);
assets.small_url = getLink(settings.store.smallImageLink, trackData);
}
const buttons: ActivityButton[] = [];
@ -233,6 +286,8 @@ export default definePlugin({
name: customFormat(settings.store.nameString, trackData),
details: customFormat(settings.store.detailsString, trackData),
state: isRadio ? undefined : customFormat(settings.store.stateString, trackData),
details_url: getLink(settings.store.detailsLink, trackData),
state_url: getLink(settings.store.stateLink, trackData),
timestamps: (trackData.playerPosition && trackData.duration && settings.store.enableTimestamps) ? {
start: Date.now() - (trackData.playerPosition * 1000),

View file

@ -19,6 +19,7 @@ async function applescript(cmds: string[]) {
interface RemoteData {
appleMusicLink?: string,
appleMusicArtistLink?: string;
songLink?: string,
albumArtwork?: string,
artistArtwork?: string;
@ -65,6 +66,7 @@ async function fetchRemoteData({ id, name, artist, album }: { id: string, name:
id,
data: {
appleMusicLink: songData.trackViewUrl,
appleMusicArtistLink: songData.artistViewUrl,
songLink: `https://song.link/i/${new URL(songData.trackViewUrl).searchParams.get("i")}`,
albumArtwork: (songData.artworkUrl100).replace("100x100", "512x512"),
artistArtwork: artistArtworkURL

View file

@ -1,6 +1,6 @@
# BetterRoleContext
Adds options to copy role color, edit role and view role icon when right clicking roles in the user profile
Adds options to copy role color, edit role and view role icon when right clicking roles in the user profile or in the member list
![](https://github.com/Vendicated/Vencord/assets/45497981/354220a4-09f3-4c5f-a28e-4b19ca775190)

View file

@ -6,16 +6,16 @@
import { definePluginSettings } from "@api/Settings";
import { getUserSettingLazy } from "@api/UserSettings";
import { ImageIcon } from "@components/Icons";
import { CopyIdIcon, ImageIcon } from "@components/Icons";
import { copyToClipboard } from "@utils/clipboard";
import { Devs } from "@utils/constants";
import { getCurrentChannel, getCurrentGuild, openImageModal } from "@utils/discord";
import { getCurrentChannel, getCurrentGuild, getIntlMessage, openImageModal } from "@utils/discord";
import { isTruthy } from "@utils/guards";
import { classes } from "@utils/misc";
import definePlugin, { OptionType } from "@utils/types";
import { Guild, PopoutProps, Role } from "@vencord/discord-types";
import { findByCodeLazy, findByPropsLazy, findCssClassesLazy } from "@webpack";
import { GuildRoleStore, Menu, PermissionStore, Popout, useRef } from "@webpack/common";
import { ContextMenuApi, GuildRoleStore, Menu, PermissionStore, Popout, useRef } from "@webpack/common";
import { ComponentType } from "react";
const GuildSettingsActions = findByPropsLazy("open", "selectRole", "updateGuild");
@ -184,22 +184,63 @@ export function buildExtraRoleContextMenuItems(role: Role, guild: Guild, popoutR
return { before, after };
}
export function openRoleContextMenu(event: React.MouseEvent<HTMLElement>, { guildId, id: roleId }: { guildId: string; id: string; }) {
const guild = getCurrentGuild();
if (!guild || guild.id !== guildId) return;
const role = GuildRoleStore.getRole(guildId, roleId);
if (!role) return;
ContextMenuApi.openContextMenu(event, () => {
const popoutRef = useRef(null);
const { before, after } = buildExtraRoleContextMenuItems(role, guild, popoutRef);
return (
<Menu.Menu
navId="vc-better-role-context-member-list"
onClose={ContextMenuApi.closeContextMenu}
aria-label="Role Actions"
>
{before}
{after}
<Menu.MenuItem
key="vc-better-role-context-copy-role-id"
id="vc-better-role-context-copy-role-id"
label={getIntlMessage("COPY_ID_ROLE")}
icon={CopyIdIcon}
action={() => copyToClipboard(role.id)}
/>
</Menu.Menu>
);
});
}
export default definePlugin({
name: "BetterRoleContext",
description: "Adds options to copy role color / edit role / view role icon when right clicking roles in the user profile",
description: "Adds options to copy role color / edit role / view role icon when right clicking roles in the user profile or in the member list",
tags: ["Roles", "Appearance"],
authors: [Devs.Ven, Devs.goodbee],
authors: [Devs.Ven, Devs.goodbee, Devs.nightmaresan],
dependencies: ["UserSettingsAPI"],
settings,
patches: [{
find: ".ROLE_MENTION)",
replacement: {
match: /function (\i)(?=.+?renderPopout:.{0,20}\1,\{guildId:\i,channelId:\i)/,
replace: "$self.RoleMembers=$1;$&"
openRoleContextMenu,
patches: [
{
find: ".ROLE_MENTION)",
replacement: {
match: /function (\i)(?=.+?renderPopout:.{0,20}\1,\{guildId:\i,channelId:\i)/,
replace: "$self.RoleMembers=$1;$&"
}
},
// Conflicts with RoleColorEverywhere which changes the code at the end of our match. (and also uses same find & similar match)
// However, BetterRoleContext applies first (alphabetic order), so it's not an issue
{
find: 'tutorialId:"whos-online',
replacement: {
match: /(?<=#{intl::CHANNEL_MEMBERS_A11Y_LABEL}.{0,200}?"aria-hidden":!0,)children:.{0,200}?(?:—|\\u2014) ",\i\]\}\)\]/,
replace: "onContextMenu:e=>$self.openRoleContextMenu(e,arguments[0]),$&"
}
}
}],
],
start() {
// DeveloperMode needs to be enabled for the context menu to be shown

View file

@ -58,7 +58,7 @@ function AllowLevelSetting({ settingKey }: AllowLevelSettingProps) {
const AllowLevelSettings = ErrorBoundary.wrap(() => {
return (
<SettingsSection name="Filter List" description="Always allow loggers of these types">
<SettingsSection name="Filter List" id="filterList" description="Always allow loggers of these types">
<div style={{ display: "flex", flexDirection: "row" }}>
{Object.keys(settings.store.allowLevel).map(key => (
<AllowLevelSetting key={key} settingKey={key as keyof AllowLevels} />

View file

@ -50,6 +50,7 @@ const settings = definePluginSettings({
default: false
},
applyColorOnlyInDms: {
displayName: "Apply Color Only In DMs",
description: "Apply colors only in direct messages; do not apply colors in servers.",
restartNeeded: false,
type: OptionType.BOOLEAN,

View file

@ -69,6 +69,7 @@ function setActivity(activity: Activity | null) {
const settings = definePluginSettings({
apiKey: {
displayName: "API Key",
description: "Custom Last.fm API key. Not required but highly recommended to avoid rate limiting with our shared key",
type: OptionType.STRING,
},
@ -172,6 +173,7 @@ const settings = definePluginSettings({
],
},
showLastFmLogo: {
displayName: "Show Last.fm Logo",
description: "Show the Last.fm logo by the album cover",
type: OptionType.BOOLEAN,
default: true,

View file

@ -19,6 +19,7 @@ import { JSX } from "react";
const settings = definePluginSettings({
showAtSymbol: {
type: OptionType.BOOLEAN,
displayName: "Show @ Symbol",
description: "Whether the the @ symbol should be displayed on user mentions",
default: true
}

View file

@ -116,6 +116,7 @@ const settings = definePluginSettings({
]
},
idList: {
displayName: "ID List",
description: "Guild/channel/user IDs to blacklist or whitelist (separate with comma)",
type: OptionType.STRING,
default: "",

View file

@ -25,6 +25,7 @@ import type { MouseEvent } from "react";
interface URLReplacementRule {
match: RegExp;
replace: (...matches: string[]) => string;
displayName?: string;
description: string;
shortlinkMatch?: RegExp;
accountViewReplace?: (userId: string) => string;
@ -59,6 +60,7 @@ const UrlReplacementRules: Record<string, URLReplacementRule> = {
itunes: {
match: /^https:\/\/(?:geo\.)?music\.apple\.com\/([a-z]{2}\/)?(album|artist|playlist|song|curator)\/([^/?#]+)\/?([^/?#]+)?(?:\?.*)?(?:#.*)?$/,
replace: (_, lang, type, name, id) => id ? `itunes://music.apple.com/us/${type}/${name}/${id}` : `itunes://music.apple.com/us/${type}/${name}`,
displayName: "iTunes",
description: "Open Apple Music links in the iTunes app"
},
vrcx: {
@ -77,6 +79,7 @@ const pluginSettings = definePluginSettings(
Object.entries(UrlReplacementRules).reduce((acc, [key, rule]) => {
acc[key] = {
type: OptionType.BOOLEAN,
displayName: rule.displayName,
description: rule.description,
default: true,
};

View file

@ -18,7 +18,7 @@
import ErrorBoundary from "@components/ErrorBoundary";
import { Flex } from "@components/Flex";
import { InfoIcon, OwnerCrownIcon } from "@components/Icons";
import { CopyIdIcon, InfoIcon, OwnerCrownIcon } from "@components/Icons";
import { buildExtraRoleContextMenuItems } from "@plugins/betterRoleContext";
import { cl, getGuildPermissionSpecMap, loadGetGuildPermissionSpecMap } from "@plugins/permissionsViewer/utils";
import { copyToClipboard } from "@utils/clipboard";
@ -198,15 +198,6 @@ function RolesAndUsersPermissionsComponent({ permissions, guild, modalProps, hea
);
}
function IDIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="currentColor" d="M15.3 14.48c-.46.45-1.08.67-1.86.67h-1.39V9.2h1.39c.78 0 1.4.22 1.86.67.46.45.68 1.22.68 2.31 0 1.1-.22 1.86-.68 2.31Z" />
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3H5Zm1 15h2.04V7.34H6V17Zm4-9.66V17h3.44c1.46 0 2.6-.42 3.38-1.25.8-.83 1.2-2.02 1.2-3.58s-.4-2.75-1.2-3.58c-.79-.83-1.92-1.25-3.38-1.25H10Z" />
</svg>
);
}
function ViewAsRoleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24">
@ -231,7 +222,7 @@ function RoleContextMenu({ guild, roleId, onClose }: { guild: Guild; roleId: str
<Menu.MenuItem
id={cl("copy-role-id")}
label={getIntlMessage("COPY_ID_ROLE")}
icon={IDIcon}
icon={CopyIdIcon}
action={() => copyToClipboard(roleId)}
/>

View file

@ -48,11 +48,13 @@ export const settings = definePluginSettings({
},
canCollapseDmSection: {
type: OptionType.BOOLEAN,
displayName: "Can Collapse DM Section",
description: "Allow uncategorised DMs section to be collapsable",
default: false
},
dmSectionCollapsed: {
type: OptionType.BOOLEAN,
displayName: "DM Section Collapsed",
description: "Collapse DM section",
default: false,
hidden: true

View file

@ -36,6 +36,7 @@ const settings = definePluginSettings({
placeholder: "Google"
},
customEngineURL: {
displayName: "Custom Engine URL",
description: "The URL of your Engine",
type: OptionType.STRING,
placeholder: "https://google.com/search?q="

View file

@ -40,7 +40,7 @@ export async function updateAuth(newAuth: ReviewDBAuth) {
});
}
export function authorize(callback?: any) {
export function authorize(callback?: () => void) {
openModal(props =>
<OAuth2AuthorizeModal
{...props}
@ -50,7 +50,7 @@ export function authorize(callback?: any) {
permissions={0n}
clientId="915703782174752809"
cancelCompletesFlow={false}
callback={async (response: any) => {
callback={async (response: { location: string }) => {
try {
const url = new URL(response.location);
url.searchParams.append("clientMod", "vencord");
@ -60,7 +60,7 @@ export function authorize(callback?: any) {
if (!res.ok) {
const { message } = await res.json();
showToast(message || "An error occured while authorizing", Toasts.Type.FAILURE);
showToast(message ?? "An error occured while authorizing", Toasts.Type.FAILURE);
return;
}

View file

@ -18,13 +18,13 @@
import { Auth, getToken } from "@plugins/reviewDB/auth";
import { Review, ReviewType } from "@plugins/reviewDB/entities";
import { blockUser, deleteReview, reportReview, unblockUser } from "@plugins/reviewDB/reviewDbApi";
import { blockUser, deleteReview, deleteReviewVote, reportReview, unblockUser, voteReview } from "@plugins/reviewDB/reviewDbApi";
import { settings } from "@plugins/reviewDB/settings";
import { canBlockReviewAuthor, canDeleteReview, canReportReview, cl, showToast } from "@plugins/reviewDB/utils";
import { openUserProfile } from "@utils/discord";
import { classes } from "@utils/misc";
import { findCssClassesLazy } from "@webpack";
import { ConfirmModal,IconUtils, openModal as openVencordModal, Parser, Timestamp, useState } from "@webpack/common";
import { ConfirmModal, IconUtils, openModal as openVencordModal, Parser, Timestamp, useEffect, useState } from "@webpack/common";
import { openBlockModal } from "./BlockedUserModal";
import { BlockButton, DeleteButton, ReportButton } from "./MessageButton";
@ -40,6 +40,14 @@ const dateFormat = new Intl.DateTimeFormat();
export default function ReviewComponent({ review, refetch, profileId }: { review: Review; refetch(): void; profileId: string; }) {
const [showAll, setShowAll] = useState(false);
const [localVote, setLocalVote] = useState<boolean | null>(review.userVote ?? null);
const [score, setScore] = useState(review.score ?? 0);
const [isVoting, setIsVoting] = useState(false);
useEffect(() => {
setLocalVote(review.userVote ?? null);
setScore(review.score ?? 0);
}, [review.score, review.userVote]);
function openModal() {
openUserProfile(review.sender.discordID);
@ -56,13 +64,9 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
onConfirm={async () => {
if (!(await getToken())) {
return showToast("You must be logged in to delete reviews.");
} else {
deleteReview(review.id).then(res => {
if (res) {
refetch();
}
});
}
const res = await deleteReview(review.id);
if (res) refetch();
}}
/>
));
@ -79,9 +83,8 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
onConfirm={async () => {
if (!(await getToken())) {
return showToast("You must be logged in to report reviews.");
} else {
reportReview(review.id);
}
await reportReview(review.id);
}}
/>
));
@ -103,27 +106,87 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
onConfirm={async () => {
if (!(await getToken())) {
return showToast("You must be logged in to block users.");
} else {
blockUser(review.sender.discordID);
}
await blockUser(review.sender.discordID);
}}
/>
));
}
async function submitVote(isUpvote: boolean) {
if (isVoting) return;
if (review.sender.discordID === Auth.user?.discordID) {
return showToast("You cannot vote on your own review.");
}
setIsVoting(true);
try {
if (localVote === isUpvote) {
if (await deleteReviewVote(review.id)) {
setLocalVote(null);
setScore(currentScore => currentScore + (isUpvote ? -1 : 1));
}
return;
}
if (await voteReview(review.id, isUpvote)) {
const delta = localVote == null
? isUpvote ? 1 : -1
: isUpvote ? 2 : -2;
setLocalVote(isUpvote);
setScore(currentScore => currentScore + delta);
}
} finally {
setIsVoting(false);
}
}
return (
<div className={classes(cl("review"), MessageClasses.cozyMessage, AvatarClasses.wrapper, MessageClasses.message, MessageClasses.groupStart, AvatarClasses.cozy)} style={
{
marginLeft: "0px",
paddingLeft: "52px", // wth is this
paddingLeft: "52px",
// nobody knows anymore
}
}>
{review.id !== 0 && (
<div className={cl("vote-column")}>
<span className={classes(cl("vote-column-score"), score > 0 && cl("vote-column-score-positive"), score < 0 && cl("vote-column-score-negative"))}>
{score}
</span>
<div className={cl("vote-column-buttons")}>
<button
className={classes(cl("vote-column-button"), !!localVote && cl("vote-column-up-selected"))}
disabled={isVoting}
onClick={() => submitVote(true)}
type="button"
>
<svg height="20" viewBox="0 0 12 12" width="20" fill="none">
<path d="M3 7.5 6 4.5l3 3" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</svg>
</button>
<button
className={classes(cl("vote-column-button"), localVote === false && cl("vote-column-down-selected"))}
disabled={isVoting}
onClick={() => submitVote(false)}
type="button"
>
<svg height="20" viewBox="0 0 12 12" width="20" fill="none">
<path d="M3 4.5 6 7.5l3-3" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
</svg>
</button>
</div>
</div>
)}
<img
className={classes(AvatarClasses.avatar, AvatarClasses.clickable)}
onClick={openModal}
src={review.sender.profilePhoto || IconUtils.getDefaultAvatarURL(review.sender.discordID)}
src={review.sender.profilePhoto ?? IconUtils.getDefaultAvatarURL(review.sender.discordID)}
style={{ left: "0px", zIndex: 0 }}
onError={e => e.currentTarget.src = IconUtils.getDefaultAvatarURL(review.sender.discordID)}
/>
@ -163,7 +226,6 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
{dateFormat.format(review.timestamp * 1000)}
</Timestamp>)
}
<div className={cl("review-comment")}>
{(review.comment.length > 200 && !showAll)
? (

View file

@ -62,7 +62,7 @@ export default function ReviewsView({
}: Props) {
const [signal, refetch] = useForceUpdater(true);
const [reviewData] = useAwaiter(() => getReviews(discordId, { offset: (page - 1) * REVIEWS_PER_PAGE }), {
const [reviewData] = useAwaiter(() => getReviews(discordId, { offset: (page - 1) * REVIEWS_PER_PAGE, fetchVotes: true }), {
fallbackValue: null,
deps: [refetchSignal, signal, page],
onSuccess: data => {

View file

@ -70,7 +70,7 @@ export interface ReviewDBUser {
username: string;
type: UserType;
profilePhoto: string;
badges: any[];
badges: Badge[];
}
export interface ReviewDBCurrentUser extends ReviewDBUser {
@ -93,9 +93,11 @@ export interface ReviewAuthor {
export interface Review {
comment: string,
id: number,
score?: number,
star: number,
sender: ReviewAuthor,
timestamp: number;
type?: ReviewType;
replies?: Review[];
userVote?: boolean | null;
}

View file

@ -96,49 +96,51 @@ export default definePlugin({
setTimeout(async () => {
if (!Auth.token) return;
const user = await getCurrentUserInfo(Auth.token);
updateAuth({ user });
const user = await getCurrentUserInfo();
if (user) {
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 (notifyReviews) {
if (lastReviewId && lastReviewId < user.lastReviewID) {
s.lastReviewId = user.lastReviewID;
if (user.lastReviewID !== 0)
showToast("You have new reviews on your profile!");
}
}
}
const { notification } = user;
if (notification) {
const props = 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"
})
)
} : {};
const { notification } = user;
if (notification) {
const props = 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"
})
)
} : {};
openModal(modalProps => (
<ConfirmModal
{...modalProps}
title={notification.title}
confirmText={props.confirmText ?? "OK"}
cancelText={props.cancelText}
variant="primary"
onCancel={props.onCancel}
>
{Parser.parse(
notification.content,
false
)}
</ConfirmModal>
));
openModal(modalProps => (
<ConfirmModal
{...modalProps}
title={notification.title}
confirmText={props.confirmText ?? "OK"}
cancelText={props.cancelText}
variant="primary"
onCancel={props.onCancel}
>
{Parser.parse(
notification.content,
false
)}
</ConfirmModal>
));
readNotification(notification.id);
readNotification(notification.id);
}
}
}, 4000);
},

View file

@ -1,20 +1,8 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Toasts } from "@webpack/common";
@ -36,19 +24,51 @@ export interface UserReviewsData {
hasOptedOut: boolean;
}
const WarningFlag = 0b00000010;
async function rdbRequest(path: string, options: RequestInit = {}) {
return fetch(API_URL + path, {
...options,
headers: {
...options.headers,
Authorization: await getToken() || "",
}
});
export interface ReviewVote {
reviewID: number;
isUpvote: boolean;
}
export async function getReviews(id: string, { limit, offset = 0 }: { limit?: number; offset?: number; } = {}): Promise<UserReviewsData> {
interface ReviewVotesData {
message?: string;
votes: ReviewVote[];
}
const WarningFlag = 0b00000010;
async function rdbRequest<T = unknown>(path: string, options: RequestInit = {}): Promise<T | null> {
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: await getToken() ?? "",
...options.headers as Record<string, string>,
};
if (options.body) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(API_URL + path, {
...options,
headers,
}).catch(err => {
showToast("Network error: Failed to connect to ReviewDB.", Toasts.Type.FAILURE);
return null;
});
if (!res) return null;
const data = await res.json().catch(() => null);
if (!res.ok) {
const message = data?.message ?? `ReviewDB: Request failed with status ${res.status}`;
showToast(message, Toasts.Type.FAILURE);
return null;
}
return data as T;
}
export async function getReviews(id: string, { limit, offset = 0, fetchVotes = false }: { limit?: number; offset?: number; fetchVotes?: boolean } = {}): Promise<UserReviewsData> {
let flags = 0;
if (!settings.store.showWarning) flags |= WarningFlag;
@ -57,6 +77,7 @@ export async function getReviews(id: string, { limit, offset = 0 }: { limit?: nu
if (offset) params.append("offset", String(offset));
if (limit) params.append("limit", String(limit));
const votesPromise = fetchVotes ? getReviewVotes(id).catch(() => []) : Promise.resolve([]);
const req = await fetch(`${API_URL}/users/${id}/reviews?${params}`);
const res = (req.ok)
@ -93,10 +114,33 @@ export async function getReviews(id: string, { limit, offset = 0 }: { limit?: nu
};
}
if (!fetchVotes || res.reviews.length === 0) return res;
const votes = await votesPromise;
if (votes.length === 0) return res;
const voteByReviewId = new Map<number, boolean>();
for (const vote of votes) {
voteByReviewId.set(vote.reviewID, vote.isUpvote);
}
res.reviews = res.reviews.map(review => ({
...review,
userVote: voteByReviewId.get(review.id) ?? null,
}));
return res;
}
export async function addReview(review: any): Promise<UserReviewsData | null> {
export async function getReviewVotes(id: string): Promise<ReviewVote[]> {
const token = await getToken();
if (!token) return [];
const res = await rdbRequest<ReviewVotesData>(`/users/${id}/reviews/votes`);
return res?.votes ?? [];
}
export async function addReview(review: { userid: string; comment: string }): Promise<UserReviewsData | null> {
const token = await getToken();
if (!token) {
@ -105,75 +149,88 @@ export async function addReview(review: any): Promise<UserReviewsData | null> {
return null;
}
return await rdbRequest(`/users/${review.userid}/reviews`, {
const data = await rdbRequest<UserReviewsData>(`/users/${review.userid}/reviews`, {
method: "PUT",
body: JSON.stringify(review),
headers: {
"Content-Type": "application/json",
}
}).then(async r => {
const data = await r.json() as UserReviewsData;
showToast(data.message);
return r.ok ? data : null;
});
if (data?.message) showToast(data.message);
return data;
}
export async function deleteReview(id: number): Promise<UserReviewsData | null> {
return await rdbRequest(`/users/${id}/reviews`, {
const data = await rdbRequest<UserReviewsData>(`/users/${id}/reviews`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
reviewid: id
})
}).then(async r => {
const data = await r.json() as UserReviewsData;
showToast(data.message);
return r.ok ? data : null;
});
if (data?.message) showToast(data.message);
return data;
}
export async function reportReview(id: number) {
const res = await rdbRequest("/reports", {
const data = await rdbRequest<UserReviewsData>("/reports", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
reviewid: id,
})
}).then(r => r.json()) as UserReviewsData;
});
if (data?.message) showToast(data.message);
}
showToast(res.message);
export async function voteReview(id: number, isUpvote: boolean) {
const token = await getToken();
if (!token) {
showToast("Please authorize to vote on reviews.");
authorize();
return false;
}
const data = await rdbRequest<{ message?: string }>(`/reviews/${id}/vote`, {
method: "POST",
body: JSON.stringify({ isUpvote })
});
if (!data) return false;
return true;
}
export async function deleteReviewVote(id: number) {
const token = await getToken();
if (!token) {
showToast("Please authorize to vote on reviews.");
authorize();
return false;
}
const data = await rdbRequest<{ message?: string }>(`/reviews/${id}/vote`, {
method: "DELETE",
});
if (!data) return false;
return true;
}
async function patchBlock(action: "block" | "unblock", userId: string) {
const res = await rdbRequest("/blocks", {
const data = await rdbRequest("/blocks", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
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 (!data) return;
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 } });
}
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 } });
}
}
@ -181,25 +238,17 @@ export const blockUser = (userId: string) => patchBlock("block", userId);
export const unblockUser = (userId: string) => patchBlock("unblock", userId);
export async function fetchBlocks(): Promise<ReviewDBUser[]> {
const res = await rdbRequest("/blocks", {
method: "GET",
headers: {
Accept: "application/json",
}
});
if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`);
return res.json();
return await rdbRequest<ReviewDBUser[]>("/blocks") ?? [];
}
export function getCurrentUserInfo(token: string): Promise<ReviewDBCurrentUser> {
return rdbRequest("/users", {
export function getCurrentUserInfo(): Promise<ReviewDBCurrentUser | null> {
return rdbRequest<ReviewDBCurrentUser>("/users", {
method: "POST",
}).then(r => r.json());
});
}
export async function readNotification(id: number) {
return rdbRequest(`/notifications?id=${id}`, {
return await rdbRequest(`/notifications?id=${id}`, {
method: "PATCH"
});
}

View file

@ -42,7 +42,7 @@
.vc-rdb-review {
padding-top: 8px !important;
padding-bottom: 8px !important;
padding-right: 32px !important;
padding-right: 64px !important;
}
.vc-rdb-review:hover {
@ -57,6 +57,7 @@
.vc-rdb-review-comment {
overflow-y: hidden;
margin-top: 1px;
line-height: 1.2;
margin-bottom: 8px;
color: var(--text-default);
font-size: 15px;
@ -116,4 +117,76 @@
.vc-rdb-profile-popout-disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.vc-rdb-vote-column {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
}
.vc-rdb-vote-column-buttons {
display: flex;
flex-direction: column;
gap: 0;
}
.vc-rdb-vote-column-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--interactive-muted);
cursor: pointer;
}
.vc-rdb-vote-column-button:disabled {
cursor: default;
opacity: 0.65;
}
.vc-rdb-vote-column-button:hover:not(:disabled) {
background: var(--background-modifier-hover);
color: var(--interactive-hover);
}
.vc-rdb-vote-column-score {
min-width: 15px;
color: var(--text-muted);
font-size: 1rem;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.vc-rdb-vote-column-score-positive,
.vc-rdb-vote-column-up-selected {
color: var(--status-positive);
}
.vc-rdb-vote-column-score-negative,
.vc-rdb-vote-column-down-selected {
color: var(--status-danger);
}
.vc-rdb-vote-column-up-selected:hover:not(:disabled) {
background: color-mix(in srgb, var(--status-positive) 14%, transparent);
color: var(--status-positive);
}
.vc-rdb-vote-column-down-selected:hover:not(:disabled) {
background: color-mix(in srgb, var(--status-danger) 14%, transparent);
color: var(--status-danger);
}

View file

@ -51,6 +51,7 @@ export const settings = definePluginSettings({
},
tryHljs: {
type: OptionType.SELECT,
displayName: "Try Highlight.js",
description: "Use the more lightweight default Discord highlighter and theme.",
options: [
{

View file

@ -92,6 +92,7 @@
padding-left: 5px;
padding-right: 8px;
user-select: none;
white-space: nowrap;
}
.vc-shiki-root .vc-shiki-table-cell:last-child {

View file

@ -61,6 +61,7 @@ export const settings = definePluginSettings({
},
deeplApiKey: {
type: OptionType.STRING,
displayName: "DeepL API Key",
description: "Your DeepL API key (from deepl.com/your-account)",
default: ""
},

View file

@ -45,7 +45,8 @@ interface VoiceStateChangeEvent {
// not say the second mute, which would lead you to believe they're unmuted
function speak(text: string) {
if (!text) return;
// Don't narrate in the overlay window, otherwise everything is said twice
if (!text || window.__OVERLAY__) return;
const { volume, rate } = settings.store;

View file

@ -61,6 +61,7 @@ const settings = definePluginSettings({
},
imgSize: {
type: OptionType.SELECT,
displayName: "Image Size",
description: "The image size to use",
options: ["128", "256", "512", "1024", "2048", "4096"].map(n => ({ label: n, value: n, default: n === "1024" }))
}

View file

@ -21,19 +21,19 @@ import "./style.css";
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings, migratePluginSettings } from "@api/Settings";
import { CodeBlock } from "@components/CodeBlock";
import { Divider } from "@components/Divider";
import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading";
import { HeadingSecondary } from "@components/Heading";
import { Margins } from "@components/margins";
import { Devs } from "@utils/constants";
import { copyWithToast, getCurrentGuild, getIntlMessage } from "@utils/discord";
import { isTruthy } from "@utils/guards";
import { Margins } from "@utils/margins";
import definePlugin, { IconComponent, OptionType } from "@utils/types";
import { Message } from "@vencord/discord-types";
import { ChannelStore, GuildRoleStore, Menu, Modal, openModal, UserProfileStore } from "@webpack/common";
import { MouseEventHandler } from "react";
const CopyIcon: IconComponent = ({ height = 20, width = 20, className }) => {
const CopyRawIcon: IconComponent = ({ height = 20, width = 20, className }) => {
return (
<svg
viewBox="0 0 20 20"
@ -77,12 +77,12 @@ function openViewRawModal(json: string, type: string, msgContent?: string) {
<ErrorBoundary>
<Modal
{...props}
title="View Raw"
title={`Raw ${type} Data`}
size="xl"
actions={[
{
text: `Copy ${type} JSON`,
variant: "primary",
text: `Copy ${type} Data`,
variant: "secondary",
onClick: () => copyWithToast(json, `${type} data copied to clipboard!`)
},
msgContent && {
@ -94,18 +94,12 @@ function openViewRawModal(json: string, type: string, msgContent?: string) {
>
{!!msgContent && (
<>
<Heading tag="h5">Content</Heading>
<div className="vc-viewraw-codeblock">
<CodeBlock content={msgContent} lang="" />
</div>
<Divider className={Margins.bottom20} />
<HeadingSecondary>Message Content</HeadingSecondary>
<CodeBlock className="vc-viewRaw-codeBlock" content={msgContent} lang="" />
<HeadingSecondary className={Margins.top16}>Message Data</HeadingSecondary>
</>
)}
<Heading tag="h5">{type} Data</Heading>
<div className="vc-viewraw-codeblock">
<CodeBlock content={json} lang="json" />
</div>
<CodeBlock className="vc-viewRaw-codeBlock" content={json} lang="json" />
</Modal>
</ErrorBoundary >
));
@ -155,7 +149,7 @@ function MakeContextCallback(name: "Guild" | "Role" | "User" | "Channel" | "Mess
id={id}
label="View Raw"
action={action}
icon={CopyIcon}
icon={CopyRawIcon}
/>
);
};
@ -173,7 +167,7 @@ const devContextCallback: NavContextMenuPatchCallback = (children, { id }: { id:
id={"vc-view-role-raw"}
label="View Raw"
action={() => openViewRawModal(JSON.stringify(role, null, 4), "Role")}
icon={CopyIcon}
icon={CopyRawIcon}
/>
);
};
@ -200,7 +194,7 @@ export default definePlugin({
},
messagePopoverButton: {
icon: CopyIcon,
icon: CopyRawIcon,
render(msg) {
const handleClick = () => {
if (settings.store.clickMethod === "Right") {
@ -228,7 +222,7 @@ export default definePlugin({
return {
label,
icon: CopyIcon,
icon: CopyRawIcon,
message: msg,
channel: ChannelStore.getChannel(msg.channel_id),
onClick: handleClick,

View file

@ -1,5 +1,6 @@
.vc-viewraw-codeblock {
white-space: nowrap !important;
word-break: normal !important;
min-width: 40px !important;
}
.vc-viewRaw-codeBlock,
.vc-viewRaw-codeBlock :is(pre, code, [class*="codeContainer"]) {
max-width: unset;
width: 100%;
box-sizing: border-box;
}

View file

@ -103,6 +103,7 @@ const settings = definePluginSettings({
},
preferUDP: {
type: OptionType.BOOLEAN,
displayName: "Prefer UDP",
description: "Enable if you use an older build of XSOverlay unable to connect through websockets. This setting is ignored on web.",
default: false,
disabled: () => IS_WEB
@ -119,11 +120,13 @@ const settings = definePluginSettings({
},
dmNotifications: {
type: OptionType.BOOLEAN,
displayName: "DM Notifications",
description: "Allow Direct Message notifications",
default: true
},
groupDmNotifications: {
type: OptionType.BOOLEAN,
displayName: "Group DM Notifications",
description: "Allow Group DM notifications",
default: true
},

View file

@ -658,6 +658,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "prism",
id: 390884143749136386n,
},
nightmaresan: {
name: "NightmareSan",
id: 304239816466235392n
},
} satisfies Record<string, Dev>);
export const EquicordDevs = Object.freeze({

View file

@ -292,6 +292,7 @@ export type PluginSettingDef =
export interface PluginSettingDefCommon extends IsDisabledOrHidden, IsValid<unknown> {
description: string;
displayName?: string;
placeholder?: string;
onChange?(newValue: any): void;
/**
@ -375,7 +376,7 @@ export interface PluginSettingSliderDef extends PluginSettingDefCommon {
stickToMarkers?: boolean;
}
export interface PluginSettingComponentDef extends Omit<PluginSettingDefCommon, "description" | "placeholder"> {
export interface PluginSettingComponentDef extends Omit<PluginSettingDefCommon, "description" | "placeholder" | "displayName"> {
type: OptionType.COMPONENT;
component: (props: PluginSettingComponentProps) => ReactNode | Promise<ReactNode>;
default?: any;