/* * 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 { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; import { classNameFactory } from "@utils/css"; import { classes } from "@utils/misc"; import definePlugin, { OptionType } from "@utils/types"; import type { Channel, Role } from "@vencord/discord-types"; import { findCssClassesLazy } from "@webpack"; import { ChannelStore, PermissionsBits, PermissionStore, Tooltip } from "@webpack/common"; import HiddenChannelLockScreen, { setChannelBeginHeader } from "./components/HiddenChannelLockScreen"; export const cl = classNameFactory("vc-shc-"); const ChannelListClasses = findCssClassesLazy("modeSelected", "modeMuted", "unread", "icon"); const enum ShowMode { LockIcon, HiddenIconWithMutedStyle } const CONNECT = 1n << 20n; export const settings = definePluginSettings({ hideUnreads: { description: "Hide Unreads", type: OptionType.BOOLEAN, default: true, restartNeeded: true }, showMode: { description: "The mode used to display hidden channels.", type: OptionType.SELECT, options: [ { label: "Plain style with Lock Icon instead", value: ShowMode.LockIcon, default: true }, { label: "Muted style with hidden eye icon on the right", value: ShowMode.HiddenIconWithMutedStyle }, ], restartNeeded: true }, defaultAllowedUsersAndRolesDropdownState: { description: "Whether the allowed users and roles dropdown on hidden channels should be open by default", type: OptionType.BOOLEAN, default: true } }); function isUncategorized(objChannel: { channel: Channel; comparator: number; }) { return objChannel.channel.id === "null" && objChannel.channel.name === "Uncategorized" && objChannel.comparator === -1; } export default definePlugin({ name: "ShowHiddenChannels", description: "Show channels that you do not have access to view.", tags: ["Servers", "Utility"], authors: [Devs.BigDuck, Devs.AverageReactEnjoyer, Devs.D3SOX, Devs.Ven, Devs.Nuckyz, Devs.Nickyux, Devs.dzshn], settings, patches: [ { // RenderLevel defines if a channel is hidden, collapsed in category, visible, etc find: '"placeholder-channel-id"', replacement: [ // Remove the special logic for channels we don't have access to { match: /if\(!\i\.\i\.can\(\i\.\i\.VIEW_CHANNEL.+?{if\(this\.id===\i\).+?threadIds:\[\]}}/, replace: "" }, // Do not check for unreads when selecting the render level if the channel is hidden { match: /(?<=&&)(?=!\i\.\i\.hasUnread\(this\.record\.id\))/, replace: "$self.isHiddenChannel(this.record)||" }, // Make channels we dont have access to be the same level as normal ones { match: /(this\.record\)\?{renderLevel:(.+?),threadIds.+?renderLevel:).+?(?=,threadIds)/g, replace: (_, rest, defaultRenderLevel) => `${rest}${defaultRenderLevel}` }, // Remove permission checking for getRenderLevel function { match: /(getRenderLevel\(\i\){.+?return)!\i\.\i\.can\(\i\.\i\.VIEW_CHANNEL,this\.record\)\|\|/, replace: (_, rest) => `${rest} ` } ] }, { find: "VoiceChannel, transitionTo: Channel does not have a guildId", replacement: [ { // Do not show confirmation to join a voice channel when already connected to another if clicking on a hidden voice channel match: /(?<=getIgnoredUsersForVoiceChannel\((\i)\.id\)[^;]{0,300}?;return\()/, replace: (_, channel) => `!$self.isHiddenChannel(${channel})&&` }, { // Prevent Discord from trying to connect to hidden voice channels match: /(?=\|\|\i\.\i\.selectVoiceChannel\((\i)\.id\))/, replace: (_, channel) => `||$self.isHiddenChannel(${channel})` }, { // Make Discord show inside the channel if clicking on a hidden or locked channel match: /!__OVERLAY__&&\((?<=selectVoiceChannel\((\i)\.id\).+?)/, replace: (m, channel) => `${m}$self.isHiddenChannel(${channel},true)||` } ] }, // Prevent Discord from trying to connect to hidden stage channels { find: ".AUDIENCE),{isSubscriptionGated", replacement: { match: /(\i)\.isRoleSubscriptionTemplatePreviewChannel\(\)/, replace: (m, channel) => `${m}||$self.isHiddenChannel(${channel})` } }, { find: 'tutorialId:"instant-invite"', replacement: [ // Render null instead of the buttons if the channel is hidden ...[ "renderEditButton", "renderInviteButton", ].map(func => ({ match: new RegExp(`(?<=${func}\\(\\){)`, "g"), // Global because Discord has multiple declarations of the same functions replace: "if($self.isHiddenChannel(this?.props?.channel))return null;" })) ] }, { find: "VoiceChannel.renderPopout: There must always be something to render", all: true, // Render null instead of the buttons if the channel is hidden replacement: { match: /(?<=renderOpenChatButton(?:",|=)\(\)=>{)/, replace: "if($self.isHiddenChannel(this?.props?.channel))return null;" } }, { find: "#{intl::CHANNEL_TOOLTIP_DIRECTORY}", predicate: () => settings.store.showMode === ShowMode.LockIcon, replacement: { // Lock Icon match: /(?<=(\i)\.isNSFW\(\);)switch\(\i\.type\).{0,15}\.GUILD_ANNOUNCEMENT/, replace: (m, channel) => `if($self.isHiddenChannel(${channel}))return $self.LockIcon;${m}` } }, { find: "UNREAD_IMPORTANT:", predicate: () => settings.store.showMode === ShowMode.HiddenIconWithMutedStyle, replacement: [ // Make the channel appear as muted if it's hidden { match: /Children\.count.+?;(?=return\(0,\i\.jsxs?\)\(\i\.\i,{focusTarget:)(?<={channel:(\i),name:\i,muted:(\i).+?;)/, replace: (m, channel, muted) => `${m}${muted}=$self.isHiddenChannel(${channel})?true:${muted};` }, // Add the hidden eye icon if the channel is hidden { match: /\.Children\.count.+?:null(?<=,channel:(\i).+?)/, replace: (m, channel) => `${m},$self.isHiddenChannel(${channel})?$self.HiddenChannelIcon():null` }, // Make voice channels also appear as muted if they are muted { match: /(?<=\?\i\.\i:\i\.\i,)(.{0,150}?)if\((\i)(?:\)return |\?)(\i\.MUTED)/, replace: (_, otherClasses, isMuted, mutedClassExpression) => `${isMuted}?${mutedClassExpression}:"",${otherClasses}if(${isMuted})return ""` } ] }, { find: "UNREAD_IMPORTANT:", replacement: [ { // Make muted channels also appear as unread if hide unreads is false, using the HiddenIconWithMutedStyle and the channel is hidden predicate: () => settings.store.hideUnreads === false && settings.store.showMode === ShowMode.HiddenIconWithMutedStyle, match: /(?<=\.LOCKED;if\()(?<={channel:(\i).+?)/, replace: (_, channel) => `!$self.isHiddenChannel(${channel})&&` }, { // Hide unreads predicate: () => settings.store.hideUnreads === true, match: /Children\.count.+?;(?=return\(0,\i\.jsxs?\)\(\i\.\i,{focusTarget:)(?<={channel:(\i),name:\i,.+?unread:(\i).+?)/, replace: (m, channel, unread) => `${m}${unread}=$self.isHiddenChannel(${channel})?false:${unread};` } ] }, { // Hide the new version of unreads box for hidden channels find: '"ChannelListUnreadsStore"', replacement: { match: /(?<=\.id\)\))(?=&&\(0,\i\.\i\)\((\i)\))/, replace: (_, channel) => `&&!$self.isHiddenChannel(${channel})` } }, { // Make the old version of unreads box not visible for hidden channels find: "renderBottomUnread(){", replacement: { match: /(?<=!0\))(?=&&\(0,\i\.\i\)\((\i\.record)\))/, replace: "&&!$self.isHiddenChannel($1)" } }, { // Make the state of the old version of unreads box not include hidden channels find: "GUILD_EVENT)}),[", replacement: { match: /(?<=\.id\)\))(?=&&\(0,\i\.\i\)\((\i)\))/, replace: "&&!$self.isHiddenChannel($1)" } }, // Only render the channel header and buttons that work when transitioning to a hidden channel { find: "Missing channel in Channel.renderHeaderToolbar", replacement: [ { match: /renderHeaderToolbar(?:",|=)\(\)=>{.+?case \i\.\i\.GUILD_TEXT:(?=.+?(\i\.push.{0,50}channel:(\i)},"notifications"\)\)))(?<=isLurking:(\i).+?)/, replace: (m, pushNotificationButtonExpression, channel, isLurking) => `${m}if(!${isLurking}&&$self.isHiddenChannel(${channel})){${pushNotificationButtonExpression};break;}` }, { match: /renderHeaderToolbar(?:",|=)\(\)=>{.+?case \i\.\i\.GUILD_MEDIA:(?=.+?(\i\.push.{0,40}channel:(\i)},"notifications"\)\)))(?<=isLurking:(\i).+?)/, replace: (m, pushNotificationButtonExpression, channel, isLurking) => `${m}if(!${isLurking}&&$self.isHiddenChannel(${channel})){${pushNotificationButtonExpression};break;}` }, { match: /renderMobileToolbar(?:",|=)\(\)=>{.+?case \i\.\i\.GUILD_DIRECTORY:(?<=let{channel:(\i).+?)/, replace: (m, channel) => `${m}if($self.isHiddenChannel(${channel}))break;` }, { match: /(?<=renderHeaderBar(?:",|=)\(\)=>{.+?hideSearch:(\i)\.isDirectory\(\))/, replace: (_, channel) => `||$self.isHiddenChannel(${channel})` }, { match: /(?<=renderSidebar\(\){)/, replace: "if($self.isHiddenChannel(this?.props?.channel))return null;" }, { match: /(?<=renderChat\(\){)/, 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: /forceFetch:\i,isPreload:.+?}=\i;(?=.+?getChannel\((\i)\))/, replace: (m, channelId) => `${m}if($self.isHiddenChannel({channelId:${channelId}}))return;` } }, // Patch keybind handlers so you can't accidentally jump to hidden channels { find: '"alt+shift+down"', replacement: { match: /(?<=getChannel\(\i\);return null!=(\i))(?=.{0,200}?>0\)&&\(0,\i\.\i\)\(\i\))/, replace: (_, channel) => `&&!$self.isHiddenChannel(${channel})` } }, // Patch keybind handlers so you can't accidentally jump to hidden channels { find: ".APPLICATION_STORE&&null!=", replacement: { match: /getState\(\)\.channelId.+?(?=\.map\(\i=>\i\.id)/, replace: "$&.filter(e=>!$self.isHiddenChannel(e))" } }, { find: "#{intl::ROLE_REQUIRED_SINGLE_USER_MESSAGE}", replacement: [ { // Change the role permission check to CONNECT if the channel is locked match: /(forceRoles:.+?)(\i\.\i\(\i\.\i\.ADMINISTRATOR,\i\.\i\.VIEW_CHANNEL\))(?<=context:(\i)}.+?)/, replace: (_, rest, mergedPermissions, channel) => `${rest}$self.swapViewChannelWithConnectPermission(${mergedPermissions},${channel})` }, { // Change the permissionOverwrite check to CONNECT if the channel is locked match: /permissionOverwrites\[.+?\i=(?<=context:(\i)}.+?)(?=(.+?)VIEW_CHANNEL)/, replace: (m, channel, permCheck) => `${m}!Vencord.Webpack.Common.PermissionStore.can(${CONNECT}n,${channel})?${permCheck}CONNECT):` }, { // Include the @everyone role in the allowed roles list for Hidden Channels match: /getSortedRoles.+?\.filter\(\i=>(?=!)/, replace: m => `${m}$self.isHiddenChannel(arguments[0]?.channel)?true:` }, { // If the @everyone role has the required permissions, make the array only contain it match: /forceRoles:.+?.value\(\)(?<=channel:(\i).+?)/, replace: (m, channel) => `${m}.reduce(...$self.makeAllowedRolesReduce(${channel}.guild_id))` }, { // Patch the header to only return allowed users and roles if it's a hidden channel or locked channel (Like when it's used on the HiddenChannelLockScreen) match: /return\(0,\i\.jsxs?\)\(\i\.\i,{channelId:(\i)\.id,children:\[(?=.{0,1000}?(\(0,\i\.jsxs?\)\("div",{className:\i\.\i,children:\[.{0,100}\i\.length>0.+?\]}\)),)/, replace: (m, channel, allowedUsersAndRolesComponent) => `if($self.isHiddenChannel(${channel},true)){return${allowedUsersAndRolesComponent};}${m}` }, { // Export the channel for the users allowed component patch match: /maxUsers:\d+?,users:\i(?<=channel:(\i).+?)/, replace: (m, channel) => `${m},shcChannel:${channel}` }, { // Always render the component for multiple allowed users match: /1!==\i\.length(?=\|\|)/, replace: "true" } ] }, { find: '="interactive-text-default",overflowCountClassName:', replacement: [ { // Create a variable for the channel prop match: /let{users:\i,maxUsers:\i,/, replace: "let{shcChannel}=arguments[0];$&" }, { // Make Discord always render the plus button if the component is used inside the HiddenChannelLockScreen match: /\i>0(?=&&!\i&&!\i)/, replace: m => `($self.isHiddenChannel(typeof shcChannel!=="undefined"?shcChannel:void 0,true)?true:${m})` }, { // Show only the plus text without overflowed children amount // if the overflow amount is <= 0 and the component is used inside the HiddenChannelLockScreen match: /(?<=`\+\$\{)\i(?=\})/, replace: overflowTextAmount => "" + `$self.isHiddenChannel(typeof shcChannel!=="undefined"?shcChannel:void 0,true)&&(${overflowTextAmount}-1)<=0?"":${overflowTextAmount}` } ] }, { find: "#{intl::CHANNEL_CALL_CURRENT_SPEAKER}", replacement: [ { // Remove the open chat button for the HiddenChannelLockScreen match: /(?<=&&)\i\.push\(.{0,120}"chat-spacer"/, replace: "(arguments[0]?.inCall||!$self.isHiddenChannel(arguments[0]?.channel,true))&&$&" } ] }, { find: "#{intl::EMBEDDED_ACTIVITIES_DEVELOPER_ACTIVITY_SHELF_FETCH_ERROR}", replacement: [ { // Render our HiddenChannelLockScreen component instead of the main voice channel component match: /renderContent\(\i\){.+?this\.renderVoiceChannelEffects.+?children:/, replace: "$&!this?.props?.inCall&&$self.isHiddenChannel(this?.props?.channel,true)?$self.HiddenChannelLockScreen(this?.props?.channel):" }, { // Disable gradients for the HiddenChannelLockScreen of voice channels match: /renderContent\(\i\){.+?disableGradients:/, replace: "$&!this?.props?.inCall&&$self.isHiddenChannel(this?.props?.channel,true)||" }, { // Disable useless components for the HiddenChannelLockScreen of voice channels match: /(?:{|,)render(?!Header|ExternalHeader).{0,30}?:/g, replace: "$&!this?.props?.inCall&&$self.isHiddenChannel(this?.props?.channel,true)?()=>null:" }, { // Disable bad CSS class which mess up hidden voice channels styling match: /(?=\i\|\|\i!==\i\.\i\.FULL_SCREEN.{0,100}?this\._callContainerRef)/, replace: '$&!this?.props?.inCall&&$self.isHiddenChannel(this?.props?.channel,true)?"":' } ] }, { find: '"HasBeenInStageChannel"', replacement: [ { // Render our HiddenChannelLockScreen component instead of the main stage channel component match: /screenMessage:(\i)\?.+?children:(?=!\1)(?<=let \i,{channel:(\i).+?)/, replace: (m, _isPopoutOpen, channel) => `${m}$self.isHiddenChannel(${channel})?$self.HiddenChannelLockScreen(${channel}):` }, { // Disable useless components for the HiddenChannelLockScreen of stage channels match: /render(?:BottomLeft|BottomCenter|BottomRight|ChatToasts):\(\)=>(?<=let \i,{channel:(\i).+?)/g, replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})?null:` }, { // Disable gradients for the HiddenChannelLockScreen of stage channels match: /"124px".+?disableGradients:(?<=let \i,{channel:(\i).+?)/, replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})||` }, { // Disable strange styles applied to the header for the HiddenChannelLockScreen of stage channels match: /"124px".+?style:(?<=let \i,{channel:(\i).+?)/, replace: (m, channel) => `${m}$self.isHiddenChannel(${channel})?void 0:` } ] }, { find: "#{intl::STAGE_FULL_MODERATOR_TITLE}", replacement: [ { // Remove the divider and amount of users in stage channel components for the HiddenChannelLockScreen match: /\(0,\i\.jsx\)\(\i\.\i\.Divider.+?}\)]}\)(?=.+?:(\i)\.guild_id)/, replace: (m, channel) => `$self.isHiddenChannel(${channel})?null:(${m})` }, { // Remove the open chat button for the HiddenChannelLockScreen match: /(?<=numRequestToSpeak:\i\}\)\}\):null,!\i&&)\(0,\i\.jsxs?\).{0,280}?iconClassName:/, replace: "!$self.isHiddenChannel(arguments[0]?.channel,true)&&$&" } ] }, { // Make the chat input bar channel list contain hidden channels find: ",queryStaticRouteChannels(", replacement: [ { // Make the getChannels call to GuildChannelStore return hidden channels match: /(?<=queryChannels\(\i\){.+?getChannels\(\i)(?=\))/, replace: ",true" }, { // Avoid filtering out hidden channels from the channel list match: /(?<=queryChannels\(\i\){.+?\)\((\i)\.type\))(?=&&!\i\.\i\.can\()/, replace: "&&!$self.isHiddenChannel($1)" } ] }, { find: "\"^/guild-stages/(\\\\d+)(?:/)?(\\\\d+)?\"", replacement: { // Make mentions of hidden channels work match: /\i\.\i\.can\(\i\.\i\.VIEW_CHANNEL,\i\)/, replace: "true" }, }, { find: 'getConfig({location:"channel_mention"})', replacement: { // Show inside voice channel instead of trying to join them when clicking on a channel mention match: /(?<=getChannel\(\i\);if\(null!=(\i)).{0,200}?return void (?=\i\.default\.selectVoiceChannel)/, replace: (m, channel) => `${m}!$self.isHiddenChannel(${channel})&&` } }, { find: '"GuildChannelStore"', replacement: [ { // Make GuildChannelStore contain hidden channels match: /isChannelGated\(.+?\)(?=&&)/, replace: m => `${m}&&false` }, { // Filter hidden channels from GuildChannelStore.getChannels unless told otherwise match: /(?<=getChannels\(\i)(\){.*?)return (.+?)}/, replace: (_, rest, channels) => `,shouldIncludeHidden${rest}return $self.resolveGuildChannels(${channels},shouldIncludeHidden??arguments[0]==="@favorites");}` }, ] }, { find: "GuildTooltip - ", replacement: { // Make GuildChannelStore.getChannels return hidden channels match: /(?<=getChannels\(\i)(?=\))/, replace: ",true" } }, { find: '"NowPlayingViewStore"', replacement: { // Make active now voice states on hidden channels match: /(getVoiceStateForUser.{0,150}?)&&\i\.\i\.canWithPartialContext.{0,20}VIEW_CHANNEL.+?}\)(?=\?)/, replace: "$1" } }, { find: "#{intl::ROLE_REQUIRED_SINGLE_USER_MESSAGE}", replacement: { match: /(?=function (\i)\(\i\){let{channel:.{0,200}?getSortedRoles\()/, replace: "$self.ChannelBeginHeader=$1;" } } ], set ChannelBeginHeader(value: any) { setChannelBeginHeader(value); }, swapViewChannelWithConnectPermission(mergedPermissions: bigint, channel: Channel) { if (!PermissionStore.can(PermissionsBits.CONNECT, channel)) { mergedPermissions &= ~PermissionsBits.VIEW_CHANNEL; mergedPermissions |= PermissionsBits.CONNECT; } return mergedPermissions; }, isHiddenChannel(channel: Channel & { channelId?: string; }, checkConnect = false) { try { if (channel == null || Object.hasOwn(channel, "channelId") && channel.channelId == null) return false; if (channel.channelId != null) channel = ChannelStore.getChannel(channel.channelId); if (channel == null || channel.isDM() || channel.isGroupDM() || channel.isMultiUserDM()) return false; if (["browse", "customize", "guide"].includes(channel.id)) return false; return !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel) || checkConnect && !PermissionStore.can(PermissionsBits.CONNECT, channel); } catch (e) { console.error("[ViewHiddenChannels#isHiddenChannel]: ", e); return false; } }, resolveGuildChannels(channels: Record | string | number>, shouldIncludeHidden: boolean) { if (shouldIncludeHidden) return channels; const res = {}; for (const [key, maybeObjChannels] of Object.entries(channels)) { if (!Array.isArray(maybeObjChannels)) { res[key] = maybeObjChannels; continue; } res[key] ??= []; for (const objChannel of maybeObjChannels) { if (isUncategorized(objChannel) || objChannel.channel.id === null || !this.isHiddenChannel(objChannel.channel)) res[key].push(objChannel); } } return res; }, makeAllowedRolesReduce(guildId: string) { return [ (prev: Array, _: Role, index: number, originalArray: Array) => { if (index !== 0) return prev; const everyoneRole = originalArray.find(role => role.id === guildId); if (everyoneRole) return [everyoneRole]; return originalArray; }, [] as Array ]; }, HiddenChannelLockScreen: (channel: any) => , LockIcon: ErrorBoundary.wrap(() => ( ), { noop: true }), HiddenChannelIcon: ErrorBoundary.wrap(() => ( {({ onMouseLeave, onMouseEnter }) => ( )} ), { noop: true }) });