Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/plugins/reviewDB/components/MessageButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,31 @@ export function BlockButton({ onClick, isBlocked }: { onClick(): void; isBlocked
</Tooltip>
);
}

export function VoteButton({ isUpvote, isSelected, disabled, onClick }: { isUpvote: boolean; isSelected: boolean; disabled?: boolean; onClick(): void; }) {
return (
<Tooltip text={isUpvote ? "Upvote Review" : "Downvote Review"}>
{props => (
<div
{...props}
className={classes(
iconClasses.button,
disabled && iconClasses.disabled,
isSelected && (isUpvote ? "vc-rdb-vote-up-selected" : "vc-rdb-vote-down-selected")
)}
onClick={disabled ? undefined : onClick}
aria-disabled={disabled}
role="button"
>
<svg height="20" viewBox="0 0 24 24" width="20" fill="currentColor">
<path
d={isUpvote
? "M12 3 4 11h5v10h6V11h5L12 3Z"
: "M12 21 4 13h5V3h6v10h5l-8 8Z"}
/>
</svg>
</div>
)}
</Tooltip>
);
}
48 changes: 45 additions & 3 deletions src/plugins/reviewDB/components/ReviewComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Comment on lines +43 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Introduce a loading state to prevent concurrent voting requests and use useEffect to keep the local state in sync with the review prop. Since useState only initializes once, the local score and localVote would otherwise become stale if the parent component refetches data.

    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.userVote, review.score]);

const [isVoting, setIsVoting] = useState(false);

useEffect(() => {
setLocalVote(review.userVote ?? null);
setScore(review.score ?? 0);
}, [review.score, review.userVote]);

function openModal() {
openUserProfile(review.sender.discordID);
Expand Down Expand Up @@ -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);
}
}
Comment on lines +115 to +140
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Implement the isVoting check to prevent race conditions from multiple clicks. Additionally, use a functional update for setScore to ensure the score is updated correctly based on the most recent state, and wrap the API call in a try...finally block to ensure the loading state is reset even if the request fails.

    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(s => s + delta);
            }
        } finally {
            setIsVoting(false);
        }
    }


return (
<div className={classes(cl("review"), MessageClasses.cozyMessage, AvatarClasses.wrapper, MessageClasses.message, MessageClasses.groupStart, AvatarClasses.cozy)} style={
{
Expand Down Expand Up @@ -156,6 +191,11 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
{dateFormat.format(review.timestamp * 1000)}
</Timestamp>)
}
{review.id !== 0 && (
<span className={cl("vote-score")}>
{score}
</span>
)}

<div className={cl("review-comment")}>
{(review.comment.length > 200 && !showAll)
Expand All @@ -177,6 +217,8 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
{canReportReview(review) && <ReportButton onClick={reportRev} />}
{canBlockReviewAuthor(profileId, review) && <BlockButton isBlocked={isAuthorBlocked} onClick={blockReviewSender} />}
{canDeleteReview(profileId, review) && <DeleteButton onClick={delReview} />}
<VoteButton isUpvote isSelected={localVote === true} disabled={isVoting} onClick={() => submitVote(true)} />
<VoteButton isUpvote={false} isSelected={localVote === false} disabled={isVoting} onClick={() => submitVote(false)} />
</div>
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/reviewDB/components/ReviewsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/reviewDB/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
61 changes: 60 additions & 1 deletion src/plugins/reviewDB/reviewDbApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand All @@ -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<UserReviewsData> {
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;

Expand Down Expand Up @@ -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<ReviewVote[]> {
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<UserReviewsData | null> {

const token = await getToken();
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion src/plugins/reviewDB/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,20 @@
.vc-rdb-profile-popout-disabled {
opacity: 0.4;
cursor: not-allowed;
}
}

.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);
}
Loading