From 6f19368b707478dff7e166f73340e2b62cb73720 Mon Sep 17 00:00:00 2001 From: thororen1234 <78185467+thororen1234@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:31:22 -0400 Subject: [PATCH] SidebarChat Returns for nowTM --- .github/workflows/reportBrokenPlugins.yml | 11 +- src/equicordplugins/sidebarChat/index.tsx | 583 +++++++++++++++++++++ src/equicordplugins/sidebarChat/store.ts | 149 ++++++ src/equicordplugins/sidebarChat/styles.css | 19 + 4 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 src/equicordplugins/sidebarChat/index.tsx create mode 100644 src/equicordplugins/sidebarChat/store.ts create mode 100644 src/equicordplugins/sidebarChat/styles.css diff --git a/.github/workflows/reportBrokenPlugins.yml b/.github/workflows/reportBrokenPlugins.yml index 3ff938350..13af9dcea 100644 --- a/.github/workflows/reportBrokenPlugins.yml +++ b/.github/workflows/reportBrokenPlugins.yml @@ -17,6 +17,14 @@ on: visible for everyone, so DO NOT pass sensitive webhooks like discord webhook. This is meant to be used by Equibot." required: false + pr_repo: + type: string + description: "Fork repo to checkout when testing a PR" + required: false + pr_branch: + type: string + description: "Branch to checkout when testing a PR" + required: false schedule: # # Every day at midnight - cron: 0 0 * * * @@ -32,7 +40,8 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref }} + repository: ${{ inputs.pr_repo || github.repository }} + ref: ${{ inputs.pr_branch || (github.event_name == 'schedule' && 'dev' || github.ref) }} - uses: pnpm/action-setup@v6 diff --git a/src/equicordplugins/sidebarChat/index.tsx b/src/equicordplugins/sidebarChat/index.tsx new file mode 100644 index 000000000..69c5e51a4 --- /dev/null +++ b/src/equicordplugins/sidebarChat/index.tsx @@ -0,0 +1,583 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { HeaderBarButton } from "@api/HeaderBar"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs, EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { getCurrentChannel } from "@utils/discord"; +import definePlugin from "@utils/types"; +import { Channel, Guild, User } from "@vencord/discord-types"; +import { ChannelType } from "@vencord/discord-types/enums"; +import { + extractAndLoadChunksLazy, + findByPropsLazy, + findComponentByCodeLazy, + findCssClassesLazy, + findStoreLazy, +} from "@webpack"; +import { + ChannelActionCreators, + ChannelRouter, + ChannelStore, + FluxDispatcher, + GuildStore, + Menu, + MessageActions, + MessageStore, + PermissionsBits, + PermissionStore, + PopoutActions, + PopoutWindowStore, + RelationshipStore, + SelectedChannelStore, + SelectedGuildStore, + useCallback, + useEffect, + useLayoutEffect, + UserStore, + useState, + useStateFromStores, +} from "@webpack/common"; + +import { getOpenPopoutWindowKeys, getPersistedPopoutChannelIds, getPopoutWindowKey, isPopoutWindowOpen, settings, SidebarStore, syncPersistedPopoutWindows } from "./store"; + +const cl = classNameFactory("vc-sidebar-chat-"); + +const HeaderBar = findComponentByCodeLazy("toolbarClassName:", "}),onDoubleClick:"); +const ForumView = findComponentByCodeLazy("sidebarState"); + +const ArrowsLeftRightIcon = ({ color, ...rest }) => { + return ( + + ); +}; + +const WindowLaunchIcon = findComponentByCodeLazy("1-1h6a1 1 0 1 0 0-2H5Z"); +const XSmallIcon = findComponentByCodeLazy("1.4L12 13.42l5.3 5.3Z"); +const Chat = findComponentByCodeLazy("filterAfterTimestamp:", "chatInputType"); +const Resize = findComponentByCodeLazy("sidebarType:", "RESIZE_HANDLE_WIDTH)"); +const ChannelHeader = findComponentByCodeLazy("`channel-${"); +const PopoutWindow = findComponentByCodeLazy("Missing guestWindow reference"); +const FullChannelView = findComponentByCodeLazy("showFollowButton:"); +const WanderingCubesLoading = findComponentByCodeLazy('="wanderingCubes"'); + +const ChatInputTypes = findByPropsLazy("FORM", "NORMAL"); +const Sidebars = findByPropsLazy("ThreadSidebar", "MessageRequestSidebar"); +const ChatClasses = findCssClassesLazy("threadSidebarOpen", "loader"); + +const ChannelSectionStore = findStoreLazy("ChannelSectionStore"); + +const requireForumView = extractAndLoadChunksLazy( + ["Missing channel in Channel.renderHeaderToolbar"], + /Promise\.all\(\[((?:\i\.e\("\d+"\),?)+)\]\)\.then\(\i\.bind\(\i,(\d+)\)\)[^}]{0,100}?name:"ForumChannel"/ +); + +function getChannelTitle(channel: Channel | null | undefined) { + if (!channel) return "Chat"; + + if (channel.isPrivate()) { + const recipientId = channel.getRecipientId?.(); + if (!channel.name && recipientId) { + const user = UserStore.getUser(recipientId); + if (user) { + return RelationshipStore.getNickname(recipientId) || user.globalName || user.username || "DM"; + } + } + + return channel.name || "DM"; + } + + return channel.name || "Chat"; +} + +function canOpenPopout(channel: Channel) { + if (channel.isPrivate()) return true; + return !channel.isCategory() && !channel.isDirectory() && !channel.isVocal(); +} + +function getPopoutMenuLabel(channelId: string) { + return isPopoutWindowOpen(channelId) ? "Close popout chat" : "Popout chat"; +} + +let restorePersistedPopoutsInterval: number | null = null; +let restoringPersistedPopouts = false; + +function clearPersistedPopoutRestoreLoop() { + restoringPersistedPopouts = false; + if (restorePersistedPopoutsInterval !== null) { + window.clearInterval(restorePersistedPopoutsInterval); + restorePersistedPopoutsInterval = null; + } +} + +async function waitForChannel(channelId: string, timeoutMs = 2500) { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + const channel = ChannelStore.getChannel(channelId); + if (channel) return channel; + + await new Promise(resolve => setTimeout(resolve, 80)); + } + + return null; +} + +async function waitForDmChannel(userId: string, timeoutMs = 2500) { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + const channelId = ChannelStore.getDMFromUserId?.(userId); + if (channelId) return channelId; + + await new Promise(resolve => setTimeout(resolve, 80)); + } + + return null; +} + +async function openPopoutFromUserMenu(userId: string) { + try { + const channelId = await Promise.resolve(ChannelActionCreators.getOrEnsurePrivateChannel(userId)); + if (!channelId) return; + + const channel = await waitForChannel(channelId); + if (channel) openPopout(channel.id); + return; + } catch { + const fallbackChannelId = await waitForDmChannel(userId); + if (!fallbackChannelId) return; + + const channel = await waitForChannel(fallbackChannelId); + if (channel) openPopout(channel.id); + } +} + +function closePopout(channelId: string, syncPersistence = true) { + const windowKey = getPopoutWindowKey(channelId); + PopoutActions.close(windowKey); + if (syncPersistence && !restoringPersistedPopouts) { + syncPersistedPopoutWindows(); + } +} + +function openPopout(channelId: string, syncPersistence = true) { + const channel = ChannelStore.getChannel(channelId); + if (!channel || !canOpenPopout(channel)) return; + + const windowKey = getPopoutWindowKey(channelId); + + if (isPopoutWindowOpen(channelId)) { + closePopout(channelId, syncPersistence); + return; + } + + const title = getChannelTitle(channel); + + PopoutActions.open( + windowKey, + () => , + { + defaultWidth: 854, + defaultHeight: 480, + } + ); + + PopoutActions.setAlwaysOnTop(windowKey, settings.store.popoutAlwaysOnTop); + if (syncPersistence && !restoringPersistedPopouts) { + syncPersistedPopoutWindows(); + } +} + +function restorePersistedPopouts() { + if (!settings.store.persistPopoutWindows) return; + + clearPersistedPopoutRestoreLoop(); + + const pendingRestoreIds = new Set(getPersistedPopoutChannelIds()); + if (pendingRestoreIds.size === 0) return; + + restoringPersistedPopouts = true; + + const attemptRestore = () => { + for (const channelId of pendingRestoreIds) { + const channel = ChannelStore.getChannel(channelId); + if (!channel || !canOpenPopout(channel)) continue; + + pendingRestoreIds.delete(channelId); + openPopout(channelId, false); + } + + if (pendingRestoreIds.size === 0) { + clearPersistedPopoutRestoreLoop(); + syncPersistedPopoutWindows(); + } + }; + + attemptRestore(); + + if (pendingRestoreIds.size > 0) { + restorePersistedPopoutsInterval = window.setInterval(attemptRestore, 250); + } +} + +const createSidebarChatContextMenuItem = (id: string, guildId: string | null) => { + return ( + { + FluxDispatcher.dispatch({ + // @ts-ignore + type: "VC_SIDEBAR_CHAT_NEW", + guildId, + id, + }); + }} + /> + ); +}; + +const createPopoutChatContextMenuItem = (id: string, label: string, action: () => void | Promise) => { + return ( + { + void action(); + }} + /> + ); +}; + +const UserContextPatch: NavContextMenuPatchCallback = (children, args: { user: User; }) => { + const checks = [ + args.user, + args.user.id !== UserStore.getCurrentUser().id, + ]; + if (checks.some(check => !check)) return; + const channelId = ChannelStore.getDMFromUserId?.(args.user.id) ?? null; + const isOpen = channelId ? isPopoutWindowOpen(channelId) : false; + + children.push(createSidebarChatContextMenuItem(args.user.id, null)); + children.push(createPopoutChatContextMenuItem( + args.user.id, + isOpen ? "Close popout chat" : "Popout chat", + () => { + if (channelId && isOpen) { + closePopout(channelId); + return; + } + + return openPopoutFromUserMenu(args.user.id); + } + )); +}; + +const ChannelContextPatch: NavContextMenuPatchCallback = (children, args: { channel: Channel; }) => { + const checks = [ + args.channel, + args.channel.type !== ChannelType.GUILD_CATEGORY, + PermissionStore.can(PermissionsBits.VIEW_CHANNEL, args.channel) || args.channel.type === ChannelType.GROUP_DM, + ]; + if (checks.some(check => !check)) return; + children.push(createSidebarChatContextMenuItem(args.channel.id, args.channel.guild_id)); + children.push(createPopoutChatContextMenuItem( + args.channel.id, + getPopoutMenuLabel(args.channel.id), + () => openPopout(args.channel.id) + )); +}; + +export default definePlugin({ + name: "SidebarChat", + authors: [Devs.Joona, EquicordDevs.justjxke], + description: "Open a channel or DM as a sidebar or a popout.", + tags: ["Appearance", "Chat", "Servers"], + dependencies: ["HeaderBarAPI"], + patches: [ + { + find: 'case"pendingFriends":', + group: true, + replacement: [ + { + match: /ChannelRenderer"\),/, + replace: "$&vc_SidebarChat=$self.renderSidebar()," + }, + { + match: /return(\(0,\i\.jsxs?\)\(\i\.\i,{}\))}(?<=default:.{1,250})/, + replace: "return [$1, vc_SidebarChat]}" + }, + ], + }, + ], + + headerBarButton: { + icon: WindowLaunchIcon, + render: () => ( + <> + + + + ) + }, + + stop() { + clearPersistedPopoutRestoreLoop(); + syncPersistedPopoutWindows(); + for (const windowKey of getOpenPopoutWindowKeys()) { + PopoutActions.close(windowKey); + } + }, + + async start() { + restorePersistedPopouts(); + }, + + settings, + contextMenus: { + "user-context": UserContextPatch, + "channel-context": ChannelContextPatch, + "thread-context": ChannelContextPatch, + "gdm-context": ChannelContextPatch, + }, + + toolboxActions: { + "Open Previous Chat"() { + FluxDispatcher.dispatch({ + // @ts-ignore + type: "VC_SIDEBAR_CHAT_PREVIOUS", + }); + } + }, + + renderSidebar() { + const { guild, channel /* width*/ } = useStateFromStores( + [SidebarStore, GuildStore, ChannelStore], () => { + const { channelId, guildId } = SidebarStore.getState(); + return { + guild: GuildStore.getGuild(guildId), + channel: ChannelStore.getChannel(channelId) + }; + }, [] + ); + + const [channelSidebar, guildSidebar] = useStateFromStores( + [ChannelSectionStore, SelectedChannelStore, ChannelStore], () => { + const currentChannelId = SelectedChannelStore.getChannelId(); + const currentGuildId = SelectedGuildStore.getGuildId(); + return [ + ChannelSectionStore.getSidebarState(currentChannelId), + ChannelSectionStore.getGuildSidebarState(currentGuildId), + ]; + }, [] + ); + + useEffect(() => { + if (!channel?.id || MessageStore.getLastMessage(channel.id)) return; + MessageActions.fetchMessages({ + channelId: channel.id, + limit: 50, + }); + }, [channel?.id]); + + const [width, setWidth] = useState(window.innerWidth); + + useLayoutEffect(() => { + const handleResize = () => setWidth(window.innerWidth); + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const [View, setViewComponent] = useState(null); + + useEffect(() => { + if (!channel) return; + + if (channel.isForumLikeChannel()) { + requireForumView().then(() => { + setViewComponent( + + ); + }); + + setViewComponent( +
+ +
+ ); + } else { + setViewComponent( + + ); + } + }, [channel]); + + if (!channel || channelSidebar || guildSidebar) return null; + + return ( + + +
+ {View} + + + ); + }, +}); + +const Header = ({ guild, channel }: { guild: Guild; channel: Channel; }) => { + const recipientId = channel.isPrivate() ? channel.getRecipientId() as string : null; + + const name = useStateFromStores([UserStore, RelationshipStore], () => getChannelTitle(channel), [channel.id, channel.name]); + + const parentChannel = useStateFromStores( + [ChannelStore], () => ChannelStore.getChannel(channel?.parent_id), + [channel?.parent_id] + ); + + const closeSidebar = () => FluxDispatcher.dispatch({ type: "VC_SIDEBAR_CHAT_CLOSE", }); + + const isPopoutOpen = useStateFromStores( + [PopoutWindowStore], () => isPopoutWindowOpen(channel.id), + [channel.id] + ); + + const openPopoutClick = useCallback(() => openPopout(channel.id), [channel.id]); + + const switchChannels = useCallback(() => { + const mainChannel = getCurrentChannel()!; + FluxDispatcher.dispatch({ + // @ts-ignore + type: "VC_SIDEBAR_CHAT_NEW", + guildId: mainChannel.guild_id, + id: mainChannel.id, + }); + ChannelRouter.transitionToChannel(channel.id); + }, [channel.id]); + + return ( + + + + + + } + > + + + ); +}; + +const RenderPopout = ErrorBoundary.wrap(({ channel, name, windowKey }: { channel: Channel; name: string; windowKey: string; }) => { + // Copy from an unexported function of the one they use in the experiment + // right click a channel and search withTitleBar:!0,windowKey + useEffect(() => { + if (!channel?.id || MessageStore.getLastMessage(channel.id)) return; + + MessageActions.fetchMessages({ + channelId: channel.id, + limit: 50, + }); + }, [channel?.id]); + + return ( + +
+ +
+
+ ); +}); + +function PopoutHeaderButton() { + const channelState = useStateFromStores( + [SelectedChannelStore, ChannelStore, PopoutWindowStore], + () => { + const channelId = SelectedChannelStore.getChannelId(); + const channel = channelId ? ChannelStore.getChannel(channelId) : null; + + return { + channel, + isOpen: channel ? isPopoutWindowOpen(channel.id) : false, + label: getChannelTitle(channel) + }; + }, + [] + ); + + if (!channelState.channel || !canOpenPopout(channelState.channel)) return null; + + const { channel, isOpen, label } = channelState; + + return ( + openPopout(channel.id)} + /> + ); +} + +const WrappedPopoutHeaderButton = ErrorBoundary.wrap(PopoutHeaderButton, { noop: true }); + +function PopoutPersistenceSync() { + const openWindowKeySignature = useStateFromStores( + [PopoutWindowStore], + () => getOpenPopoutWindowKeys().join("|"), + [] + ); + + useEffect(() => { + if (restoringPersistedPopouts) return; + syncPersistedPopoutWindows(); + }, [openWindowKeySignature]); + + return null; +} diff --git a/src/equicordplugins/sidebarChat/store.ts b/src/equicordplugins/sidebarChat/store.ts new file mode 100644 index 000000000..841306f39 --- /dev/null +++ b/src/equicordplugins/sidebarChat/store.ts @@ -0,0 +1,149 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { proxyLazy } from "@utils/lazy"; +import { OptionType } from "@utils/types"; +import { Flux as TFlux } from "@vencord/discord-types"; +import { ChannelActionCreators, Flux as FluxWP, FluxDispatcher, PopoutActions, PopoutWindowStore } from "@webpack/common"; + +interface IFlux extends TFlux { + PersistedStore: TFlux["Store"]; +} + +export const settings = definePluginSettings({ + persistSidebar: { + type: OptionType.BOOLEAN, + description: "Keep the sidebar chat open across Discord restarts", + default: true, + }, + persistPopoutWindows: { + type: OptionType.BOOLEAN, + description: "Restore open popout chats after Discord restarts.", + default: true, + onChange: value => { + if (!value) { + settings.store.persistedPopoutWindowIds = []; + return; + } + + syncPersistedPopoutWindows(); + } + }, + persistedPopoutWindowIds: { + type: OptionType.CUSTOM, + description: "Persisted popout chat channel IDs.", + default: [] as string[], + hidden: true + }, + popoutAlwaysOnTop: { + type: OptionType.BOOLEAN, + description: "Keep popout chat windows above all others.", + default: true, + onChange: value => { + setAlwaysOnTopForOpenPopouts(value); + } + }, +}); + +export const SidebarStore = proxyLazy(() => { + const current = { + guildId: "", + channelId: "", + width: 0 + }; + + let previous = { ...current }; + + class SidebarStore extends (FluxWP as IFlux).PersistedStore { + static persistKey = "SidebarStore"; + + // @ts-ignore + initialize(previousState: { guildId?: string; channelId?: string; width?: number; } | undefined) { + if (!settings.store.persistSidebar || !previousState) return; + const { guildId, channelId, width } = previousState; + current.guildId = guildId || ""; + current.channelId = channelId || ""; + current.width = width || 0; + } + + getState() { + return current; + } + } + + const store = new SidebarStore(FluxDispatcher, { + // @ts-ignore + async VC_SIDEBAR_CHAT_NEW({ guildId: newGId, id }: { guildId: string | null; id: string; }) { + previous = { ...current }; + + current.guildId = newGId || ""; + + if (current.guildId) { + current.channelId = id; + store.emitChange(); + return; + } + + current.channelId = await ChannelActionCreators.getOrEnsurePrivateChannel(id); + store.emitChange(); + }, + + VC_SIDEBAR_CHAT_PREVIOUS() { + if (previous.channelId) { + current.guildId = previous.guildId; + current.channelId = previous.channelId; + } + store.emitChange(); + }, + + VC_SIDEBAR_CHAT_CLOSE() { + previous = { ...current }; + current.guildId = ""; + current.channelId = ""; + store.emitChange(); + }, + }); + + return store; +}); + +const WINDOW_PREFIX = "DISCORD_VC_SC-"; + +export function getPopoutWindowKey(channelId: string) { + return `${WINDOW_PREFIX}${channelId}`; +} + +export function getOpenPopoutWindowKeys() { + return PopoutWindowStore.getWindowKeys().filter(key => key.startsWith(WINDOW_PREFIX)); +} + +export function isPopoutWindowOpen(channelId: string) { + return PopoutWindowStore.getWindowOpen(getPopoutWindowKey(channelId)); +} + +export function getPersistedPopoutChannelIds() { + return settings.store.persistedPopoutWindowIds ?? []; +} + +export function getOpenPopoutChannelIds() { + return getOpenPopoutWindowKeys().map(key => key.slice(WINDOW_PREFIX.length)); +} + +export function syncPersistedPopoutWindows() { + if (!settings.store.persistPopoutWindows) { + settings.store.persistedPopoutWindowIds = []; + return; + } + + settings.store.persistedPopoutWindowIds = getOpenPopoutChannelIds(); +} + +export function setAlwaysOnTopForOpenPopouts(value: boolean) { + for (const windowKey of getOpenPopoutWindowKeys()) { + PopoutActions.setAlwaysOnTop(windowKey, value); + } +} diff --git a/src/equicordplugins/sidebarChat/styles.css b/src/equicordplugins/sidebarChat/styles.css new file mode 100644 index 000000000..7f03ae8f5 --- /dev/null +++ b/src/equicordplugins/sidebarChat/styles.css @@ -0,0 +1,19 @@ +[class*="content"] > [class*="page"] > * { + min-width: 0; +} + +.vc-sidebar-chat-popout { + align-items: stretch; + background-color: var(--background-primary); +} + +.vc-sidebar-chat-window { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + overflow: hidden; + width: 100%; + background-color: var(--background-primary); + color: var(--text-default); +} \ No newline at end of file