SidebarChat Returns for nowTM

This commit is contained in:
thororen1234 2026-07-15 22:31:22 -04:00
parent 939f0de522
commit 6f19368b70
No known key found for this signature in database
4 changed files with 761 additions and 1 deletions

View file

@ -17,6 +17,14 @@ on:
visible for everyone, so DO NOT pass sensitive webhooks like visible for everyone, so DO NOT pass sensitive webhooks like
discord webhook. This is meant to be used by Equibot." discord webhook. This is meant to be used by Equibot."
required: false 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: schedule:
# # Every day at midnight # # Every day at midnight
- cron: 0 0 * * * - cron: 0 0 * * *
@ -32,7 +40,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: 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 - uses: pnpm/action-setup@v6

View file

@ -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 (
<svg
aria-hidden="true"
role="img"
xmlns="http://www.w3.org/2000/svg"
fill={color}
viewBox="0 0 24 24"
{...rest}>
<path d="M2.3 7.7a1 1 0 0 1 0-1.4l4-4a1 1 0 0 1 1.4 1.4L5.42 6H21a1 1 0 1 1 0 2H5.41l2.3 2.3a1 1 0 1 1-1.42 1.4l-4-4ZM17.7 21.7l4-4a1 1 0 0 0 0-1.4l-4-4a1 1 0 0 0-1.4 1.4l2.29 2.3H3a1 1 0 1 0 0 2h15.59l-2.3 2.3a1 1 0 0 0 1.42 1.4Z" />
</svg>
);
};
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,
() => <RenderPopout channel={channel} name={title} windowKey={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 (
<Menu.MenuItem
id={`vc-sidebar-chat-${id}`}
label={"Open Sidebar Chat"}
action={() => {
FluxDispatcher.dispatch({
// @ts-ignore
type: "VC_SIDEBAR_CHAT_NEW",
guildId,
id,
});
}}
/>
);
};
const createPopoutChatContextMenuItem = (id: string, label: string, action: () => void | Promise<void>) => {
return (
<Menu.MenuItem
id={`vc-sidebar-chat-popout-${id}`}
label={label}
action={() => {
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: () => (
<>
<PopoutPersistenceSync />
<WrappedPopoutHeaderButton />
</>
)
},
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<React.ReactNode>(null);
useEffect(() => {
if (!channel) return;
if (channel.isForumLikeChannel()) {
requireForumView().then(() => {
setViewComponent(
<ForumView
channel={channel}
guild={guild}
sidebarState={null}
/>
);
});
setViewComponent(
<div className={ChatClasses.loader}>
<WanderingCubesLoading />
</div>
);
} else {
setViewComponent(
<Chat
channel={channel}
guild={guild}
chatInputType={ChatInputTypes.SIDEBAR}
/>
);
}
}, [channel]);
if (!channel || channelSidebar || guildSidebar) return null;
return (
<ErrorBoundary noop>
<Resize
sidebarType={Sidebars.MessageRequestSidebar}
maxWidth={~~(width * 0.31)/* width - 690*/}
>
<Header channel={channel} guild={guild} />
{View}
</Resize>
</ErrorBoundary>
);
},
});
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 (
<HeaderBar
toolbar={
<>
<HeaderBarButton icon={ArrowsLeftRightIcon} tooltip="Switch channels" onClick={switchChannels} />
<HeaderBarButton
key={`${channel.id}-${isPopoutOpen ? "open" : "closed"}`}
icon={isPopoutOpen ? XSmallIcon : WindowLaunchIcon}
tooltip={isPopoutOpen ? "Close popout chat" : "Popout chat"}
selected={isPopoutOpen}
onClick={openPopoutClick}
/>
<HeaderBarButton icon={XSmallIcon} tooltip="Close Sidebar Chat" onClick={closeSidebar} />
</>
}
>
<ChannelHeader
channel={channel}
channelName={name}
guild={guild}
parentChannel={parentChannel}
/>
</HeaderBar>
);
};
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 (
<PopoutWindow
withTitleBar
windowKey={windowKey}
title={name || "Equicord"}
channelId={channel.id}
>
<div className={cl("window")}>
<FullChannelView providedChannel={channel} />
</div>
</PopoutWindow>
);
});
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 (
<HeaderBarButton
key={`${channel.id}-${isOpen ? "open" : "closed"}`}
icon={isOpen ? XSmallIcon : WindowLaunchIcon}
tooltip={isOpen ? "Close popout chat" : `Popout chat for ${label}`}
aria-label="Popout chat"
selected={isOpen}
onClick={() => 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;
}

View file

@ -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);
}
}

View file

@ -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);
}