Add RoseUtils: fork of nexpid's NexxUtils with the SpotifyPlayButton crash fixed
|
|
@ -42,13 +42,15 @@ To install: open Discord with Revenge installed, go to **Settings → Plugins**,
|
|||
| --- | --- | --- | --- |
|
||||
| ViewRaw | Revived | See a message's raw JSON via long-press - searchable, syntax-highlighted, and copyable. Fork of [bwlok's ViewRaw](https://github.com/bwlok/revenge-plugins/tree/master/plugins/ViewRaw), GPLv3. | `https://rp.jarviscli.dev/view-raw/install/` |
|
||||
| Key Inspector | New | One-tap scan of every internal key this repo's plugins depend on, plus a full dump of Revenge's plugin API tree - for diagnosing crashes entirely from a phone. | `https://rp.jarviscli.dev/key-inspector/install/` |
|
||||
| RoseUtils | Revived | Spotify listen-along, a Tenor GIF fix, colorful channels, and more - independently toggleable modules. Fork of [nexpid's NexxUtils](https://github.com/nexpid/RevengePlugins/tree/main/src/plugins/nexxutils), CC-BY 4.0. | `https://rp.jarviscli.dev/rose-utils/install/` |
|
||||
|
||||
"Revived" means the plugin previously existed elsewhere, stopped working after a Discord/API update, and has been rebuilt here.
|
||||
"Rebuilt" means it's a from-scratch reimplementation of a previously-broken plugin with expanded features. "New" means
|
||||
it didn't exist anywhere in the Revenge/Vendetta plugin ecosystem before.
|
||||
|
||||
**Licensing note:** everything in this repo is original work except `plugins/view-raw`, which is a fork of a
|
||||
GPLv3-licensed plugin and carries its own `LICENSE` file in that folder - the rest of the repo isn't GPLv3.
|
||||
**Licensing note:** everything in this repo is original work except `plugins/view-raw` (GPLv3, forked from
|
||||
bwlok's ViewRaw) and `plugins/rose-utils` (CC-BY 4.0, forked from nexpid's NexxUtils) - the rest of the repo
|
||||
isn't under either of those licenses.
|
||||
|
||||
## Why some of these broke before, and what's different now
|
||||
|
||||
|
|
|
|||
BIN
plugins/rose-utils/assets/ColorfulChannels/announcement/lock.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/controller.png
Normal file
|
After Width: | Height: | Size: 844 B |
BIN
plugins/rose-utils/assets/ColorfulChannels/forum/lock.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/forum/warning.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/image/lock.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/image/warning.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/lock.png
Normal file
|
After Width: | Height: | Size: 776 B |
BIN
plugins/rose-utils/assets/ColorfulChannels/lockBottom.png
Normal file
|
After Width: | Height: | Size: 769 B |
BIN
plugins/rose-utils/assets/ColorfulChannels/stage/lock.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/text/controller.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/text/lock.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/text/warning.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
3
plugins/rose-utils/assets/ColorfulChannels/tpconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"root": "ColorfulChannels"
|
||||
}
|
||||
BIN
plugins/rose-utils/assets/ColorfulChannels/voice/lock.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/voice/warning.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
plugins/rose-utils/assets/ColorfulChannels/warning.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
plugins/rose-utils/assets/ColorfulChannels/warningBottom.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
plugins/rose-utils/assets/MiniMod/NerdEmoji.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
3
plugins/rose-utils/assets/MiniMod/tpconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"root": "Minimod"
|
||||
}
|
||||
BIN
plugins/rose-utils/assets/SpotifyListenAlong/UserPlayIcon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
9
plugins/rose-utils/manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "RoseUtils",
|
||||
"description": "A small collection of independent utilities - Spotify listen-along, a Tenor download fix, colorful channel icons, and more. Fork of nexpid's NexxUtils.",
|
||||
"authors": [
|
||||
{ "name": "everestmcarthur" },
|
||||
{ "name": "nexpid" }
|
||||
],
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
28
plugins/rose-utils/src/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { logger } from "@vendetta";
|
||||
import modules from "./modules";
|
||||
import { vstorage } from "./lib/Module";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
export default {
|
||||
onLoad() {
|
||||
vstorage.modules ??= {};
|
||||
|
||||
for (const module of modules) {
|
||||
try {
|
||||
if (module.storage.enabled) module.start();
|
||||
} catch (e) {
|
||||
logger.error(`[RoseUtils] Failed to start "${module.label}" on load: ${e}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
for (const module of modules) {
|
||||
try {
|
||||
module.stop();
|
||||
} catch (e) {
|
||||
logger.error(`[RoseUtils] Failed to stop "${module.label}" cleanly: ${e}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
settings: Settings,
|
||||
};
|
||||
163
plugins/rose-utils/src/lib/Module.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
|
||||
// Simplified from nexpid's original Module class (nexxutils) - same shape (id/label/settings/
|
||||
// handlers, per-module enable storage, error tracking), with the fancier UI-kit bits (custom
|
||||
// error viewer modal, choose-sheets, table rows) traded for this repo's plain @shared components.
|
||||
export const vstorage = storage as {
|
||||
modules: Record<string, { enabled: boolean; options: Record<string, any> }>;
|
||||
};
|
||||
|
||||
export enum ModuleCategory {
|
||||
Useful = "Useful",
|
||||
Fixes = "Fixes",
|
||||
Fun = "Fun"
|
||||
}
|
||||
|
||||
export type ModuleSetting =
|
||||
& { label: string; subLabel?: string | ((value: any) => string); predicate?: (this: AnyModule) => boolean }
|
||||
& (
|
||||
| { type: "toggle"; default: boolean }
|
||||
| { type: "button"; action: (this: AnyModule) => void }
|
||||
| { type: "choose"; choices: string[]; default: string }
|
||||
);
|
||||
|
||||
export interface ModuleMeta {
|
||||
sublabel: string;
|
||||
category: ModuleCategory;
|
||||
}
|
||||
|
||||
export type AnyModule = Module<Record<string, ModuleSetting>>;
|
||||
|
||||
class Patches {
|
||||
private store: (() => void)[] = [];
|
||||
|
||||
add(patch: () => void) {
|
||||
this.store.push(patch);
|
||||
}
|
||||
|
||||
unpatch() {
|
||||
for (const p of this.store) {
|
||||
try {
|
||||
p();
|
||||
} catch {
|
||||
// A patch failing to undo shouldn't block the others from unwinding.
|
||||
}
|
||||
}
|
||||
this.store = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class Module<Settings extends Record<string, ModuleSetting>> {
|
||||
id: string;
|
||||
label: string;
|
||||
meta: ModuleMeta;
|
||||
settings: Settings;
|
||||
errors: Record<string, string> = {};
|
||||
patches = new Patches();
|
||||
|
||||
private handlers: {
|
||||
onStart: (this: Module<Settings>) => void;
|
||||
onStop: (this: Module<Settings>) => void;
|
||||
};
|
||||
private started = false;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor({ id, label, meta, settings, handlers }: {
|
||||
id: string;
|
||||
label: string;
|
||||
meta: ModuleMeta;
|
||||
settings?: Settings;
|
||||
handlers: {
|
||||
onStart: (this: Module<Settings>) => void;
|
||||
onStop: (this: Module<Settings>) => void;
|
||||
};
|
||||
}) {
|
||||
this.id = id;
|
||||
this.label = label;
|
||||
this.meta = meta;
|
||||
this.settings = settings ?? ({} as Settings);
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
get storage(): { enabled: boolean; options: { [k in keyof Settings]: any } } {
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(this.settings)
|
||||
.filter(([, s]) => "default" in s)
|
||||
// @ts-expect-error settings union narrows fine at runtime
|
||||
.map(([k, s]) => [k, s.default]),
|
||||
);
|
||||
|
||||
vstorage.modules[this.id] ??= { enabled: false, options };
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
const opts = vstorage.modules[this.id].options;
|
||||
if (typeof v !== typeof opts[k]) opts[k] = v;
|
||||
}
|
||||
|
||||
return vstorage.modules[this.id] as any;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.storage.enabled = !this.storage.enabled;
|
||||
if (this.storage.enabled) this.start();
|
||||
else this.stop();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
restart() {
|
||||
if (!this.started) return;
|
||||
this.stop();
|
||||
this.start();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) return;
|
||||
|
||||
try {
|
||||
this.started = true;
|
||||
this.handlers.onStart.call(this);
|
||||
} catch (e) {
|
||||
this.stop();
|
||||
this.started = false;
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
|
||||
logger.error(`[RoseUtils/${this.label}] Failed to start: ${err}`);
|
||||
this.errors.Start = String(err.stack ?? err);
|
||||
showToast(`${this.label} failed to start - see RoseUtils settings for details`, undefined);
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.started) return;
|
||||
|
||||
try {
|
||||
this.started = false;
|
||||
this.handlers.onStop.call(this);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
logger.error(`[RoseUtils/${this.label}] Failed to stop cleanly: ${err}`);
|
||||
this.errors.Stop = String(err.stack ?? err);
|
||||
} finally {
|
||||
this.patches.unpatch();
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
useRefresh() {
|
||||
const [, forceUpdate] = React.useReducer((n: number) => ~n, 0);
|
||||
React.useEffect(() => {
|
||||
this.listeners.add(forceUpdate);
|
||||
return () => {
|
||||
this.listeners.delete(forceUpdate);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
|
||||
const { View, Text } = ReactNative;
|
||||
|
||||
const VARIANTS: Record<string, { bg: string; fg: string }> = {
|
||||
destructive: { bg: "#F23F42", fg: "#FFFFFF" },
|
||||
default: { bg: "#4E5058", fg: "#FFFFFF" },
|
||||
};
|
||||
|
||||
export default function TextBadge({ variant = "default", children }: { variant?: string; children: any }) {
|
||||
const colors = VARIANTS[variant] ?? VARIANTS.default;
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.bg, borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1, marginLeft: 4, alignSelf: "center" }}>
|
||||
<Text style={{ color: colors.fg, fontSize: 10, fontWeight: "700", textTransform: "uppercase" }}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
BIN
plugins/rose-utils/src/modules/ColorfulChannels/dark.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
161
plugins/rose-utils/src/modules/ColorfulChannels/index.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { findByName, findByProps } from "@vendetta/metro";
|
||||
import { React, ReactNative as RN } from "@vendetta/metro/common";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { semanticColors } from "@vendetta/ui";
|
||||
import { getAssetByID, getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { resolveSemanticColor } from "@shared/lib/color";
|
||||
|
||||
import TextBadge from "./components/TextBadge";
|
||||
|
||||
import announcementLock from "../../../assets/ColorfulChannels/announcement/lock.png";
|
||||
import announcementWarning from "../../../assets/ColorfulChannels/announcement/warning.png";
|
||||
import controller from "../../../assets/ColorfulChannels/controller.png";
|
||||
import forumLock from "../../../assets/ColorfulChannels/forum/lock.png";
|
||||
import forumWarning from "../../../assets/ColorfulChannels/forum/warning.png";
|
||||
import imageLock from "../../../assets/ColorfulChannels/image/lock.png";
|
||||
import imageWarning from "../../../assets/ColorfulChannels/image/warning.png";
|
||||
import lock from "../../../assets/ColorfulChannels/lock.png";
|
||||
import lockBottom from "../../../assets/ColorfulChannels/lockBottom.png";
|
||||
import stageLock from "../../../assets/ColorfulChannels/stage/lock.png";
|
||||
import textController from "../../../assets/ColorfulChannels/text/controller.png";
|
||||
import textLock from "../../../assets/ColorfulChannels/text/lock.png";
|
||||
import textWarning from "../../../assets/ColorfulChannels/text/warning.png";
|
||||
import voiceLock from "../../../assets/ColorfulChannels/voice/lock.png";
|
||||
import voiceWarning from "../../../assets/ColorfulChannels/voice/warning.png";
|
||||
import warning from "../../../assets/ColorfulChannels/warning.png";
|
||||
import warningBottom from "../../../assets/ColorfulChannels/warningBottom.png";
|
||||
import { Module, ModuleCategory } from "../../lib/Module";
|
||||
import dark from "./dark.png";
|
||||
import light from "./light.png";
|
||||
|
||||
enum GlyphKind {
|
||||
Lock = "Lock",
|
||||
Warning = "Warning",
|
||||
Controller = "Controller",
|
||||
}
|
||||
|
||||
function getGlyphOverlay(kind: GlyphKind, bottom?: boolean) {
|
||||
if (kind === GlyphKind.Lock) return bottom ? lockBottom : lock;
|
||||
if (kind === GlyphKind.Warning) return bottom ? warningBottom : warning;
|
||||
if (kind === GlyphKind.Controller) return controller;
|
||||
}
|
||||
|
||||
const glyphColors = {
|
||||
[GlyphKind.Lock]: ["#f0b232", "STATUS_WARNING"],
|
||||
[GlyphKind.Warning]: ["#f23f43", "STATUS_DANGER"],
|
||||
[GlyphKind.Controller]: ["#23a55a", "STATUS_POSITIVE"],
|
||||
} satisfies Record<GlyphKind, [fallback: string, semanticTokenName: string]>;
|
||||
|
||||
const glyphs = [
|
||||
{ name: "Announcements", kind: GlyphKind.Lock, base: announcementLock },
|
||||
{ name: "Announcements", kind: GlyphKind.Warning, base: announcementWarning },
|
||||
{ name: "Text", kind: GlyphKind.Lock, base: textLock },
|
||||
{ name: "Text", kind: GlyphKind.Warning, base: textWarning },
|
||||
{ name: "Text", kind: GlyphKind.Controller, base: textController },
|
||||
{ name: "Voice", kind: GlyphKind.Lock, base: voiceLock },
|
||||
{ name: "Voice", kind: GlyphKind.Warning, base: voiceWarning },
|
||||
{ name: "Forum", kind: GlyphKind.Lock, base: forumLock },
|
||||
{ name: "Forum", kind: GlyphKind.Warning, base: forumWarning },
|
||||
{ name: "Stage", kind: GlyphKind.Lock, bottom: true, base: stageLock },
|
||||
{ name: "Stage", kind: GlyphKind.Warning, bottom: true, base: 0 },
|
||||
{ name: "Image", kind: GlyphKind.Lock, bottom: true, base: imageLock },
|
||||
{ name: "Image", kind: GlyphKind.Warning, bottom: true, base: imageWarning },
|
||||
];
|
||||
|
||||
const ChannelInfo = findByName("ChannelInfo", false);
|
||||
const BaseChannelIconModule = findByProps("BaseChannelIcon");
|
||||
|
||||
export default new Module({
|
||||
id: "colorful-channels",
|
||||
label: "Colorful Channels",
|
||||
meta: {
|
||||
sublabel: "Gives channel icons with symbols colors to make them more aesthetically pleasing",
|
||||
category: ModuleCategory.Useful,
|
||||
},
|
||||
settings: {
|
||||
nsfwTag: {
|
||||
label: "NSFW tag",
|
||||
subLabel: "Adds a red NSFW tag next to channel names",
|
||||
type: "toggle",
|
||||
default: true,
|
||||
},
|
||||
colorIcons: {
|
||||
label: "Colored icon symbols",
|
||||
subLabel: "Turns locked channel symbols yellow, NSFW channel symbols red",
|
||||
type: "toggle",
|
||||
default: true,
|
||||
},
|
||||
colorIconsFallback: {
|
||||
label: "Fallback colors for colored icon symbols",
|
||||
subLabel: "Uses yellow, red and green colors regardless of theme",
|
||||
type: "toggle",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
handlers: {
|
||||
onStart() {
|
||||
if (this.storage.options.nsfwTag && ChannelInfo) {
|
||||
this.patches.add(
|
||||
after("default", ChannelInfo, ([{ channel }]: any[], ret: any) =>
|
||||
React.createElement(
|
||||
React.Fragment,
|
||||
{},
|
||||
channel?.nsfw_ && React.createElement(TextBadge, { variant: "destructive" }, "nsfw"),
|
||||
ret,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.storage.options.colorIcons && BaseChannelIconModule?.BaseChannelIcon) {
|
||||
this.patches.add(
|
||||
after("BaseChannelIcon", BaseChannelIconModule, ([{ source, style }]: any[], ret: any) => {
|
||||
try {
|
||||
const name = typeof source?.original === "number"
|
||||
? getAssetByID(source.original)?.name
|
||||
: typeof source === "number"
|
||||
? getAssetByID(source)?.name
|
||||
: null;
|
||||
if (!name) return;
|
||||
|
||||
const glyph = glyphs.find((x) => name === `${x.name}${x.kind}Icon`);
|
||||
if (!glyph) return;
|
||||
|
||||
const [fallbackColor, tokenName] = glyphColors[glyph.kind];
|
||||
const overlay = getGlyphOverlay(glyph.kind, glyph.bottom);
|
||||
|
||||
const iconColorKey = ret?.props?.color?.replace(/-/g, "_")?.toUpperCase();
|
||||
const iconTint = iconColorKey && (semanticColors as Record<string, any>)?.[iconColorKey]
|
||||
? resolveSemanticColor((semanticColors as Record<string, any>)[iconColorKey])
|
||||
: undefined;
|
||||
|
||||
const styled = RN.StyleSheet.flatten([
|
||||
style,
|
||||
ret?.props?.style,
|
||||
{ width: 18, height: 18, tintColor: iconTint },
|
||||
]);
|
||||
|
||||
const overlayTint = this.storage.options.colorIconsFallback
|
||||
? fallbackColor
|
||||
: resolveSemanticColor((semanticColors as Record<string, any>)?.[tokenName]) ?? fallbackColor;
|
||||
|
||||
return React.createElement(
|
||||
RN.View,
|
||||
{},
|
||||
React.createElement(RN.Image, { style: styled, source: glyph.base }),
|
||||
React.createElement(
|
||||
RN.View,
|
||||
{ style: { position: "absolute", right: 0, bottom: 0 } },
|
||||
React.createElement(RN.Image, { style: { ...styled, tintColor: overlayTint }, source: overlay }),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Leave the channel icon as Discord rendered it.
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onStop() {},
|
||||
},
|
||||
});
|
||||
BIN
plugins/rose-utils/src/modules/ColorfulChannels/light.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
plugins/rose-utils/src/modules/Minimod/dark.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
50
plugins/rose-utils/src/modules/Minimod/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { findByStoreName } from "@vendetta/metro";
|
||||
import { patchRows } from "@shared/lib/patchRows";
|
||||
|
||||
import { Module, ModuleCategory } from "../../lib/Module";
|
||||
import dark from "./dark.png";
|
||||
import light from "./light.png";
|
||||
|
||||
// It's just like Among Us
|
||||
const GuildMemberStore = findByStoreName("GuildMemberStore");
|
||||
|
||||
export default new Module({
|
||||
id: "minimod",
|
||||
label: "Minimod",
|
||||
meta: {
|
||||
sublabel: "Lets you see some moderator-only things, like whether a member is currently timed out",
|
||||
category: ModuleCategory.Fun,
|
||||
},
|
||||
settings: {
|
||||
showTimeouts: {
|
||||
label: "Show timeouts",
|
||||
subLabel: "Show member timeout icons in chat",
|
||||
type: "toggle",
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
handlers: {
|
||||
onStart() {
|
||||
if (!this.storage.options.showTimeouts || !GuildMemberStore?.getCommunicationDisabledUserMap) return;
|
||||
|
||||
this.patches.add(
|
||||
patchRows((rows) => {
|
||||
if (!rows.some((row) => "message" in row)) return;
|
||||
|
||||
const timedOut = GuildMemberStore.getCommunicationDisabledUserMap();
|
||||
const now = Date.now();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.type !== 1) continue;
|
||||
|
||||
const date = new Date(timedOut[`${row.message.guildId}-${row.message.authorId}`]);
|
||||
if (date.getTime() > now) {
|
||||
row.message.communicationDisabled = true;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
onStop() {},
|
||||
},
|
||||
});
|
||||
BIN
plugins/rose-utils/src/modules/Minimod/light.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
|
@ -0,0 +1,57 @@
|
|||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { React, ReactNative as RN } from "@vendetta/metro/common";
|
||||
import { getAssetIDByName, getAssetByID } from "@vendetta/ui/assets";
|
||||
import { fluxSubscribe } from "@shared/lib/flux";
|
||||
|
||||
const { View, TouchableOpacity, Image } = RN;
|
||||
|
||||
const SpotifyStore = findByStoreName("SpotifyStore");
|
||||
const SelectedChannelStore = findByStoreName("SelectedChannelStore");
|
||||
const DraftStore = findByStoreName("DraftStore");
|
||||
|
||||
const sendMessageModule = findByProps("sendMessage", "revealMessage");
|
||||
const clearDraftModule = findByProps("clearDraft");
|
||||
|
||||
const sendInvite = () => {
|
||||
if (!sendMessageModule?.sendMessage || !clearDraftModule?.clearDraft) return;
|
||||
|
||||
const activity = SpotifyStore?.getActivity?.();
|
||||
if (!activity?.party?.id) return;
|
||||
|
||||
const channel = SelectedChannelStore?.getChannelId?.();
|
||||
if (!channel) return;
|
||||
|
||||
sendMessageModule.sendMessage(
|
||||
channel,
|
||||
{
|
||||
content: DraftStore?.getDraft?.(channel, 0) ?? "",
|
||||
tts: false,
|
||||
invalidEmojis: [],
|
||||
validNonShortcutEmojis: [],
|
||||
},
|
||||
true,
|
||||
{ activityAction: { activity, type: 3 } },
|
||||
);
|
||||
|
||||
clearDraftModule.clearDraft(channel, 0);
|
||||
};
|
||||
|
||||
export default function InviteButton() {
|
||||
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
|
||||
React.useEffect(() => fluxSubscribe("SPOTIFY_PLAYER_STATE", forceUpdate));
|
||||
|
||||
const canInvite = !!(sendMessageModule?.sendMessage && clearDraftModule?.clearDraft && SpotifyStore?.getActivity?.()?.party?.id);
|
||||
const asset = getAssetByID(getAssetIDByName("ic_spotify_white_16px"));
|
||||
|
||||
return (
|
||||
<View style={{ opacity: canInvite ? 1 : 0.4, paddingHorizontal: 8, justifyContent: "center" }}>
|
||||
<TouchableOpacity disabled={!canInvite} onPress={sendInvite}>
|
||||
{asset?.uri ? (
|
||||
<Image source={{ uri: asset.uri }} style={{ width: 22, height: 22 }} />
|
||||
) : (
|
||||
<RN.Text style={{ color: "#1ED760", fontWeight: "700" }}>♫</RN.Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
BIN
plugins/rose-utils/src/modules/SendSpotifyInvite/dark.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
39
plugins/rose-utils/src/modules/SendSpotifyInvite/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { findByTypeName } from "@vendetta/metro";
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import dark from "./dark.png";
|
||||
import light from "./light.png";
|
||||
|
||||
import { Module, ModuleCategory } from "../../lib/Module";
|
||||
import InviteButton from "./components/InviteButton";
|
||||
|
||||
const MediaKeyboardListHeader = findByTypeName("MediaKeyboardListHeader", false);
|
||||
|
||||
export default new Module({
|
||||
id: "send-spotify-invite",
|
||||
label: "Send Spotify invite",
|
||||
meta: {
|
||||
sublabel: "Adds a button in the chat attachment menu to invite the channel to listen along to Spotify",
|
||||
category: ModuleCategory.Useful,
|
||||
},
|
||||
handlers: {
|
||||
onStart() {
|
||||
if (!MediaKeyboardListHeader) return;
|
||||
|
||||
this.patches.add(
|
||||
after("type", MediaKeyboardListHeader, (_: any, ret: any) => {
|
||||
if (!ret?.props || !Array.isArray(ret.props.children)) return;
|
||||
|
||||
return {
|
||||
...ret,
|
||||
props: {
|
||||
...ret.props,
|
||||
children: [...ret.props.children, React.createElement(InviteButton, {})],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
onStop() {},
|
||||
},
|
||||
});
|
||||
BIN
plugins/rose-utils/src/modules/SendSpotifyInvite/light.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -0,0 +1,33 @@
|
|||
import { ReactNative as RN } from "@vendetta/metro/common";
|
||||
import { Button } from "@vendetta/ui/components";
|
||||
|
||||
import UserPlayIcon from "../../../../assets/SpotifyListenAlong/UserPlayIcon.png";
|
||||
import { doSync, type ListenButtonProps, useListenButton, useLoading } from "./core";
|
||||
|
||||
export default function ClassicListenButton({ button, activity }: ListenButtonProps) {
|
||||
const { disabled, userId } = useListenButton(activity);
|
||||
const { loading: _loading, trigger } = useLoading();
|
||||
const loading = _loading && !disabled;
|
||||
|
||||
return (
|
||||
<RN.View style={{ flexDirection: "row", flex: 1 }}>
|
||||
<RN.View style={{ paddingRight: 6 }}>
|
||||
<Button
|
||||
size="small"
|
||||
text=""
|
||||
style={[button?.style, { width: 32 }, !!disabled && { opacity: 0.5 }]}
|
||||
renderIcon={() =>
|
||||
loading ? (
|
||||
<RN.ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<RN.Image source={UserPlayIcon} style={{ width: 20, height: 20, tintColor: "#fff" }} />
|
||||
)}
|
||||
onPress={() => !loading && (doSync(disabled, activity, userId), !disabled && trigger())}
|
||||
/>
|
||||
</RN.View>
|
||||
<RN.View style={{ flexGrow: 1 }}>
|
||||
<Button {...button} />
|
||||
</RN.View>
|
||||
</RN.View>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { fluxSubscribe } from "@shared/lib/flux";
|
||||
|
||||
const SpotifyProtocolStore = findByStoreName("SpotifyProtocolStore");
|
||||
const SpotifyStore = findByStoreName("SpotifyStore");
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
|
||||
const { play, sync } = findByProps("play", "sync") ?? {};
|
||||
|
||||
interface Activity {
|
||||
party: { id: `${string}:${string}` };
|
||||
}
|
||||
export interface ListenButtonProps {
|
||||
button: any;
|
||||
activity: Activity;
|
||||
}
|
||||
|
||||
export function doSync(disabled: string | false, activity: Activity, userId: string) {
|
||||
if (typeof disabled === "string") return showToast(disabled, undefined);
|
||||
if (!play || !sync) return showToast("Couldn't find Spotify's sync action - this feature may be unavailable on your Discord version.", undefined);
|
||||
|
||||
if (SpotifyStore?.getActivity()) return sync(activity, userId);
|
||||
|
||||
fluxSubscribe("SPOTIFY_PLAYER_STATE", () => sync(activity, userId), true);
|
||||
play(activity, userId);
|
||||
}
|
||||
|
||||
export function useListenButton(activity: ListenButtonProps["activity"]): {
|
||||
disabled: false | string;
|
||||
userId: string;
|
||||
} {
|
||||
const [val, forceUpdate] = React.useReducer((x: number) => ~x, 0);
|
||||
React.useEffect(() => fluxSubscribe("SPOTIFY_PLAYER_STATE", forceUpdate));
|
||||
|
||||
const userId = activity.party.id.split(":")[1];
|
||||
const logic = React.useMemo(() => {
|
||||
const selfUserId = UserStore?.getCurrentUser?.()?.id;
|
||||
const selfActivity = SpotifyStore?.getActivity?.() as Activity | undefined;
|
||||
const hasConnectedAccount = !!SpotifyStore?.hasConnectedAccount?.();
|
||||
const canPlaySpotify = !!SpotifyStore?.getActiveSocketAndDevice?.() || !!SpotifyProtocolStore?.isProtocolRegistered?.();
|
||||
|
||||
return {
|
||||
isYou: selfUserId === userId,
|
||||
isListeningAlong: selfActivity?.party?.id === activity.party.id,
|
||||
isPlayable: hasConnectedAccount && canPlaySpotify,
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, activity, val]);
|
||||
|
||||
let disabled: false | string = false;
|
||||
if (logic.isYou) disabled = "Listen along with someone else, not yourself.";
|
||||
else if (logic.isListeningAlong) disabled = "You're already along for this ride.";
|
||||
else if (!logic.isPlayable) disabled = "Spotify is not detected";
|
||||
|
||||
return { disabled, userId };
|
||||
}
|
||||
|
||||
export function useLoading() {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
return {
|
||||
loading,
|
||||
trigger: () => (setLoading(true), setTimeout(() => setLoading(false), 5_000)),
|
||||
};
|
||||
}
|
||||
BIN
plugins/rose-utils/src/modules/SpotifyListenAlong/dark.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
42
plugins/rose-utils/src/modules/SpotifyListenAlong/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import dark from "./dark.png";
|
||||
import light from "./light.png";
|
||||
|
||||
import { Module, ModuleCategory } from "../../lib/Module";
|
||||
import ClassicListenButton from "./components/ClassicListenButton";
|
||||
|
||||
// Confirmed against decompiled current-build Discord source (app/modules/now_playing/native/
|
||||
// UserActivitySpotify.tsx): SpotifyPlayButton is a plain function component now, not a class -
|
||||
// `after("render", SpotifyPlayButton.prototype, ...)` is why this threw "render is not a
|
||||
// function" on every activity render. Patching the exported property directly works for both
|
||||
// function and class components, and the function's own first argument already carries
|
||||
// { activity, style } directly - no need to dig through fiber internals for it anymore.
|
||||
const SpotifyModule = findByProps("SpotifyPlayButton");
|
||||
|
||||
export default new Module({
|
||||
id: "spotify-listen-along",
|
||||
label: "Add Listen Along",
|
||||
meta: {
|
||||
sublabel: "Ports the Spotify listen along feature from desktop to mobile in Spotify activities",
|
||||
category: ModuleCategory.Useful,
|
||||
},
|
||||
handlers: {
|
||||
onStart() {
|
||||
if (!SpotifyModule?.SpotifyPlayButton) return;
|
||||
|
||||
this.patches.add(
|
||||
after("SpotifyPlayButton", SpotifyModule, ([props], result) => {
|
||||
if (!result) return;
|
||||
return React.createElement(ClassicListenButton, {
|
||||
button: result.props,
|
||||
activity: (props as any)?.activity,
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
onStop() {},
|
||||
},
|
||||
});
|
||||
BIN
plugins/rose-utils/src/modules/SpotifyListenAlong/light.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
plugins/rose-utils/src/modules/TenorGifFix/dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
65
plugins/rose-utils/src/modules/TenorGifFix/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { before } from "@vendetta/patcher";
|
||||
import dark from "./dark.png";
|
||||
import light from "./light.png";
|
||||
|
||||
import { Module, ModuleCategory } from "../../lib/Module";
|
||||
|
||||
const MediaManager = findByProps("downloadMediaAsset");
|
||||
const mediaShareActions = findByProps("useMediaShareActions");
|
||||
|
||||
function parseURL(url: string) {
|
||||
const path = url.split("/");
|
||||
const tenorIndex = path.findIndex((x) => x.endsWith(".tenor.com"));
|
||||
if (tenorIndex === -1) return;
|
||||
|
||||
const [host, id, file] = path.slice(tenorIndex, tenorIndex + 3);
|
||||
if (!host || !id || !file) return;
|
||||
|
||||
return `https://${host}/${id.slice(0, -2)}AC/${file.split(".")[0]}.gif`;
|
||||
}
|
||||
|
||||
export default new Module({
|
||||
id: "tenor-gif-fix",
|
||||
label: "Tenor GIF Fix",
|
||||
meta: {
|
||||
sublabel: "Fixes a several-year old bug which downloads gifs from Tenor as videos instead of gif files",
|
||||
category: ModuleCategory.Fixes,
|
||||
},
|
||||
handlers: {
|
||||
onStart() {
|
||||
// Older Discord versions.
|
||||
if (MediaManager?.downloadMediaAsset) {
|
||||
this.patches.add(
|
||||
before("downloadMediaAsset", MediaManager, (args: any[]) => {
|
||||
const url = args[0];
|
||||
if (!url || typeof url !== "string") return;
|
||||
|
||||
const parsed = parseURL(url);
|
||||
if (parsed) {
|
||||
args[0] = parsed;
|
||||
args[1] = 1;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Newer Discord versions.
|
||||
if (mediaShareActions?.useMediaShareActions) {
|
||||
this.patches.add(
|
||||
before("useMediaShareActions", mediaShareActions, (args: any[]) => {
|
||||
const source = args[0]?.source;
|
||||
if (!source?.uri) return;
|
||||
|
||||
const parsed = parseURL(source.uri);
|
||||
if (parsed) {
|
||||
source.sourceURI = parsed;
|
||||
delete source.videoURI;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onStop() {},
|
||||
},
|
||||
});
|
||||
BIN
plugins/rose-utils/src/modules/TenorGifFix/light.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
10
plugins/rose-utils/src/modules/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import SpotifyListenAlong from "./SpotifyListenAlong";
|
||||
import TenorGifFix from "./TenorGifFix";
|
||||
import SendSpotifyInvite from "./SendSpotifyInvite";
|
||||
import Minimod from "./Minimod";
|
||||
import ColorfulChannels from "./ColorfulChannels";
|
||||
|
||||
// BetterComponents (Material-style Switch/Toast/Alert redesign) isn't ported - it needs
|
||||
// react-native-reanimated, which isn't part of this build, and it was already disabled by
|
||||
// default upstream due to known issues ("the great component functionification of 2025").
|
||||
export default [SpotifyListenAlong, TenorGifFix, SendSpotifyInvite, Minimod, ColorfulChannels];
|
||||
112
plugins/rose-utils/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { React, clipboard } from "@vendetta/metro/common";
|
||||
import { Forms } from "@vendetta/ui/components";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import modules from "../modules";
|
||||
import { ModuleCategory, type AnyModule, type ModuleSetting } from "../lib/Module";
|
||||
|
||||
const { FormSection, FormSwitchRow, FormRow } = Forms;
|
||||
|
||||
function resolveSubLabel(setting: ModuleSetting, value: any): string | undefined {
|
||||
return typeof setting.subLabel === "function" ? setting.subLabel(value) : setting.subLabel;
|
||||
}
|
||||
|
||||
function ModuleSettingRow({ module, settingKey }: { module: AnyModule; settingKey: string }) {
|
||||
const setting = module.settings[settingKey];
|
||||
if (setting.predicate && !setting.predicate.call(module)) return null;
|
||||
|
||||
const value = module.storage.options[settingKey];
|
||||
const subLabel = resolveSubLabel(setting, value);
|
||||
const disabled = !module.storage.enabled;
|
||||
|
||||
if (setting.type === "toggle") {
|
||||
return (
|
||||
<FormSwitchRow
|
||||
label={setting.label}
|
||||
subLabel={subLabel}
|
||||
value={!!value}
|
||||
disabled={disabled}
|
||||
onValueChange={(v: boolean) => {
|
||||
module.storage.options[settingKey] = v;
|
||||
module.restart();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (setting.type === "button") {
|
||||
return (
|
||||
<FormRow
|
||||
label={setting.label}
|
||||
subLabel={subLabel}
|
||||
disabled={disabled}
|
||||
onPress={() => setting.action.call(module)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// "choose" - cycles through choices on tap instead of a full picker sheet.
|
||||
return (
|
||||
<FormRow
|
||||
label={setting.label}
|
||||
subLabel={[subLabel, value].filter(Boolean).join(" - ")}
|
||||
disabled={disabled}
|
||||
onPress={() => {
|
||||
const idx = setting.choices.indexOf(value);
|
||||
module.storage.options[settingKey] = setting.choices[(idx + 1) % setting.choices.length];
|
||||
module.restart();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleSection({ module }: { module: AnyModule }) {
|
||||
module.useRefresh();
|
||||
|
||||
const errorEntries = Object.entries(module.errors);
|
||||
|
||||
return (
|
||||
<FormSection title={module.label}>
|
||||
<FormSwitchRow
|
||||
label="Enabled"
|
||||
subLabel={module.meta.sublabel}
|
||||
value={!!module.storage.enabled}
|
||||
onValueChange={() => module.toggle()}
|
||||
/>
|
||||
{module.storage.enabled && Object.keys(module.settings).map((key) => (
|
||||
<ModuleSettingRow key={key} module={module} settingKey={key} />
|
||||
))}
|
||||
{errorEntries.length > 0 && (
|
||||
<FormRow
|
||||
label={`${errorEntries.length} error${errorEntries.length === 1 ? "" : "s"} - tap to copy`}
|
||||
onPress={() => {
|
||||
const report = errorEntries.map(([label, stack]) => `[${module.label}] ${label}\n${stack}`).join("\n\n");
|
||||
clipboard.setString(report);
|
||||
showToast("Copied - paste this when reporting the issue", undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: ModuleCategory[] = [ModuleCategory.Useful, ModuleCategory.Fixes, ModuleCategory.Fun];
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
RoseUtils is a collection of small, independent utilities - toggle whichever ones
|
||||
you want. Fork of nexpid's NexxUtils, with fixes for lookups that broke on newer
|
||||
Discord versions. If a module shows an error below, tap it to copy the details for
|
||||
a bug report.
|
||||
</NoteBox>
|
||||
{CATEGORY_ORDER.flatMap((category) =>
|
||||
modules
|
||||
.filter((m) => m.meta.category === category)
|
||||
.map((module) => <ModuleSection key={module.id} module={module} />),
|
||||
)}
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,43 @@
|
|||
import { find, findByStoreName } from "@vendetta/metro";
|
||||
import { semanticColors } from "@vendetta/ui";
|
||||
|
||||
export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||
|
||||
const ThemeStore = findByStoreName("ThemeStore");
|
||||
|
||||
// @vendetta/ui exposes the semanticColors token map but not a resolver function for it - every
|
||||
// plugin that needs one has to find it the same way, so it lives here instead of being
|
||||
// reimplemented per plugin.
|
||||
const rawResolveSemanticColor: (theme: any, semanticColor: any) => string | undefined =
|
||||
find((m: any) => m?.default?.internal?.resolveSemanticColor)?.default?.internal?.resolveSemanticColor ??
|
||||
find((m: any) => m?.meta?.resolveSemanticColor)?.meta?.resolveSemanticColor ??
|
||||
(() => undefined);
|
||||
|
||||
/** Resolves a semanticColors[TOKEN_NAME] descriptor to a real color for the current theme. */
|
||||
export function resolveSemanticColor(token: any, theme: any = ThemeStore?.theme): string | undefined {
|
||||
if (!token) return undefined;
|
||||
try {
|
||||
return rawResolveSemanticColor(theme, token);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as resolveSemanticColor, but tries a list of token names in order and falls back to a
|
||||
* hardcoded hex color if every one of them is missing or fails to resolve - Discord renames these
|
||||
* tokens across versions often enough that a single hardcoded name isn't reliable on its own.
|
||||
*/
|
||||
export function resolveSemanticColorSafe(tokenNames: string[], fallbackHex: string, theme: any = ThemeStore?.theme): string {
|
||||
for (const name of tokenNames) {
|
||||
const token = (semanticColors as Record<string, any> | undefined)?.[name];
|
||||
if (!token) continue;
|
||||
const resolved = resolveSemanticColor(token, theme);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return fallbackHex;
|
||||
}
|
||||
|
||||
export function isValidHex(value: string | undefined | null): value is string {
|
||||
return !!value && HEX_REGEX.test(value);
|
||||
}
|
||||
|
|
|
|||
11
shared/lib/flux.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||
|
||||
/** Subscribes to a Flux event; returns an unsubscribe function. Pass once=true to auto-unsubscribe after the first fire. */
|
||||
export function fluxSubscribe(event: string, callback: (...args: any[]) => void, once = false): () => void {
|
||||
const listener = (...args: any[]) => {
|
||||
if (once) FluxDispatcher.unsubscribe(event, listener);
|
||||
callback(...args);
|
||||
};
|
||||
FluxDispatcher.subscribe(event, listener);
|
||||
return () => FluxDispatcher.unsubscribe(event, listener);
|
||||
}
|
||||
41
shared/lib/patchRows.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
import { ReactNative } from "@vendetta/metro/common";
|
||||
import { after, before } from "@vendetta/patcher";
|
||||
|
||||
export type RowHandler = (rows: any[]) => void;
|
||||
|
||||
/**
|
||||
* Chat rows are rendered natively - message data crosses the JS/native bridge as JSON via
|
||||
* DCDChatManager.updateRows on newer builds, or via RowManager.generate (one row per call) on
|
||||
* older ones. This runs `handler` against the row array either way, so callers don't have to
|
||||
* reimplement the bridge-vs-RowManager fallback themselves.
|
||||
*/
|
||||
export function patchRows(handler: RowHandler): () => void {
|
||||
const { NativeModules } = ReactNative;
|
||||
const DCDChatManager = NativeModules?.DCDChatManager;
|
||||
|
||||
if (DCDChatManager?.updateRows) {
|
||||
return before("updateRows", DCDChatManager, (args: any[]) => {
|
||||
try {
|
||||
const rows = JSON.parse(args[1]);
|
||||
handler(rows);
|
||||
args[1] = JSON.stringify(rows);
|
||||
} catch {
|
||||
// Leave args untouched - better to skip the transform than break message loading.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const RowManager = findByName("RowManager", false);
|
||||
if (RowManager?.prototype?.generate) {
|
||||
return after("generate", RowManager.prototype, (_args: any[], row: any) => {
|
||||
try {
|
||||
handler([row]);
|
||||
} catch {
|
||||
// Skip this row.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {};
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
"status": "revived",
|
||||
"accent": "#9B59B6",
|
||||
"tagline": "Shows a user's pronouns in their profile, pulled from pronoundb.org.",
|
||||
"note": "Previously broken because PronounDB shut down the API it used (HTTP 410) - migrated to their current v2 API. Requires people to have set pronouns at pronoundb.org; nothing to configure in the plugin itself.",
|
||||
"note": "Previously broken because PronounDB shut down the API it used (HTTP 410) - migrated to their current v2 API. Also fixed the profile-page lookup itself: Discord renamed the top-level profile component to UserProfileContent and the bio card to UserProfileAboutMeCard (confirmed against decompiled current-build source), so both the new and old names are checked. Requires people to have set pronouns at pronoundb.org; nothing to configure in the plugin itself.",
|
||||
"howItWorks": "PronounDB is a separate, independent service (not run by Discord or Revenge) where people manually set their own pronouns once and link the profile to their Discord account. This plugin adds a \"Pronouns\" section near the bio on anyone's profile who has done that. Lookups are batched and cached client-side, so opening a profile for the first time may take a moment while it fetches, and re-opening the same profile later is instant.",
|
||||
"features": [
|
||||
"Adds a native-feeling \"Pronouns\" section to profiles",
|
||||
|
|
@ -142,7 +142,7 @@
|
|||
"status": "revived",
|
||||
"accent": "#1ABC9C",
|
||||
"tagline": "Long-press a role pill to copy its color as hex.",
|
||||
"note": "Previously broken (unguarded React-tree lookups that could throw on a shape mismatch). Rebuilt with defensive checks throughout.",
|
||||
"note": "Previously broken - the component was renamed from ThemedRolePill to just RolePill (confirmed against decompiled current-build Discord source), so the old lookup never found anything. Now checks both names, with defensive checks throughout so a shape mismatch never crashes.",
|
||||
"howItWorks": "Long-press a role pill - in the member list, a role list, wherever Discord renders one as its own element - and the color gets copied to your clipboard as a hex code. Useful for designers, bot builders, or staff matching embed colors to a role.",
|
||||
"features": [
|
||||
"Works on any rendered role pill, not just one specific screen",
|
||||
|
|
@ -188,7 +188,7 @@
|
|||
"status": "revived",
|
||||
"accent": "#EB459E",
|
||||
"tagline": "Profile theming and profile effects without Nitro.",
|
||||
"note": "Fork of shipwr3ckd's FPTE. Previously crashed every time you opened the profile editor - confirmed via on-device diagnostics that several color tokens it referenced (HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, TEXT_NORMAL) no longer exist on current Discord builds. Rebuilt with a fallback chain of real, currently-valid token names instead of a single hardcoded one.",
|
||||
"note": "Fork of shipwr3ckd's FPTE. Previously crashed every time you opened the profile editor - confirmed via on-device diagnostics that several color tokens it referenced (HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, TEXT_NORMAL) no longer exist on current Discord builds. Rebuilt with a fallback chain of real, currently-valid token names instead of a single hardcoded one. A second, separate bug (an unguarded lookup that crashed the whole plugin bundle before it could even load) is also fixed.",
|
||||
"howItWorks": "Encodes your chosen primary color, accent color, and profile effect into invisible zero-width characters appended to your About Me - other people running a compatible plugin (this one, or the original) decode and render it as if you had real Nitro profile theming, without Discord's servers ever knowing. The builder UI for picking colors/effects lives right in the profile editor. Only the two files that were actually confirmed crashing were investigated and fixed; the rest of this plugin's original patches (Nitro upsell card, purchase checks, profile data fetching) are carried over as-is, now with crash isolation so any other latent issue only disables that one patch instead of the whole plugin.",
|
||||
"features": [
|
||||
"Pick a primary color, accent color, and profile effect from the profile editor",
|
||||
|
|
@ -197,5 +197,23 @@
|
|||
],
|
||||
"commands": [],
|
||||
"limitations": "Only the confirmed profile-editor crash was root-caused and fixed with real on-device data. The plugin's other patches (Nitro upsell card, purchase/profile data fetching) weren't individually re-audited - crash isolation means a problem there disables just that patch rather than the whole plugin, but hasn't been proven bug-free the way the color-resolution fix has."
|
||||
},
|
||||
"rose-utils": {
|
||||
"category": "Utility",
|
||||
"status": "revived",
|
||||
"accent": "#F0637E",
|
||||
"tagline": "A grab-bag of independent utilities - Spotify listen-along, a Tenor fix, colorful channels, and more.",
|
||||
"note": "Fork of nexpid's NexxUtils. Fixed the reported \"render is not a function in SpotifyPlayButton\" crash - Discord converted it from a class component to a function component, confirmed against decompiled current-build source, so patching .prototype.render no longer worked. Also fixed six other lookups across the plugin that had the same unguarded pattern that crashed FPTE's whole bundle at load time - any one of them failing would have taken the entire plugin down, not just one module.",
|
||||
"howItWorks": "Each utility is its own independently toggleable module: Spotify listen-along buttons on activities, a fix for Tenor GIFs downloading as videos, an invite-to-listen-along button in the chat attachment menu, a fun \"minimod\" that shows member timeouts in chat, and colorful/NSFW-tagged channel icons. Every module tracks its own errors and shows a one-tap \"copy report\" row if something goes wrong, so a problem in one module is easy to report without digging through logs. The original's Material-Design component reskin (Switch/Toast/Alert) wasn't ported - it depends on react-native-reanimated, which isn't part of this build, and it was already disabled by default upstream due to known issues.",
|
||||
"features": [
|
||||
"Spotify listen-along buttons on activities (fixed)",
|
||||
"Tenor GIF download fix",
|
||||
"Send-a-Spotify-invite button in the attachment menu",
|
||||
"Minimod: shows member timeouts in chat",
|
||||
"Colorful, NSFW-tagged channel icons",
|
||||
"Per-module error tracking with one-tap copy for bug reports"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "The Material-Design component reskin from the original isn't included (needs react-native-reanimated). Each module was fixed and defensively guarded, but only the specific reported crash was verified against live decompiled source - the rest are guarded, not individually confirmed working on-device."
|
||||
}
|
||||
}
|
||||
|
|
|
|||