From 8bd54173dbb1af446e1d710dd65dd3d67f68fc0e Mon Sep 17 00:00:00 2001 From: Manti <67705577+mantikafasi@users.noreply.github.com> Date: Mon, 15 Jan 2024 00:46:24 +0300 Subject: [PATCH] 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 --- src/plugins/reviewDB/auth.tsx | 78 +++++++ .../reviewDB/components/BlockedUserModal.tsx | 99 +++++++++ .../reviewDB/components/MessageButton.tsx | 85 ++++++++ .../reviewDB/components/ReviewBadge.tsx | 50 +++++ .../reviewDB/components/ReviewComponent.tsx | 188 ++++++++++++++++ .../reviewDB/components/ReviewModal.tsx | 105 +++++++++ .../reviewDB/components/ReviewsView.tsx | 200 ++++++++++++++++++ src/plugins/reviewDB/entities.ts | 100 +++++++++ src/plugins/reviewDB/index.tsx | 157 ++++++++++++++ src/plugins/reviewDB/reviewDbApi.ts | 197 +++++++++++++++++ src/plugins/reviewDB/settings.tsx | 93 ++++++++ src/plugins/reviewDB/style.css | 121 +++++++++++ src/plugins/reviewDB/utils.tsx | 43 ++++ 13 files changed, 1516 insertions(+) create mode 100644 src/plugins/reviewDB/auth.tsx create mode 100644 src/plugins/reviewDB/components/BlockedUserModal.tsx create mode 100644 src/plugins/reviewDB/components/MessageButton.tsx create mode 100644 src/plugins/reviewDB/components/ReviewBadge.tsx create mode 100644 src/plugins/reviewDB/components/ReviewComponent.tsx create mode 100644 src/plugins/reviewDB/components/ReviewModal.tsx create mode 100644 src/plugins/reviewDB/components/ReviewsView.tsx create mode 100644 src/plugins/reviewDB/entities.ts create mode 100644 src/plugins/reviewDB/index.tsx create mode 100644 src/plugins/reviewDB/reviewDbApi.ts create mode 100644 src/plugins/reviewDB/settings.tsx create mode 100644 src/plugins/reviewDB/style.css create mode 100644 src/plugins/reviewDB/utils.tsx diff --git a/src/plugins/reviewDB/auth.tsx b/src/plugins/reviewDB/auth.tsx new file mode 100644 index 00000000..1d95e47d --- /dev/null +++ b/src/plugins/reviewDB/auth.tsx @@ -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 { + 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 => + { + 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); + } + }} + /> + ); +} diff --git a/src/plugins/reviewDB/components/BlockedUserModal.tsx b/src/plugins/reviewDB/components/BlockedUserModal.tsx new file mode 100644 index 00000000..43c81eb5 --- /dev/null +++ b/src/plugins/reviewDB/components/BlockedUserModal.tsx @@ -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 ( + + {tooltipProps => ( +
+ + + +
+ )} +
+ ); +} + +function BlockedUser({ user, isBusy, setIsBusy }: { user: ReviewDBUser; isBusy: boolean; setIsBusy(v: boolean): void; }) { + const [gone, setGone] = useState(false); + if (gone) return null; + + return ( +
+ + {user.username} + { + setIsBusy(true); + try { + await unblockUser(user.discordID); + setGone(true); + } finally { + setIsBusy(false); + } + }} + /> +
+ ); +} + +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 Failed to fetch blocks: ${String(error)}; + if (!blocks.length) + return No blocked users.; + + return ( + <> + {blocks.map(b => ( + + ))} + + ); +} + +export function openBlockModal() { + openModal(modalProps => ( + + + Blocked Users + + + + {Auth.token ? : You are not logged into ReviewDB!} + + + )); +} diff --git a/src/plugins/reviewDB/components/MessageButton.tsx b/src/plugins/reviewDB/components/MessageButton.tsx new file mode 100644 index 00000000..9b0b4be1 --- /dev/null +++ b/src/plugins/reviewDB/components/MessageButton.tsx @@ -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 . +*/ + +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 ( + + {props => ( +
+ +
+ )} +
+ ); +} + +export function ReportButton({ onClick }: { onClick(): void; }) { + return ( + + {props => ( +
+ + + +
+ )} +
+ ); +} + +export function BlockButton({ onClick, isBlocked }: { onClick(): void; isBlocked: boolean; }) { + return ( + + {props => ( +
+ + {isBlocked + ? + : + } + +
+ )} +
+ ); +} diff --git a/src/plugins/reviewDB/components/ReviewBadge.tsx b/src/plugins/reviewDB/components/ReviewBadge.tsx new file mode 100644 index 00000000..665b9bb0 --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewBadge.tsx @@ -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 . +*/ + +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) => ( + {props.children} + ); + + return ( + + {({ onMouseEnter, onMouseLeave }) => ( + + {badge.description} + + )} + + ); +} diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx new file mode 100644 index 00000000..5f3d135b --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -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 . +*/ + +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 ( +
+ + +
+ openModal()} + > + {review.sender.username} + + + {review.type === ReviewType.System && ( + + + System + + + )} +
+ {isAuthorBlocked && ( + openBlockModal()} + /> + )} + {review.sender.badges.map(badge => )} + + { + !settings.store.hideTimestamps && review.type !== ReviewType.System && ( + + {dateFormat.format(review.timestamp * 1000)} + ) + } + +
+ {Parser.parseGuildEventDescription(review.comment)} +
+ + {review.id !== 0 && ( +
+
+ {canReportReview(review) && } + {canBlockReviewAuthor(profileId, review) && } + {canDeleteReview(profileId, review) && } +
+
+ )} +
+ ); + }; +}); diff --git a/src/plugins/reviewDB/components/ReviewModal.tsx b/src/plugins/reviewDB/components/ReviewModal.tsx new file mode 100644 index 00000000..9669a2b3 --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewModal.tsx @@ -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 . +*/ + +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(); + const [signal, refetch] = useForceUpdater(true); + const [page, setPage] = useState(1); + + const ref = useRef(null); + + const reviewCount = data?.reviewCount; + const ownReview = data?.reviews.find(r => r.sender.discordID === Auth.user?.discordID); + + return ( + + + + + {name}'s Reviews + {!!reviewCount && ({reviewCount} Reviews)} + + + + + +
+ ref.current?.scrollTo({ top: 0, behavior: "smooth" })} + hideOwnReview + /> +
+
+ + +
+ {ownReview && ( + + )} + + + {!!reviewCount && ( + + )} +
+
+
+
+ ); +} + +export function openReviewsModal(discordId: string, name: string) { + openModal(props => ( + + )); +} diff --git a/src/plugins/reviewDB/components/ReviewsView.tsx b/src/plugins/reviewDB/components/ReviewsView.tsx new file mode 100644 index 00000000..abb856b9 --- /dev/null +++ b/src/plugins/reviewDB/components/ReviewsView.tsx @@ -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 . +*/ + +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 ( + <> + + + {showInput && ( + 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 ( +
+ {reviews?.map(review => + (review.sender.discordID !== myId || !hideOwnReview) && + + )} + + {reviews?.length === 0 && ( + + Looks like nobody reviewed this user yet. You could be the first! + + )} +
+ ); +} + + +export function ReviewsInputComponent({ discordId, isAuthor, refetch, name }: { discordId: string, name: string; isAuthor: boolean; refetch(): void; }) { + const { token } = Auth; + const editorRef = useRef(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 ( + <> +
{ + if (!token) { + showToast("Opening authorization window..."); + authorize(); + } + }}> + 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, + }; + } + } + /> +
+ + + ); +} diff --git a/src/plugins/reviewDB/entities.ts b/src/plugins/reviewDB/entities.ts new file mode 100644 index 00000000..a871d90f --- /dev/null +++ b/src/plugins/reviewDB/entities.ts @@ -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 . +*/ + +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; +} diff --git a/src/plugins/reviewDB/index.tsx b/src/plugins/reviewDB/index.tsx new file mode 100644 index 00000000..d8357faf --- /dev/null +++ b/src/plugins/reviewDB/index.tsx @@ -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 . +*/ + +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( + 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(); + + return ( + 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} + > + setReviewCount(r.reviewCount)} + showInput + /> + + ); + }, { message: "Failed to render Reviews" }) +}); diff --git a/src/plugins/reviewDB/reviewDbApi.ts b/src/plugins/reviewDB/reviewDbApi.ts new file mode 100644 index 00000000..a87fbcb8 --- /dev/null +++ b/src/plugins/reviewDB/reviewDbApi.ts @@ -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 . +*/ + +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 { + 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 { + 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 { + 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 { + 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 { + 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() || "", + }, + }); +} diff --git a/src/plugins/reviewDB/settings.tsx b/src/plugins/reviewDB/settings.tsx new file mode 100644 index 00000000..efcb8058 --- /dev/null +++ b/src/plugins/reviewDB/settings.tsx @@ -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 . +*/ + +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: () => ( + + ) + }, + 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: () => ( + + ) + }, + website: { + type: OptionType.COMPONENT, + description: "ReviewDB website", + component: () => ( + + ) + }, + supportServer: { + type: OptionType.COMPONENT, + description: "ReviewDB Support Server", + component: () => ( + + ) + } +}).withPrivateSettings<{ + lastReviewId?: number; + reviewsDropdownState?: boolean; +}>(); diff --git a/src/plugins/reviewDB/style.css b/src/plugins/reviewDB/style.css new file mode 100644 index 00000000..a812ecaf --- /dev/null +++ b/src/plugins/reviewDB/style.css @@ -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; +} diff --git a/src/plugins/reviewDB/utils.tsx b/src/plugins/reviewDB/utils.tsx new file mode 100644 index 00000000..eeaca120 --- /dev/null +++ b/src/plugins/reviewDB/utils.tsx @@ -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 . +*/ + +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; +}