Merge branch 'dev'
This commit is contained in:
commit
3104735cb1
44 changed files with 1217 additions and 526 deletions
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
6
.github/workflows/nixosBuild.yml
vendored
6
.github/workflows/nixosBuild.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/reportBrokenPlugins.yml
vendored
4
.github/workflows/reportBrokenPlugins.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
148
src/equicordplugins/collapsibleUi/index.tsx
Normal file
148
src/equicordplugins/collapsibleUi/index.tsx
Normal 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"));
|
||||
},
|
||||
});
|
||||
70
src/equicordplugins/collapsibleUi/settings.ts
Normal file
70
src/equicordplugins/collapsibleUi/settings.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
213
src/equicordplugins/collapsibleUi/style.css
Normal file
213
src/equicordplugins/collapsibleUi/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/equicordplugins/customStatusTimeouts/index.tsx
Normal file
115
src/equicordplugins/customStatusTimeouts/index.tsx
Normal 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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),$&"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -33,4 +33,8 @@ export class LimitedMap<K, V> {
|
|||
get(key: K) {
|
||||
return this.map.get(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
72
src/equicordplugins/messageTranslate/settings.ts
Normal file
72
src/equicordplugins/messageTranslate/settings.ts
Normal 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);
|
||||
}
|
||||
9
src/equicordplugins/messageTranslate/styles.css
Normal file
9
src/equicordplugins/messageTranslate/styles.css
Normal 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;
|
||||
}
|
||||
21
src/equicordplugins/messageTranslate/types.ts
Normal file
21
src/equicordplugins/messageTranslate/types.ts
Normal 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; };
|
||||
81
src/equicordplugins/messageTranslate/utils/translate.ts
Normal file
81
src/equicordplugins/messageTranslate/utils/translate.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate(-55%, -50%);
|
||||
}
|
||||
|
||||
.vc-spotify-button-row {
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@
|
|||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform: translate(-55%, -50%);
|
||||
}
|
||||
|
||||
#eq-tdl-info-wrapper {
|
||||
|
|
|
|||
|
|
@ -1447,7 +1447,7 @@ export default definePlugin({
|
|||
group: true,
|
||||
replacement: [
|
||||
{
|
||||
match: /(?<=\i\)&&"xs"===\i;)/,
|
||||
match: /(?<=null&&"xs"===\i;)/,
|
||||
replace: "const shouldHideMembersListActivelyPlayingIcon=$self.shouldHideMembersListActivelyPlayingIcon();"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# NoDefaultHangStatus
|
||||
|
||||
Disable the default hang status when joining voice channels
|
||||
|
||||

|
||||
|
|
@ -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,"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)},",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue