diff --git a/src/plugins/showTimeoutDuration/README.md b/src/plugins/showTimeoutDetails/README.md similarity index 100% rename from src/plugins/showTimeoutDuration/README.md rename to src/plugins/showTimeoutDetails/README.md diff --git a/src/plugins/showTimeoutDetails/TimeoutReasonStore.ts b/src/plugins/showTimeoutDetails/TimeoutReasonStore.ts new file mode 100644 index 000000000..283b976ef --- /dev/null +++ b/src/plugins/showTimeoutDetails/TimeoutReasonStore.ts @@ -0,0 +1,110 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { proxyLazy } from "@utils/lazy"; +import { findByPropsLazy } from "@webpack"; +import { Constants, Flux, FluxDispatcher, GuildMemberStore, PermissionsBits, PermissionStore, RestAPI, useStateFromStores } from "@webpack/common"; + + +const AuditLogReasons: { + MEMBER_UPDATE: number; + AUTO_MODERATION_USER_COMMUNICATION_DISABLED: number; + [reason: string]: number; +} = findByPropsLazy("ALL", "AUTO_MODERATION_USER_COMMUNICATION_DISABLED", "MEMBER_UPDATE"); + +export type TimeoutEntry = { + reason: string | undefined; + moderator: string | undefined; // User ID of moderator, undefined if automod did the timeout + automod: boolean | undefined; + automodRuleName: string | undefined; + automodChannelId: string | undefined; + expires: string | undefined; // used to compare if timeout reason is different + loading: boolean; +}; + +export const NoTimeout: TimeoutEntry = { + reason: undefined, + moderator: undefined, + automod: undefined, + automodRuleName: undefined, + automodChannelId: undefined, + expires: undefined, + loading: false +}; + +export const TimeoutLoading: TimeoutEntry = { + reason: undefined, + moderator: undefined, + automod: undefined, + automodRuleName: undefined, + automodChannelId: undefined, + expires: undefined, + loading: true +}; + +export const TimeoutReasonStore = proxyLazy(() => { + class TimeoutReasonStore extends Flux.Store { + public reasonMap = new Map(); + + getReason(guildId: string, userId: string) { + const member = GuildMemberStore.getMember(guildId, userId); + + if (!member?.communicationDisabledUntil) return NoTimeout; + if (new Date(member?.communicationDisabledUntil!) <= new Date()) return NoTimeout; + + const reason = this.reasonMap.get(`${guildId}-${userId}`); + // Return if timeout reason entry is found and is up to date, or if it's still loading + if (reason && (reason.loading ? true : reason.expires === member?.communicationDisabledUntil)) return reason; + + // The indicator being visible does not depend on any data here. This just returns that there's no extra information about the timeout. + if (!PermissionStore.canWithPartialContext(PermissionsBits.VIEW_AUDIT_LOG, { guildId })) return NoTimeout; + + // Stop requesting data multiple times + this.reasonMap.set(`${guildId}-${userId}`, TimeoutLoading); + + RestAPI.get({ + url: Constants.Endpoints.GUILD_AUDIT_LOG(guildId), + query: { + // action_type is intentionally not specified here as we need multiple types of audit log actions. + target_id: userId, + limit: 100 + } + }).then(logs => { + const entry = logs.body.audit_log_entries.find((entry: { action_type: number; changes: any[]; }) => { + if (entry.action_type === AuditLogReasons.AUTO_MODERATION_USER_COMMUNICATION_DISABLED) return true; + if (entry.action_type === AuditLogReasons.MEMBER_UPDATE && entry?.changes.some((change: { key: string; }) => change.key === "communication_disabled_until")) return true; + }); + + if (!entry) return this.reasonMap.set(`${guildId}-${userId}`, NoTimeout); + + const isAutomod = entry.action_type === AuditLogReasons.AUTO_MODERATION_USER_COMMUNICATION_DISABLED; + + this.reasonMap.set(`${guildId}-${userId}`, { + reason: entry.reason, + moderator: isAutomod ? undefined : entry.user_id, + automod: isAutomod, + automodRuleName: isAutomod ? entry?.options?.auto_moderation_rule_name : undefined, + automodChannelId: isAutomod ? entry?.options?.channel_id : undefined, + expires: member?.communicationDisabledUntil, + loading: false + }); + + // Re-render the timeout indicator components + this.emitChange(); + }); + + return TimeoutLoading; + } + } + + const store = new TimeoutReasonStore(FluxDispatcher, {}); + + return store; +}); + +export const useTimeoutReason = (guildId: string, userId: string) => useStateFromStores([TimeoutReasonStore], () => { + return TimeoutReasonStore.getReason(guildId, userId); +}); diff --git a/src/plugins/showTimeoutDetails/components/TimeoutDetailsPopout.tsx b/src/plugins/showTimeoutDetails/components/TimeoutDetailsPopout.tsx new file mode 100644 index 000000000..f95bf5f2e --- /dev/null +++ b/src/plugins/showTimeoutDetails/components/TimeoutDetailsPopout.tsx @@ -0,0 +1,108 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { SafetyIcon } from "@components/Icons"; +import { classes, Margins } from "@utils/index"; +import { findByPropsLazy, findComponentByCodeLazy } from "@webpack"; +import { Button, Dialog, GuildMemberStore, GuildStore, i18n, Parser, PermissionsBits, PermissionStore, Text, UserStore, useState, useStateFromStores } from "@webpack/common"; +import { Message } from "discord-types/general"; + +import { CountDown } from ".."; +import { useTimeoutReason } from "../TimeoutReasonStore"; +import TimeoutDetailsRow from "./TimeoutDetailsRow"; + +const PopoutClasses = findByPropsLazy("container", "scroller", "list"); + +const TimeoutIcon = findComponentByCodeLazy("M12 23c.08 0 .14-.08.11-.16a2.88 2.88 0 0 1 .29-2.31l2.2-3.85"); +const ChannelIcon = findComponentByCodeLazy("h4.97l-.8 4.84a1 1 0 0 0 1.97.32l.86-5.16H20a1"); +const CustomAutoModRuleIcon = findComponentByCodeLazy("a1 1 0 0 1 1-1h8a1 1 0 0 1 0 2H3a1 1 0 0 1-1-1ZM3 19a1 1 0 1 0 0 2h8a1 1 0 0 0 0-2H3Z"); +const MessageIcon = findComponentByCodeLazy('"M12 22a10 10 0 1 0-8.45-4.64c.13.19.11.44-.04.61l-2.06 2.37A1 1 0 0 0 2.2 22H12Z"'); + +const { setCommunicationDisabledUntil } = findByPropsLazy("setCommunicationDisabledUntil"); + +export default function TimeoutDetailsPopout({ closePopout, guildId, userId, message }: { closePopout(): void; guildId: string; userId: string; message: Message; }) { + const user = UserStore.getUser(userId); + const member = GuildMemberStore.getMember(guildId, userId); + const reason = useTimeoutReason(guildId, userId); + const hasModerationPermission = useStateFromStores([PermissionStore], () => PermissionStore.canManageUser(PermissionsBits.MODERATE_MEMBERS, userId, GuildStore.getGuild(guildId))); + const parse = (text: string) => Parser.parse(text, true, { + channelId: message.channel_id, + messageId: message.id + }); + + const [cancelling, setCancelling] = useState(false); + + return + + Timeout details for {user.username} + +
+ + + + + + + {reason.automod ? i18n.Messages.GUILD_SETTINGS_AUTOMOD_TITLE : parse(`<@${reason.moderator}>`)} + + + + {parse(`<#${reason.automodChannelId}>`)} + + + + {reason.automodRuleName} + + + + {reason.reason} + + + {hasModerationPermission &&
} +
; +} diff --git a/src/plugins/showTimeoutDetails/components/TimeoutDetailsRow.tsx b/src/plugins/showTimeoutDetails/components/TimeoutDetailsRow.tsx new file mode 100644 index 000000000..d78de5036 --- /dev/null +++ b/src/plugins/showTimeoutDetails/components/TimeoutDetailsRow.tsx @@ -0,0 +1,26 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findByPropsLazy } from "@webpack"; +import { Tooltip } from "@webpack/common"; +import { JSXElementConstructor, ReactNode } from "react"; + +const rowClasses = findByPropsLazy("row", "rowIcon", "rowGuildName"); + +export default function TimeoutDetailsRow(props: { + description: ReactNode; + icon: JSXElementConstructor; + children: ReactNode; + condition?: boolean | string; +}) { + if (props.condition === undefined ? !props.children : !props.condition) return null; + return
+ + {p => } + + {props.children} +
; +} diff --git a/src/plugins/showTimeoutDetails/components/TooltipWrapper.tsx b/src/plugins/showTimeoutDetails/components/TooltipWrapper.tsx new file mode 100644 index 000000000..85194d8e6 --- /dev/null +++ b/src/plugins/showTimeoutDetails/components/TooltipWrapper.tsx @@ -0,0 +1,92 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { classes, Margins } from "@utils/index"; +import { findByPropsLazy } from "@webpack"; +import { ChannelStore, GuildMemberStore, i18n, Popout, Text, Tooltip } from "@webpack/common"; +import { Message } from "discord-types/general"; +import { FunctionComponent, ReactNode } from "react"; + +import { CountDown, DisplayStyle, settings } from ".."; +import { useTimeoutReason } from "../TimeoutReasonStore"; +import TimeoutDetailsPopout from "./TimeoutDetailsPopout"; + +const clickableClasses = findByPropsLazy("clickable", "avatar", "username"); + +export default function TooltipWrapper({ message, children, text }: { message: Message; children: FunctionComponent; text: ReactNode; }) { + const guildId = ChannelStore.getChannel(message.channel_id)?.guild_id; + const timeoutReason = useTimeoutReason(guildId, message.author.id); + + if (settings.store.displayStyle === DisplayStyle.Tooltip) return ( + } + > + {popoutProps => { e.stopPropagation(); popoutProps.onClick(e); }} // stop double click to reply/edit + > + {children(props)} + } + + )} + />; + return ( + } + > + {popoutProps =>
{ e.stopPropagation(); popoutProps.onClick(e); }} // stop double click to reply/edit + > + + + + {renderTimeout(message, true)} timeout remaining + +
} +
+ ); +} + +function renderTimeout(message: Message, inline: boolean) { + const guildId = ChannelStore.getChannel(message.channel_id)?.guild_id; + if (!guildId) return null; + + const member = GuildMemberStore.getMember(guildId, message.author.id); + if (!member?.communicationDisabledUntil) return null; + + const countdown = () => <> + + + + + + ; + + return inline + ? countdown() + : <> + {i18n.Messages.GUILD_ENABLE_COMMUNICATION_TIME_REMAINING.format({ + username: message.author.username, + countdown + })} +
+ Click for more details. + ; +} diff --git a/src/plugins/showTimeoutDetails/index.tsx b/src/plugins/showTimeoutDetails/index.tsx new file mode 100644 index 000000000..5a850ba09 --- /dev/null +++ b/src/plugins/showTimeoutDetails/index.tsx @@ -0,0 +1,61 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { definePluginSettings, migratePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType } from "@utils/types"; +import { findComponentLazy } from "@webpack"; + +import TooltipWrapper from "./components/TooltipWrapper"; +import { TimeoutReasonStore } from "./TimeoutReasonStore"; + +export const CountDown = findComponentLazy(m => m.prototype?.render?.toString().includes(".MAX_AGE_NEVER")); + +export const enum DisplayStyle { + Tooltip = "tooltip", + Inline = "ssalggnikool" +} + +export const settings = definePluginSettings({ + displayStyle: { + description: "How to display the timeout duration and reason", + type: OptionType.SELECT, + options: [ + { label: "In the Tooltip", value: DisplayStyle.Tooltip }, + { label: "Next to the timeout icon", value: DisplayStyle.Inline, default: true }, + ], + } +}); + +migratePluginSettings("ShowTimeoutDetails", "ShowTimeoutDuration"); + +export default definePlugin({ + name: "ShowTimeoutDetails", + description: "Shows how much longer a user's timeout will last and why they are timed out, either in the timeout icon tooltip or next to it", + authors: [Devs.Ven, Devs.Sqaaakoi], + tags: ["ShowTimeoutDuration", "ShowTimeoutReason"], + + settings, + + patches: [ + { + find: ".GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY", + replacement: [ + { + match: /(\i)\.Tooltip,{(text:.{0,30}\.Messages\.GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY)/, + replace: "$self.TooltipWrapper,{message:arguments[0].message,$2" + } + ] + } + ], + + TimeoutReasonStore, + + TooltipWrapper: ErrorBoundary.wrap(TooltipWrapper, { noop: true }) +}); diff --git a/src/plugins/showTimeoutDetails/styles.css b/src/plugins/showTimeoutDetails/styles.css new file mode 100644 index 000000000..b28d23391 --- /dev/null +++ b/src/plugins/showTimeoutDetails/styles.css @@ -0,0 +1,35 @@ +.vc-std-wrapper { + display: flex; + align-items: center; +} + +.vc-std-wrapper [class*="communicationDisabled"] { + margin-right: 0; +} + +.vc-std-wrapper-text { + color: var(--status-danger); +} + +.vc-std-automod :is(svg, .vc-std-wrapper-text) { + color: var(--status-warning); +} + +.vc-std-tooltip { + max-width: 220px; +} + +.vc-std-popout { + width: unset; + min-width: 180px; + max-width: 360px; + color: var(--text-normal); +} + +.vc-std-popout-button-wrapper { + margin-top: 8px; +} + +.vc-std-popout-button { + padding: 2px 10px; +} diff --git a/src/plugins/showTimeoutDuration/index.tsx b/src/plugins/showTimeoutDuration/index.tsx deleted file mode 100644 index bfe806802..000000000 --- a/src/plugins/showTimeoutDuration/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Vencord, a Discord client mod - * Copyright (c) 2024 Vendicated and contributors - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -import "./styles.css"; - -import { definePluginSettings } from "@api/Settings"; -import ErrorBoundary from "@components/ErrorBoundary"; -import { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; -import { findComponentLazy } from "@webpack"; -import { ChannelStore, GuildMemberStore, i18n, Text, Tooltip } from "@webpack/common"; -import { Message } from "discord-types/general"; -import { FunctionComponent, ReactNode } from "react"; - -const CountDown = findComponentLazy(m => m.prototype?.render?.toString().includes(".MAX_AGE_NEVER")); - -const enum DisplayStyle { - Tooltip = "tooltip", - Inline = "ssalggnikool" -} - -const settings = definePluginSettings({ - displayStyle: { - description: "How to display the timeout duration", - type: OptionType.SELECT, - options: [ - { label: "In the Tooltip", value: DisplayStyle.Tooltip }, - { label: "Next to the timeout icon", value: DisplayStyle.Inline, default: true }, - ], - } -}); - -function renderTimeout(message: Message, inline: boolean) { - const guildId = ChannelStore.getChannel(message.channel_id)?.guild_id; - if (!guildId) return null; - - const member = GuildMemberStore.getMember(guildId, message.author.id); - if (!member?.communicationDisabledUntil) return null; - - const countdown = () => ( - - ); - - return inline - ? countdown() - : i18n.Messages.GUILD_ENABLE_COMMUNICATION_TIME_REMAINING.format({ - username: message.author.username, - countdown - }); -} - -export default definePlugin({ - name: "ShowTimeoutDuration", - description: "Shows how much longer a user's timeout will last, either in the timeout icon tooltip or next to it", - authors: [Devs.Ven, Devs.Sqaaakoi], - - settings, - - patches: [ - { - find: ".GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY", - replacement: [ - { - match: /(\i)\.Tooltip,{(text:.{0,30}\.Messages\.GUILD_COMMUNICATION_DISABLED_ICON_TOOLTIP_BODY)/, - replace: "$self.TooltipWrapper,{message:arguments[0].message,$2" - } - ] - } - ], - - TooltipWrapper: ErrorBoundary.wrap(({ message, children, text }: { message: Message; children: FunctionComponent; text: ReactNode; }) => { - if (settings.store.displayStyle === DisplayStyle.Tooltip) return ; - return ( -
- - - {renderTimeout(message, true)} timeout remaining - -
- ); - }, { noop: true }) -}); diff --git a/src/plugins/showTimeoutDuration/styles.css b/src/plugins/showTimeoutDuration/styles.css deleted file mode 100644 index a6f830c38..000000000 --- a/src/plugins/showTimeoutDuration/styles.css +++ /dev/null @@ -1,8 +0,0 @@ -.vc-std-wrapper { - display: flex; - align-items: center; -} - -.vc-std-wrapper [class*="communicationDisabled"] { - margin-right: 0; -}