diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 8ebc61bb..a13640e1 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -103,7 +103,7 @@ const ErrorBoundary = LazyComponent(() => { }; }) as React.ComponentType> & { - wrap(Component: React.ComponentType, errorBoundaryProps?: Props): React.ComponentType; + wrap(Component: React.ComponentType, errorBoundaryProps?: Props): React.ComponentType; }; ErrorBoundary.wrap = (Component, errorBoundaryProps) => props => ( diff --git a/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx b/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx new file mode 100644 index 00000000..e5c5ee27 --- /dev/null +++ b/src/plugins/showHiddenChannels/components/HiddenChannelLockScreen.tsx @@ -0,0 +1,202 @@ +/* + * 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 ErrorBoundary from "@components/ErrorBoundary"; +import { LazyComponent } from "@utils/misc"; +import { proxyLazy } from "@utils/proxyLazy"; +import { formatDuration } from "@utils/text"; +import { find, findByCode, findByPropsLazy, findLazy } from "@webpack"; +import { moment, Parser, SnowflakeUtils, Text, Timestamp, Tooltip } from "@webpack/common"; +import { Channel } from "discord-types/general"; + +enum SortOrderTypesTyping { + LATEST_ACTIVITY = 0, + CREATION_DATE = 1 +} + +enum ForumLayoutTypesTyping { + DEFAULT = 0, + LIST = 1, + GRID = 2 +} + +interface DefaultReaction { + emojiId: string | null; + emojiName: string | null; +} + +interface Tag { + id: string; + name: string; + emojiId: string | null; + emojiName: string | null; + moderated: boolean; +} + +interface ExtendedChannel extends Channel { + defaultThreadRateLimitPerUser?: number; + defaultSortOrder?: SortOrderTypesTyping | null; + defaultForumLayout?: ForumLayoutTypesTyping; + defaultReactionEmoji?: DefaultReaction | null; + availableTags?: Array; +} + +const ChatClasses = findByPropsLazy("chat", "chatContent"); +const TagClasses = findLazy(m => typeof m.tags === "string" && Object.entries(m).length === 1); // Object exported with a single key called tags +const ChannelTypes = findByPropsLazy("GUILD_TEXT", "GUILD_FORUM"); +const SortOrderTypes = findLazy(m => typeof m.LATEST_ACTIVITY === "number"); +const ForumLayoutTypes = findLazy(m => typeof m.LIST === "number"); +const ChannelFlags = findLazy(m => typeof m.REQUIRE_TAG === "number"); +const TagComponent = LazyComponent(() => find(m => { + if (typeof m !== "function") return false; + + const code = Function.prototype.toString.call(m); + // Get the component which doesn't include increasedActivity logic + return code.includes(".Messages.FORUM_TAG_A11Y_FILTER_BY_TAG") && !code.includes("increasedActivityPill"); +})); +const EmojiComponent = LazyComponent(() => findByCode('.jumboable?"jumbo":"default"')); + +const ChannelTypesToChannelNames = proxyLazy(() => ({ + [ChannelTypes.GUILD_TEXT]: "text", + [ChannelTypes.GUILD_ANNOUNCEMENT]: "announcement", + [ChannelTypes.GUILD_FORUM]: "forum" +})); + +const SortOrderTypesToNames = proxyLazy(() => ({ + [SortOrderTypes.LATEST_ACTIVITY]: "Latest activity", + [SortOrderTypes.CREATION_DATE]: "Creation date" +})); + +const ForumLayoutTypesToNames = proxyLazy(() => ({ + [ForumLayoutTypes.DEFAULT]: "Not set", + [ForumLayoutTypes.LIST]: "List view", + [ForumLayoutTypes.GRID]: "Gallery view" +})); + +// Icon from the modal when clicking a message link you don't have access to view +const HiddenChannelLogo = "/assets/433e3ec4319a9d11b0cbe39342614982.svg"; + +function HiddenChannelLockScreen({ channel }: { channel: ExtendedChannel; }) { + const { + type, + topic, + lastMessageId, + defaultForumLayout, + lastPinTimestamp, + defaultAutoArchiveDuration, + availableTags, + id: channelId, + rateLimitPerUser, + defaultThreadRateLimitPerUser, + defaultSortOrder, + defaultReactionEmoji + } = channel; + + return ( +
+ + +
+ This is a hidden {ChannelTypesToChannelNames[type]} channel. + {channel.isNSFW() && + + {({ onMouseLeave, onMouseEnter }) => ( + + + + )} + + } +
+ + + You can not see the {channel.isForumChannel() ? "posts" : "messages"} of this channel. + {channel.isForumChannel() && topic && topic.length > 0 && "However you may see its guidelines:"} + + + {channel.isForumChannel() && topic && topic.length > 0 && ( +
+ {Parser.parseTopic(topic, false, { channelId })} +
+ )} + + {lastMessageId && + + Last {channel.isForumChannel() ? "post" : "message"} created: + + + } + + {lastPinTimestamp && + Last message pin: + } + {(rateLimitPerUser ?? 0) > 0 && + Slowmode: {formatDuration(rateLimitPerUser! * 1000)} + } + {(defaultThreadRateLimitPerUser ?? 0) > 0 && + + Default thread slowmode: {formatDuration(defaultThreadRateLimitPerUser! * 1000)} + + } + {(defaultAutoArchiveDuration ?? 0) > 0 && + + Default inactivity duration before archiving {channel.isForumChannel() ? "posts" : "threads"}: + {formatDuration(defaultAutoArchiveDuration! * 1000 * 60)} + + } + {defaultForumLayout != null && + Default layout: {ForumLayoutTypesToNames[defaultForumLayout]} + } + {defaultSortOrder != null && + Default sort order: {SortOrderTypesToNames[defaultSortOrder]} + } + {defaultReactionEmoji != null && +
+ Default reaction emoji: + +
+ } + {channel.hasFlag(ChannelFlags.REQUIRE_TAG) && + Posts on this forum require a tag to be set. + } + {availableTags && availableTags.length > 0 && +
+ Available tags: +
+ {availableTags.map(tag => )} +
+
+ } +
+ ); +} + +export default ErrorBoundary.wrap(HiddenChannelLockScreen); diff --git a/src/plugins/showHiddenChannels.tsx b/src/plugins/showHiddenChannels/index.tsx similarity index 64% rename from src/plugins/showHiddenChannels.tsx rename to src/plugins/showHiddenChannels/index.tsx index 283eb83a..abb443e7 100644 --- a/src/plugins/showHiddenChannels.tsx +++ b/src/plugins/showHiddenChannels/index.tsx @@ -16,27 +16,20 @@ * along with this program. If not, see . */ +import "./style.css"; import { definePluginSettings } from "@api/settings"; -import { Badge } from "@components/Badge"; -import { Flex } from "@components/Flex"; +import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; -import { ModalContent, ModalFooter, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; -import { proxyLazy } from "@utils/proxyLazy"; import definePlugin, { OptionType } from "@utils/types"; import { findByPropsLazy, findLazy } from "@webpack"; -import { Button, ChannelStore, moment, Parser, PermissionStore, SnowflakeUtils, Text, Timestamp, Tooltip } from "@webpack/common"; +import { ChannelStore, PermissionStore, Tooltip } from "@webpack/common"; import { Channel } from "discord-types/general"; +import HiddenChannelLockScreen from "./components/HiddenChannelLockScreen"; + const ChannelListClasses = findByPropsLazy("channelName", "subtitle", "modeMuted", "iconContainer"); const Permissions = findLazy(m => typeof m.VIEW_CHANNEL === "bigint"); -const ChannelTypes = findByPropsLazy("GUILD_TEXT", "GUILD_FORUM"); - -const ChannelTypesToChannelName = proxyLazy(() => ({ - [ChannelTypes.GUILD_TEXT]: "TEXT", - [ChannelTypes.GUILD_ANNOUNCEMENT]: "ANNOUNCEMENT", - [ChannelTypes.GUILD_FORUM]: "FORUM" -})); enum ShowMode { LockIcon, @@ -97,16 +90,8 @@ export default definePlugin({ ] }, { - // inside the onMouseDown handler, we check if the channel is hidden and open the modal if it is find: "VoiceChannel.renderPopout: There must always be something to render", replacement: [ - { - match: /(?=(?\i)\.handleThreadsPopoutClose\(\))/, - replace: "if($self.isHiddenChannel($.props.channel)&&arguments[0].button===0){" - + "$self.onHiddenChannelSelected($.props.channel);" - + "return;" - + "}" - }, // Do nothing when trying to join a voice channel if the channel is hidden { match: /(?<=handleClick=function\(\){)(?=.{1,80}(?\i)\.handleVoiceConnect\(\))/, @@ -179,6 +164,46 @@ export default definePlugin({ replace: "&&!$self.isHiddenChannel($)" } }, + // Only render the channel header and buttons that work when transitioning to a hidden channel + { + find: "Missing channel in Channel.renderHeaderToolbar", + replacement: [ + { + match: /(?<=renderHeaderToolbar=function.+?case \i\.\i\.GUILD_TEXT:)(?=.+?;(?.+?{channel:(?\i)},"notifications"\)\);))/, + replace: "if($self.isHiddenChannel($)){$break;}" + }, + { + match: /(?<=renderHeaderToolbar=function.+?case \i\.\i\.GUILD_FORUM:if\(!\i\){)(?=.+?;(?.+?{channel:(?\i)},"notifications"\)\)))/, + replace: "if($self.isHiddenChannel($)){$;break;}" + }, + { + match: /(?<=(?\i)\.renderMobileToolbar=function.+?case \i\.\i\.GUILD_FORUM:)/, + replace: "if($self.isHiddenChannel($.props.channel))break;" + }, + { + match: /(?<=renderHeaderBar=function.+?hideSearch:(?\i)\.isDirectory\(\))/, + replace: "||$self.isHiddenChannel($)" + }, + { + match: /(?<=renderSidebar=function\(\){)/, + replace: "if($self.isHiddenChannel(this.props.channel))return null;" + }, + { + match: /(?<=renderChat=function\(\){)/, + replace: "if($self.isHiddenChannel(this.props.channel))return $self.HiddenChannelLockScreen(this.props.channel);" + }, + ] + }, + // Avoid trying to fetch messages from hidden channels + { + find: '"MessageManager"', + replacement: [ + { + match: /(?<=if\(null!=(?\i)\).{1,100}"Skipping fetch because channelId is a static route".{1,10}else{)/, + replace: "if($self.isHiddenChannel({channelId:$}))return;" + }, + ] + }, // Patch keybind handlers so you can't accidentally jump to hidden channels { find: '"alt+shift+down"', @@ -194,6 +219,14 @@ export default definePlugin({ replace: ".filter(ch=>!$self.isHiddenChannel(ch))" } }, + // Export the emoji component used on the lock screen + { + find: 'jumboable?"jumbo":"default"', + replacement: { + match: /(?<=\i:\(\)=>\i)(?=}.+?(?\i)=function.{1,20}node,\i=\i.isInteracting)/, + replace: ",hc1:()=>$" // Blame Ven length check for the small name :pensive_cry: + } + } ], isHiddenChannel(channel: Channel & { channelId?: string; }) { @@ -205,56 +238,7 @@ export default definePlugin({ return !PermissionStore.can(Permissions.VIEW_CHANNEL, channel); }, - onHiddenChannelSelected(channel: Channel) { - // Check for type, otherwise it would attempt to show the modal for stage channels - if ([ChannelTypes.GUILD_TEXT, ChannelTypes.GUILD_ANNOUNCEMENT, ChannelTypes.GUILD_FORUM].includes(channel.type)) { - openModal(modalProps => ( - - - - #{channel.name} - {} - {channel.isNSFW() && } - - - - You don't have permission to view {channel.type === ChannelTypes.GUILD_FORUM ? "posts" : "messages"} in this channel. - {(channel.topic ?? "").length > 0 && ( - <> - - {channel.type === ChannelTypes.GUILD_FORUM ? "Guidelines:" : "Topic:"} - -
- {Parser.parseTopic(channel.topic, false, { channelId: channel.id })} -
- - )} - {channel.lastMessageId && ( - <> - - {channel.type === ChannelTypes.GUILD_FORUM ? "Last Post Created" : "Last Message Sent:"} - -
- -
- - )} -
- - - - - -
- )); - } - }, + HiddenChannelLockScreen: (channel: any) => , LockIcon: () => ( - + ), - HiddenChannelIcon: () => ( + HiddenChannelIcon: ErrorBoundary.wrap(() => ( {({ onMouseLeave, onMouseEnter }) => ( - + )} - ) + ), { noop: true }) }); diff --git a/src/plugins/showHiddenChannels/style.css b/src/plugins/showHiddenChannels/style.css new file mode 100644 index 00000000..73957efc --- /dev/null +++ b/src/plugins/showHiddenChannels/style.css @@ -0,0 +1,78 @@ +.shc-lock-screen-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.shc-lock-screen-container > * { + margin: 5px; +} + +.shc-lock-screen-logo { + width: 180px; + height: 180px; +} + +.shc-lock-screen-heading-container { + display: flex; + flex-direction: row; + align-items: center; +} + +.shc-lock-screen-heading-container > * { + margin: inherit; +} + +.shc-lock-screen-heading-nsfw-icon > path { + fill: var(--text-normal); + fill-rule: evenodd; +} + +.shc-lock-screen-topic-container { + color: var(--text-normal); + background-color: var(--background-secondary); + border-radius: 5px; + padding: 5px; + max-width: 70vw; +} + +.shc-lock-screen-tags-container { + background-color: var(--background-secondary); + border-radius: 5px; + padding: 5px; + max-width: 70vw; +} + +.shc-lock-screen-tags-container > * { + margin: inherit; +} + +.shc-lock-screen-tags-container > [class^="tags"] { + flex-wrap: wrap; +} + +.shc-evenodd-fill-current-color { + fill-rule: evenodd; + fill: currentcolor; +} + +.shc-hidden-channel-icon { + margin-left: 6px; + z-index: 0; + cursor: not-allowed; +} + +.shc-lock-screen-default-emoji-container { + display: flex; + flex-direction: row; + align-items: center; +} + +.shc-lock-screen-default-emoji-container > [class^="emojiContainer"] { + background-color: var(--background-secondary); + border-radius: 8px; + padding: 3px 4px; + margin-left: 5px; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 41e1597a..b80bde3b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -27,4 +27,5 @@ export * as Modals from "./modal"; export * from "./onceDefined"; export * from "./proxyLazy"; export * from "./Queue"; +export * from "./text"; diff --git a/src/utils/text.ts b/src/utils/text.ts index 17826e80..fae33436 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { moment } from "@webpack/common"; + // Utils for readable text transformations eg: `toTitle(fromKebab())` // Case style to words @@ -34,3 +36,26 @@ export const wordsToPascal = (words: string[]) => words.map(w => w[0].toUpperCase() + w.slice(1)).join(""); export const wordsToTitle = (words: string[]) => words.map(w => w[0].toUpperCase() + w.slice(1)).join(" "); + +/** + * Forms milliseconds into a human readable string link "1 day, 2 hours, 3 minutes and 4 seconds" + * @param ms Milliseconds + * @param short Whether to use short units like "d" instead of "days" + */ +export function formatDuration(ms: number, short: boolean = false) { + const dur = moment.duration(ms); + return (["years", "months", "weeks", "days", "hours", "minutes", "seconds"] as const).reduce((res, unit) => { + const x = dur[unit](); + if (x > 0 || res.length) { + if (res.length) + res += unit === "seconds" ? " and " : ", "; + + const unitStr = short + ? unit[0] + : x === 1 ? unit.slice(0, -1) : unit; + + res += `${x} ${unitStr}`; + } + return res; + }, "").replace(/((,|and) \b0 \w+)+$/, "") || "now"; +}