ReviewDB: it's a big day for reddit nation (#4216)

add updoots/downdoots
This commit is contained in:
Manti 2026-06-14 20:57:59 +03:00 • committed by GitHub
parent 2ca831f91c
commit e8415d7c64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 328 additions and 140 deletions

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

@ -61,7 +61,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,8 +93,10 @@ export interface ReviewAuthor {
export interface Review {
comment: string,
id: number,
score?: number,
star: number,
sender: ReviewAuthor,
timestamp: number;
type?: ReviewType;
userVote?: boolean | null;
}

View file

@ -115,49 +115,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);
}