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)}
)
}
-
{(review.comment.length > 200 && !showAll)
? (
diff --git a/src/plugins/reviewDB/components/ReviewsView.tsx b/src/plugins/reviewDB/components/ReviewsView.tsx
index 2564eff20..0a87431e3 100644
--- a/src/plugins/reviewDB/components/ReviewsView.tsx
+++ b/src/plugins/reviewDB/components/ReviewsView.tsx
@@ -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 => {
diff --git a/src/plugins/reviewDB/entities.ts b/src/plugins/reviewDB/entities.ts
index 77e9827b0..8f61acecb 100644
--- a/src/plugins/reviewDB/entities.ts
+++ b/src/plugins/reviewDB/entities.ts
@@ -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;
}
diff --git a/src/plugins/reviewDB/index.tsx b/src/plugins/reviewDB/index.tsx
index 6651b00b5..4a1dc6de0 100644
--- a/src/plugins/reviewDB/index.tsx
+++ b/src/plugins/reviewDB/index.tsx
@@ -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 => (
-
- {Parser.parse(
- notification.content,
- false
- )}
-
- ));
+ openModal(modalProps => (
+
+ {Parser.parse(
+ notification.content,
+ false
+ )}
+
+ ));
- readNotification(notification.id);
+ readNotification(notification.id);
+ }
}
}, 4000);
},
diff --git a/src/plugins/reviewDB/reviewDbApi.ts b/src/plugins/reviewDB/reviewDbApi.ts
index 94c369770..48de46e73 100644
--- a/src/plugins/reviewDB/reviewDbApi.ts
+++ b/src/plugins/reviewDB/reviewDbApi.ts
@@ -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
.
-*/
+ * 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
{
+interface ReviewVotesData {
+ message?: string;
+ votes: ReviewVote[];
+}
+
+const WarningFlag = 0b00000010;
+
+async function rdbRequest(path: string, options: RequestInit = {}): Promise {
+ const headers: Record = {
+ Accept: "application/json",
+ Authorization: await getToken() ?? "",
+ ...options.headers as Record,
+ };
+
+ 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 {
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();
+ 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 {
+export async function getReviewVotes(id: string): Promise {
+ const token = await getToken();
+ if (!token) return [];
+
+ const res = await rdbRequest(`/users/${id}/reviews/votes`);
+ return res?.votes ?? [];
+}
+
+export async function addReview(review: { userid: string; comment: string }): Promise {
const token = await getToken();
if (!token) {
@@ -105,75 +149,88 @@ export async function addReview(review: any): Promise {
return null;
}
- return await rdbRequest(`/users/${review.userid}/reviews`, {
+ const data = await rdbRequest(`/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 {
- return await rdbRequest(`/users/${id}/reviews`, {
+ const data = await rdbRequest(`/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("/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 {
- 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("/blocks") ?? [];
}
-export function getCurrentUserInfo(token: string): Promise {
- return rdbRequest("/users", {
+export function getCurrentUserInfo(): Promise {
+ return rdbRequest("/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"
});
}
diff --git a/src/plugins/reviewDB/style.css b/src/plugins/reviewDB/style.css
index 50e1f0e6c..61b0be011 100644
--- a/src/plugins/reviewDB/style.css
+++ b/src/plugins/reviewDB/style.css
@@ -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;
-}
\ No newline at end of file
+}
+
+
+.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);
+}
diff --git a/src/plugins/shikiCodeblocks.desktop/settings.ts b/src/plugins/shikiCodeblocks.desktop/settings.ts
index b4a0dc464..80cda6dd9 100644
--- a/src/plugins/shikiCodeblocks.desktop/settings.ts
+++ b/src/plugins/shikiCodeblocks.desktop/settings.ts
@@ -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: [
{
diff --git a/src/plugins/shikiCodeblocks.desktop/shiki.css b/src/plugins/shikiCodeblocks.desktop/shiki.css
index 9ca34484b..97928509c 100644
--- a/src/plugins/shikiCodeblocks.desktop/shiki.css
+++ b/src/plugins/shikiCodeblocks.desktop/shiki.css
@@ -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 {
diff --git a/src/plugins/translate/settings.tsx b/src/plugins/translate/settings.tsx
index 426788f3e..5c65c8d02 100644
--- a/src/plugins/translate/settings.tsx
+++ b/src/plugins/translate/settings.tsx
@@ -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: ""
},
diff --git a/src/plugins/vcNarrator/index.tsx b/src/plugins/vcNarrator/index.tsx
index 28fde0b20..b6b85c941 100644
--- a/src/plugins/vcNarrator/index.tsx
+++ b/src/plugins/vcNarrator/index.tsx
@@ -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;
diff --git a/src/plugins/viewIcons/index.tsx b/src/plugins/viewIcons/index.tsx
index d7a74223b..5b67511d5 100644
--- a/src/plugins/viewIcons/index.tsx
+++ b/src/plugins/viewIcons/index.tsx
@@ -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" }))
}
diff --git a/src/plugins/viewRaw/index.tsx b/src/plugins/viewRaw/index.tsx
index 9bbe84562..14afa8491 100644
--- a/src/plugins/viewRaw/index.tsx
+++ b/src/plugins/viewRaw/index.tsx
@@ -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 (