diff --git a/src/plugins/reviewDB/components/MessageButton.tsx b/src/plugins/reviewDB/components/MessageButton.tsx index 10c92db856..27f99fbd4b 100644 --- a/src/plugins/reviewDB/components/MessageButton.tsx +++ b/src/plugins/reviewDB/components/MessageButton.tsx @@ -83,3 +83,31 @@ export function BlockButton({ onClick, isBlocked }: { onClick(): void; isBlocked ); } + +export function VoteButton({ isUpvote, isSelected, disabled, onClick }: { isUpvote: boolean; isSelected: boolean; disabled?: boolean; onClick(): void; }) { + return ( + + {props => ( +
+ + + +
+ )} +
+ ); +} diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx index a29bd504c5..8530a2b8dc 100644 --- a/src/plugins/reviewDB/components/ReviewComponent.tsx +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -18,16 +18,16 @@ 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, 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 { Alerts, IconUtils, Parser, Timestamp, useState } from "@webpack/common"; +import { Alerts, IconUtils, Parser, Timestamp, useEffect, useState } from "@webpack/common"; import { openBlockModal } from "./BlockedUserModal"; -import { BlockButton, DeleteButton, ReportButton } from "./MessageButton"; +import { BlockButton, DeleteButton, ReportButton, VoteButton } from "./MessageButton"; import ReviewBadge from "./ReviewBadge"; const MessageClasses = findCssClassesLazy("cozyMessage", "message", "groupStart", "buttons", "buttonsInner"); @@ -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); @@ -104,6 +112,33 @@ export default function ReviewComponent({ review, refetch, profileId }: { review }); } + async function submitVote(isUpvote: boolean) { + if (isVoting) return; + + if (review.sender.discordID === Auth.user?.discordID) { + return showToast("You cannot vote on your own review."); + } + + if (localVote === isUpvote) { + return showToast(`You already ${isUpvote ? "upvoted" : "downvoted"} this review.`); + } + + setIsVoting(true); + + try { + 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 && ( + + {score} + + )}
{(review.comment.length > 200 && !showAll) @@ -177,6 +217,8 @@ export default function ReviewComponent({ review, refetch, profileId }: { review {canReportReview(review) && } {canBlockReviewAuthor(profileId, review) && } {canDeleteReview(profileId, review) && } + submitVote(true)} /> + submitVote(false)} />
)} diff --git a/src/plugins/reviewDB/components/ReviewsView.tsx b/src/plugins/reviewDB/components/ReviewsView.tsx index e53e0bbd58..bd9a94826b 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 a871d90f2b..be50844832 100644 --- a/src/plugins/reviewDB/entities.ts +++ b/src/plugins/reviewDB/entities.ts @@ -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/reviewDbApi.ts b/src/plugins/reviewDB/reviewDbApi.ts index 94c3697707..39badcb2cb 100644 --- a/src/plugins/reviewDB/reviewDbApi.ts +++ b/src/plugins/reviewDB/reviewDbApi.ts @@ -36,6 +36,16 @@ export interface UserReviewsData { hasOptedOut: boolean; } +export interface ReviewVote { + reviewID: number; + isUpvote: boolean; +} + +interface ReviewVotesData { + message?: string; + votes: ReviewVote[]; +} + const WarningFlag = 0b00000010; async function rdbRequest(path: string, options: RequestInit = {}) { @@ -48,7 +58,7 @@ async function rdbRequest(path: string, options: RequestInit = {}) { }); } -export async function getReviews(id: string, { limit, offset = 0 }: { limit?: number; offset?: number; } = {}): Promise { +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; @@ -93,9 +103,36 @@ export async function getReviews(id: string, { limit, offset = 0 }: { limit?: nu }; } + if (!fetchVotes || res.reviews.length === 0) return res; + + const votes = await getReviewVotes(id); + if (votes.length === 0) return res; + + const voteByReviewId = new Map(votes.map(vote => [vote.reviewID, vote.isUpvote])); + res.reviews = res.reviews.map(review => ({ + ...review, + userVote: voteByReviewId.get(review.id) ?? null, + })); + return res; } +export async function getReviewVotes(id: string): Promise { + const token = await getToken(); + if (!token) return []; + + const req = await rdbRequest(`/users/${id}/reviews/votes`, { + headers: { + Accept: "application/json", + } + }); + + if (!req.ok) return []; + + const res = await req.json() as ReviewVotesData; + return res.votes ?? []; +} + export async function addReview(review: any): Promise { const token = await getToken(); @@ -150,6 +187,28 @@ export async function reportReview(id: number) { 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 res = await rdbRequest(`/reviews/${id}/vote`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ isUpvote }) + }); + + const data = await res.json() as { message?: string; }; + showToast(data.message ?? (res.ok ? "Vote recorded" : "Failed to vote"), res.ok ? Toasts.Type.SUCCESS : Toasts.Type.FAILURE); + return res.ok; +} + async function patchBlock(action: "block" | "unblock", userId: string) { const res = await rdbRequest("/blocks", { method: "PATCH", diff --git a/src/plugins/reviewDB/style.css b/src/plugins/reviewDB/style.css index a15bb2320f..6f33bf1114 100644 --- a/src/plugins/reviewDB/style.css +++ b/src/plugins/reviewDB/style.css @@ -135,4 +135,20 @@ .vc-rdb-profile-popout-disabled { opacity: 0.4; cursor: not-allowed; -} \ No newline at end of file +} + +.vc-rdb-vote-score { + margin-left: 8px; + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1.375rem; + vertical-align: baseline; +} + +.vc-rdb-vote-up-selected { + color: var(--status-positive); +} + +.vc-rdb-vote-down-selected { + color: var(--status-danger); +}