diff --git a/README.md b/README.md index d21a548..84ae07f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plugins/rose-utils/assets/ColorfulChannels/announcement/lock.png b/plugins/rose-utils/assets/ColorfulChannels/announcement/lock.png new file mode 100644 index 0000000..21fd500 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/announcement/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/announcement/warning.png b/plugins/rose-utils/assets/ColorfulChannels/announcement/warning.png new file mode 100644 index 0000000..c691abc Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/announcement/warning.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/controller.png b/plugins/rose-utils/assets/ColorfulChannels/controller.png new file mode 100644 index 0000000..752ed54 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/controller.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/forum/lock.png b/plugins/rose-utils/assets/ColorfulChannels/forum/lock.png new file mode 100644 index 0000000..8a5e8af Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/forum/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/forum/warning.png b/plugins/rose-utils/assets/ColorfulChannels/forum/warning.png new file mode 100644 index 0000000..6beb211 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/forum/warning.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/image/lock.png b/plugins/rose-utils/assets/ColorfulChannels/image/lock.png new file mode 100644 index 0000000..6441795 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/image/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/image/warning.png b/plugins/rose-utils/assets/ColorfulChannels/image/warning.png new file mode 100644 index 0000000..e582452 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/image/warning.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/lock.png b/plugins/rose-utils/assets/ColorfulChannels/lock.png new file mode 100644 index 0000000..323afc3 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/lockBottom.png b/plugins/rose-utils/assets/ColorfulChannels/lockBottom.png new file mode 100644 index 0000000..5d31eab Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/lockBottom.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/stage/lock.png b/plugins/rose-utils/assets/ColorfulChannels/stage/lock.png new file mode 100644 index 0000000..f6346dc Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/stage/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/text/controller.png b/plugins/rose-utils/assets/ColorfulChannels/text/controller.png new file mode 100644 index 0000000..48151f6 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/text/controller.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/text/lock.png b/plugins/rose-utils/assets/ColorfulChannels/text/lock.png new file mode 100644 index 0000000..10c32ee Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/text/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/text/warning.png b/plugins/rose-utils/assets/ColorfulChannels/text/warning.png new file mode 100644 index 0000000..1715494 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/text/warning.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/tpconfig.json b/plugins/rose-utils/assets/ColorfulChannels/tpconfig.json new file mode 100644 index 0000000..31caa37 --- /dev/null +++ b/plugins/rose-utils/assets/ColorfulChannels/tpconfig.json @@ -0,0 +1,3 @@ +{ + "root": "ColorfulChannels" +} diff --git a/plugins/rose-utils/assets/ColorfulChannels/voice/lock.png b/plugins/rose-utils/assets/ColorfulChannels/voice/lock.png new file mode 100644 index 0000000..d9aa794 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/voice/lock.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/voice/warning.png b/plugins/rose-utils/assets/ColorfulChannels/voice/warning.png new file mode 100644 index 0000000..d9aa794 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/voice/warning.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/warning.png b/plugins/rose-utils/assets/ColorfulChannels/warning.png new file mode 100644 index 0000000..07b9916 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/warning.png differ diff --git a/plugins/rose-utils/assets/ColorfulChannels/warningBottom.png b/plugins/rose-utils/assets/ColorfulChannels/warningBottom.png new file mode 100644 index 0000000..e2485b5 Binary files /dev/null and b/plugins/rose-utils/assets/ColorfulChannels/warningBottom.png differ diff --git a/plugins/rose-utils/assets/MiniMod/NerdEmoji.png b/plugins/rose-utils/assets/MiniMod/NerdEmoji.png new file mode 100644 index 0000000..62edb9b Binary files /dev/null and b/plugins/rose-utils/assets/MiniMod/NerdEmoji.png differ diff --git a/plugins/rose-utils/assets/MiniMod/tpconfig.json b/plugins/rose-utils/assets/MiniMod/tpconfig.json new file mode 100644 index 0000000..9728de6 --- /dev/null +++ b/plugins/rose-utils/assets/MiniMod/tpconfig.json @@ -0,0 +1,3 @@ +{ + "root": "Minimod" +} diff --git a/plugins/rose-utils/assets/SpotifyListenAlong/UserPlayIcon.png b/plugins/rose-utils/assets/SpotifyListenAlong/UserPlayIcon.png new file mode 100644 index 0000000..5397356 Binary files /dev/null and b/plugins/rose-utils/assets/SpotifyListenAlong/UserPlayIcon.png differ diff --git a/plugins/rose-utils/manifest.json b/plugins/rose-utils/manifest.json new file mode 100644 index 0000000..f758625 --- /dev/null +++ b/plugins/rose-utils/manifest.json @@ -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" +} diff --git a/plugins/rose-utils/src/index.ts b/plugins/rose-utils/src/index.ts new file mode 100644 index 0000000..9219c08 --- /dev/null +++ b/plugins/rose-utils/src/index.ts @@ -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, +}; diff --git a/plugins/rose-utils/src/lib/Module.ts b/plugins/rose-utils/src/lib/Module.ts new file mode 100644 index 0000000..f159ffa --- /dev/null +++ b/plugins/rose-utils/src/lib/Module.ts @@ -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 }>; +}; + +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>; + +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> { + id: string; + label: string; + meta: ModuleMeta; + settings: Settings; + errors: Record = {}; + patches = new Patches(); + + private handlers: { + onStart: (this: Module) => void; + onStop: (this: Module) => 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) => void; + onStop: (this: Module) => 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); + }; + }, []); + } +} diff --git a/plugins/rose-utils/src/modules/ColorfulChannels/components/TextBadge.tsx b/plugins/rose-utils/src/modules/ColorfulChannels/components/TextBadge.tsx new file mode 100644 index 0000000..22206ca --- /dev/null +++ b/plugins/rose-utils/src/modules/ColorfulChannels/components/TextBadge.tsx @@ -0,0 +1,18 @@ +import { React, ReactNative } from "@vendetta/metro/common"; + +const { View, Text } = ReactNative; + +const VARIANTS: Record = { + 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 ( + + {children} + + ); +} diff --git a/plugins/rose-utils/src/modules/ColorfulChannels/dark.png b/plugins/rose-utils/src/modules/ColorfulChannels/dark.png new file mode 100644 index 0000000..3a40ba6 Binary files /dev/null and b/plugins/rose-utils/src/modules/ColorfulChannels/dark.png differ diff --git a/plugins/rose-utils/src/modules/ColorfulChannels/index.ts b/plugins/rose-utils/src/modules/ColorfulChannels/index.ts new file mode 100644 index 0000000..27d7042 --- /dev/null +++ b/plugins/rose-utils/src/modules/ColorfulChannels/index.ts @@ -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; + +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)?.[iconColorKey] + ? resolveSemanticColor((semanticColors as Record)[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)?.[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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/ColorfulChannels/light.png b/plugins/rose-utils/src/modules/ColorfulChannels/light.png new file mode 100644 index 0000000..2a547f5 Binary files /dev/null and b/plugins/rose-utils/src/modules/ColorfulChannels/light.png differ diff --git a/plugins/rose-utils/src/modules/Minimod/dark.png b/plugins/rose-utils/src/modules/Minimod/dark.png new file mode 100644 index 0000000..ae1a4df Binary files /dev/null and b/plugins/rose-utils/src/modules/Minimod/dark.png differ diff --git a/plugins/rose-utils/src/modules/Minimod/index.ts b/plugins/rose-utils/src/modules/Minimod/index.ts new file mode 100644 index 0000000..72bf8ba --- /dev/null +++ b/plugins/rose-utils/src/modules/Minimod/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/Minimod/light.png b/plugins/rose-utils/src/modules/Minimod/light.png new file mode 100644 index 0000000..9391ac8 Binary files /dev/null and b/plugins/rose-utils/src/modules/Minimod/light.png differ diff --git a/plugins/rose-utils/src/modules/SendSpotifyInvite/components/InviteButton.tsx b/plugins/rose-utils/src/modules/SendSpotifyInvite/components/InviteButton.tsx new file mode 100644 index 0000000..788f743 --- /dev/null +++ b/plugins/rose-utils/src/modules/SendSpotifyInvite/components/InviteButton.tsx @@ -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 ( + + + {asset?.uri ? ( + + ) : ( + ♫ + )} + + + ); +} diff --git a/plugins/rose-utils/src/modules/SendSpotifyInvite/dark.png b/plugins/rose-utils/src/modules/SendSpotifyInvite/dark.png new file mode 100644 index 0000000..aca25c7 Binary files /dev/null and b/plugins/rose-utils/src/modules/SendSpotifyInvite/dark.png differ diff --git a/plugins/rose-utils/src/modules/SendSpotifyInvite/index.ts b/plugins/rose-utils/src/modules/SendSpotifyInvite/index.ts new file mode 100644 index 0000000..f4eee60 --- /dev/null +++ b/plugins/rose-utils/src/modules/SendSpotifyInvite/index.ts @@ -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() {}, + }, +}); diff --git a/plugins/rose-utils/src/modules/SendSpotifyInvite/light.png b/plugins/rose-utils/src/modules/SendSpotifyInvite/light.png new file mode 100644 index 0000000..0fc06e3 Binary files /dev/null and b/plugins/rose-utils/src/modules/SendSpotifyInvite/light.png differ diff --git a/plugins/rose-utils/src/modules/SpotifyListenAlong/components/ClassicListenButton.tsx b/plugins/rose-utils/src/modules/SpotifyListenAlong/components/ClassicListenButton.tsx new file mode 100644 index 0000000..9f5c4de --- /dev/null +++ b/plugins/rose-utils/src/modules/SpotifyListenAlong/components/ClassicListenButton.tsx @@ -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 ( + + +