Add RoseUtils: fork of nexpid's NexxUtils with the SpotifyPlayButton crash fixed

This commit is contained in:
everestmcarthur 2026-08-01 14:13:44 -04:00
parent 38c0aa1025
commit 12d8d9021d
50 changed files with 975 additions and 5 deletions

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,3 @@
{
"root": "ColorfulChannels"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,3 @@
{
"root": "Minimod"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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"
}

View 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,
};

View 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);
};
}, []);
}
}

View file

@ -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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View 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() {},
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View 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() {},
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

@ -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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View 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() {},
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -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>
);
}

View file

@ -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)),
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View 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() {},
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View 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() {},
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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];

View 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>
);
}

View file

@ -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
View 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
View 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 () => {};
}

View file

@ -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."
}
}