Bring back ReviewDB (#2097)

Changes from old version:
- You can now delete reviews on your own profile
- You can now block up to 50 users. This will prevent them from leaving reviews on your profile

Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
Manti 2024-01-15 00:46:24 +03:00 committed by GitHub
parent 2ab1c50c73
commit 8bd54173db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1516 additions and 0 deletions

View file

@ -0,0 +1,78 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { DataStore } from "@api/index";
import { Logger } from "@utils/Logger";
import { openModal } from "@utils/modal";
import { findByPropsLazy } from "@webpack";
import { showToast, Toasts, UserStore } from "@webpack/common";
import { ReviewDBAuth } from "./entities";
const DATA_STORE_KEY = "rdb-auth";
const OAuth = findByPropsLazy("OAuth2AuthorizeModal");
export let Auth: ReviewDBAuth = {};
export async function initAuth() {
Auth = await getAuth() ?? {};
}
export async function getAuth(): Promise<ReviewDBAuth | undefined> {
const auth = await DataStore.get(DATA_STORE_KEY);
return auth?.[UserStore.getCurrentUser()?.id];
}
export async function getToken() {
const auth = await getAuth();
return auth?.token;
}
export async function updateAuth(newAuth: ReviewDBAuth) {
return DataStore.update(DATA_STORE_KEY, auth => {
auth ??= {};
Auth = auth[UserStore.getCurrentUser().id] ??= {};
if (newAuth.token) Auth.token = newAuth.token;
if (newAuth.user) Auth.user = newAuth.user;
return auth;
});
}
export function authorize(callback?: any) {
openModal(props =>
<OAuth.OAuth2AuthorizeModal
{...props}
scopes={["identify"]}
responseType="code"
redirectUri="https://manti.vendicated.dev/api/reviewdb/auth"
permissions={0n}
clientId="915703782174752809"
cancelCompletesFlow={false}
callback={async (response: any) => {
try {
const url = new URL(response.location);
url.searchParams.append("clientMod", "vencord");
const res = await fetch(url, {
headers: new Headers({ Accept: "application/json" })
});
const { token, success } = await res.json();
if (success) {
updateAuth({ token });
showToast("Successfully logged in!");
callback?.();
} else if (res.status === 1) {
showToast("An Error occurred while logging in.", Toasts.Type.FAILURE);
}
} catch (e) {
new Logger("ReviewDB").error("Failed to authorize", e);
}
}}
/>
);
}

View file

@ -0,0 +1,99 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, openModal } from "@utils/modal";
import { useAwaiter } from "@utils/react";
import { Forms, Tooltip, useState } from "@webpack/common";
import { Auth } from "../auth";
import { ReviewDBUser } from "../entities";
import { fetchBlocks, unblockUser } from "../reviewDbApi";
import { cl } from "../utils";
function UnblockButton(props: { onClick?(): void; }) {
return (
<Tooltip text="Unblock user">
{tooltipProps => (
<div
{...tooltipProps}
role="button"
onClick={props.onClick}
className={cl("block-modal-unblock")}
>
<svg height="20" viewBox="0 -960 960 960" width="20" fill="var(--status-danger)">
<path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z" />
</svg>
</div>
)}
</Tooltip>
);
}
function BlockedUser({ user, isBusy, setIsBusy }: { user: ReviewDBUser; isBusy: boolean; setIsBusy(v: boolean): void; }) {
const [gone, setGone] = useState(false);
if (gone) return null;
return (
<div className={cl("block-modal-row")}>
<img src={user.profilePhoto} alt="" />
<Forms.FormText className={cl("block-modal-username")}>{user.username}</Forms.FormText>
<UnblockButton
onClick={isBusy ? undefined : async () => {
setIsBusy(true);
try {
await unblockUser(user.discordID);
setGone(true);
} finally {
setIsBusy(false);
}
}}
/>
</div>
);
}
function Modal() {
const [isBusy, setIsBusy] = useState(false);
const [blocks, error, pending] = useAwaiter(fetchBlocks, {
onError: e => new Logger("ReviewDB").error("Failed to fetch blocks", e),
fallbackValue: [],
});
if (pending)
return null;
if (error)
return <Forms.FormText>Failed to fetch blocks: ${String(error)}</Forms.FormText>;
if (!blocks.length)
return <Forms.FormText>No blocked users.</Forms.FormText>;
return (
<>
{blocks.map(b => (
<BlockedUser
key={b.discordID}
user={b}
isBusy={isBusy}
setIsBusy={setIsBusy}
/>
))}
</>
);
}
export function openBlockModal() {
openModal(modalProps => (
<ModalRoot {...modalProps}>
<ModalHeader className={cl("block-modal-header")}>
<Forms.FormTitle style={{ margin: 0 }}>Blocked Users</Forms.FormTitle>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent className={cl("block-modal")}>
{Auth.token ? <Modal /> : <Forms.FormText>You are not logged into ReviewDB!</Forms.FormText>}
</ModalContent>
</ModalRoot>
));
}

View file

@ -0,0 +1,85 @@
/*
* 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/>.
*/
import { DeleteIcon } from "@components/Icons";
import { classes } from "@utils/misc";
import { findByPropsLazy } from "@webpack";
import { Tooltip } from "@webpack/common";
const iconClasses = findByPropsLazy("button", "wrapper", "disabled", "separator");
export function DeleteButton({ onClick }: { onClick(): void; }) {
return (
<Tooltip text="Delete Review">
{props => (
<div
{...props}
className={classes(iconClasses.button, iconClasses.dangerous)}
onClick={onClick}
role="button"
>
<DeleteIcon width="20" height="20" />
</div>
)}
</Tooltip>
);
}
export function ReportButton({ onClick }: { onClick(): void; }) {
return (
<Tooltip text="Report Review">
{props => (
<div
{...props}
className={iconClasses.button}
onClick={onClick}
role="button"
>
<svg width="20" height="20" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M20,6.002H14V3.002C14,2.45 13.553,2.002 13,2.002H4C3.447,2.002 3,2.45 3,3.002V22.002H5V14.002H10.586L8.293,16.295C8.007,16.581 7.922,17.011 8.076,17.385C8.23,17.759 8.596,18.002 9,18.002H20C20.553,18.002 21,17.554 21,17.002V7.002C21,6.45 20.553,6.002 20,6.002Z"
/>
</svg>
</div>
)}
</Tooltip>
);
}
export function BlockButton({ onClick, isBlocked }: { onClick(): void; isBlocked: boolean; }) {
return (
<Tooltip text={`${isBlocked ? "Unblock" : "Block"} user`}>
{props => (
<div
{...props}
className={iconClasses.button}
onClick={onClick}
role="button"
>
<svg height="20" viewBox="0 -960 960 960" width="20" fill="currentColor">
{isBlocked
? <path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
: <path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q54 0 104-17.5t92-50.5L228-676q-33 42-50.5 92T160-480q0 134 93 227t227 93Zm252-124q33-42 50.5-92T800-480q0-134-93-227t-227-93q-54 0-104 17.5T284-732l448 448Z" />
}
</svg>
</div>
)}
</Tooltip>
);
}

View file

@ -0,0 +1,50 @@
/*
* 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/>.
*/
import { MaskedLink, React, Tooltip } from "@webpack/common";
import { HTMLAttributes } from "react";
import { Badge } from "../entities";
import { cl } from "../utils";
export default function ReviewBadge(badge: Badge & { onClick?(): void; }) {
const Wrapper = badge.redirectURL
? MaskedLink
: (props: HTMLAttributes<HTMLDivElement>) => (
<span {...props} role="button">{props.children}</span>
);
return (
<Tooltip
text={badge.name}>
{({ onMouseEnter, onMouseLeave }) => (
<Wrapper className={cl("blocked-badge")} href={badge.redirectURL!} onClick={badge.onClick}>
<img
className={cl("badge")}
width="22px"
height="22px"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
src={badge.icon}
alt={badge.description}
/>
</Wrapper>
)}
</Tooltip>
);
}

View file

@ -0,0 +1,188 @@
/*
* 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/>.
*/
import { openUserProfile } from "@utils/discord";
import { classes } from "@utils/misc";
import { LazyComponent } from "@utils/react";
import { filters, findBulk } from "@webpack";
import { Alerts, moment, Parser, showToast, Timestamp } from "@webpack/common";
import { Auth, getToken } from "../auth";
import { Review, ReviewType } from "../entities";
import { blockUser, deleteReview, reportReview, unblockUser } from "../reviewDbApi";
import { settings } from "../settings";
import { canBlockReviewAuthor, canDeleteReview, canReportReview, cl } from "../utils";
import { openBlockModal } from "./BlockedUserModal";
import { BlockButton, DeleteButton, ReportButton } from "./MessageButton";
import ReviewBadge from "./ReviewBadge";
export default LazyComponent(() => {
// this is terrible, blame ven
const p = filters.byProps;
const [
{ cozyMessage, buttons, message, buttonsInner, groupStart },
{ container, isHeader },
{ avatar, clickable, username, wrapper, cozy },
buttonClasses,
botTag
] = findBulk(
p("cozyMessage"),
p("container", "isHeader"),
p("avatar", "zalgo"),
p("button", "wrapper", "selected"),
p("botTag", "botTagRegular")
);
const dateFormat = new Intl.DateTimeFormat();
return function ReviewComponent({ review, refetch, profileId }: { review: Review; refetch(): void; profileId: string; }) {
function openModal() {
openUserProfile(review.sender.discordID);
}
function delReview() {
Alerts.show({
title: "Are you sure?",
body: "Do you really want to delete this review?",
confirmText: "Delete",
cancelText: "Nevermind",
onConfirm: async () => {
if (!(await getToken())) {
return showToast("You must be logged in to delete reviews.");
} else {
deleteReview(review.id).then(res => {
if (res.success) {
refetch();
}
showToast(res.message);
});
}
}
});
}
function reportRev() {
Alerts.show({
title: "Are you sure?",
body: "Do you really you want to report this review?",
confirmText: "Report",
cancelText: "Nevermind",
// confirmColor: "red", this just adds a class name and breaks the submit button guh
onConfirm: async () => {
if (!(await getToken())) {
return showToast("You must be logged in to report reviews.");
} else {
reportReview(review.id);
}
}
});
}
const isAuthorBlocked = Auth?.user?.blockedUsers?.includes(review.sender.discordID) ?? false;
function blockReviewSender() {
if (isAuthorBlocked)
return unblockUser(review.sender.discordID);
Alerts.show({
title: "Are you sure?",
body: "Do you really you want to block this user? They will be unable to leave further reviews on your profile. You can unblock users in the plugin settings.",
confirmText: "Block",
cancelText: "Nevermind",
// confirmColor: "red", this just adds a class name and breaks the submit button guh
onConfirm: async () => {
if (!(await getToken())) {
return showToast("You must be logged in to block users.");
} else {
blockUser(review.sender.discordID);
}
}
});
}
return (
<div className={classes(cozyMessage, wrapper, message, groupStart, cozy, cl("review"))} style={
{
marginLeft: "0px",
paddingLeft: "52px", // wth is this
paddingRight: "16px"
}
}>
<img
className={classes(avatar, clickable)}
onClick={openModal}
src={review.sender.profilePhoto || "/assets/1f0bfc0865d324c2587920a7d80c609b.png?size=128"}
style={{ left: "0px", zIndex: 0 }}
/>
<div style={{ display: "inline-flex", justifyContent: "center", alignItems: "center" }}>
<span
className={classes(clickable, username)}
style={{ color: "var(--channels-default)", fontSize: "14px" }}
onClick={() => openModal()}
>
{review.sender.username}
</span>
{review.type === ReviewType.System && (
<span
className={classes(botTag.botTagVerified, botTag.botTagRegular, botTag.botTag, botTag.px, botTag.rem)}
style={{ marginLeft: "4px" }}>
<span className={botTag.botText}>
System
</span>
</span>
)}
</div>
{isAuthorBlocked && (
<ReviewBadge
name="You have blocked this user"
description="You have blocked this user"
icon="/assets/aaee57e0090991557b66.svg"
type={0}
onClick={() => openBlockModal()}
/>
)}
{review.sender.badges.map(badge => <ReviewBadge {...badge} />)}
{
!settings.store.hideTimestamps && review.type !== ReviewType.System && (
<Timestamp timestamp={moment(review.timestamp * 1000)} >
{dateFormat.format(review.timestamp * 1000)}
</Timestamp>)
}
<div className={cl("review-comment")}>
{Parser.parseGuildEventDescription(review.comment)}
</div>
{review.id !== 0 && (
<div className={classes(container, isHeader, buttons)} style={{
padding: "0px",
}}>
<div className={classes(buttonClasses.wrapper, buttonsInner)} >
{canReportReview(review) && <ReportButton onClick={reportRev} />}
{canBlockReviewAuthor(profileId, review) && <BlockButton isBlocked={isAuthorBlocked} onClick={blockReviewSender} />}
{canDeleteReview(profileId, review) && <DeleteButton onClick={delReview} />}
</div>
</div>
)}
</div>
);
};
});

View file

@ -0,0 +1,105 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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/>.
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { useForceUpdater } from "@utils/react";
import { Paginator, Text, useRef, useState } from "@webpack/common";
import { Auth } from "../auth";
import { Response, REVIEWS_PER_PAGE } from "../reviewDbApi";
import { cl } from "../utils";
import ReviewComponent from "./ReviewComponent";
import ReviewsView, { ReviewsInputComponent } from "./ReviewsView";
function Modal({ modalProps, discordId, name }: { modalProps: any; discordId: string; name: string; }) {
const [data, setData] = useState<Response>();
const [signal, refetch] = useForceUpdater(true);
const [page, setPage] = useState(1);
const ref = useRef<HTMLDivElement>(null);
const reviewCount = data?.reviewCount;
const ownReview = data?.reviews.find(r => r.sender.discordID === Auth.user?.discordID);
return (
<ErrorBoundary>
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<Text variant="heading-lg/semibold" className={cl("modal-header")}>
{name}'s Reviews
{!!reviewCount && <span> ({reviewCount} Reviews)</span>}
</Text>
<ModalCloseButton onClick={modalProps.onClose} />
</ModalHeader>
<ModalContent scrollerRef={ref}>
<div className={cl("modal-reviews")}>
<ReviewsView
discordId={discordId}
name={name}
page={page}
refetchSignal={signal}
onFetchReviews={setData}
scrollToTop={() => ref.current?.scrollTo({ top: 0, behavior: "smooth" })}
hideOwnReview
/>
</div>
</ModalContent>
<ModalFooter className={cl("modal-footer")}>
<div>
{ownReview && (
<ReviewComponent
refetch={refetch}
review={ownReview}
profileId={discordId}
/>
)}
<ReviewsInputComponent
isAuthor={ownReview != null}
discordId={discordId}
name={name}
refetch={refetch}
/>
{!!reviewCount && (
<Paginator
currentPage={page}
maxVisiblePages={5}
pageSize={REVIEWS_PER_PAGE}
totalCount={reviewCount}
onPageChange={setPage}
/>
)}
</div>
</ModalFooter>
</ModalRoot>
</ErrorBoundary>
);
}
export function openReviewsModal(discordId: string, name: string) {
openModal(props => (
<Modal
modalProps={props}
discordId={discordId}
name={name}
/>
));
}

View file

@ -0,0 +1,200 @@
/*
* 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/>.
*/
import { LazyComponent, useAwaiter, useForceUpdater } from "@utils/react";
import { find, findByPropsLazy } from "@webpack";
import { Forms, React, RelationshipStore, showToast, useRef, UserStore } from "@webpack/common";
import { Auth, authorize } from "../auth";
import { Review } from "../entities";
import { addReview, getReviews, Response, REVIEWS_PER_PAGE } from "../reviewDbApi";
import { settings } from "../settings";
import { cl } from "../utils";
import ReviewComponent from "./ReviewComponent";
const Slate = findByPropsLazy("Editor", "Transforms");
const InputTypes = findByPropsLazy("ChatInputTypes");
const InputComponent = LazyComponent(() => find(m => m.default?.type?.render?.toString().includes("default.CHANNEL_TEXT_AREA")).default);
interface UserProps {
discordId: string;
name: string;
}
interface Props extends UserProps {
onFetchReviews(data: Response): void;
refetchSignal?: unknown;
showInput?: boolean;
page?: number;
scrollToTop?(): void;
hideOwnReview?: boolean;
}
export default function ReviewsView({
discordId,
name,
onFetchReviews,
refetchSignal,
scrollToTop,
page = 1,
showInput = false,
hideOwnReview = false,
}: Props) {
const [signal, refetch] = useForceUpdater(true);
const [reviewData] = useAwaiter(() => getReviews(discordId, (page - 1) * REVIEWS_PER_PAGE), {
fallbackValue: null,
deps: [refetchSignal, signal, page],
onSuccess: data => {
if (settings.store.hideBlockedUsers)
data!.reviews = data!.reviews?.filter(r => !RelationshipStore.isBlocked(r.sender.discordID));
scrollToTop?.();
onFetchReviews(data!);
}
});
if (!reviewData) return null;
return (
<>
<ReviewList
refetch={refetch}
reviews={reviewData!.reviews}
hideOwnReview={hideOwnReview}
profileId={discordId}
/>
{showInput && (
<ReviewsInputComponent
name={name}
discordId={discordId}
refetch={refetch}
isAuthor={reviewData!.reviews?.some(r => r.sender.discordID === UserStore.getCurrentUser().id)}
/>
)}
</>
);
}
function ReviewList({ refetch, reviews, hideOwnReview, profileId }: { refetch(): void; reviews: Review[]; hideOwnReview: boolean; profileId: string; }) {
const myId = UserStore.getCurrentUser().id;
return (
<div className={cl("view")}>
{reviews?.map(review =>
(review.sender.discordID !== myId || !hideOwnReview) &&
<ReviewComponent
key={review.id}
review={review}
refetch={refetch}
profileId={profileId}
/>
)}
{reviews?.length === 0 && (
<Forms.FormText className={cl("placeholder")}>
Looks like nobody reviewed this user yet. You could be the first!
</Forms.FormText>
)}
</div>
);
}
export function ReviewsInputComponent({ discordId, isAuthor, refetch, name }: { discordId: string, name: string; isAuthor: boolean; refetch(): void; }) {
const { token } = Auth;
const editorRef = useRef<any>(null);
const inputType = InputTypes.ChatInputTypes.FORM;
inputType.disableAutoFocus = true;
const channel = {
flags_: 256,
guild_id_: null,
id: "0",
getGuildId: () => null,
isPrivate: () => true,
isActiveThread: () => false,
isArchivedLockedThread: () => false,
isDM: () => true,
roles: { "0": { permissions: 0n } },
getRecipientId: () => "0",
hasFlag: () => false,
};
return (
<>
<div onClick={() => {
if (!token) {
showToast("Opening authorization window...");
authorize();
}
}}>
<InputComponent
className={cl("input")}
channel={channel}
placeholder={
!token
? "You need to authorize to review users!"
: isAuthor
? `Update review for @${name}`
: `Review @${name}`
}
type={inputType}
disableThemedBackground={true}
setEditorRef={ref => editorRef.current = ref}
textValue=""
onSubmit={
async res => {
const response = await addReview({
userid: discordId,
comment: res.value,
});
if (response?.success) {
refetch();
const slateEditor = editorRef.current.ref.current.getSlateEditor();
const { Editor, Transform } = Slate;
// clear editor
Transform.delete(slateEditor, {
at: {
anchor: Editor.start(slateEditor, []),
focus: Editor.end(slateEditor, []),
}
});
} else if (response?.message) {
showToast(response.message);
}
// even tho we need to return this, it doesnt do anything
return {
shouldClear: false,
shouldRefocus: true,
};
}
}
/>
</div>
</>
);
}

View file

@ -0,0 +1,100 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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/>.
*/
export const enum UserType {
Banned = -1,
Normal = 0,
Admin = 1
}
export const enum ReviewType {
User = 0,
Server = 1,
Support = 2,
System = 3
}
export const enum NotificationType {
Info = 0,
Ban = 1,
Unban = 2,
Warning = 3
}
export interface ReviewDBAuth {
token?: string;
user?: ReviewDBCurrentUser;
}
export interface Badge {
name: string;
description: string;
icon: string;
redirectURL?: string;
type: number;
}
export interface BanInfo {
id: string;
discordID: string;
reviewID: number;
reviewContent: string;
banEndDate: number;
}
export interface Notification {
id: number;
title: string;
content: string;
type: NotificationType;
}
export interface ReviewDBUser {
ID: number;
discordID: string;
username: string;
type: UserType;
profilePhoto: string;
badges: any[];
}
export interface ReviewDBCurrentUser extends ReviewDBUser {
warningCount: number;
clientMod: string;
banInfo: BanInfo | null;
notification: Notification | null;
lastReviewID: number;
blockedUsers?: string[];
}
export interface ReviewAuthor {
id: number,
discordID: string,
username: string,
profilePhoto: string,
badges: Badge[];
}
export interface Review {
comment: string,
id: number,
star: number,
sender: ReviewAuthor,
timestamp: number;
type?: ReviewType;
}

View file

@ -0,0 +1,157 @@
/*
* 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/>.
*/
import "./style.css";
import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu";
import ErrorBoundary from "@components/ErrorBoundary";
import ExpandableHeader from "@components/ExpandableHeader";
import { OpenExternalIcon } from "@components/Icons";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin from "@utils/types";
import { Alerts, Menu, Parser, showToast, useState } from "@webpack/common";
import { Guild, User } from "discord-types/general";
import { Auth, initAuth, updateAuth } from "./auth";
import { openReviewsModal } from "./components/ReviewModal";
import ReviewsView from "./components/ReviewsView";
import { NotificationType } from "./entities";
import { getCurrentUserInfo, readNotification } from "./reviewDbApi";
import { settings } from "./settings";
const guildPopoutPatch: NavContextMenuPatchCallback = (children, props: { guild: Guild, onClose(): void; }) => () => {
children.push(
<Menu.MenuItem
label="View Reviews"
id="vc-rdb-server-reviews"
icon={OpenExternalIcon}
action={() => openReviewsModal(props.guild.id, props.guild.name)}
/>
);
};
export default definePlugin({
name: "ReviewDB",
description: "Review other users (Adds a new settings to profiles)",
authors: [Devs.mantikafasi, Devs.Ven],
settings,
patches: [
{
find: "showBorder:null",
replacement: {
match: /user:(\i),setNote:\i,canDM.+?\}\)/,
replace: "$&,$self.getReviewsComponent($1)"
}
}
],
flux: {
CONNECTION_OPEN: initAuth,
},
async start() {
addContextMenuPatch("guild-header-popout", guildPopoutPatch);
const s = settings.store;
const { lastReviewId, notifyReviews } = s;
const legacy = s as any as { token?: string; };
if (legacy.token) {
await updateAuth({ token: legacy.token });
legacy.token = undefined;
new Logger("ReviewDB").info("Migrated legacy settings");
}
await initAuth();
setTimeout(async () => {
if (!Auth.token) return;
const user = await getCurrentUserInfo(Auth.token);
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 (user.notification) {
const props = user.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"
})
)
} : {};
Alerts.show({
title: user.notification.title,
body: (
Parser.parse(
user.notification.content,
false
)
),
...props
});
readNotification(user.notification.id);
}
}, 4000);
},
stop() {
removeContextMenuPatch("guild-header-popout", guildPopoutPatch);
},
getReviewsComponent: ErrorBoundary.wrap((user: User) => {
const [reviewCount, setReviewCount] = useState<number>();
return (
<ExpandableHeader
headerText="User Reviews"
onMoreClick={() => openReviewsModal(user.id, user.username)}
moreTooltipText={
reviewCount && reviewCount > 50
? `View all ${reviewCount} reviews`
: "Open Review Modal"
}
onDropDownClick={state => settings.store.reviewsDropdownState = !state}
defaultState={settings.store.reviewsDropdownState}
>
<ReviewsView
discordId={user.id}
name={user.username}
onFetchReviews={r => setReviewCount(r.reviewCount)}
showInput
/>
</ExpandableHeader>
);
}, { message: "Failed to render Reviews" })
});

View file

@ -0,0 +1,197 @@
/*
* 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/>.
*/
import { showToast, Toasts } from "@webpack/common";
import { Auth, authorize, getToken, updateAuth } from "./auth";
import { Review, ReviewDBCurrentUser, ReviewDBUser } from "./entities";
import { settings } from "./settings";
const API_URL = "https://manti.vendicated.dev";
export const REVIEWS_PER_PAGE = 50;
export interface Response {
success: boolean,
message: string;
reviews: Review[];
updated: boolean;
hasNextPage: boolean;
reviewCount: number;
}
const WarningFlag = 0b00000010;
export async function getReviews(id: string, offset = 0): Promise<Response> {
let flags = 0;
if (!settings.store.showWarning) flags |= WarningFlag;
const params = new URLSearchParams({
flags: String(flags),
offset: String(offset)
});
const req = await fetch(`${API_URL}/api/reviewdb/users/${id}/reviews?${params}`);
const res = (req.status === 200)
? await req.json() as Response
: {
success: false,
message: "An Error occured while fetching reviews. Please try again later.",
reviews: [],
updated: false,
hasNextPage: false,
reviewCount: 0
};
if (!res.success) {
showToast(res.message, Toasts.Type.FAILURE);
return {
...res,
reviews: [
{
id: 0,
comment: "An Error occured while fetching reviews. Please try again later.",
star: 0,
timestamp: 0,
sender: {
id: 0,
username: "Error",
profilePhoto: "https://cdn.discordapp.com/attachments/1045394533384462377/1084900598035513447/646808599204593683.png?size=128",
discordID: "0",
badges: []
}
}
]
};
}
return res;
}
export async function addReview(review: any): Promise<Response | null> {
review.token = await getToken();
if (!review.token) {
showToast("Please authorize to add a review.");
authorize();
return null;
}
return fetch(API_URL + `/api/reviewdb/users/${review.userid}/reviews`, {
method: "PUT",
body: JSON.stringify(review),
headers: {
"Content-Type": "application/json",
}
})
.then(r => r.json())
.then(res => {
showToast(res.message);
return res ?? null;
});
}
export async function deleteReview(id: number): Promise<Response> {
return fetch(API_URL + `/api/reviewdb/users/${id}/reviews`, {
method: "DELETE",
headers: new Headers({
"Content-Type": "application/json",
Accept: "application/json",
}),
body: JSON.stringify({
token: await getToken(),
reviewid: id
})
}).then(r => r.json());
}
export async function reportReview(id: number) {
const res = await fetch(API_URL + "/api/reviewdb/reports", {
method: "PUT",
headers: new Headers({
"Content-Type": "application/json",
Accept: "application/json",
}),
body: JSON.stringify({
reviewid: id,
token: await getToken()
})
}).then(r => r.json()) as Response;
showToast(res.message);
}
async function patchBlock(action: "block" | "unblock", userId: string) {
const res = await fetch(API_URL + "/api/reviewdb/blocks", {
method: "PATCH",
headers: new Headers({
"Content-Type": "application/json",
Accept: "application/json",
Authorization: await getToken() || ""
}),
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 (Auth?.user?.blockedUsers) {
const newBlockedUsers = action === "block"
? [...Auth.user.blockedUsers, userId]
: Auth.user.blockedUsers.filter(id => id !== userId);
updateAuth({ user: { ...Auth.user, blockedUsers: newBlockedUsers } });
}
}
}
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 fetch(API_URL + "/api/reviewdb/blocks", {
method: "GET",
headers: new Headers({
Accept: "application/json",
Authorization: await getToken() || ""
})
});
if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`);
return res.json();
}
export function getCurrentUserInfo(token: string): Promise<ReviewDBCurrentUser> {
return fetch(API_URL + "/api/reviewdb/users", {
body: JSON.stringify({ token }),
method: "POST",
}).then(r => r.json());
}
export async function readNotification(id: number) {
return fetch(API_URL + `/api/reviewdb/notifications?id=${id}`, {
method: "PATCH",
headers: {
"Authorization": await getToken() || "",
},
});
}

View file

@ -0,0 +1,93 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 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/>.
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
import { Button } from "@webpack/common";
import { authorize, getToken } from "./auth";
import { openBlockModal } from "./components/BlockedUserModal";
export const settings = definePluginSettings({
authorize: {
type: OptionType.COMPONENT,
description: "Authorize with ReviewDB",
component: () => (
<Button onClick={authorize}>
Authorize with ReviewDB
</Button>
)
},
notifyReviews: {
type: OptionType.BOOLEAN,
description: "Notify about new reviews on startup",
default: true,
},
showWarning: {
type: OptionType.BOOLEAN,
description: "Display warning to be respectful at the top of the reviews list",
default: true,
},
hideTimestamps: {
type: OptionType.BOOLEAN,
description: "Hide timestamps on reviews",
default: false,
},
hideBlockedUsers: {
type: OptionType.BOOLEAN,
description: "Hide reviews from blocked users",
default: true,
},
manageBlocks: {
type: OptionType.COMPONENT,
description: "Manage Blocked Users",
component: () => (
<Button onClick={openBlockModal}>Manage Blocked Users</Button>
)
},
website: {
type: OptionType.COMPONENT,
description: "ReviewDB website",
component: () => (
<Button onClick={async () => {
let url = "https://reviewdb.mantikafasi.dev/";
const token = await getToken();
if (token)
url += "/api/redirect?token=" + encodeURIComponent(token);
VencordNative.native.openExternal(url);
}}>
ReviewDB website
</Button>
)
},
supportServer: {
type: OptionType.COMPONENT,
description: "ReviewDB Support Server",
component: () => (
<Button onClick={() => {
VencordNative.native.openExternal("https://discord.gg/eWPBSbvznt");
}}>
ReviewDB Support Server
</Button>
)
}
}).withPrivateSettings<{
lastReviewId?: number;
reviewsDropdownState?: boolean;
}>();

View file

@ -0,0 +1,121 @@
[class|="section"]:not([class|="lastSection"]) + .vc-rdb-view {
margin-top: 12px;
}
.vc-rdb-badge {
vertical-align: middle;
margin-left: 4px;
}
.vc-rdb-input {
margin-top: 6px;
margin-bottom: 12px;
resize: none;
overflow: hidden;
background: transparent;
border: 1px solid var(--profile-message-input-border-color);
}
.vc-rdb-modal-footer > div {
width: 100%;
margin: 6px 16px;
}
/* When input becomes disabled(while sending review), input adds unneccesary padding to left, this prevents it */
.vc-rdb-input > div > div {
padding-left: 0 !important;
}
.vc-rdb-placeholder {
margin-bottom: 4px;
font-weight: bold;
font-style: italic;
color: var(--text-muted);
}
.vc-rdb-input * {
font-size: 14px;
}
.vc-rdb-modal-footer {
padding: 0;
}
.vc-rdb-modal-footer .vc-rdb-input {
margin-bottom: 0;
background: var(--input-background);
}
.vc-rdb-modal-footer [class|="pageControlContainer"] {
margin-top: 0;
}
.vc-rdb-modal-header {
flex-grow: 1;
}
.vc-rdb-modal-reviews {
margin-top: 16px;
}
.vc-rdb-review {
margin-top: 8px;
margin-bottom: 8px;
}
.vc-rdb-review-comment img {
vertical-align: text-top;
}
.vc-rdb-review-comment {
overflow-y: hidden;
margin-top: 1px;
margin-bottom: 8px;
color: var(--text-normal);
font-size: 15px;
}
.vc-rdb-blocked-badge {
cursor: pointer;
}
.vc-rdb-block-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.vc-rdb-block-modal {
padding: 1em;
display: grid;
gap: 0.75em;
}
.vc-rdb-block-modal-row {
display: flex;
height: 2em;
gap: 0.5em;
align-items: center;
}
.vc-rdb-block-modal-row img {
border-radius: 50%;
height: 2em;
width: 2em;
}
.vc-rdb-block-modal img::before {
content: "";
display: block;
width: 100%;
height: 100%;
background-color: var(--background-modifier-accent);
}
.vc-rdb-block-modal-username {
flex-grow: 1;
}
.vc-rdb-block-modal-unblock {
cursor: pointer;
}

View file

@ -0,0 +1,43 @@
/*
* 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/>.
*/
import { classNameFactory } from "@api/Styles";
import { UserStore } from "@webpack/common";
import { Auth } from "./auth";
import { Review, UserType } from "./entities";
export const cl = classNameFactory("vc-rdb-");
export function canDeleteReview(profileId: string, review: Review) {
const myId = UserStore.getCurrentUser().id;
return (
myId === profileId
|| review.sender.discordID === myId
|| Auth.user?.type === UserType.Admin
);
}
export function canBlockReviewAuthor(profileId: string, review: Review) {
const myId = UserStore.getCurrentUser().id;
return profileId === myId && review.sender.discordID !== myId;
}
export function canReportReview(review: Review) {
return review.sender.discordID !== UserStore.getCurrentUser().id;
}