Merge branch 'dev'

This commit is contained in:
thororen1234 2026-04-15 15:44:32 -04:00
commit 3104735cb1
No known key found for this signature in database
44 changed files with 1217 additions and 526 deletions

View file

@ -23,10 +23,10 @@ jobs:
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies
@ -72,6 +72,7 @@ jobs:
gh release upload latest --clobber dist/release/*
- name: Upload Plugin JSONs to Equibored repo
if: github.repository == 'Equicord/Equicord'
run: |
git config --global user.name "GitHub-Actions"
git config --global user.email actions@github.com

View file

@ -1,4 +1,4 @@
name: Build
name: Build Branches
on:
push:
@ -28,6 +28,7 @@ jobs:
Build:
name: Build Equicord
runs-on: ubuntu-latest
if: github.repository == 'Equicord/Equicord'
steps:
- uses: actions/checkout@v4
@ -37,11 +38,11 @@ jobs:
- uses: pnpm/action-setup@v3
if: github.ref_name != 'bun'
- name: Use Node.js 20
- name: Use Node.js 24
if: github.ref_name != 'bun'
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies
@ -64,9 +65,8 @@ jobs:
pnpm generateVencordPluginJson dist/vencordplugins.json
pnpm generateDevsList dist/devs.json
- uses: actions/checkout@v4
if: github.ref_name == 'bun'
- uses: oven-sh/setup-bun@v2
if: github.ref_name == 'bun'
with:
bun-version: latest
- name: Install dependencies

View file

@ -20,10 +20,10 @@ jobs:
- uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies
@ -50,7 +50,7 @@ jobs:
cp browser/browser.* release
cp Equicord.user.{js,js.LEGAL.txt} release
# copy the plugin data jsons, the extension zips and the desktop/vesktop asars
# copy the plugin data jsons, the extension zips and the desktop/equibop asars
cp *.{json,zip,asar} release
# legacy un-asared files

View file

@ -62,11 +62,11 @@ jobs:
if: steps.check.outputs.release == 'true'
uses: pnpm/action-setup@v3
- name: Use Node.js 20
- name: Use Node.js 24
if: steps.check.outputs.release == 'true'
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies

View file

@ -33,10 +33,10 @@ jobs:
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies

View file

@ -18,10 +18,10 @@ jobs:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3 # Install pnpm using packageManager key in package.json
- name: Use Node.js 20
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"
- name: Install dependencies

View file

@ -18,97 +18,31 @@
* limitations under the License.
*/
import { Logger } from "@utils/Logger";
const logger = new Logger("DataStore");
let loggedFailure = false;
const memoryDatabases = new Map<string, Map<string, Map<IDBValidKey, unknown>>>();
interface MemoryStore {
__memoryStore: true;
get<T>(key: IDBValidKey): T | undefined;
put(value: unknown, key: IDBValidKey): void;
delete(key: IDBValidKey): void;
clear(): void;
getAllKeys<KeyType extends IDBValidKey>(): KeyType[];
getAll<T>(): T[];
entries<KeyType extends IDBValidKey, ValueType>(): [KeyType, ValueType][];
}
type StoreLike = IDBObjectStore | MemoryStore;
function handleFailure(error: unknown) {
if (loggedFailure) return;
loggedFailure = true;
logger.error("IndexedDB unavailable, using empty storage.", error);
}
function withFailureFallback<T>(promise: Promise<T>, fallback: T): Promise<T> {
return promise.catch(error => {
handleFailure(error);
return fallback;
});
}
function getMemoryStore(dbName: string, storeName: string): MemoryStore {
let database = memoryDatabases.get(dbName);
if (!database) {
database = new Map();
memoryDatabases.set(dbName, database);
}
let data = database.get(storeName);
if (!data) {
data = new Map<IDBValidKey, unknown>();
database.set(storeName, data);
}
return {
__memoryStore: true,
get: <T>(key: IDBValidKey) => data.get(key) as T | undefined,
put: (value, key) => { data.set(key, value); },
delete: key => { data.delete(key); },
clear: () => { data.clear(); },
getAllKeys: <KeyType extends IDBValidKey>() => Array.from(data.keys()) as KeyType[],
getAll: <T>() => Array.from(data.values()) as T[],
entries: <KeyType extends IDBValidKey, ValueType>() => Array.from(data.entries()) as [KeyType, ValueType][]
};
}
function isMemoryStore(store: StoreLike): store is MemoryStore {
return "__memoryStore" in store;
}
export function promisifyRequest<T = undefined>(
request: IDBRequest<T> | IDBTransaction,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const target = request as IDBRequest<T> & IDBTransaction;
target.oncomplete = target.onsuccess = () => resolve(target.result);
target.onabort = target.onerror = () => reject(target.error);
// @ts-expect-error - file size hacks
request.oncomplete = request.onsuccess = () => resolve(request.result);
// @ts-expect-error - file size hacks
request.onabort = request.onerror = () => reject(request.error);
});
}
export function createStore(dbName: string, storeName: string): UseStore {
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
const dbp = promisifyRequest(request).catch(error => {
handleFailure(error);
return null;
});
const memoryStore = getMemoryStore(dbName, storeName);
const dbp = promisifyRequest(request);
return async (txMode, callback) => {
const db = await dbp;
if (!db) return callback(memoryStore);
return callback(db.transaction(storeName, txMode).objectStore(storeName));
};
return (txMode, callback) =>
dbp.then(db =>
callback(db.transaction(storeName, txMode).objectStore(storeName)),
);
}
export type UseStore = <T>(
txMode: IDBTransactionMode,
callback: (store: StoreLike) => T | PromiseLike<T>,
callback: (store: IDBObjectStore) => T | PromiseLike<T>,
) => Promise<T>;
let defaultGetStoreFunc: UseStore | undefined;
@ -120,68 +54,88 @@ function defaultGetStore() {
return defaultGetStoreFunc;
}
/**
* Get a value by its key.
*
* @param key
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function get<T = any>(
key: IDBValidKey,
customStore = defaultGetStore(),
): Promise<T | undefined> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.get(key) as T | undefined;
return promisifyRequest(store.get(key));
}), undefined);
return customStore("readonly", store => promisifyRequest(store.get(key)));
}
/**
* Set a value with a key.
*
* @param key
* @param value
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function set(
key: IDBValidKey,
value: any,
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.put(value, key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
/**
* Set multiple values at once. This is faster than calling set() multiple times.
* It's also atomic – if one of the pairs can't be added, none will be added.
*
* @param entries Array of entries, where each entry is an array of `[key, value]`.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function setMany(
entries: [IDBValidKey, any][],
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
entries.forEach(entry => store.put(entry[1], entry[0]));
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
/**
* Get multiple values by their keys
*
* @param keys
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function getMany<T = any>(
keys: IDBValidKey[],
customStore = defaultGetStore(),
): Promise<T[]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) {
return keys.map(key => store.get(key) as T | undefined);
}
return Promise.all(keys.map(key => promisifyRequest(store.get(key))));
}), []);
return customStore("readonly", store =>
Promise.all(keys.map(key => promisifyRequest(store.get(key)))),
);
}
/**
* Update a value. This lets you see the old value and update it as an atomic operation.
*
* @param key
* @param updater A callback that takes the old value and returns a new value.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function update<T = any>(
key: IDBValidKey,
updater: (oldValue: T | undefined) => T,
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore(
return customStore(
"readwrite",
store => {
if (isMemoryStore(store)) {
store.put(updater(store.get(key) as T | undefined), key);
return;
}
return new Promise((resolve, reject) => {
store =>
// Need to create the promise manually.
// If I try to chain promises, the transaction closes in browsers
// that use a promise polyfill (IE10/11).
new Promise((resolve, reject) => {
store.get(key).onsuccess = function () {
try {
store.put(updater(this.result), key);
@ -190,42 +144,52 @@ export function update<T = any>(
reject(err);
}
};
});
},
), undefined);
}),
);
}
/**
* Delete a particular key from the store.
*
* @param key
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function del(
key: IDBValidKey,
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.delete(key);
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
/**
* Delete multiple keys at once.
*
* @param keys List of keys to delete.
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function delMany(
keys: IDBValidKey[],
customStore = defaultGetStore(),
): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
keys.forEach(key => store.delete(key));
if (isMemoryStore(store)) return;
return customStore("readwrite", (store: IDBObjectStore) => {
keys.forEach((key: IDBValidKey) => store.delete(key));
return promisifyRequest(store.transaction);
}), undefined);
});
}
/**
* Clear all values in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function clear(customStore = defaultGetStore()): Promise<void> {
return withFailureFallback(customStore("readwrite", store => {
return customStore("readwrite", store => {
store.clear();
if (isMemoryStore(store)) return;
return promisifyRequest(store.transaction);
}), undefined);
});
}
function eachCursor(
@ -240,12 +204,16 @@ function eachCursor(
return promisifyRequest(store.transaction);
}
/**
* Get all keys in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function keys<KeyType extends IDBValidKey>(
customStore = defaultGetStore(),
): Promise<KeyType[]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAllKeys<KeyType>();
return customStore("readonly", store => {
// Fast path for modern browsers
if (store.getAllKeys) {
return promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
@ -253,45 +221,59 @@ export function keys<KeyType extends IDBValidKey>(
}
const items: KeyType[] = [];
return eachCursor(store, cursor =>
items.push(cursor.key as KeyType),
).then(() => items);
}), [] as KeyType[]);
});
}
/**
* Get all values in the store.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function values<T = any>(customStore = defaultGetStore()): Promise<T[]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.getAll<T>();
return customStore("readonly", store => {
// Fast path for modern browsers
if (store.getAll) {
return promisifyRequest(store.getAll() as IDBRequest<T[]>);
}
const items: T[] = [];
return eachCursor(store, cursor => items.push(cursor.value as T)).then(
() => items,
);
}), [] as T[]);
});
}
/**
* Get all entries in the store. Each entry is an array of `[key, value]`.
*
* @param customStore Method to get a custom store. Use with caution (see the docs).
*/
export function entries<KeyType extends IDBValidKey, ValueType = any>(
customStore = defaultGetStore(),
): Promise<[KeyType, ValueType][]> {
return withFailureFallback(customStore("readonly", store => {
if (isMemoryStore(store)) return store.entries<KeyType, ValueType>();
return customStore("readonly", store => {
// Fast path for modern browsers
// (although, hopefully we'll get a simpler path some day)
if (store.getAll && store.getAllKeys) {
return Promise.all([
promisifyRequest(
store.getAllKeys() as unknown as IDBRequest<KeyType[]>,
),
promisifyRequest(store.getAll() as IDBRequest<ValueType[]>),
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]] as [KeyType, ValueType]));
]).then(([keys, values]) => keys.map((key, i) => [key, values[i]]));
}
const items: [KeyType, ValueType][] = [];
return eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items);
}), [] as [KeyType, ValueType][]);
return customStore("readonly", store =>
eachCursor(store, cursor =>
items.push([cursor.key as KeyType, cursor.value]),
).then(() => items),
);
});
}

View file

@ -22,8 +22,8 @@ export default definePlugin({
find: ".DISPLAY_NAME_STYLES_COACHMARK)",
replacement: [
{
match: /(?<=className:(\i)\.\i,style:\i,)children:\[/,
replace: "children:[...$self.renderButtons(arguments[0],$1),"
match: /children:\[(?=.{0,50}accountContainerRef:\i)/,
replace: "children:[...$self.renderButtons(arguments[0]),"
},
// fix discord weird shrink with extra buttons
{

View file

@ -45,7 +45,7 @@ export default definePlugin({
// Show all activities in the user popout/sidebar
find: /onOpenUserProfileModal:\i,onClose:\i\}\),null/,
replacement: {
match: /((\i)=.{0,10}(\i)\.id\).*?,onInteraction:\i\}\),).{0,250}onClose:\i\}\)/,
match: /((\i)=.{0,10}(\i)\.id\).*?,onOpenUserProfileModal:\i\}\),).{0,250}onClose:\i\}\)/,
replace: "$1$self.showAllActivitiesComponent({ activity: $2, user: $3 })"
},
predicate: () => settings.store.userPopout

View file

@ -0,0 +1,148 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChannelToolbarButton } from "@api/HeaderBar";
import ErrorBoundary from "@components/ErrorBoundary";
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import { classes } from "@utils/misc";
import definePlugin from "@utils/types";
import { ContextMenuApi, Menu } from "@webpack/common";
import type { ReactNode, SVGProps } from "react";
import { PanelId, panelRegistry, settings, toolbarPanelOrder } from "./settings";
import managedStyle from "./style.css?managed";
const cl = classNameFactory("vc-collapsible-ui-");
type StateStore = ReturnType<typeof settings.use>;
function PanelsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<path fill="currentColor" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3H3V5Zm0 5h6v11H5a2 2 0 0 1-2-2V10Zm8 0h10v9a2 2 0 0 1-2 2H11V10Zm2-5h8v3h-8V5Z" />
</svg>
);
}
function getStateClasses(store: StateStore) {
const classNames = [cl("state"), cl(`transition-${store.transitionMs}`)];
for (const panelId of toolbarPanelOrder) {
const panel = panelRegistry[panelId];
if (store[panel.collapsedKey]) classNames.push(cl(`${panel.classId}-collapsed`));
}
return classes(...classNames);
}
function togglePanel(panelId: PanelId) {
const key = panelRegistry[panelId].collapsedKey;
settings.store[key] = !settings.store[key];
}
const ToolbarMenu = ErrorBoundary.wrap(({ onClose }: { onClose(): void; }) => {
const store = settings.use();
return (
<Menu.Menu navId="vc-collapsible-ui-toolbar-menu" onClose={onClose} aria-label="Collapsible UI">
{toolbarPanelOrder.map(panelId => {
const panel = panelRegistry[panelId];
const collapsed = store[panel.collapsedKey];
return (
<Menu.MenuCheckboxItem
key={panelId}
id={`vc-collapsible-ui-${panel.classId}`}
label={panel.label}
checked={!collapsed}
action={() => togglePanel(panelId)}
/>
);
})}
</Menu.Menu>
);
}, { noop: true });
const ToolbarButtons = ErrorBoundary.wrap(() => {
const store = settings.use();
const anyCollapsed = toolbarPanelOrder.some(panelId => store[panelRegistry[panelId].collapsedKey]);
return (
<>
<span aria-hidden className={getStateClasses(store)} style={{ display: "none" }} />
<ChannelToolbarButton
icon={PanelsIcon}
tooltip="Collapsible UI"
aria-label="Collapsible UI"
selected={anyCollapsed}
onClick={event => ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />)}
onContextMenu={event => ContextMenuApi.openContextMenu(event, () => <ToolbarMenu onClose={ContextMenuApi.closeContextMenu} />)}
/>
</>
);
}, { noop: true });
const ChatButtonsRow = ErrorBoundary.wrap(({ buttons }: { buttons: ReactNode[]; }) => {
const { chatButtonsCollapsed } = settings.use();
if (buttons.length === 0) return <>{buttons}</>;
return (
<div
className={classes(
cl("chat-buttons"),
chatButtonsCollapsed && cl("chat-buttons-collapsed")
)}
>
<div className={cl("chat-buttons-items")}>
{buttons}
</div>
</div>
);
}, { noop: true });
export default definePlugin({
name: "CollapsibleUI",
description: "Native collapsible channel, member, chat button, and user area surfaces.",
authors: [EquicordDevs.benjii],
tags: ["ui", "sidebar", "layout"],
managedStyle,
settings,
headerBarButton: {
icon: PanelsIcon,
location: "channeltoolbar",
priority: 25,
render: () => <ToolbarButtons />,
},
patches: [
{
find: ".DISPLAY_NAME_STYLES_COACHMARK)",
replacement: {
match: /className:(\i\.\i),(?=style:(\i),children:\[)/,
replace: "className:$self.userAreaControlsClass($1),"
}
},
{
find: '"sticker")',
replacement: {
match: /(?<="div",\{.{0,15}children:)(.+?)\}/,
replace: "$self.wrapChatButtons($1)}"
}
}
],
wrapChatButtons(buttons: ReactNode[]) {
if (!Array.isArray(buttons) || buttons.length === 0) return buttons;
return <ChatButtonsRow buttons={buttons} />;
},
userAreaControlsClass(className: string) {
return classes(className, cl("user-area-controls"));
},
});

View file

@ -0,0 +1,70 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const transitionMarkers = [0, 80, 120, 160, 200, 260, 320];
export const panelRegistry = {
channelList: {
id: "channelList",
classId: "channel-list",
label: "Channel List",
collapsedKey: "channelListCollapsed",
},
membersList: {
id: "membersList",
classId: "members-list",
label: "Members List",
collapsedKey: "membersListCollapsed",
},
chatButtons: {
id: "chatButtons",
classId: "chat-buttons",
label: "Message Buttons",
collapsedKey: "chatButtonsCollapsed",
},
userArea: {
id: "userArea",
classId: "user-area",
label: "User Area",
collapsedKey: "userAreaCollapsed",
},
} as const;
export type PanelId = keyof typeof panelRegistry;
export const toolbarPanelOrder = ["channelList", "membersList", "chatButtons", "userArea"] as const satisfies readonly PanelId[];
export const settings = definePluginSettings({
transitionMs: {
type: OptionType.SLIDER,
description: "Panel transition speed in milliseconds.",
default: 160,
markers: transitionMarkers,
},
channelListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the channel list as collapsed.",
default: false,
},
membersListCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the members list as collapsed.",
default: false,
},
chatButtonsCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the message button row as collapsed.",
default: false,
},
userAreaCollapsed: {
type: OptionType.BOOLEAN,
description: "Persist the user area as collapsed.",
default: false,
},
});

View file

@ -0,0 +1,213 @@
body:has(.vc-collapsible-ui-state) {
--vc-cui-chat-buttons-width: min(320px, 36vw);
--vc-cui-collapsed-size: 12px;
--vc-cui-hover-peek: 18px;
--vc-cui-hover-delay: 120ms;
--vc-cui-user-area-collapsed-width: 52px;
--vc-cui-transition: 160ms;
--vc-cui-ease: cubic-bezier(.2, 0, 0, 1);
}
body:has(.vc-collapsible-ui-transition-0) {
--vc-cui-transition: 0ms;
}
body:has(.vc-collapsible-ui-transition-80) {
--vc-cui-transition: 80ms;
}
body:has(.vc-collapsible-ui-transition-120) {
--vc-cui-transition: 120ms;
}
body:has(.vc-collapsible-ui-transition-160) {
--vc-cui-transition: 160ms;
}
body:has(.vc-collapsible-ui-transition-200) {
--vc-cui-transition: 200ms;
}
body:has(.vc-collapsible-ui-transition-260) {
--vc-cui-transition: 260ms;
}
body:has(.vc-collapsible-ui-transition-320) {
--vc-cui-transition: 320ms;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebarList_"]:hover) [class*="sidebarList_"] {
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebarList_"]:hover) [class*="sidebarList_"] > * {
opacity: 1;
pointer-events: auto;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebarList_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebarList_"] {
width: var(--vc-cui-collapsed-size) !important;
inline-size: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
min-inline-size: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important;
max-inline-size: var(--vc-cui-collapsed-size) !important;
flex: 0 0 var(--vc-cui-collapsed-size) !important;
overflow: hidden !important;
transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
inline-size var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
min-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease),
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
flex-basis var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebarList_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebarList_"] > * {
opacity: 0;
pointer-events: none;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover)) [class*="sidebar_"] > [class*="panels_"] {
width: var(--vc-cui-user-area-collapsed-width) !important;
inline-size: var(--vc-cui-user-area-collapsed-width) !important;
min-width: var(--vc-cui-user-area-collapsed-width) !important;
min-inline-size: var(--vc-cui-user-area-collapsed-width) !important;
max-width: var(--vc-cui-user-area-collapsed-width) !important;
max-inline-size: var(--vc-cui-user-area-collapsed-width) !important;
overflow: hidden !important;
transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
inline-size var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
min-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease),
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover)) .vc-collapsible-ui-user-area-controls {
max-width: 0 !important;
min-width: 0 !important;
overflow: hidden !important;
opacity: 0;
pointer-events: none;
transition:
max-width var(--vc-cui-transition) var(--vc-cui-ease),
opacity var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-channel-list-collapsed):has([class*="sidebar_"] > [class*="panels_"]:hover) .vc-collapsible-ui-user-area-controls {
opacity: 1;
pointer-events: auto;
}
body:has(.vc-collapsible-ui-members-list-collapsed) [class*="membersWrap"] {
overflow: hidden !important;
transition:
width var(--vc-cui-transition) var(--vc-cui-ease),
min-width var(--vc-cui-transition) var(--vc-cui-ease),
max-width var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:hover) [class*="membersWrap"],
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:focus-within) [class*="membersWrap"] {
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:hover) [class*="membersWrap"] > :where(*),
body:has(.vc-collapsible-ui-members-list-collapsed):has([class*="membersWrap"]:focus-within) [class*="membersWrap"] > :where(*) {
opacity: 1;
pointer-events: auto;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
transition-delay: var(--vc-cui-hover-delay);
}
body:has(.vc-collapsible-ui-members-list-collapsed):not(:has([class*="membersWrap"]:hover), :has([class*="membersWrap"]:focus-within)) [class*="membersWrap"] {
width: var(--vc-cui-collapsed-size) !important;
min-width: var(--vc-cui-collapsed-size) !important;
max-width: var(--vc-cui-collapsed-size) !important;
}
body:has(.vc-collapsible-ui-members-list-collapsed):not(:has([class*="membersWrap"]:hover), :has([class*="membersWrap"]:focus-within)) [class*="membersWrap"] > :where(*) {
opacity: 0;
pointer-events: none;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
body:has(.vc-collapsible-ui-user-area-collapsed):has([class*="sidebar_"] > [class*="panels_"]:hover) [class*="sidebar_"] > [class*="panels_"] > :where(*),
body:has(.vc-collapsible-ui-user-area-collapsed):has([class*="sidebar_"] > [class*="panels_"]:focus-within) [class*="sidebar_"] > [class*="panels_"] > :where(*) {
opacity: 1;
pointer-events: auto;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
body:has(.vc-collapsible-ui-user-area-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:focus-within)) [class*="sidebar_"] > [class*="panels_"] {
height: var(--vc-cui-hover-peek) !important;
block-size: var(--vc-cui-hover-peek) !important;
min-height: var(--vc-cui-hover-peek) !important;
min-block-size: var(--vc-cui-hover-peek) !important;
max-height: var(--vc-cui-hover-peek) !important;
max-block-size: var(--vc-cui-hover-peek) !important;
overflow: hidden !important;
border-top-width: 0 !important;
padding-block: 0 !important;
transition:
height var(--vc-cui-transition) var(--vc-cui-ease),
block-size var(--vc-cui-transition) var(--vc-cui-ease),
min-height var(--vc-cui-transition) var(--vc-cui-ease),
min-block-size var(--vc-cui-transition) var(--vc-cui-ease),
max-height var(--vc-cui-transition) var(--vc-cui-ease),
max-block-size var(--vc-cui-transition) var(--vc-cui-ease),
padding var(--vc-cui-transition) var(--vc-cui-ease) !important;
}
body:has(.vc-collapsible-ui-user-area-collapsed):not(:has([class*="sidebar_"] > [class*="panels_"]:hover), :has([class*="sidebar_"] > [class*="panels_"]:focus-within)) [class*="sidebar_"] > [class*="panels_"] > :where(*) {
opacity: 0;
pointer-events: none;
transition: opacity var(--vc-cui-transition) var(--vc-cui-ease);
}
.vc-collapsible-ui-chat-buttons {
display: flex;
align-items: center;
min-inline-size: 0;
}
.vc-collapsible-ui-chat-buttons-collapsed {
min-inline-size: var(--vc-cui-hover-peek);
}
.vc-collapsible-ui-chat-buttons-items {
display: flex;
align-items: center;
min-inline-size: 0;
max-inline-size: var(--vc-cui-chat-buttons-width);
overflow: hidden;
transition:
max-inline-size var(--vc-cui-transition) var(--vc-cui-ease),
opacity var(--vc-cui-transition) var(--vc-cui-ease),
margin var(--vc-cui-transition) var(--vc-cui-ease);
}
.vc-collapsible-ui-chat-buttons-collapsed .vc-collapsible-ui-chat-buttons-items {
max-inline-size: 0;
opacity: 0;
pointer-events: none;
}
.vc-collapsible-ui-chat-buttons-collapsed:hover .vc-collapsible-ui-chat-buttons-items {
max-inline-size: var(--vc-cui-chat-buttons-width);
opacity: 1;
pointer-events: auto;
transition-delay: var(--vc-cui-hover-delay);
}
@media (prefers-reduced-motion: reduce) {
body:has(.vc-collapsible-ui-state) {
--vc-cui-transition: 0ms;
}
}

View file

@ -0,0 +1,115 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
const Millis = {
HALF_SECOND: 500,
SECOND: 1e3,
MINUTE: 6e4,
HOUR: 36e5,
DAY: 864e5,
WEEK: 6048e5,
DAYS_30: 2592e6
};
const settings = definePluginSettings({
showForeverOnTop: {
type: OptionType.BOOLEAN,
description: "Show the Forever option at the top of the list instead of the bottom.",
restartNeeded: true,
default: true
},
extraSeconds: {
type: OptionType.STRING,
description: "Extra seconds to add, separated by a comma (e.g. 5, 10, 30)",
restartNeeded: true,
default: "15, 30, 45"
},
extraMinutes: {
type: OptionType.STRING,
description: "Extra minutes to add, separated by a comma (e.g. 5, 10, 30)",
restartNeeded: true,
default: "5, 10, 30"
},
extraHours: {
type: OptionType.STRING,
description: "Extra hours to add, separated by a comma (e.g. 2, 4, 6, 12)",
restartNeeded: true,
default: "2, 4, 6, 12"
},
extraDays: {
type: OptionType.STRING,
description: "Extra days to add, separated by a comma (e.g. 1, 2)",
restartNeeded: true,
default: "1, 2"
},
});
export default definePlugin({
name: "CustomStatusTimeouts",
description: "Adds configurable timeout presets to the status (presence) menu.",
authors: [EquicordDevs.Kiri, EquicordDevs.thororen],
settings,
patches: [
{
find: "#{intl::DURATION_FOREVER}",
replacement: {
match: /\[\{duration.*?#{intl::DURATION_FOREVER}\)\}\]/,
replace: "$self.buildTimeouts($&)"
}
}
],
buildTimeouts(existing) {
const parse = (str: string) => str.split(",").map(s => Number(s.trim())).filter(Boolean);
const seconds = parse(settings.store.extraSeconds);
const minutes = parse(settings.store.extraMinutes);
const hours = parse(settings.store.extraHours);
const days = parse(settings.store.extraDays);
// I was testing weeks and months and for some reason random ones didnt apply look below for the working ones
// tested weeks between 1-12 working weeks were 1, 2, 3, 8, 9, 10 the rest didnt work
// tested months between 1-12 working months were 2, 4, 10, 12 the rest didnt work
// for months I only tested evens except for 1 so I hardcoded them to avoid user complaints
const weeks = [1, 2, 3];
const months = [2, 4];
const extra = [
...seconds.map(s => ({
duration: s * Millis.SECOND,
label: () => `For ${s} ${s === 1 ? "Second" : "Seconds"}`
})),
...minutes.map(m => ({
duration: m * Millis.MINUTE,
label: () => `For ${m} ${m === 1 ? "Minute" : "Minutes"}`
})),
...hours.map(h => ({
duration: h * Millis.HOUR,
label: () => `For ${h} ${h === 1 ? "Hour" : "Hours"}`
})),
...days.map(d => ({
duration: d * Millis.DAY,
label: () => `For ${d} ${d === 1 ? "Day" : "Days"}`
})),
...weeks.map(w => ({
duration: w * Millis.WEEK,
label: () => `For ${w} ${w === 1 ? "Week" : "Weeks"}`
})),
...months.map(m => ({
duration: m * Millis.DAYS_30,
label: () => `For ${m} ${m === 1 ? "Month" : "Months"}`
})),
];
return [...existing, ...extra].sort((a, b) => {
if (a.duration === undefined) return settings.store.showForeverOnTop ? -1 : 1;
if (b.duration === undefined) return settings.store.showForeverOnTop ? 1 : -1;
return a.duration - b.duration;
});
}
});

View file

@ -155,7 +155,7 @@ export default definePlugin({
patches: [
// Voice user rows (voice channel sidebar list)
{
find: '"system:click_outside","user:escape"',
find: ".VOICE_USER],shouldShow:",
replacement: {
match: /(?<=GuildChannelUserContextMenu.{0,150})"data-dnd-name":\i/,
replace: "\"data-dragify-user\":!0,\"data-user-id\":arguments[0].user?.id,draggable:!0,onDragStart:e=>$self.onUserDragStart(e,arguments[0].user),$&"

View file

@ -209,6 +209,12 @@ export const settings = definePluginSettings({
default: false,
hidden: true
},
preserveOriginalFilename: {
type: OptionType.BOOLEAN,
description: "Preserve the original filename when uploading.",
default: true,
hidden: true
},
autoCopy: {
type: OptionType.BOOLEAN,
description: "Auto copy upload URL",
@ -508,6 +514,13 @@ export function SettingsComponent() {
/>
</SettingsSection>
<SettingsSection tag="label" name="Preserve Original Filename" description="Use the original filename instead of naming uploads as upload.ext" inlineSetting>
<Switch
checked={Boolean((store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename)}
onChange={v => (store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename = v}
/>
</SettingsSection>
<SettingsSection tag="label" name="Auto Copy URL" description="Automatically copy the uploaded file URL to clipboard" inlineSetting>
<Switch
checked={store.autoCopy}

View file

@ -880,9 +880,24 @@ async function normalizeUploadBlob(blob: Blob, sourceUrl?: string): Promise<{ bl
}
const mimeType = getMimeFromExtension(ext);
const { preserveOriginalFilename } = settings.store;
let sourceFileName = "";
if (blob instanceof File && blob.name) {
sourceFileName = blob.name;
} else if (sourceUrl && URL.canParse(sourceUrl)) {
const segment = new URL(sourceUrl).pathname.split("/").pop();
if (segment) sourceFileName = decodeURIComponent(segment);
}
let filename = `upload.${ext}`;
if (preserveOriginalFilename && sourceFileName) {
const dotIndex = sourceFileName.lastIndexOf(".");
filename = `${sourceFileName.slice(0, dotIndex < 0 ? undefined : dotIndex)}.${ext}`;
}
return {
blob: new Blob([blob], { type: mimeType }),
filename: `upload.${ext}`
filename
};
}

View file

@ -6,7 +6,7 @@
import "./style.css";
import { Devs, EquicordDevs } from "@utils/constants";
import { EquicordDevs } from "@utils/constants";
import definePlugin from "@utils/types";
import { FluxDispatcher } from "@webpack/common";
@ -157,7 +157,7 @@ export default definePlugin({
oldTrendingCat = instance.props.trendingCategories;
}
if (settings.store.onlyShowCollections) {
instance.props.trendingCategories = [...this.sortedCollections(), ...instance.props.favorites];
instance.props.trendingCategories = this.sortedCollections();
} else if (oldTrendingCat != null) {
instance.props.trendingCategories = [...this.sortedCollections(), ...oldTrendingCat];
}

View file

@ -74,7 +74,7 @@ function ButtonsInnerComponent({ buttons }: { buttons: ReactNode[]; }) {
export default definePlugin({
name: "HideChatButtons",
description: "able to hide the chat buttons",
description: "Able to hide the chat buttons",
settings: settings,
authors: [EquicordDevs.iamme],
patches: [

View file

@ -6,7 +6,7 @@
import { DataStore } from "@api/index";
import { Guild, User } from "@vencord/discord-types";
import { ChannelStore, GuildMemberStore, GuildStore, InviteActions, MessageStore, PermissionsBits, PermissionStore, UserStore, } from "@webpack/common";
import { ChannelStore, GuildMemberStore, GuildStore, MessageStore, UserStore, } from "@webpack/common";
export interface IUserExtra {
isOwner?: boolean;
@ -104,9 +104,7 @@ export class Data {
extra: { ...previouExtra, ...extra },
iconURL: user.getAvatarURL(),
};
if (source && !processedGuilds.has(source.id) && this.hasInvitePermissions(source.id)) {
this.collectInviteLink(source.id);
if (source && !processedGuilds.has(source.id)) {
processedGuilds.add(source.id);
}
}
@ -148,10 +146,6 @@ export class Data {
}
this.processUsersToCollection([...target]);
if (guild && this.hasInvitePermissions(guild.id)) {
this.collectInviteLink(guild.id);
}
}
}
@ -176,38 +170,6 @@ export class Data {
this.processUsersToCollection([...target]);
}
hasInvitePermissions(guildId: string): boolean {
const guild = GuildStore.getGuild(guildId);
if (!guild) return false;
const currentUser = UserStore.getCurrentUser();
if (!currentUser) return false;
if (guild.ownerId === currentUser.id) return true;
const member = GuildMemberStore.getMember(guildId, currentUser.id);
if (!member) return false;
return PermissionStore.can(PermissionsBits.CREATE_INSTANT_INVITE, guild);
}
async collectInviteLink(guildId: string) {
try {
const invites = await InviteActions.getInvites(guildId);
if (invites && invites.length > 0) {
const invite = invites[0];
const inviteCode = invite.code;
const inviteLink = `https://discord.gg/${inviteCode}`;
if (this.usersCollection[guildId]) {
this.usersCollection[guildId].inviteLink = inviteLink;
}
}
} catch (error) {
console.warn("Failed to collect invite link for guild:", guildId, error);
}
}
storageAutoSaveProtocol() {
this._storageAutoSaveProtocol_interval = setInterval(
this.updateStorage.bind(this),

View file

@ -17,7 +17,7 @@
*/
import { Flogger, settings } from ".";
import { addMessageIDB, countMessagesIDB, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db";
import { addMessageIDB, db, DBMessageStatus, deleteMessagesBulkIDB, getOlderThanTimestampForGuildsIDB, getOldestMessagesIDB } from "./db";
import { LoggedMessage, LoggedMessageJSON } from "./types";
import { cleanupMessage } from "./utils";
import { cacheMessageImages } from "./utils/saveImage";
@ -47,7 +47,7 @@ export const addMessage = async (message: LoggedMessage | LoggedMessageJSON, sta
}
if (settings.store.messageLimit > 0) {
const currentMessageCount = await countMessagesIDB();
const currentMessageCount = await db.count("messages");
if (currentMessageCount > settings.store.messageLimit) {
const messagesToDelete = currentMessageCount - settings.store.messageLimit;
if (messagesToDelete <= 0 || messagesToDelete >= settings.store.messageLimit) return;

View file

@ -16,8 +16,8 @@ import { type User } from "@vencord/discord-types";
import { find, findByCode, findByCodeLazy } from "@webpack";
import { Alerts, ChannelStore, ContextMenuApi, FluxDispatcher, GuildStore, Menu, NavigationRouter, React, TabBar, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
import { clearMessagesIDB, DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db";
import { cl, settings } from "../index";
import { DBMessageRecord, deleteMessageIDB, deleteMessagesBulkIDB } from "../db";
import { clearLogs, cl, settings } from "../index";
import { LoggedMessage, LoggedMessageJSON } from "../types";
import { messageJsonToMessageClass } from "../utils";
import { importLogs } from "../utils/settingsUtils";
@ -147,7 +147,7 @@ export function LogsModal({ modalProps, initalQuery }: Props) {
confirmVariant: "critical-primary",
cancelText: "Cancel",
onConfirm: async () => {
await clearMessagesIDB();
await clearLogs();
reset();
}

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { ChannelStore, Toasts } from "@webpack/common";
import { DBSchema, IDBPDatabase, openDB } from "idb";
@ -38,63 +37,17 @@ export interface MLIDB extends DBSchema {
by_timestamp_and_message_id: [string, string];
};
};
}
const logger = new Logger("MessageLoggerEnhanced");
let db: IDBPDatabase<MLIDB> | null = null;
let dbPromise: Promise<IDBPDatabase<MLIDB> | null> | null = null;
let loggedFailure = false;
export let db: IDBPDatabase<MLIDB>;
export const cachedMessages = new Map<string, LoggedMessageJSON>();
function handleDbFailure(error: unknown) {
if (loggedFailure) return;
loggedFailure = true;
logger.error("Message storage unavailable, using memory only.", error);
}
async function ensureDb() {
if (db) return db;
if (!dbPromise) {
dbPromise = openDB<MLIDB>(DB_NAME, DB_VERSION, {
upgrade(database) {
const messageStore = database.createObjectStore("messages", { keyPath: "message_id" });
messageStore.createIndex("by_channel_id", "channel_id");
messageStore.createIndex("by_status", "status");
messageStore.createIndex("by_timestamp", "message.timestamp");
messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]);
}
}).then(database => {
db = database;
return database;
}).catch(error => {
handleDbFailure(error);
return null;
});
}
db = await dbPromise;
return db;
}
async function withDb<T>(fallback: T, callback: (database: IDBPDatabase<MLIDB>) => Promise<T>): Promise<T> {
const database = await ensureDb();
if (!database) return fallback;
try {
return await callback(database);
} catch (error) {
handleDbFailure(error);
return fallback;
}
}
async function cacheRecords(records: DBMessageRecord[], cacheAttachmentBlobs = true) {
// this is probably not the best way to do this
async function cacheRecords(records: DBMessageRecord[]) {
for (const r of records) {
cacheRecord(r);
if (!cacheAttachmentBlobs) continue;
for (const att of r.message.attachments) {
const blobUrl = await getAttachmentBlobUrl(att);
if (blobUrl) {
@ -103,7 +56,6 @@ async function cacheRecords(records: DBMessageRecord[], cacheAttachmentBlobs = t
}
}
}
return records;
}
@ -116,56 +68,57 @@ async function cacheRecord(record?: DBMessageRecord | null) {
}
export async function initIDB() {
await ensureDb();
if (db) return;
db = await openDB<MLIDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
const messageStore = db.createObjectStore("messages", { keyPath: "message_id" });
messageStore.createIndex("by_channel_id", "channel_id");
messageStore.createIndex("by_status", "status");
messageStore.createIndex("by_timestamp", "message.timestamp");
messageStore.createIndex("by_timestamp_and_message_id", ["channel_id", "message.timestamp"]);
}
});
}
void initIDB();
initIDB();
export async function hasMessageIDB(message_id: string) {
if (cachedMessages.has(message_id)) return true;
return withDb(false, database => database.count("messages", message_id).then(count => count > 0));
return cachedMessages.has(message_id) || (await db.count("messages", message_id)) > 0;
}
export async function countMessagesIDB() {
if (!db) return cachedMessages.size;
return db.count("messages");
}
export async function countMessagesByStatusIDB(status: DBMessageStatus) {
return withDb(0, database => database.countFromIndex("messages", "by_status", status));
return db.countFromIndex("messages", "by_status", status);
}
export async function getAllMessagesIDB() {
return withDb([], async database => cacheRecords(await database.getAll("messages")));
return cacheRecords(await db.getAll("messages"));
}
export async function getMessagesForChannelIDB(channel_id: string) {
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_channel_id", channel_id)));
return cacheRecords(await db.getAllFromIndex("messages", "by_channel_id", channel_id));
}
export async function getMessageIDB(message_id: string) {
return withDb<DBMessageRecord | null>(null, async database => {
const record = await database.get("messages", message_id);
await cacheRecord(record);
return record ?? null;
});
return cacheRecord(await db.get("messages", message_id));
}
export async function getMessagesByStatusIDB(status: DBMessageStatus) {
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_status", status)));
return cacheRecords(await db.getAllFromIndex("messages", "by_status", status));
}
export async function getOldestMessagesIDB(limit: number) {
return withDb([], async database => cacheRecords(await database.getAllFromIndex("messages", "by_timestamp", undefined, limit)));
return cacheRecords(await db.getAllFromIndex("messages", "by_timestamp", undefined, limit));
}
export async function* iterateAllMessagesIDB(batchSize = 100) {
if (!db && !(await ensureDb())) return;
let lastId: string | undefined;
while (true) {
const batch: DBMessageRecord[] = [];
const tx = db!.transaction("messages");
// new transaction for each batch to avoid timeouts during yield
const tx = db.transaction("messages");
const range = lastId ? IDBKeyRange.lowerBound(lastId, true) : undefined;
let cursor = await tx.store.openCursor(range);
@ -177,6 +130,7 @@ export async function* iterateAllMessagesIDB(batchSize = 100) {
if (batch.length === 0) break;
lastId = batch[batch.length - 1].message_id;
yield await cacheRecords(batch);
if (batch.length < batchSize) break;
@ -184,20 +138,22 @@ export async function* iterateAllMessagesIDB(batchSize = 100) {
}
export async function getOlderThanTimestampIDB(timestamp: string) {
return withDb([], async database => {
const tx = database.transaction("messages", "readonly");
const index = tx.store.index("by_timestamp");
const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp));
const tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_timestamp");
if (!cursor) return [];
const cursor = await index.openCursor(IDBKeyRange.upperBound(timestamp));
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
if (!cursor) {
return [];
}
return cacheRecords(messages, false);
});
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
return cacheRecords(messages);
}
export async function getOlderThanTimestampForGuildsIDB(timestamp: string, currentChannelId?: string, preserveCurrentChannel?: boolean) {
@ -212,106 +168,102 @@ export async function getOlderThanTimestampForGuildsIDB(timestamp: string, curre
}
export async function getDateStortedMessagesByStatusIDB(newest: boolean, limit: number, status: DBMessageStatus) {
return withDb([], async database => {
const tx = database.transaction("messages", "readonly");
const index = tx.store.index("by_status");
const direction = newest ? "prev" : "next";
const cursor = await index.openCursor(IDBKeyRange.only(status), direction);
const tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_status");
if (!cursor) {
return [];
}
const direction = newest ? "prev" : "next";
const cursor = await index.openCursor(IDBKeyRange.only(status), direction);
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
if (messages.length >= limit) break;
}
if (!cursor) {
console.log("No messages found");
return [];
}
return cacheRecords(messages);
});
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
if (messages.length >= limit) break;
}
return cacheRecords(messages);
}
export async function getMessagesByChannelAndAfterTimestampIDB(channel_id: string, start: string) {
return withDb([], async database => {
const tx = database.transaction("messages", "readonly");
const index = tx.store.index("by_timestamp_and_message_id");
const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"]));
const tx = db.transaction("messages", "readonly");
const { store } = tx;
const index = store.index("by_timestamp_and_message_id");
if (!cursor) {
return [];
}
const cursor = await index.openCursor(IDBKeyRange.bound([channel_id, start], [channel_id, "\uffff"]));
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
if (!cursor) {
console.log("No messages found in range");
return [];
}
return cacheRecords(messages, false);
});
const messages: DBMessageRecord[] = [];
for await (const c of cursor) {
messages.push(c.value);
}
return cacheRecords(messages);
}
export async function addMessageIDB(message: LoggedMessageJSON, status: DBMessageStatus) {
stripTransientRenderState(message);
cachedMessages.set(message.id, message);
return withDb(void 0, async database => {
await database.put("messages", {
channel_id: message.channel_id,
message_id: message.id,
status,
message,
});
if (!db) await initIDB();
await db.put("messages", {
channel_id: message.channel_id,
message_id: message.id,
status,
message,
});
cachedMessages.set(message.id, message);
}
export async function addMessagesBulkIDB(messages: LoggedMessageJSON[], status?: DBMessageStatus) {
messages.forEach(stripTransientRenderState);
const tx = db.transaction("messages", "readwrite");
const { store } = tx;
await Promise.all([
...messages.map(message => store.add({
channel_id: message.channel_id,
message_id: message.id,
status: status ?? getMessageStatus(message),
message,
})),
tx.done
]);
messages.forEach(message => cachedMessages.set(message.id, message));
return withDb(void 0, async database => {
const tx = database.transaction("messages", "readwrite");
const { store } = tx;
await Promise.all([
...messages.map(message => store.add({
channel_id: message.channel_id,
message_id: message.id,
status: status ?? getMessageStatus(message),
message,
})),
tx.done
]);
});
}
export async function deleteMessageIDB(message_id: string) {
cachedMessages.delete(message_id);
await db.delete("messages", message_id);
return withDb(void 0, database => database.delete("messages", message_id));
cachedMessages.delete(message_id);
}
export async function deleteMessagesBulkIDB(message_ids: string[]) {
const tx = db.transaction("messages", "readwrite");
const { store } = tx;
await Promise.all([...message_ids.map(id => store.delete(id)), tx.done]);
message_ids.forEach(id => cachedMessages.delete(id));
return withDb(void 0, async database => {
const tx = database.transaction("messages", "readwrite");
const { store } = tx;
await Promise.all([
...message_ids.map(id => store.delete(id)),
tx.done
]);
});
}
export async function clearMessagesIDB() {
export async function clearMessagesIDB(showToast = true) {
cachedMessages.clear();
await db.clear("messages");
if (!showToast) return;
return withDb(void 0, database => database.clear("messages")).then(() => {
Toasts.show({
type: Toasts.Type.MESSAGE,
message: "Cleared message log database and cache.",
id: Toasts.genId()
});
Toasts.show({
type: Toasts.Type.MESSAGE,
message: "Cleared message log database and cache.",
id: Toasts.genId()
});
}

View file

@ -36,8 +36,15 @@ export const Flogger = new Logger("MessageLoggerEnhanced", "#f26c6c");
export const cacheSentMessages = new LimitedMap<string, LoggedMessageJSON>();
export const cl = classNameFactory("vc-msg-logger-enhanced-");
let didClearLogsOnStartup = false;
const cacheThing = findByPropsLazy("commit", "getOrCreate");
export async function clearLogs(showToast = true) {
await idb.clearMessagesIDB(showToast);
cacheSentMessages.clear();
}
let oldGetMessage: typeof MessageStore.getMessage;
const handledMessageIds = new Set();
@ -396,6 +403,15 @@ export default definePlugin({
Native.init();
if (settings.store.clearLogsOnRestart && !didClearLogsOnStartup) {
try {
await clearLogs(false);
didClearLogsOnStartup = true;
} catch (e) {
Flogger.error("Failed to clear logs on restart", e);
}
}
const { imageCacheDir, logsDir } = await Native.getSettings();
settings.store.imageCacheDir = imageCacheDir;
settings.store.logsDir = logsDir;

View file

@ -11,10 +11,9 @@ import { OptionType } from "@utils/types";
import { Alerts, useState } from "@webpack/common";
import { Settings } from "Vencord";
import { Native } from ".";
import { clearLogs, Native } from ".";
import { ImageCacheDir, LogsDir } from "./components/FolderSelectInput";
import { openLogModal } from "./components/LogsModal";
import { clearMessagesIDB } from "./db";
import { blockedExts } from "./list";
import { DEFAULT_IMAGE_CACHE_DIR } from "./utils/constants";
import { exportLogs, importLogs } from "./utils/settingsUtils";
@ -260,6 +259,13 @@ export const settings = definePluginSettings({
component: ExportLogsButton
},
clearLogsOnRestart: {
type: OptionType.BOOLEAN,
description: "Clear logs when Discord restarts.",
default: false,
restartNeeded: true,
},
openLogs: {
type: OptionType.COMPONENT,
description: "Open Logs",
@ -298,7 +304,7 @@ export const settings = definePluginSettings({
confirmText: "Clear",
cancelText: "Cancel",
onConfirm: async () => {
await clearMessagesIDB();
await clearLogs();
},
})}
>

View file

@ -33,4 +33,8 @@ export class LimitedMap<K, V> {
get(key: K) {
return this.map.get(key);
}
clear() {
this.map.clear();
}
}

View file

@ -24,6 +24,7 @@ import { DBMessageStatus } from "../db";
import { LoggedMessageJSON } from "../types";
import { DEFAULT_IMAGE_CACHE_DIR } from "./constants";
import { DISCORD_EPOCH } from "./index";
import { memoize } from "./memoize";
const MessageClass: any = findLazy(m => m?.prototype?.isEdited);
const AuthorClass = findLazy(m => m?.prototype?.getAvatarURL);
@ -92,7 +93,7 @@ export const mapTimestamp = (m: any) => {
return m;
};
export const messageJsonToMessageClass = (log: { message: LoggedMessageJSON; }) => {
export const messageJsonToMessageClass = memoize((log: { message: LoggedMessageJSON; }) => {
// console.time("message populate");
if (!log?.message) return null;
@ -122,7 +123,7 @@ export const messageJsonToMessageClass = (log: { message: LoggedMessageJSON; })
// console.timeEnd("message populate");
return message;
};
});
export function parseJSON(json?: string | null) {
try {

View file

@ -21,8 +21,8 @@ import { chooseFile as chooseFileWeb } from "@utils/web";
import { Toasts } from "@webpack/common";
import { showSaveFilePicker } from "native-file-system-adapter";
import { Native } from "..";
import { addMessagesBulkIDB, clearMessagesIDB, iterateAllMessagesIDB } from "../db";
import { Native, clearLogs } from "..";
import { addMessagesBulkIDB, iterateAllMessagesIDB } from "../db";
import { LoggedMessageJSON } from "../types";
export async function importLogs() {
@ -34,7 +34,7 @@ export async function importLogs() {
for await (const logItems of iterateLogItems()) {
if (!cleared) {
await clearMessagesIDB();
await clearLogs(false);
cleared = true;
}

View file

@ -4,64 +4,111 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import "./styles.css";
const settings = definePluginSettings(
{
targetLanguage: {
type: OptionType.STRING,
description: "The language messages should be translated to",
default: "en",
restartNeeded: true
},
confidenceRequirement: {
type: OptionType.STRING,
description: "The confidence required to translated the message. Best not to edit unless you know what you're doing",
default: "0.8",
restartNeeded: true
},
});
import { EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin from "@utils/types";
import { ChannelStore, FluxDispatcher, MessageStore, UserStore } from "@webpack/common";
async function translateAPI(sourceLang: string, targetLang: string, text: string): Promise<any> {
import { getIgnoredChannels, getIgnoredGuilds, getIgnoredUsers, settings } from "./settings";
import { MessageWithContent } from "./types";
import { clearCache, getCached, hasFailed, isInProgress, translate } from "./utils/translate";
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&dj=1&q=${encodeURIComponent(text)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to translate "${text}" from ${sourceLang} to ${targetLang}: ${response.status} ${response.statusText}`);
const cl = classNameFactory("mt-");
const translatedMessages = new Map<string, string>();
function shouldTranslate(message: MessageWithContent): boolean {
if (!message.content || typeof message.content !== "string") return false;
if (hasFailed(message.id, message.content)) return false;
if (settings.store.skipOwnMessages) {
const currentUserId = UserStore.getCurrentUser()?.id;
if (currentUserId && message.author?.id === currentUserId) return false;
}
return await response.json();
if (settings.store.skipBotMessages && message.author?.bot) return false;
if (message.author && getIgnoredUsers().has(message.author.id)) return false;
if (message.channel_id && getIgnoredChannels().has(message.channel_id)) return false;
const guildId = message.channel_id ? ChannelStore.getChannel(message.channel_id)?.guild_id : null;
if (guildId && getIgnoredGuilds().has(guildId)) return false;
return true;
}
async function TranslateMessage(string) {
// there may be a better way to do this lmao
if (string.includes("(Translated)")) return string;
function triggerReRender(message: MessageWithContent) {
const current = MessageStore.getMessage(message.channel_id, message.id);
if (!current) return;
const response = await translateAPI("auto", settings.store.targetLanguage, string);
if (response.src === settings.store.targetLanguage || response.confidence < settings.store.confidenceRequirement) return string;
const { sentences }: { sentences: { trans?: string; }[]; } = await response;
const translatedText = sentences.map(s => s?.trans).filter(Boolean).join("");
return `${translatedText} *(Translated)*`;
FluxDispatcher.dispatch({
type: "MESSAGE_UPDATE",
message: current,
});
}
export default definePlugin({
name: "MessageTranslate",
description: "Auto translate messages to your language",
authors: [Devs.Samwich],
description: "Auto translate messages to your language with caching, per-channel toggles, and more options.",
authors: [EquicordDevs.creations],
settings,
TranslateMessage: TranslateMessage,
patches: [
{
find: '.CUSTOM_GIFT?""',
replacement: {
match: /renderContentOnly:\i}=\i;/,
replace: "$&$self.TranslateMessage(arguments[0].message.content).then(response => arguments[0].message.content = response);"
}
replacement: [
{
match: /message:(\i),message:\{id:\i\}.{0,200}renderContentOnly:\i\}=\i;/,
replace: "$&$1=$self.transformMessage($1);",
},
{
match: /childrenMessageContent:(\i),/g,
replace: "childrenMessageContent:$self.wrapContent($1,arguments[0].message.id),",
},
],
},
]
],
transformMessage(message: MessageWithContent): MessageWithContent {
if (!settings.store.autoTranslate || !shouldTranslate(message)) {
translatedMessages.delete(message.id);
return message;
}
const cached = getCached(message.id);
if (cached) {
if (cached.original !== message.content) {
clearCache(message.id);
translatedMessages.delete(message.id);
return message;
}
translatedMessages.set(message.id, cached.sourceLang);
return Object.assign(Object.create(Object.getPrototypeOf(message)), message, {
content: cached.translated,
}) as MessageWithContent;
}
translatedMessages.delete(message.id);
if (!isInProgress(message.id)) {
translate(message.id, message.content).then(result => {
if (result) triggerReRender(message);
});
}
return message;
},
wrapContent(content: any, messageId: string) {
const sourceLang = translatedMessages.get(messageId);
if (!sourceLang) return content;
return (
<>
{content}
{settings.store.showIndicator && (
<div className={cl("indicator")}>translated from {sourceLang}</div>
)}
</>
);
},
});

View file

@ -0,0 +1,72 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { OptionType } from "@utils/types";
export const settings = definePluginSettings({
targetLanguage: {
type: OptionType.STRING,
description: "Target language code for translations (e.g. en, es, fr, de, ja).",
default: "en",
},
confidenceRequirement: {
type: OptionType.NUMBER,
description: "Minimum confidence (0 to 1) required to show a translation.",
default: 0.8,
},
autoTranslate: {
type: OptionType.BOOLEAN,
description: "Automatically translate messages as they appear.",
default: true,
},
skipOwnMessages: {
type: OptionType.BOOLEAN,
description: "Do not translate your own messages.",
default: true,
},
skipBotMessages: {
type: OptionType.BOOLEAN,
description: "Do not translate bot messages.",
default: false,
},
ignoredGuilds: {
type: OptionType.STRING,
description: "Comma-separated list of server IDs to not translate in.",
default: "",
},
ignoredChannels: {
type: OptionType.STRING,
description: "Comma-separated list of channel IDs to not translate in.",
default: "",
},
ignoredUsers: {
type: OptionType.STRING,
description: "Comma-separated list of user IDs to not translate.",
default: "",
},
showIndicator: {
type: OptionType.BOOLEAN,
description: "Append a small (translated) indicator to translated messages.",
default: true,
},
});
function parseIdList(value: string): Set<string> {
return new Set(value.split(",").map(s => s.trim()).filter(Boolean));
}
export function getIgnoredGuilds(): Set<string> {
return parseIdList(settings.store.ignoredGuilds);
}
export function getIgnoredChannels(): Set<string> {
return parseIdList(settings.store.ignoredChannels);
}
export function getIgnoredUsers(): Set<string> {
return parseIdList(settings.store.ignoredUsers);
}

View file

@ -0,0 +1,9 @@
.mt-indicator {
color: var(--text-muted);
font-style: italic;
font-weight: 400;
font-size: 0.7rem;
line-height: 1;
margin-top: 0.15em;
opacity: 0.75;
}

View file

@ -0,0 +1,21 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Message } from "@vencord/discord-types";
export interface TranslateResponse {
src: string;
confidence: number;
sentences: { trans?: string; }[];
}
export interface CachedTranslation {
original: string;
translated: string;
sourceLang: string;
}
export type MessageWithContent = Message & { content: string; };

View file

@ -0,0 +1,81 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { settings } from "../settings";
import { CachedTranslation, TranslateResponse } from "../types";
const logger = new Logger("MessageTranslate");
const translationCache = new Map<string, CachedTranslation>();
const inProgress = new Set<string>();
const failed = new Map<string, string>();
export function getCached(messageId: string): CachedTranslation | undefined {
return translationCache.get(messageId);
}
export function hasFailed(messageId: string, text: string): boolean {
return failed.get(messageId) === text;
}
export function isInProgress(messageId: string): boolean {
return inProgress.has(messageId);
}
export function clearCache(messageId: string) {
translationCache.delete(messageId);
failed.delete(messageId);
}
async function fetchTranslation(text: string, targetLang: string): Promise<TranslateResponse> {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&dj=1&q=${encodeURIComponent(text)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Translation API returned ${response.status} ${response.statusText}`);
}
return await response.json();
}
export async function translate(messageId: string, text: string): Promise<CachedTranslation | null> {
if (inProgress.has(messageId)) return null;
if (translationCache.has(messageId)) return translationCache.get(messageId)!;
inProgress.add(messageId);
try {
const targetLang = settings.store.targetLanguage;
const response = await fetchTranslation(text, targetLang);
if (response.src === targetLang || response.confidence < settings.store.confidenceRequirement) {
failed.set(messageId, text);
return null;
}
const translatedText = response.sentences.map(s => s.trans).filter(Boolean).join("");
if (!translatedText || translatedText === text) {
failed.set(messageId, text);
return null;
}
const entry: CachedTranslation = {
original: text,
translated: translatedText,
sourceLang: response.src,
};
translationCache.set(messageId, entry);
return entry;
} catch (e) {
logger.error("Translation failed", e);
failed.set(messageId, text);
return null;
} finally {
inProgress.delete(messageId);
}
}

View file

@ -70,7 +70,7 @@
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform: translate(-55%, -50%);
}
.vc-spotify-button-row {

View file

@ -135,7 +135,7 @@
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform: translate(-55%, -50%);
}
#eq-tdl-info-wrapper {

View file

@ -1447,7 +1447,7 @@ export default definePlugin({
group: true,
replacement: [
{
match: /(?<=\i\)&&"xs"===\i;)/,
match: /(?<=null&&"xs"===\i;)/,
replace: "const shouldHideMembersListActivelyPlayingIcon=$self.shouldHideMembersListActivelyPlayingIcon();"
},
{

View file

@ -198,9 +198,6 @@ const DB_VERSION = 2;
const VOICES_STORE = "voices";
import { Logger } from "@utils/Logger";
const logger = new Logger("VcNarratorCustom");
const META_STORE = "voices_meta";
type VoiceMeta = {
@ -209,76 +206,49 @@ type VoiceMeta = {
lastAccess: number;
};
let openDbPromise: Promise<IDBDatabase | null> | undefined;
let loggedFailure = false;
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = event => {
const db = request.result;
const upgradeTx = request.transaction;
if (!db.objectStoreNames.contains(VOICES_STORE)) {
db.createObjectStore(VOICES_STORE);
}
if (!db.objectStoreNames.contains(META_STORE)) {
const meta = db.createObjectStore(META_STORE);
meta.createIndex("by_lastAccess", "lastAccess");
}
function handleDbFailure(error: unknown) {
if (loggedFailure) return;
loggedFailure = true;
logger.error("Voice cache storage unavailable, falling back to memory.", error);
}
function openDB(): Promise<IDBDatabase | null> {
if (!openDbPromise) {
openDbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = event => {
const db = request.result;
const upgradeTx = request.transaction;
if (!db.objectStoreNames.contains(VOICES_STORE)) {
db.createObjectStore(VOICES_STORE);
}
if (!db.objectStoreNames.contains(META_STORE)) {
const meta = db.createObjectStore(META_STORE);
meta.createIndex("by_lastAccess", "lastAccess");
}
if (upgradeTx && (event?.oldVersion ?? 0) < 2) {
const now = Date.now();
const voices = upgradeTx.objectStore(VOICES_STORE);
const meta = upgradeTx.objectStore(META_STORE);
const cursorReq = voices.openCursor();
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) return;
const blob = cursor.value as Blob | undefined;
meta.put(
{
size: blob?.size ?? 0,
createdAt: now,
lastAccess: now,
} satisfies VoiceMeta,
String(cursor.key)
);
cursor.continue();
};
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).then(db => db).catch(error => {
handleDbFailure(error);
return null;
});
}
return openDbPromise;
}
async function withDb<T>(fallback: T, callback: (db: IDBDatabase) => Promise<T>): Promise<T> {
const db = await openDB();
if (!db) return fallback;
try {
return await callback(db);
} catch (error) {
handleDbFailure(error);
return fallback;
}
if (upgradeTx && (event?.oldVersion ?? 0) < 2) {
const now = Date.now();
const voices = upgradeTx.objectStore(VOICES_STORE);
const meta = upgradeTx.objectStore(META_STORE);
const cursorReq = voices.openCursor();
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (!cursor) return;
const blob = cursor.value as Blob | undefined;
meta.put(
{
size: blob?.size ?? 0,
createdAt: now,
lastAccess: now,
} satisfies VoiceMeta,
String(cursor.key)
);
cursor.continue();
};
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; entries: number; }> {
return withDb<{ bytes: number; entries: number; }>({ bytes: 0, entries: 0 }, db => new Promise<{ bytes: number; entries: number; }>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(META_STORE, "readonly");
const store = tx.objectStore(META_STORE);
@ -298,7 +268,7 @@ export async function getPersistentTtsCacheStats(): Promise<{ bytes: number; ent
cursor.continue();
};
cursorReq.onerror = () => reject(cursorReq.error);
}));
});
}
export async function clearTtsCache(): Promise<void> {
@ -309,17 +279,19 @@ export async function clearTtsCache(): Promise<void> {
}
ttsCache.clear();
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
tx.objectStore(VOICES_STORE).clear();
tx.objectStore(META_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
}));
});
}
async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX_BYTES): Promise<void> {
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
@ -372,11 +344,12 @@ async function trimPersistentCacheToMaxBytes(maxBytes = PERSISTENT_TTS_CACHE_MAX
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
}));
});
}
export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | null> {
return withDb<Blob | null>(null, db => new Promise<Blob | null>((resolve, reject) => {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
@ -400,11 +373,12 @@ export async function getCachedVoiceFromDB(cacheKey: string): Promise<Blob | nul
resolve(blob);
};
request.onerror = () => reject(request.error);
}));
});
}
export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<void> {
await withDb<void>(void 0, db => new Promise<void>((resolve, reject) => {
const db = await openDB();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([VOICES_STORE, META_STORE], "readwrite");
const voicesStore = tx.objectStore(VOICES_STORE);
const metaStore = tx.objectStore(META_STORE);
@ -418,7 +392,7 @@ export async function setCachedVoiceInDB(cacheKey: string, blob: Blob): Promise<
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
}));
});
await trimPersistentCacheToMaxBytes();
}

View file

@ -39,7 +39,7 @@ export default definePlugin({
group: true,
replacement: [
{
match: /return.{1,150},(?=keyboardModeEnabled)/,
match: /return.{1,200},(?=keyboardModeEnabled)/,
replace: "const vcWallpaperFreeUrl=$self.WallpaperState(arguments[0].channel);$&vcWallpaperFreeUrl,"
},
{

View file

@ -21,7 +21,7 @@ import { getCustomColorString } from "@equicordplugins/customUserColors";
import { hash as h64 } from "@intrnl/xxhash64";
import { Devs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import { useMemo } from "@webpack/common";
import { useMemo, UserStore } from "@webpack/common";
// Calculate a CSS color string based on the user ID
function calculateNameColorForUser(id?: string) {
@ -121,9 +121,25 @@ export default definePlugin({
return colorString;
}
return (!settings.store.applyColorOnlyToUsersWithoutColor || !colorString)
const dmColor = (!settings.store.applyColorOnlyToUsersWithoutColor || !colorString)
? color
: colorString;
// guarantee minimum difference in dms
if (context?.channel?.isPrivate?.() && dmColor && userId) {
const currentUserId = UserStore.getCurrentUser()?.id;
if (currentUserId && userId !== currentUserId) {
const currentUserColor = Number(h64(currentUserId) % 360n);
const otherUserColor = Number(h64(userId) % 360n);
const colorDiff = Math.min(Math.abs(currentUserColor - otherUserColor), 360 - Math.abs(currentUserColor - otherUserColor));
if (colorDiff < 45) {
const newColor = (otherUserColor + 180) % 360;
return `hsl(${newColor}, 100%, ${settings.store.lightness}%)`;
}
}
}
return dmColor;
},
calculateNameColorForListContext(context: any) {

View file

@ -1,5 +0,0 @@
# NoDefaultHangStatus
Disable the default hang status when joining voice channels
![Visualization](https://github.com/Vendicated/Vencord/assets/24937357/329a9742-236f-48f7-94ff-c3510eca505a)

View file

@ -1,26 +0,0 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { migrateSettingToPlugin } from "@api/Settings";
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
migrateSettingToPlugin("NoDefaultHangStatus", "EquicordHelper", "noDefaultHangStatus");
export default definePlugin({
name: "NoDefaultHangStatus",
description: "Disable the default hang status when joining voice channels",
authors: [Devs.D3SOX],
patches: [
{
find: ".CHILLING)",
replacement: {
match: /{enableHangStatus:(\i),/,
replace: "{_enableHangStatus:$1=false,"
}
}
]
});

View file

@ -29,8 +29,8 @@ export default definePlugin({
{
find: '"PLAINTEXT_PREVIEW":"OTHER"',
replacement: {
match: /=>"IMAGE"===\i\|\|"VIDEO"===\i(?:\|\|("VISUAL_PLACEHOLDER"===\i))?;/,
replace: (_, visualPlaceholderPred) => visualPlaceholderPred != null ? `=>${visualPlaceholderPred};` : "=>false;"
match: /return"IMAGE"===\i\|\|"VIDEO"===\i(?:\|\|("VISUAL_PLACEHOLDER"===\i)\|\|\i&&"CLIP"===\i)?/,
replace: (_, visualPlaceholderPred) => visualPlaceholderPred != null ? `return ${visualPlaceholderPred}` : "return false"
}
},
{

View file

@ -196,7 +196,7 @@ export default definePlugin({
patches: [
// Avatar component used in User DMs "User Profile" popup in the right and User Profile Modal pfp
{
find: "imageClassName:null!=",
find: ".AVATAR),{trackUserProfileAction:",
replacement: {
match: /avatarSrc:(\i),eventHandlers:(\i).+?"div",.{0,100}className:\i,/,
replace: "$&style:{cursor:\"pointer\"},onClick:()=>{$self.openAvatar($1)},",

View file

@ -1284,6 +1284,10 @@ export const EquicordDevs = Object.freeze({
name: "Ape",
id: 722832640360841308n,
},
Kiri: {
name: "Kiri",
id: 310525496771346434n,
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly

View file

@ -58,8 +58,8 @@ export const TooltipContainer = TooltipContainerComponent as never;
export const TextInput = waitForComponent<t.TextInput>("TextInput", filters.componentByCode("#{intl::MAXIMUM_LENGTH_ERROR}", '"input"'));
export const TextArea = waitForComponent<t.TextArea>("TextArea", filters.componentByCode("this.getPaddingRight()},id:"));
export const Select = waitForComponent<t.Select>("Select", filters.componentByCode('"Select"'));
export const SearchableSelect = waitForComponent<t.SearchableSelect>("SearchableSelect", filters.componentByCode('"SearchableSelect"'));
export const Select = waitForComponent<t.Select>("Select", filters.componentByCode('selectionMode:"single",onSelectionChange:', "isSelected:"));
export const SearchableSelect = waitForComponent<t.SearchableSelect>("SearchableSelect", filters.componentByCode('?"multiple":"single"'));
export const Slider = waitForComponent<t.Slider>("Slider", filters.componentByCode("markDash", "this.renderMark("));
export const Popout = waitForComponent<t.Popout>("Popout", filters.componentByCode("ref:this.ref,", "renderPopout:this.renderPopout,"));
export const Dialog = waitForComponent<t.Dialog>("Dialog", filters.componentByCode('role:"dialog",tabIndex:-1'));