diff --git a/src/plugins/reviewDB/auth.tsx b/src/plugins/reviewDB/auth.tsx index 7cf783f09..cd8728980 100644 --- a/src/plugins/reviewDB/auth.tsx +++ b/src/plugins/reviewDB/auth.tsx @@ -40,7 +40,7 @@ export async function updateAuth(newAuth: ReviewDBAuth) { }); } -export function authorize(callback?: any) { +export function authorize(callback?: () => void) { openModal(props => { + 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; } diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx index cfc713272..295ce1a52 100644 --- a/src/plugins/reviewDB/components/ReviewComponent.tsx +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -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(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 (
+ {review.id !== 0 && ( +
+ 0 && cl("vote-column-score-positive"), score < 0 && cl("vote-column-score-negative"))}> + {score} + +
+ + +
+
+ )} + 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 e53e0bbd5..bd9a94826 100644 --- a/src/plugins/reviewDB/components/ReviewsView.tsx +++ b/src/plugins/reviewDB/components/ReviewsView.tsx @@ -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 => { diff --git a/src/plugins/reviewDB/entities.ts b/src/plugins/reviewDB/entities.ts index a871d90f2..0d40d7e34 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,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; } diff --git a/src/plugins/reviewDB/index.tsx b/src/plugins/reviewDB/index.tsx index e30a264fb..22c2d7485 100644 --- a/src/plugins/reviewDB/index.tsx +++ b/src/plugins/reviewDB/index.tsx @@ -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 => ( - - {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); +}