STR v2.2.0 sticker download/conversion support

This commit is contained in:
1Lighty 2020-10-21 17:27:21 +02:00
parent 64f01b1f57
commit dfca0324aa
4 changed files with 287 additions and 168 deletions

View File

@ -1,4 +1,9 @@
# [SaveToRedux](https://1lighty.github.io/BetterDiscordStuff/?plugin=SaveToRedux "SaveToRedux") Changelog # [SaveToRedux](https://1lighty.github.io/BetterDiscordStuff/?plugin=SaveToRedux "SaveToRedux") Changelog
### 2.2.0
- Added support for stickers, they can now be downloaded properly.
- Animated stickers will be turned into a shareable GIF, but the option of downloaing the source is still available.
- Lottie sticker download resolution can be changed in settings, default is 160x160.
### 2.1.5 ### 2.1.5
- Changed to module.exports because useless backwards incompatbile changes are the motto for BBD apparently. - Changed to module.exports because useless backwards incompatbile changes are the motto for BBD apparently.

View File

@ -1,11 +1,11 @@
# SaveToRedux [![download](https://i.imgur.com/OAHgjZu.png)](https://1lighty.github.io/BetterDiscordStuff/?plugin=SaveToRedux&dl=1 "SaveToRedux") # SaveToRedux [![download](https://i.imgur.com/OAHgjZu.png)](https://1lighty.github.io/BetterDiscordStuff/?plugin=SaveToRedux&dl=1 "SaveToRedux")
Allows you to save images, videos, profile icons, server icons, reactions, emotes and custom status emotes to any folder quickly, as well as install plugins from direct links. Allows you to save images, videos, profile icons, server icons, reactions, emotes, custom status emotes and stickers to any folder quickly, as well as install plugins from direct links.
### Features ### Features
Right click on an image, video, file, user, server icon, group DM or emote to be able to set folders and save to folders, under the **Save * To** context menu submenu. Right click on an image, video, file, user, server icon, group DM, emote or sticker to be able to set folders and save to folders, under the **Save * To** context menu submenu.
With * being what you're saving, eg Image, Video, Emoji, File or Icon With * being what you're saving, eg Image, Video, Emoji, File or Icon
Right clicking a theme or plugin attachment or link will show you the option of installing it. Right clicking a theme or plugin attachment or link will show you the option of installing it.
### Preview ### Preview
Right click on nearly any image, video, file, user, server icon, group DM or emote Right click on nearly any image, video, file, user, server icon, group DM, emote or sticker
![preview](https://i.imgur.com/htOuqtw.png) ![preview](https://i.imgur.com/htOuqtw.png)
![preview2](https://cdn.discordapp.com/attachments/389049952732446733/694622056213512292/5jsZjnlrCBkz.png) ![preview2](https://cdn.discordapp.com/attachments/389049952732446733/694622056213512292/5jsZjnlrCBkz.png)
### Settings ### Settings

View File

@ -41,16 +41,16 @@ module.exports = (() => {
twitter_username: '' twitter_username: ''
} }
], ],
version: '2.1.5', version: '2.2.0',
description: 'Allows you to save images, videos, profile icons, server icons, reactions, emotes and custom status emotes to any folder quickly, as well as install plugins from direct links.', description: 'Allows you to save images, videos, profile icons, server icons, reactions, emotes, custom status emotes and stickers to any folder quickly, as well as install plugins from direct links.',
github: 'https://github.com/1Lighty', github: 'https://github.com/1Lighty',
github_raw: 'https://raw.githubusercontent.com/1Lighty/BetterDiscordPlugins/master/Plugins/SaveToRedux/SaveToRedux.plugin.js' github_raw: 'https://raw.githubusercontent.com/1Lighty/BetterDiscordPlugins/master/Plugins/SaveToRedux/SaveToRedux.plugin.js'
}, },
changelog: [ changelog: [
{ {
title: 'fixed', title: 'added',
type: 'fixed', type: 'added',
items: ['Changed to module.exports because useless backwards incompatbile changes are the motto for BBD apparently.'] items: ['Added support for stickers, they can now be downloaded properly.', 'Animated stickers will be turned into a shareable GIF, but the option of downloaing the source is still available.', 'Lottie sticker download resolution can be changed in settings, default is 160x160.']
} }
], ],
defaultConfig: [ defaultConfig: [
@ -94,7 +94,17 @@ module.exports = (() => {
] ]
}, },
{ name: 'User and Server icons get saved by the users or servers name, instead of randomized', id: 'saveByName', type: 'switch', value: true }, { name: 'User and Server icons get saved by the users or servers name, instead of randomized', id: 'saveByName', type: 'switch', value: true },
{ name: 'Append server name or DM name to image/file name', id: 'appendCurrentName', type: 'switch', value: false } { name: 'Append server name or DM name to image/file name', id: 'appendCurrentName', type: 'switch', value: false },
{
name: 'Lottie sticker save size',
id: 'lottieSize',
type: 'radio',
value: 0,
options: [
{ name: 'Default 160x160', value: 0 },
{ name: 'Max size 320x320', value: 1 }
]
},
] ]
}, },
{ type: 'category', id: 'misc', name: 'Misc', collapsible: true, shown: false, settings: [{ name: 'Context menu option at the bottom instead of top', id: 'contextMenuOnBottom', type: 'switch', value: true }] } { type: 'category', id: 'misc', name: 'Misc', collapsible: true, shown: false, settings: [{ name: 'Context menu option at the bottom instead of top', id: 'contextMenuOnBottom', type: 'switch', value: true }] }
@ -312,6 +322,17 @@ module.exports = (() => {
const useIdealExtensions = url => (url.indexOf('/a_') !== -1 ? url.replace('.webp', '.gif').replace('.png', '.gif') : url.replace('.webp', '.png')); const useIdealExtensions = url => (url.indexOf('/a_') !== -1 ? url.replace('.webp', '.gif').replace('.png', '.gif') : url.replace('.webp', '.png'));
const ImageWrapperClassname = XenoLib.getSingleClass('clickable imageWrapper');
const StickerUtils = WebpackModules.getByProps('getStickerAssetUrl');
const { StickerFormat } = WebpackModules.getByProps('StickerFormat') || {};
const WasmLottie = WebpackModules.getByPrototypes('get_rgba');
const webWorkerData = `importScripts('https://1lighty.github.io/BetterDiscordPlugins/Plugins/SaveToRedux/res/worker.js');`;
const workerDataURL = window.URL.createObjectURL(new Blob([webWorkerData], { type: 'text/javascript' }));
const StickerClasses = WebpackModules.getByProps('pngImage', 'lottieCanvas');
return class SaveToRedux extends Plugin { return class SaveToRedux extends Plugin {
constructor() { constructor() {
super(); super();
@ -359,6 +380,9 @@ module.exports = (() => {
div[id$="-str"] + .${XenoLib.getSingleClass('layerContainer layer')} { div[id$="-str"] + .${XenoLib.getSingleClass('layerContainer layer')} {
z-index: 1; z-index: 1;
} }
div[id$="-str-stickers"] + .${XenoLib.getSingleClass('layerContainer layer')} {
z-index: 2;
}
` `
); );
this.lastUsedFolder = -1; this.lastUsedFolder = -1;
@ -400,6 +424,7 @@ module.exports = (() => {
Utilities.suppressErrors(this.patchEmojiPicker.bind(this), 'EmojiPicker patch')(this.promises.state); Utilities.suppressErrors(this.patchEmojiPicker.bind(this), 'EmojiPicker patch')(this.promises.state);
Utilities.suppressErrors(this.patchReactions.bind(this), 'Reaction patch')(this.promises.state); Utilities.suppressErrors(this.patchReactions.bind(this), 'Reaction patch')(this.promises.state);
this.patchContextMenus(); this.patchContextMenus();
this.patchStickerStorePicker();
} }
patchEmojiPicker() { patchEmojiPicker() {
@ -461,6 +486,25 @@ module.exports = (() => {
Reaction.forceUpdateAll(); Reaction.forceUpdateAll();
} }
patchStickerStorePicker() {
const StickerStorePicker = WebpackModules.find(m => {
if (!m || !m.type) return false;
const typeString = String(m.type);
for (const string of ['.getStickerItemProps', '.inspectedSticker']) if (typeString.indexOf(string) === -1) return false;
return true;
});
Patcher.after(StickerStorePicker, 'type', (_, [props], ret) => {
for (const stickerClickable of ret.props.children) {
const { sticker } = Utilities.findInReactTree(stickerClickable, e => e && e.sticker && e.sticker.id) || {};
if (!sticker) continue;
const url = StickerUtils.getStickerAssetUrl(sticker);
XenoLib.createSharedContext(stickerClickable, () => XenoLib.createContextMenuGroup([
this.constructMenu(url.split('?')[0], 'Sticker', sticker.name, () => { }, undefined, '', { id: sticker.id, type: sticker.format_type })
]));
}
})
}
patchContextMenus() { patchContextMenus() {
this.patchUserContextMenus(); this.patchUserContextMenus();
this.patchImageContextMenus(); this.patchImageContextMenus();
@ -509,12 +553,13 @@ module.exports = (() => {
); );
if (!Array.isArray(menu)) return; if (!Array.isArray(menu)) return;
const [state, setState] = React.useState({}); const [state, setState] = React.useState({});
const extraData = {};
let src; let src;
let saveType = 'File'; let saveType = 'File';
let url = ''; let url = '';
let proxiedUrl = ''; let proxiedUrl = '';
let customName = ''; let customName = '';
if (isImageMenu && (Utilities.getNestedProp(props, 'target.parentNode.className') || '').indexOf(XenoLib.getSingleClass('clickable imageWrapper')) !== -1) { if (isImageMenu && (Utilities.getNestedProp(props, 'target.parentNode.className') || '').indexOf(ImageWrapperClassname) !== -1) {
const inst = ReactTools.getOwnerInstance(Utilities.getNestedProp(props, 'target.parentNode.parentNode')); const inst = ReactTools.getOwnerInstance(Utilities.getNestedProp(props, 'target.parentNode.parentNode'));
proxiedUrl = props.src; proxiedUrl = props.src;
if (inst) src = inst.props.original; if (inst) src = inst.props.original;
@ -525,6 +570,17 @@ module.exports = (() => {
proxiedUrl = ''; proxiedUrl = '';
} }
} }
const targetClassName = Utilities.getNestedProp(props, 'target.className') || '';
if (StickerClasses && (targetClassName.indexOf(StickerClasses.lottieCanvas.split(' ')[0]) !== -1 || targetClassName.indexOf(StickerClasses.pngImage.split(' ')[0]) !== -1)) {
const { memoizedProps } = Utilities.findInTree(ReactTools.getReactInstance(props.target), e => e && e.type && e.type.displayName === 'StickerMessage', { walkable: ['return'] });
const { sticker } = memoizedProps || {};
if (!sticker) return;
src = StickerUtils.getStickerAssetUrl(sticker);
customName = sticker.name;
extraData.type = sticker.format_type;
extraData.id = sticker.id;
saveType = 'Sticker';
}
if (!src) src = Utilities.getNestedProp(props, 'attachment.href') || Utilities.getNestedProp(props, 'attachment.url'); if (!src) src = Utilities.getNestedProp(props, 'attachment.href') || Utilities.getNestedProp(props, 'attachment.url');
/* is that enough specific cases? */ /* is that enough specific cases? */
if (typeof src === 'string') { if (typeof src === 'string') {
@ -572,6 +628,7 @@ module.exports = (() => {
} }
url = src; url = src;
if (!url) return; if (!url) return;
if (saveType !== 'Sticker') {
if (isImage(url) || url.indexOf('//steamuserimages') !== -1) saveType = 'Image'; if (isImage(url) || url.indexOf('//steamuserimages') !== -1) saveType = 'Image';
else if (isVideo(url)) saveType = 'Video'; else if (isVideo(url)) saveType = 'Video';
else if (isAudio(url)) saveType = 'Audio'; else if (isAudio(url)) saveType = 'Audio';
@ -608,6 +665,7 @@ module.exports = (() => {
saveType = 'Emoji'; saveType = 'Emoji';
} else if (url.indexOf('.plugin.js') === url.length - 10) saveType = 'Plugin'; } else if (url.indexOf('.plugin.js') === url.length - 10) saveType = 'Plugin';
else if (url.indexOf('.theme.css') === url.length - 10) saveType = 'Theme'; else if (url.indexOf('.theme.css') === url.length - 10) saveType = 'Theme';
}
try { try {
const submenu = this.constructMenu( const submenu = this.constructMenu(
url.split('?')[0], url.split('?')[0],
@ -618,14 +676,15 @@ module.exports = (() => {
if (!isTrustedDomain(targetUrl)) return; if (!isTrustedDomain(targetUrl)) return;
state.__STR_requesting = true; state.__STR_requesting = true;
RequestModule.head(targetUrl, (err, res) => { RequestModule.head(targetUrl, (err, res) => {
if (err) return setState({ __STR_requesting: false, __STR_requested: true }); if (err || res.statusCode !== 200) return setState({ __STR_requesting: false, __STR_requested: true });
const extension = MimeTypesModule.extension(res.headers['content-type']); const extension = MimeTypesModule.extension(res.headers['content-type']);
setState({ __STR_requesting: false, __STR_requested: true, __STR_extension: extension }); setState({ __STR_requesting: false, __STR_requested: true, __STR_extension: extension });
}); });
targetUrl; targetUrl;
}, },
state.__STR_extension, state.__STR_extension,
proxiedUrl proxiedUrl,
extraData
); );
const group = XenoLib.createContextMenuGroup([submenu]); const group = XenoLib.createContextMenuGroup([submenu]);
if (this.settings.misc.contextMenuOnBottom) menu.push(group); if (this.settings.misc.contextMenuOnBottom) menu.push(group);
@ -743,7 +802,7 @@ module.exports = (() => {
return ret; return ret;
} }
formatURL(url, requiresSize, customName, fallbackExtension, proxiedUrl, failNum = 0, forceKeepOriginal = false) { formatURL(url, requiresSize, customName, fallbackExtension, proxiedUrl, failNum = 0, forceKeepOriginal = false, forceExtension = false) {
// url = url.replace(/\/$/, ''); // url = url.replace(/\/$/, '');
if (requiresSize) url += '?size=2048'; if (requiresSize) url += '?size=2048';
else if (url.indexOf('twimg.com/') !== -1) url = url.replace(':small', ':orig').replace(':medium', ':orig').replace(':large', ':orig'); else if (url.indexOf('twimg.com/') !== -1) url = url.replace(':small', ':orig').replace(':medium', ':orig').replace(':large', ':orig');
@ -753,7 +812,7 @@ module.exports = (() => {
} }
const match = url.match(/(?:\/)([^\/]+?)(?:(?:\.)([^.\/?:]+)){0,1}(?:[^\w\/\.]+\w+){0,1}(?:(?:\?[^\/]+){0,1}|(?:\/){0,1})$/); const match = url.match(/(?:\/)([^\/]+?)(?:(?:\.)([^.\/?:]+)){0,1}(?:[^\w\/\.]+\w+){0,1}(?:(?:\?[^\/]+){0,1}|(?:\/){0,1})$/);
let name = customName || match[1]; let name = customName || match[1];
let extension = match[2] || fallbackExtension; let extension = forceExtension || match[2] || fallbackExtension;
if (url.indexOf('//media.tenor.co') !== -1) { if (url.indexOf('//media.tenor.co') !== -1) {
extension = name; extension = name;
name = url.match(/\/\/media.tenor.co\/[^\/]+\/([^\/]+)\//)[1]; name = url.match(/\/\/media.tenor.co\/[^\/]+\/([^\/]+)\//)[1];
@ -772,10 +831,17 @@ module.exports = (() => {
return ret; return ret;
} }
constructMenu(url, type, customName, onNoExtension = () => { }, fallbackExtension, proxiedUrl) { constructMenu(url, type, customName, onNoExtension = () => { }, fallbackExtension, proxiedUrl, extraData = {}) {
const subItems = []; const subItems = [];
const folderSubMenus = []; const folderSubMenus = [];
const formattedurl = this.formatURL(url, type === 'Icon' || type === 'Avatar', customName, fallbackExtension, proxiedUrl, 0, type === 'Theme' || type === 'Plugin'); let forcedExtension = false;
if (type === 'Sticker') {
if (extraData.isStickerSubMenu) {
if (extraData.type === StickerFormat.APNG) forcedExtension = 'apng';
}
else forcedExtension = extraData.type === StickerFormat.PNG ? 'png' : 'gif';
}
const formattedurl = this.formatURL(url, type === 'Icon' || type === 'Avatar', customName, fallbackExtension, proxiedUrl, 0, type === 'Theme' || type === 'Plugin', forcedExtension);
if (!formattedurl.extension) onNoExtension(formattedurl.url); if (!formattedurl.extension) onNoExtension(formattedurl.url);
let notifId; let notifId;
let downloadAttempts = 0; let downloadAttempts = 0;
@ -796,23 +862,67 @@ module.exports = (() => {
return `${bytes.toFixed(1)}${noUnit && unit.a === units[u] ? '' : ' ' + units[u]}`; return `${bytes.toFixed(1)}${noUnit && unit.a === units[u] ? '' : ' ' + units[u]}`;
} }
const unit = { a: '' }; const unit = { a: '' };
const update = () => XenoLib.Notifications.update(notifId, { content: `Downloading ${type} ${humanFileSize(receivedBytes, false, true, unit)}/${humanFileSize(totalBytes, false, false, unit)}`, progress: (receivedBytes / totalBytes) * 100 }); const update = () => XenoLib.Notifications.update(notifId, { content: `Downloading ${type} ${humanFileSize(receivedBytes.length, false, true, unit)}/${humanFileSize(totalBytes, false, false, unit)}`, progress: (receivedBytes.length / totalBytes) * 100 });
const throttledUpdate = XenoLib._.throttle(update, 50); const throttledUpdate = XenoLib._.throttle(update, 50);
let totalBytes = 0; let totalBytes = 0;
let receivedBytes = 0; let receivedBytes = '';
const req = RequestModule(formattedurl.url); const req = RequestModule({ url: formattedurl.url, encoding: null }, async (_, __, body) => {
if (type !== 'Sticker' || extraData.type === StickerFormat.PNG || extraData.isStickerSubMenu) return; // do not convert it
XenoLib.Notifications.remove(notifId);
notifId = undefined;
let sNotifId = XenoLib.Notifications.info(`Converting sticker to gif..`, { timeout: 0, loading: true });
let worker = null;
let lottieWASM = null;
try {
worker = new Worker(workerDataURL);
if (extraData.type === StickerFormat.APNG) {
await new Promise(res => {
worker.onmessage = res;
worker.postMessage(['CONVERT-APNG', body]);
});
} else {
lottieWASM = new WasmLottie(receivedBytes);
const size = this.settings.saveOptions.lottieSize ? 320 : 160;
const frames = [];
for (let i = 0, framesCount = lottieWASM.frames; i < framesCount; i++) frames.push(new Uint8ClampedArray(lottieWASM.get_bgra(i, size, size)));
await new Promise(async res => {
worker.onmessage = res;
worker.postMessage(['CONVERT-FRAMES', { width: size, height: size, framerate: 60, frames }]);
});
}
const { data } = await new Promise(res => {
worker.onmessage = res;
worker.postMessage(['DONE']);
});
XenoLib.Notifications.update(sNotifId, { content: `Saving..` });
FsModule.writeFileSync(path, Buffer.from(data));
XenoLib.Notifications.remove(sNotifId);
sNotifId = undefined;
if (openOnSave) openPath(path);
BdApi.showToast(`Saved to '${PathModule.resolve(path)}'`, { type: 'success' });
} catch (err) {
Logger.stacktrace('Failed converting to GIF', err);
BdApi.showToast(`Failed to save sticker..`, { type: 'error' });
} finally {
if (lottieWASM) lottieWASM.drop();
if (worker) worker.terminate();
}
});
req req
.on('data', chunk => { .on('data', chunk => {
receivedBytes += chunk.length; receivedBytes += chunk;
throttledUpdate(); throttledUpdate();
}) })
.on('response', res => { .on('response', res => {
if (res.statusCode == 200) { if (res.statusCode == 200) {
totalBytes = parseInt(res.headers['content-length']); totalBytes = parseInt(res.headers['content-length']);
update(); update();
if (type === 'Sticker' && extraData.type !== StickerFormat.PNG && !extraData.isStickerSubMenu) return; // do not stream download because we need to convert it first
req req
.pipe(FsModule.createWriteStream(path)) .pipe(FsModule.createWriteStream(path))
.on('finish', () => { .on('finish', () => {
XenoLib.Notifications.remove(notifId);
notifId = undefined;
if (openOnSave) openPath(path); if (openOnSave) openPath(path);
BdApi.showToast(`Saved to '${PathModule.resolve(path)}'`, { type: 'success' }); BdApi.showToast(`Saved to '${PathModule.resolve(path)}'`, { type: 'success' });
}) })
@ -824,7 +934,7 @@ module.exports = (() => {
} else if (res.statusCode == 404) { } else if (res.statusCode == 404) {
if (shouldDoMultiAttempts && downloadAttempts < 2) { if (shouldDoMultiAttempts && downloadAttempts < 2) {
downloadAttempts++; downloadAttempts++;
const newUrl = this.formatURL(url, type === 'Icon' || type === 'Avatar', customName, fallbackExtension, proxiedUrl, downloadAttempts, type === 'Theme' || type === 'Plugin').url; const newUrl = this.formatURL(url, type === 'Icon' || type === 'Avatar', customName, fallbackExtension, proxiedUrl, downloadAttempts, type === 'Theme' || type === 'Plugin', forcedExtension).url;
if (newUrl !== formattedurl.url) { if (newUrl !== formattedurl.url) {
formattedurl.url = newUrl; formattedurl.url = newUrl;
return downloadEx(path, openOnSave); return downloadEx(path, openOnSave);
@ -1041,7 +1151,7 @@ module.exports = (() => {
return XenoLib.createContextMenuSubMenu( return XenoLib.createContextMenuSubMenu(
folder.name, folder.name,
[ [
XenoLib.createContextMenuItem( extraData.onlyFolderSave ? null : XenoLib.createContextMenuItem(
'Remove Folder', 'Remove Folder',
() => { () => {
this.folders.splice(idx, 1); this.folders.splice(idx, 1);
@ -1050,7 +1160,7 @@ module.exports = (() => {
}, },
'remove-folder' 'remove-folder'
), ),
XenoLib.createContextMenuItem( extraData.onlyFolderSave ? null : XenoLib.createContextMenuItem(
'Open Folder', 'Open Folder',
() => { () => {
openPath(folder.path); openPath(folder.path);
@ -1076,7 +1186,7 @@ module.exports = (() => {
}, },
'save-and-open' 'save-and-open'
), ),
XenoLib.createContextMenuItem( extraData.onlyFolderSave ? null : XenoLib.createContextMenuItem(
'Edit', 'Edit',
() => { () => {
let __name = folder.name.slice(0); let __name = folder.name.slice(0);
@ -1119,7 +1229,10 @@ module.exports = (() => {
for (const folderIDX in this.folders) folderSubMenus.push(folderSubMenu(this.folders[folderIDX], folderIDX)); for (const folderIDX in this.folders) folderSubMenus.push(folderSubMenu(this.folders[folderIDX], folderIDX));
subItems.push( subItems.push(
...folderSubMenus, ...folderSubMenus,
XenoLib.createContextMenuItem( type === 'Sticker' && !extraData.isStickerSubMenu && extraData.type !== StickerFormat.PNG ?
XenoLib.createContextMenuSubMenu(`Save ${extraData.type === StickerFormat.LOTTIE ? 'Lottie JSON' : 'APNG'}`, this.constructMenu(url, type, customName, onNoExtension, fallbackExtension, proxiedUrl, { ...extraData, onlyItems: true, isStickerSubMenu: true, onlyFolderSave: true }), 'str-stickers')
: null,
extraData.onlyFolderSave ? null : XenoLib.createContextMenuItem(
'Add Folder', 'Add Folder',
() => { () => {
dialog dialog
@ -1215,6 +1328,7 @@ module.exports = (() => {
) )
: null : null
); );
if (extraData.onlyItems) return subItems;
return XenoLib.createContextMenuSubMenu(`Save ${type} To`, subItems, 'str', { return XenoLib.createContextMenuSubMenu(`Save ${type} To`, subItems, 'str', {
action: () => { action: () => {
if (this.lastUsedFolder === -1) return BdApi.showToast('No folder has been used yet', { type: 'error' }); if (this.lastUsedFolder === -1) return BdApi.showToast('No folder has been used yet', { type: 'error' });
@ -1272,7 +1386,7 @@ module.exports = (() => {
n = (n, e) => n && n._config && n._config.info && n._config.info.version && i(n._config.info.version, e), n = (n, e) => n && n._config && n._config.info && n._config.info.version && i(n._config.info.version, e),
e = BdApi.getPlugin('ZeresPluginLibrary'), e = BdApi.getPlugin('ZeresPluginLibrary'),
o = BdApi.getPlugin('XenoLib'); o = BdApi.getPlugin('XenoLib');
n(e, '1.2.23') && (ZeresPluginLibraryOutdated = !0), n(o, '1.3.26') && (XenoLibOutdated = !0); n(e, '1.2.24') && (ZeresPluginLibraryOutdated = !0), n(o, '1.3.29') && (XenoLibOutdated = !0);
} }
} catch (i) { } catch (i) {
console.error('Error checking if libraries are out of date', i); console.error('Error checking if libraries are out of date', i);

View File

@ -27,6 +27,6 @@ Show a notification in Discord when someone sends a message, just like on mobile
Saves all deleted and purged messages, as well as all edit history and ghost pings. With highly configurable ignore options, and even restoring deleted messages after restarting Discord. Saves all deleted and purged messages, as well as all edit history and ghost pings. With highly configurable ignore options, and even restoring deleted messages after restarting Discord.
## [SaveToRedux](https://github.com/1Lighty/BetterDiscordPlugins/tree/master/Plugins/SaveToRedux "SaveToRedux") ## [SaveToRedux](https://github.com/1Lighty/BetterDiscordPlugins/tree/master/Plugins/SaveToRedux "SaveToRedux")
Allows you to save images, videos, profile icons, server icons, reactions, emotes and custom status emotes to any folder quickly, as well as install plugins from direct links. Allows you to save images, videos, profile icons, server icons, reactions, emotes, custom status emotes and stickers to any folder quickly, as well as install plugins from direct links.
## [UnreadBadgesRedux](https://github.com/1Lighty/BetterDiscordPlugins/tree/master/Plugins/UnreadBadgesRedux "UnreadBadgesRedux") ## [UnreadBadgesRedux](https://github.com/1Lighty/BetterDiscordPlugins/tree/master/Plugins/UnreadBadgesRedux "UnreadBadgesRedux")
Shows an unread badge on folders, server icons and channels, all toggleable with the count adjustable. Shows an unread badge on folders, server icons and channels, all toggleable with the count adjustable.