diff --git a/src/plugins/baseConverter/BaseConverterAccessory.tsx b/src/plugins/baseConverter/BaseConverterAccessory.tsx
new file mode 100644
index 0000000000..1b3ccd1866
--- /dev/null
+++ b/src/plugins/baseConverter/BaseConverterAccessory.tsx
@@ -0,0 +1,164 @@
+/*
+ * Vencord, a modification for Discord's desktop app
+ * Copyright (c) 2024 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 { Message } from "@vencord/discord-types";
+import { ChannelStore, Parser, useEffect, useRef, useState, UserStore } from "@webpack/common";
+
+import { BaseConverterIcon } from "./BaseConverterIcon";
+import { settings } from "./settings";
+import { cl, ConversionResult, decode, EncodingType } from "./utils";
+
+const ConversionSetters = new Map void>();
+const DecodedMessages = new Map();
+const ReplyListeners = new Map void>>();
+
+function notifyDecode(messageId: string, data: ConversionResult) {
+ DecodedMessages.set(messageId, data);
+ ReplyListeners.get(messageId)?.forEach(fn => fn(data));
+}
+
+export function handleDecode(messageId: string, data: ConversionResult) {
+ notifyDecode(messageId, data);
+ ConversionSetters.get(messageId)?.(data);
+}
+
+function findMessageContentEl(messageId: string): HTMLElement | null {
+ return document.getElementById(`message-content-${messageId}`);
+}
+
+export function BaseConverterAccessory({ message }: { message: Message; }) {
+ const { autoDecodeReceived, receiveEncoding, aesSecret, userKeys } = settings.use(["autoDecodeReceived", "receiveEncoding", "aesSecret", "userKeys"]);
+ const authorId: string | undefined = (message as any).author?.id;
+ const currentUserId = UserStore.getCurrentUser()?.id;
+
+ // For your own messages in a DM the author is YOU, so userKeys[authorId] is
+ // meaningless. Use the DM partner's key instead, since that's who you encoded for.
+ let effectiveKey: string;
+ if (authorId && authorId === currentUserId) {
+ const channel = ChannelStore.getChannel(message.channel_id);
+ const partnerRaw = (channel as any)?.recipients?.[0];
+ const partnerId: string | undefined = typeof partnerRaw === "string" ? partnerRaw : partnerRaw?.id;
+ effectiveKey = (partnerId && userKeys?.[partnerId]) ? userKeys[partnerId] : aesSecret;
+ } else {
+ effectiveKey = (authorId && userKeys?.[authorId]) ? userKeys[authorId] : aesSecret;
+ }
+ const [result, setResult] = useState();
+ const [showOriginal, setShowOriginal] = useState(false);
+ const containerRef = useRef(null);
+
+ const referencedMessageId = (message as any).messageReference?.messageId
+ ?? (message as any).messageReference?.message_id;
+
+ const [referenceResult, setReferenceResult] = useState(
+ () => referencedMessageId ? DecodedMessages.get(referencedMessageId) : undefined
+ );
+
+ useEffect(() => {
+ if (!referencedMessageId) return;
+ const set = ReplyListeners.get(referencedMessageId) ?? new Set();
+ set.add(setReferenceResult);
+ ReplyListeners.set(referencedMessageId, set);
+ return () => {
+ set.delete(setReferenceResult);
+ if (!set.size) ReplyListeners.delete(referencedMessageId);
+ };
+ }, [referencedMessageId]);
+
+ useEffect(() => {
+ if ((message as any).vencordEmbeddedBy) return;
+
+ ConversionSetters.set(message.id, setResult);
+
+ if (autoDecodeReceived && message.content) {
+ decode(message.content, receiveEncoding as EncodingType, effectiveKey)
+ .then(decoded => {
+ if (decoded) {
+ setResult(decoded);
+ notifyDecode(message.id, decoded);
+ }
+ })
+ .catch(() => { /* silent — auto-decode is best-effort */ });
+ }
+
+ return () => void ConversionSetters.delete(message.id);
+ }, [message.id, autoDecodeReceived, receiveEncoding, effectiveKey]);
+
+ // Hide the original encrypted message content when decoded; show when toggled
+ useEffect(() => {
+ const mc = findMessageContentEl(message.id);
+ if (!mc) return;
+ mc.style.display = result && !showOriginal ? "none" : "";
+ return () => { mc.style.display = ""; };
+ }, [result, showOriginal]);
+
+ // Hide the reply bar's encoded reference text and show the decoded version
+ useEffect(() => {
+ if (!referenceResult) return;
+
+ const listItem = document.querySelector(
+ `li[id*="${message.id}"], [data-list-item-id*="${message.id}"]`
+ );
+ if (!listItem) return;
+
+ const replyContent = listItem.querySelector(
+ "[class*='repliedMessage'] [class*='messageContent']"
+ );
+ if (!replyContent || !replyContent.parentElement) return;
+
+ replyContent.style.display = "none";
+ const decoded = document.createElement("span");
+ decoded.textContent = referenceResult.text;
+ replyContent.parentElement.insertBefore(decoded, replyContent);
+
+ return () => {
+ replyContent.style.display = "";
+ decoded.remove();
+ };
+ }, [referenceResult]);
+
+ // Match decoded text color to the actual message content color
+ useEffect(() => {
+ if (!result || !containerRef.current) return;
+ const mc = findMessageContentEl(message.id);
+ if (!mc) return;
+ const color = window.getComputedStyle(mc).color;
+ containerRef.current.style.color = color;
+ return () => { if (containerRef.current) containerRef.current.style.color = ""; };
+ }, [result]);
+
+ if (!result) return null;
+
+ return (
+
+
+ {Parser.parse(result.text)}
+
+
+ {result.encoding}
+ {" — "}
+
+ {" — "}
+
+
+
+ );
+}
diff --git a/src/plugins/baseConverter/BaseConverterIcon.tsx b/src/plugins/baseConverter/BaseConverterIcon.tsx
new file mode 100644
index 0000000000..4760fa3e80
--- /dev/null
+++ b/src/plugins/baseConverter/BaseConverterIcon.tsx
@@ -0,0 +1,97 @@
+/*
+ * Vencord, a modification for Discord's desktop app
+ * Copyright (c) 2024 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 { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
+import { TooltipContainer } from "@components/TooltipContainer";
+import { classes } from "@utils/misc";
+import { IconComponent } from "@utils/types";
+import { useEffect, useState } from "@webpack/common";
+
+import { settings } from "./settings";
+import { openBaseConverterModal } from "./BaseConverterModal";
+import { cl } from "./utils";
+
+/**
+ * Binary bars → arrow → text lines icon.
+ * Three vertical bars on the left represent binary digits (tall=1, short=0),
+ * a filled arrow points right, and three horizontal lines suggest decoded text.
+ */
+export const BaseConverterIcon: IconComponent = ({ height = 20, width = 20, className }) => (
+
+);
+
+export let setShouldShowAutoEncodeTooltip: undefined | ((show: boolean) => void);
+
+export const BaseConverterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
+ const { autoEncodeOutgoing } = settings.use(["autoEncodeOutgoing"]);
+
+ const [shouldShowTooltip, setter] = useState(false);
+ useEffect(() => {
+ setShouldShowAutoEncodeTooltip = setter;
+ return () => { setShouldShowAutoEncodeTooltip = undefined; };
+ }, []);
+
+ if (!isMainChat) return null;
+
+ const toggle = () => {
+ settings.store.autoEncodeOutgoing = !autoEncodeOutgoing;
+ };
+
+ const button = (
+ {
+ if (e.shiftKey) return toggle();
+ openBaseConverterModal();
+ }}
+ onContextMenu={toggle}
+ buttonProps={{ "aria-haspopup": "dialog" }}
+ >
+
+
+ );
+
+ if (shouldShowTooltip && autoEncodeOutgoing)
+ return (
+
+ {button}
+
+ );
+
+ return button;
+};
diff --git a/src/plugins/baseConverter/BaseConverterModal.tsx b/src/plugins/baseConverter/BaseConverterModal.tsx
new file mode 100644
index 0000000000..ef58f379ac
--- /dev/null
+++ b/src/plugins/baseConverter/BaseConverterModal.tsx
@@ -0,0 +1,201 @@
+/*
+ * Vencord, a modification for Discord's desktop app
+ * Copyright (c) 2024 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 { BaseText } from "@components/BaseText";
+import { Divider } from "@components/Divider";
+import { FormSwitch } from "@components/FormSwitch";
+import { Margins } from "@utils/margins";
+import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
+import { Forms, SearchableSelect, UserStore, useState } from "@webpack/common";
+
+import { settings } from "./settings";
+import { openUserKeyModal } from "./UserKeyModal";
+import { cl, DECODE_OPTIONS, ENCODE_OPTIONS } from "./utils";
+
+function EncodingSelect({
+ label,
+ settingsKey,
+ options,
+}: {
+ label: string;
+ settingsKey: "receiveEncoding" | "sendEncoding";
+ options: typeof DECODE_OPTIONS | typeof ENCODE_OPTIONS;
+}) {
+ const currentValue = settings.use([settingsKey])[settingsKey];
+
+ return (
+
+ {label}
+ (settings.store[settingsKey] = v)}
+ />
+
+ );
+}
+
+function AesSecretInput() {
+ const aesSecret = settings.use(["aesSecret"]).aesSecret;
+ const [visible, setVisible] = useState(false);
+
+ return (
+
+ Shared AES-256-GCM Secret Key
+
+ Both users must enter the exact same key to encrypt and decrypt each other's messages.
+ The key never leaves your device — all encryption happens locally.
+ It is stored in plain text in your local Vencord settings.
+
+
+
+ );
+}
+
+function AutoDecodeToggle() {
+ const value = settings.use(["autoDecodeReceived"]).autoDecodeReceived;
+ return (
+ (settings.store.autoDecodeReceived = v)}
+ hideBorder
+ />
+ );
+}
+
+function AutoEncodeToggle() {
+ const value = settings.use(["autoEncodeOutgoing"]).autoEncodeOutgoing;
+ return (
+ (settings.store.autoEncodeOutgoing = v)}
+ hideBorder
+ />
+ );
+}
+
+function UserKeysSection() {
+ const userKeys = settings.use(["userKeys"]).userKeys ?? {};
+ const entries = Object.entries(userKeys);
+
+ if (entries.length === 0) return null;
+
+ return (
+ <>
+
+
+ Per-User AES Keys
+
+ These keys override the global secret for specific users. Right-click a user to add or update a key.
+
+ {entries.map(([userId]) => {
+ const username = UserStore.getUser(userId)?.username ?? userId;
+ return (
+
+ @{username}
+
+
+
+ );
+ })}
+
+ >
+ );
+}
+
+function BaseConverterModal({ rootProps }: { rootProps: ModalProps; }) {
+ return (
+
+
+
+ Base Converter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function openBaseConverterModal() {
+ openModal(props => );
+}
diff --git a/src/plugins/baseConverter/UserKeyModal.tsx b/src/plugins/baseConverter/UserKeyModal.tsx
new file mode 100644
index 0000000000..743a5f3873
--- /dev/null
+++ b/src/plugins/baseConverter/UserKeyModal.tsx
@@ -0,0 +1,117 @@
+/*
+ * Vencord, a modification for Discord's desktop app
+ * Copyright (c) 2024 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 { BaseText } from "@components/BaseText";
+import { Margins } from "@utils/margins";
+import { ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal";
+import { Forms, useState } from "@webpack/common";
+
+import { settings } from "./settings";
+import { cl } from "./utils";
+
+function UserKeyModal({ rootProps, userId, username }: { rootProps: ModalProps; userId: string; username: string; }) {
+ const existingKey = settings.store.userKeys?.[userId] ?? "";
+ const [key, setKey] = useState(existingKey);
+ const [visible, setVisible] = useState(false);
+
+ const save = () => {
+ const trimmed = key.trim();
+ if (!trimmed) return;
+ settings.store.userKeys = { ...settings.store.userKeys, [userId]: trimmed };
+ rootProps.onClose();
+ };
+
+ const clear = () => {
+ const { [userId]: _, ...rest } = settings.store.userKeys ?? {};
+ settings.store.userKeys = rest;
+ rootProps.onClose();
+ };
+
+ return (
+
+
+
+ AES Key — @{username}
+
+
+
+
+
+
+ Per-User AES-256-GCM Secret Key
+
+ This key overrides the global secret when sending to or auto-decoding messages from @{username}.
+ Both users must enter the exact same key. Stored in plain text in your local Vencord settings.
+
+