Delete docs/superpowers directory
Some checks failed
Build and deploy / deploy (push) Failing after 2m5s
Some checks failed
Build and deploy / deploy (push) Failing after 2m5s
This commit is contained in:
parent
6d79b27396
commit
4cc6747a7e
6 changed files with 0 additions and 1648 deletions
|
|
@ -1,314 +0,0 @@
|
|||
# Typing Avatars Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a new standalone Vendetta plugin, `TypingAvatars`, that replaces Discord's "X is typing..." indicator with a row of the avatars of whoever's currently typing.
|
||||
|
||||
**Architecture:** Reuses the proven-safe `TypingIndicatorInner` interception already verified in `role-color-everywhere/src/patches/typingWrapper.ts` (this repo, same session): catch the component via `registerTypeDetector`, wrap it so the original still renders exactly once (preserving its hooks), find the label node that used to hold the typing text, and replace its children with a new `AvatarStack` component built from `props.typingUserIds`.
|
||||
|
||||
**Tech Stack:** TypeScript, React (via `@vendetta/metro/common`), React Native (`View`/`Image`), this repo's shared `@shared/lib/createElementIntercept` and `@shared/lib/patcher` helpers, Rollup/SWC build (`node build.mjs`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No test runner exists in this repo (`package.json` has only a `build` script) — every plugin here is verified by `node build.mjs` (bundling/syntax correctness) plus live on-device verification via `revenge-devtools`, not unit tests. This plan follows that same convention.
|
||||
- Every patch point must be wrapped in try/catch that leaves the original behavior intact on failure, matching every other plugin in this repo (see `role-color-everywhere`, `copy-role-color`, `server-drawer`).
|
||||
- `channel.guild_id` (snake_case) is the confirmed field for a channel's guild id — verified in `typingWrapper.ts:46` and `rows.ts:39`. Do not use `channel.guildId`.
|
||||
- `GuildMemberStore.getMember(guildId, userId)` and `UserStore` (via `findByStoreName`) are confirmed-working store lookups elsewhere in this repo — but the exact avatar-hash property name on the returned member/user record (`member.avatar` / `user.avatar`) has **not** been live-verified. Flag this clearly in code comments; confirm in Task 4.
|
||||
- CDN avatar URLs are hand-built directly (`https://cdn.discordapp.com/...`), matching this repo's own established pattern in `server-drawer/src/components/GuildIcon.tsx:26` — not a guessed Discord internal "IconUtils" module lookup.
|
||||
- `revenge-devtools` was disconnected while this plan was written. Task 4 (live verification) cannot run until it reconnects — do not claim the plugin "works" before that task is actually completed against a live device.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold the plugin
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/typing-avatars/manifest.json`
|
||||
- Create: `plugins/typing-avatars/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a default-exported plugin object (`{ onLoad, onUnload }`) that later tasks wire their patch functions into.
|
||||
|
||||
- [x] **Step 1: Create the manifest**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "TypingAvatars",
|
||||
"description": "Replaces the \"X is typing...\" indicator with the avatars of whoever's typing.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_group"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ic_group` is a confirmed-existing bundled icon name — already used by another plugin in this repo (`grep -h '"icon"' plugins/*/manifest.json`).
|
||||
|
||||
- [x] **Step 2: Create the plugin entry point with no patches wired yet**
|
||||
|
||||
```ts
|
||||
import { logger } from "@vendetta";
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
|
||||
let unpatchAll: () => void = () => {};
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
unpatchAll = applyPatches("TypingAvatars", logger, {});
|
||||
},
|
||||
onUnload: () => unpatchAll()
|
||||
};
|
||||
```
|
||||
|
||||
- [x] **Step 3: Build and verify**
|
||||
|
||||
Run: `cd /root/revenge-plugins && node build.mjs`
|
||||
Expected: output includes `Successfully built TypingAvatars!` with no errors.
|
||||
|
||||
- [x] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add plugins/typing-avatars/manifest.json plugins/typing-avatars/src/index.ts
|
||||
git commit -m "typing-avatars: scaffold new plugin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Avatar URL resolution
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/typing-avatars/src/lib/avatarUrl.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from other tasks.
|
||||
- Produces: `getTypingAvatarURL(guildId: string | undefined, userId: string, size?: number): string | null`, used by Task 3's `AvatarStack` component.
|
||||
|
||||
- [x] **Step 1: Write the resolution helper**
|
||||
|
||||
```ts
|
||||
import { findByStoreName } from "@vendetta/metro";
|
||||
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
const GuildMemberStore = findByStoreName("GuildMemberStore");
|
||||
|
||||
function avatarExtension(hash: string): string {
|
||||
return hash.startsWith("a_") ? "gif" : "png";
|
||||
}
|
||||
|
||||
// member.avatar / user.avatar are the property names Discord's client uses for a per-server /
|
||||
// global avatar hash respectively - GuildMemberStore.getMember and UserStore.getUser are both
|
||||
// confirmed-working lookups elsewhere in this repo (typingWrapper.ts, getTag.ts), but the avatar
|
||||
// hash field itself hasn't been live-verified yet. If avatars come back broken, check these two
|
||||
// property reads first via devtools eval against a real member/user record.
|
||||
export function getTypingAvatarURL(guildId: string | undefined, userId: string, size = 32): string | null {
|
||||
if (!userId) return null;
|
||||
|
||||
const member = guildId ? GuildMemberStore?.getMember?.(guildId, userId) : null;
|
||||
const guildAvatarHash = member?.avatar;
|
||||
if (guildId && guildAvatarHash) {
|
||||
return `https://cdn.discordapp.com/guilds/${guildId}/users/${userId}/avatars/${guildAvatarHash}.${avatarExtension(guildAvatarHash)}?size=${size * 2}`;
|
||||
}
|
||||
|
||||
const user = UserStore?.getUser?.(userId);
|
||||
const globalAvatarHash = user?.avatar;
|
||||
if (globalAvatarHash) {
|
||||
return `https://cdn.discordapp.com/avatars/${userId}/${globalAvatarHash}.${avatarExtension(globalAvatarHash)}?size=${size * 2}`;
|
||||
}
|
||||
|
||||
// Discord's default avatar for users on the modern username system (no discriminator):
|
||||
// index = (snowflake >> 22) % 6. BigInt is required - the shift loses precision as a Number.
|
||||
const defaultIndex = Number((BigInt(userId) >> 22n) % 6n);
|
||||
return `https://cdn.discordapp.com/embed/avatars/${defaultIndex}.png`;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Build and verify**
|
||||
|
||||
Run: `cd /root/revenge-plugins && node build.mjs`
|
||||
Expected: `Successfully built TypingAvatars!`, no type/syntax errors.
|
||||
|
||||
- [x] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add plugins/typing-avatars/src/lib/avatarUrl.ts
|
||||
git commit -m "typing-avatars: add avatar URL resolution helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: AvatarStack component and the typing-indicator patch
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/typing-avatars/src/components/AvatarStack.tsx`
|
||||
- Create: `plugins/typing-avatars/src/patches/typingIndicator.tsx`
|
||||
- Modify: `plugins/typing-avatars/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getTypingAvatarURL` from Task 2 (`../lib/avatarUrl`).
|
||||
- Produces: `AvatarStack` (default export, props `{ typingUserIds: string[]; guildId: string | undefined }`) and `patchTypingIndicator` (default export, `() => () => void`, same shape as every other patch function in this repo).
|
||||
|
||||
- [x] **Step 1: Write the AvatarStack component**
|
||||
|
||||
```tsx
|
||||
import React from "react";
|
||||
import { View, Image, StyleSheet } from "react-native";
|
||||
import { getTypingAvatarURL } from "../lib/avatarUrl";
|
||||
|
||||
const SIZE = 20;
|
||||
const OVERLAP = 8;
|
||||
|
||||
// No text label and no tap handler by design - purely decorative avatars replacing the
|
||||
// "X is typing..." text entirely. If a lot of people are typing, this wraps to a second
|
||||
// overlapping row rather than clipping or capping with a "+N" badge.
|
||||
export default function AvatarStack({ typingUserIds, guildId }: { typingUserIds: string[]; guildId: string | undefined }) {
|
||||
if (!typingUserIds?.length) return null;
|
||||
|
||||
return (
|
||||
<View style={st.row}>
|
||||
{typingUserIds.map((userId, i) => {
|
||||
const uri = getTypingAvatarURL(guildId, userId, SIZE);
|
||||
if (!uri) return null;
|
||||
return (
|
||||
<Image
|
||||
key={userId}
|
||||
source={{ uri }}
|
||||
style={[st.avatar, i > 0 ? st.overlap : null]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const st = StyleSheet.create({
|
||||
row: { flexDirection: "row", flexWrap: "wrap", alignItems: "center" },
|
||||
avatar: { width: SIZE, height: SIZE, borderRadius: SIZE / 2 },
|
||||
overlap: { marginLeft: -OVERLAP },
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: Write the typing-indicator patch**
|
||||
|
||||
This mirrors `role-color-everywhere/src/patches/typingWrapper.ts` exactly for the detection/wrapping part (already proven live in this repo), but replaces the label's content with `AvatarStack` instead of recoloring the existing per-user text nodes. Unlike the color-matching version, this doesn't need to zip `typingUserIds` against per-user text elements at all - it only needs `props.typingUserIds` directly, so it isn't affected by the "several people are typing..." collapsed-string case that made the color patch bail out.
|
||||
|
||||
```tsx
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
import AvatarStack from "../components/AvatarStack";
|
||||
|
||||
export default function patchTypingIndicator(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
||||
registerTypeDetector(
|
||||
"typing-avatars-indicator",
|
||||
(type) => typeof type === "function" ? type.name === "TypingIndicatorInner" : type?.type?.name === "TypingIndicatorInner",
|
||||
(TypingIndicatorInner: any) => {
|
||||
const inner = typeof TypingIndicatorInner === "function" ? TypingIndicatorInner : TypingIndicatorInner.type;
|
||||
|
||||
const PatchedTypingIndicatorInner = (props: any) => {
|
||||
const ret = inner(props);
|
||||
|
||||
try {
|
||||
const label = findInReactTree(
|
||||
ret,
|
||||
(n: any) =>
|
||||
Array.isArray(n?.props?.children) &&
|
||||
n.props.children.some((c: any) => typeof c === "string" && c.includes("typing..."))
|
||||
);
|
||||
if (!label?.props) return ret;
|
||||
|
||||
const typingUserIds: string[] = props?.typingUserIds ?? [];
|
||||
if (!typingUserIds.length) return ret;
|
||||
|
||||
label.props.children = (
|
||||
<AvatarStack typingUserIds={typingUserIds} guildId={props?.channel?.guild_id} />
|
||||
);
|
||||
} catch {
|
||||
// Leave the default "X is typing..." text alone.
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
registerIntercept(TypingIndicatorInner, PatchedTypingIndicatorInner);
|
||||
}
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Wire the patch into the plugin entry point**
|
||||
|
||||
Replace the contents of `plugins/typing-avatars/src/index.ts` with:
|
||||
|
||||
```ts
|
||||
import { logger } from "@vendetta";
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
import patchTypingIndicator from "./patches/typingIndicator";
|
||||
|
||||
let unpatchAll: () => void = () => {};
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
unpatchAll = applyPatches("TypingAvatars", logger, {
|
||||
"typing indicator avatars": patchTypingIndicator
|
||||
});
|
||||
},
|
||||
onUnload: () => unpatchAll()
|
||||
};
|
||||
```
|
||||
|
||||
- [x] **Step 4: Build and verify**
|
||||
|
||||
Run: `cd /root/revenge-plugins && node build.mjs`
|
||||
Expected: `Successfully built TypingAvatars!`, no type/syntax errors.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add plugins/typing-avatars/src/components/AvatarStack.tsx plugins/typing-avatars/src/patches/typingIndicator.tsx plugins/typing-avatars/src/index.ts
|
||||
git commit -m "typing-avatars: add AvatarStack component and typing-indicator patch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy and live-verify
|
||||
|
||||
**Files:** none (verification only, using `revenge-devtools`)
|
||||
|
||||
**Interfaces:** none — this task confirms Tasks 1-3's assumptions against a real device and fixes anything wrong in place.
|
||||
|
||||
- [x] **Step 1: Push to deploy**
|
||||
|
||||
```bash
|
||||
cd /root/revenge-plugins && git push
|
||||
```
|
||||
|
||||
Confirms the auto-deploy pipeline (GitHub Actions → `rp.jarviscli.dev`) picks up the new plugin. Wait for the user to enable it on their device.
|
||||
|
||||
- [x] **Step 2: Confirm `TypingIndicatorInner` interception fires**
|
||||
|
||||
Via `revenge-devtools` `eval`, install a one-off probe patching `TypingIndicatorInner`'s call count (same technique used earlier this session for `useIsMobileQuestDockRendered`), then have the user start typing in a channel with someone else. Confirm the patch's `console.log`/counter actually fires - if it doesn't, the type-name check (`type.name === "TypingIndicatorInner"`) needs re-verification against the current Discord build, the same way this session discovered `QuestDockContentExpanded` had silently drifted.
|
||||
|
||||
- [x] **Step 3: Confirm the avatar hash field names**
|
||||
|
||||
Via `eval`, call `GuildMemberStore.getMember(guildId, userId)` and `UserStore.getUser(userId)` for a real user who is currently typing, and inspect the returned object's keys directly (`Object.keys(member)`) rather than assuming `.avatar` is correct. Fix `avatarUrl.ts` if the real field name differs, per the Global Constraints note above.
|
||||
|
||||
- [x] **Step 4: Confirm the rendered layout on-device**
|
||||
|
||||
Have the user get 1, 2, and 3+ people typing simultaneously in a test channel (or DM group) and screenshot each state. Confirm: avatars render (not broken images), the overlap direction looks right, and 3+ typers wrap to a second row rather than clipping off-screen.
|
||||
|
||||
- [x] **Step 5: Fix and re-deploy anything found wrong in Steps 2-4, then commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "typing-avatars: fix issues found in live verification"
|
||||
git push
|
||||
```
|
||||
|
||||
Only after this task's steps have actually been run against a live device should the plugin be described as working - not before.
|
||||
|
|
@ -1,964 +0,0 @@
|
|||
# RosePlugs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build RosePlugs, a new Revenge/Vendetta plugin that adds a top-level "Rosie's Plugs" settings section containing a quick-install browser for Rosie's plugins plus a direct settings shortcut for each of Rosie's plugins that's currently installed.
|
||||
|
||||
**Architecture:** A single self-contained plugin in `/root/revenge-plugins/plugins/rose-plugs/`. Two patch mechanisms adapted from Raiden's Themes' already-shipping `SETTING_RENDERER_CONFIG`/`createList` pattern (inserting a whole new section object instead of appending a row to an existing one), plus a discovery function that reads `@vendetta/plugins`' `plugins` record to find Rosie-authored installed plugins, plus a quick-install browser UI backed by the live Nexus JSON API.
|
||||
|
||||
**Tech Stack:** TypeScript, React (via `@vendetta/metro/common`), React Native components, Vendetta/Revenge plugin APIs (`@vendetta/plugins`, `@vendetta/metro`, `@vendetta/patcher`, `@vendetta/ui/*`), this repo's Rollup+SWC build (`build.mjs`), GitHub Actions + Cloudflare Workers deploy, revenge-devtools MCP for live verification.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Plugin directory: `plugins/rose-plugs/` (kebab-case, matches every other plugin in this repo). No registration step needed — `build.mjs` auto-discovers every directory under `plugins/`.
|
||||
- Manifest: id `rose-plugs`, name `RosePlugs`, author `Rosie`, `main: "src/index.ts"`.
|
||||
- Section label/title inserted into Settings: label `ROSES_PLUGS`, title `Rosie's Plugs` (exact string).
|
||||
- `@vendetta/plugins`' `plugins` record is keyed by the plugin's full install-source URL (e.g. `https://rp.jarviscli.dev/server-drawer/install/`), **not** a short id. Every lookup against it must use the full URL.
|
||||
- `getSettings(id)` returns a component **function**, confirmed live — despite `vendetta-types`' `.d.ts` claiming it returns `JSX.Element`. Always check `typeof result === "function"` before using it, and wrap with `React.createElement(Component)` when rendering it — never call it directly or treat the return value as an already-built element.
|
||||
- Discovery filter is `manifest.authors` containing an author named exactly `"Rosie"` (confirmed via live test: catches 17 of this repo's plugins; deliberately excludes Raiden's Themes, whose manifest credits "Raiden", and RP Admin, whose manifest credits "everestmcarthur" — both exclusions are intentional per user decision, not bugs to fix).
|
||||
- Nexus plugin metadata: `GET https://rp.jarviscli.dev/plugins-data.json`, an array of objects shaped exactly like `NexusPlugin` in Task 5.
|
||||
- Every task's deploy/verify steps use this repo's standard flow: `npm run build` locally first (catches TS/bundle errors before any push) → commit → `git push origin main` → `gh run watch <run-id> --exit-status` → purge Cloudflare cache for zone `76e85aab7728490e7f6351ac7b8b176f` via the `cloudflare-api` MCP tool (`POST /zones/76e85aab7728490e7f6351ac7b8b176f/purge_cache` with `{"purge_everything": true}`) → live-verify via the `revenge-devtools` MCP (`devtools_clients` to confirm a client is connected, `eval`/`get_logs` to confirm actual behavior). No task is done on a "should work" basis — confirm it live before checking the box.
|
||||
- Reopening a live client's Settings screen (navigate out, then back in) is enough to get a fresh `createList` call — confirmed directly in this session by capturing a live call. A full app reload is not required to see settings changes.
|
||||
- The new section is inserted at the very top of Settings (`sections.unshift(...)`, not anchored next to any existing section) — confirmed live via a scratch `eval` patch before Task 2 was written; the user explicitly asked for top placement after seeing an end-anchored version first.
|
||||
- Code comments only where the *why* is genuinely non-obvious (a confirmed live quirk, a workaround). Never comment what the code visibly does.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Scaffold the plugin
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/rose-plugs/manifest.json`
|
||||
- Create: `plugins/rose-plugs/src/index.ts`
|
||||
- Create: `plugins/rose-plugs/src/ui/Settings.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a buildable, installable plugin with a no-op `onLoad`/`onUnload` and a real `settings` export, reachable the normal way through Revenge's own Plugins list. Nothing here yet inserts the new section — that's Task 2.
|
||||
|
||||
- [ ] **Step 1: Create the manifest**
|
||||
|
||||
`plugins/rose-plugs/manifest.json`:
|
||||
```json
|
||||
{
|
||||
"name": "RosePlugs",
|
||||
"description": "Gives Rosie's plugins their own home in Settings, with one-tap installs for anything not installed yet.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_group"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the placeholder settings screen**
|
||||
|
||||
`plugins/rose-plugs/src/ui/Settings.tsx`:
|
||||
```tsx
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
RosePlugs gathers Rosie's other plugins under their own "Rosie's Plugs" section in
|
||||
Settings, with a quick-install browser for anything not installed yet.
|
||||
</NoteBox>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the plugin entry point**
|
||||
|
||||
`plugins/rose-plugs/src/index.ts`:
|
||||
```ts
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
export default {
|
||||
onLoad: () => {},
|
||||
onUnload: () => {},
|
||||
settings: Settings,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build locally**
|
||||
|
||||
Run: `cd /root/revenge-plugins && npm run build`
|
||||
Expected: `Successfully built RosePlugs!` appears in the output alongside every other plugin, no errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add plugins/rose-plugs
|
||||
git commit -m "RosePlugs: scaffold plugin"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Push and deploy**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
gh run list --limit 1
|
||||
```
|
||||
Then watch the run: `gh run watch <run-id> --exit-status` (use the run id from the previous command).
|
||||
|
||||
- [ ] **Step 7: Purge the Cloudflare cache**
|
||||
|
||||
Use the `cloudflare-api` MCP tool to `POST /zones/76e85aab7728490e7f6351ac7b8b176f/purge_cache` with `{"purge_everything": true}`.
|
||||
|
||||
- [ ] **Step 8: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm a client is connected.
|
||||
2. Install RosePlugs on the connected device via Revenge's Settings → Plugins → Add Plugin, using the install URL `https://rp.jarviscli.dev/rose-plugs/install/`.
|
||||
3. Confirm it appears in Revenge's own Plugins list, doesn't crash on load, and tapping into its settings shows the NoteBox text from Step 2.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Section-insertion mechanism, proven with a stub row
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/rose-plugs/src/patches/settings.ts`
|
||||
- Modify: `plugins/rose-plugs/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from earlier tasks.
|
||||
- Produces:
|
||||
- `export interface SectionRow { key: string; title: () => string; icon?: any; page: React.ComponentType<any>; }`
|
||||
- `export default function patchRosiesPlugsSection(getRows: () => SectionRow[]): () => void` — installs both settings-surface patches, returns a cleanup function. `getRows` is called fresh every time the section is rendered, so the row list can change over time (new plugins installed, etc.) without needing a plugin reload.
|
||||
|
||||
This is adapted directly from `plugins/raidens-themes/src/patches/settings.ts` (already live-verified, shipping). The difference: Raiden's Themes finds the "Revenge" section and pushes one row into its existing `settings` array. This inserts a **whole new section object** as a sibling of that section instead.
|
||||
|
||||
- [ ] **Step 1: Write the section-insertion patch**
|
||||
|
||||
`plugins/rose-plugs/src/patches/settings.ts`:
|
||||
```ts
|
||||
import { React, NavigationNative } from "@vendetta/metro/common";
|
||||
import { findByProps, findByName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { Forms } from "@vendetta/ui/components";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
|
||||
const { FormSection, FormRow } = Forms;
|
||||
|
||||
const SECTION_LABEL = "ROSES_PLUGS";
|
||||
const SECTION_TITLE = "Rosie's Plugs";
|
||||
|
||||
const tabsNavigationRef = findByProps("getRootNavigationRef");
|
||||
const settingConstants = findByProps("SETTING_RENDERER_CONFIG");
|
||||
const createListModule = findByProps("createList");
|
||||
const SettingsOverviewScreen = findByName("SettingsOverviewScreen", false);
|
||||
const TableRowIconModule = findByProps("TableRowIcon");
|
||||
|
||||
export interface SectionRow {
|
||||
key: string;
|
||||
title: () => string;
|
||||
icon?: any;
|
||||
page: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
function navigateToRow(row: SectionRow) {
|
||||
const navigation = tabsNavigationRef.getRootNavigationRef();
|
||||
const Component = row.page;
|
||||
navigation.navigate("VendettaCustomPage", {
|
||||
title: row.title(),
|
||||
render: () => React.createElement(Component),
|
||||
});
|
||||
}
|
||||
|
||||
function buildRendererRow(row: SectionRow) {
|
||||
return {
|
||||
type: "pressable",
|
||||
useTitle: row.title,
|
||||
title: row.title,
|
||||
icon: row.icon,
|
||||
IconComponent:
|
||||
row.icon && TableRowIconModule &&
|
||||
(() => React.createElement(TableRowIconModule.TableRowIcon, { source: row.icon })),
|
||||
onPress: () => navigateToRow(row),
|
||||
withArrow: true,
|
||||
};
|
||||
}
|
||||
|
||||
function Section({ tabs }: { tabs: SectionRow }) {
|
||||
const navigation = NavigationNative.useNavigation();
|
||||
|
||||
return React.createElement(FormRow, {
|
||||
label: tabs.title(),
|
||||
leading: tabs.icon ? React.createElement(FormRow.Icon, { source: tabs.icon }) : undefined,
|
||||
trailing: React.createElement(FormRow.Arrow),
|
||||
onPress: () => {
|
||||
const Component = tabs.page;
|
||||
navigation.navigate("VendettaCustomPage", {
|
||||
title: tabs.title(),
|
||||
render: () => React.createElement(Component),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function patchPanelUI(getRows: () => SectionRow[], patches: (() => void)[]) {
|
||||
const target = findByProps("renderTitle", "sections");
|
||||
if (!target) return;
|
||||
|
||||
try {
|
||||
patches.push(
|
||||
after("default", target, (_: any, ret: any) => {
|
||||
const UserSettingsOverview = findInReactTree(
|
||||
ret.props.children,
|
||||
(n: any) => n.type?.name === "UserSettingsOverview"
|
||||
);
|
||||
|
||||
if (UserSettingsOverview) {
|
||||
patches.push(
|
||||
after("render", UserSettingsOverview.type.prototype, (_args: any, res: any) => {
|
||||
const sections = findInReactTree(
|
||||
res.props.children,
|
||||
(n: any) => n?.children?.[1]?.type === FormSection
|
||||
)?.children;
|
||||
|
||||
if (sections) {
|
||||
const rows = getRows();
|
||||
rows.forEach((row, i) => {
|
||||
sections.splice(
|
||||
i,
|
||||
0,
|
||||
React.createElement(Section, { key: row.key, tabs: row })
|
||||
);
|
||||
});
|
||||
}
|
||||
}, true)
|
||||
);
|
||||
}
|
||||
}, true)
|
||||
);
|
||||
} catch {
|
||||
// This surface (the tablet/desktop-style settings panel) may not exist on this build at
|
||||
// all - patchTabsUI below covers the mobile settings list, which is what actually matters.
|
||||
}
|
||||
}
|
||||
|
||||
function patchTabsUI(getRows: () => SectionRow[], patches: (() => void)[]) {
|
||||
if (!settingConstants || !tabsNavigationRef) {
|
||||
console.warn("[RosePlugs] Missing constants for tabs UI patch");
|
||||
return;
|
||||
}
|
||||
|
||||
let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG;
|
||||
|
||||
Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const extra: Record<string, any> = {};
|
||||
for (const row of getRows()) extra[row.key] = buildRendererRow(row);
|
||||
return { ...rendererConfigValue, ...extra };
|
||||
},
|
||||
set(v: any) { rendererConfigValue = v; },
|
||||
});
|
||||
|
||||
const firstRender = Symbol("roseplugs-first-render");
|
||||
|
||||
try {
|
||||
if (!createListModule) return;
|
||||
patches.push(
|
||||
after("createList", createListModule, function (args: any) {
|
||||
if (args[0][firstRender]) return;
|
||||
args[0][firstRender] = true;
|
||||
|
||||
const [config] = args;
|
||||
const sections = config.sections;
|
||||
const rows = getRows();
|
||||
if (!rows.length || !sections) return;
|
||||
|
||||
sections.unshift({ label: SECTION_LABEL, title: SECTION_TITLE, settings: rows.map((r) => r.key) });
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
if (!SettingsOverviewScreen) return;
|
||||
patches.push(
|
||||
after("default", SettingsOverviewScreen, (args: any, ret: any) => {
|
||||
if (args[0][firstRender]) return;
|
||||
args[0][firstRender] = true;
|
||||
|
||||
const { sections } = findInReactTree(ret, (i: any) => i.props?.sections)?.props ?? {};
|
||||
const rows = getRows();
|
||||
if (!rows.length || !sections) return;
|
||||
|
||||
sections.unshift({ label: SECTION_LABEL, title: SECTION_TITLE, settings: rows.map((r) => r.key) });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function patchRosiesPlugsSection(getRows: () => SectionRow[]): () => void {
|
||||
const patches: (() => void)[] = [];
|
||||
|
||||
patchPanelUI(getRows, patches);
|
||||
patchTabsUI(getRows, patches);
|
||||
|
||||
return () => {
|
||||
for (const p of patches) p?.();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire a stub row into the plugin entry point**
|
||||
|
||||
Replace `plugins/rose-plugs/src/index.ts`:
|
||||
```ts
|
||||
import { logger } from "@vendetta";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import Settings from "./ui/Settings";
|
||||
import patchRosiesPlugsSection, { SectionRow } from "./patches/settings";
|
||||
|
||||
let unpatch: (() => void) | undefined;
|
||||
|
||||
function StubPluginsScreen() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRows(): SectionRow[] {
|
||||
return [
|
||||
{
|
||||
key: "ROSES_PLUGS_STUB",
|
||||
title: () => "Plugins",
|
||||
icon: getAssetIDByName("SettingsIcon"),
|
||||
page: StubPluginsScreen,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
try {
|
||||
unpatch = patchRosiesPlugsSection(buildRows);
|
||||
} catch (e: any) {
|
||||
logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e);
|
||||
}
|
||||
},
|
||||
onUnload: () => {
|
||||
unpatch?.();
|
||||
unpatch = undefined;
|
||||
},
|
||||
settings: Settings,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build locally**
|
||||
|
||||
Run: `cd /root/revenge-plugins && npm run build`
|
||||
Expected: `Successfully built RosePlugs!`, no errors.
|
||||
|
||||
- [ ] **Step 4: Commit, push, deploy, purge**
|
||||
|
||||
```bash
|
||||
git add plugins/rose-plugs
|
||||
git commit -m "RosePlugs: insert a new top-level settings section"
|
||||
git push origin main
|
||||
```
|
||||
Watch the deploy with `gh run watch <run-id> --exit-status`, then purge the Cloudflare cache (zone `76e85aab7728490e7f6351ac7b8b176f`, same as Task 1 Step 7).
|
||||
|
||||
- [ ] **Step 5: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. On the device, back all the way out of Settings, then reopen it (confirmed sufficient to force a fresh `createList` call — no full app reload needed).
|
||||
3. Confirm a "Rosie's Plugs" section header appears at the very top of Settings (above Account Settings), with a single "Plugins" row under it — this exact placement was already confirmed live via a scratch `eval` patch before this task was written; this step is re-confirming it against the real committed code.
|
||||
4. Tap the row — confirm it navigates to a blank screen without crashing (the stub).
|
||||
5. Use `eval` to double check no duplicate section was inserted: reopen Settings once more and confirm still exactly one "Rosie's Plugs" header, not two — this is what the `firstRender` symbol guard is protecting against.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Discover Rosie's other installed plugins
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/rose-plugs/src/patches/discoverPlugins.ts`
|
||||
- Modify: `plugins/rose-plugs/src/index.ts` (temporary logging only, removed in Task 4)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from earlier tasks.
|
||||
- Produces:
|
||||
- `export interface DiscoveredPlugin { id: string; name: string; icon?: string; settingsComponent: (() => any) | null; }`
|
||||
- `export function discoverRosiesPlugins(): DiscoveredPlugin[]` — every installed plugin whose manifest lists an author named "Rosie", excluding RosePlugs itself, sorted alphabetically by name. `settingsComponent` is the plugin's own settings component (from `getSettings`) when it has one, otherwise `null`.
|
||||
|
||||
- [ ] **Step 1: Write the discovery function**
|
||||
|
||||
`plugins/rose-plugs/src/patches/discoverPlugins.ts`:
|
||||
```ts
|
||||
import { plugins as installedPlugins, getSettings } from "@vendetta/plugins";
|
||||
|
||||
export interface DiscoveredPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
settingsComponent: (() => any) | null;
|
||||
}
|
||||
|
||||
export function discoverRosiesPlugins(): DiscoveredPlugin[] {
|
||||
const result: DiscoveredPlugin[] = [];
|
||||
|
||||
for (const id in installedPlugins) {
|
||||
const plugin = installedPlugins[id];
|
||||
const manifest = plugin?.manifest;
|
||||
if (!manifest || manifest.name === "RosePlugs") continue;
|
||||
|
||||
const authors = manifest.authors ?? [];
|
||||
if (!authors.some((a: any) => a.name === "Rosie")) continue;
|
||||
|
||||
let settingsComponent: (() => any) | null = null;
|
||||
try {
|
||||
const settingsResult = getSettings(id);
|
||||
if (typeof settingsResult === "function") settingsComponent = settingsResult;
|
||||
} catch {
|
||||
settingsComponent = null;
|
||||
}
|
||||
|
||||
result.push({ id, name: manifest.name, icon: manifest.vendetta?.icon, settingsComponent });
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Temporarily log the discovery result**
|
||||
|
||||
In `plugins/rose-plugs/src/index.ts`, add this import and line inside `onLoad`, right after the existing `try {`:
|
||||
```ts
|
||||
import { discoverRosiesPlugins } from "./patches/discoverPlugins";
|
||||
```
|
||||
```ts
|
||||
console.log(
|
||||
"[RosePlugs] discovered:",
|
||||
JSON.stringify(discoverRosiesPlugins().map((p) => ({ id: p.id, name: p.name, hasSettings: !!p.settingsComponent })))
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build, commit, push, deploy, purge**
|
||||
|
||||
Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: discover Rosie's other installed plugins"`.
|
||||
|
||||
- [ ] **Step 4: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. Fully reload the Discord app (so `onLoad` runs fresh and the log fires).
|
||||
3. `get_logs` — find the `[RosePlugs] discovered:` line and confirm it lists plugins with `manifest.authors` including "Rosie" (compare against the known live list from this session's design phase: role-color-everywhere, message-snippets, custom-user-tags, view-raw, better-timestamps, radial-status, reverse-image-search, you-bar-plus, urban-dictionary, staff-tags, rose-utils, key-inspector, copy-role-color, typing-avatars, pronoun-db, fake-profile-themes-and-effects, server-drawer — the exact live count may have changed since then, so treat that list as "should roughly match," not an exact assertion).
|
||||
4. Confirm Raiden's Themes and RP Admin are **not** in the list (expected exclusions).
|
||||
5. Spot check 2-3 entries' `hasSettings` value against whether that plugin actually has a settings screen in Revenge's own Plugins list, to sanity check the `getSettings` typeof-function check is working correctly both ways (true and false cases).
|
||||
|
||||
- [ ] **Step 5: Remove the temporary log line**
|
||||
|
||||
Delete the `console.log("[RosePlugs] discovered:", ...)` call added in Step 2 — its job was proving the discovery function works before wiring it into real UI in Task 4. Leave the import; Task 4 uses it too.
|
||||
|
||||
```bash
|
||||
git add plugins/rose-plugs/src/index.ts
|
||||
git commit -m "RosePlugs: remove temporary discovery log"
|
||||
```
|
||||
(No redeploy needed yet — Task 4 will deploy this together with the real row-wiring change.)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire discovered plugins into real settings rows
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/rose-plugs/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SectionRow` and `patchRosiesPlugsSection` from Task 2's `patches/settings.ts`; `discoverRosiesPlugins` from Task 3's `patches/discoverPlugins.ts`.
|
||||
- Produces: the real `buildRows()` used for the rest of this plan — "Plugins" (still a stub page until Task 6) followed by one row per discovered plugin that has settings.
|
||||
|
||||
- [ ] **Step 1: Replace the stub row list with real rows**
|
||||
|
||||
Replace `plugins/rose-plugs/src/index.ts`:
|
||||
```ts
|
||||
import { logger } from "@vendetta";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import Settings from "./ui/Settings";
|
||||
import patchRosiesPlugsSection, { SectionRow } from "./patches/settings";
|
||||
import { discoverRosiesPlugins } from "./patches/discoverPlugins";
|
||||
|
||||
let unpatch: (() => void) | undefined;
|
||||
|
||||
function StubPluginsScreen() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRows(): SectionRow[] {
|
||||
const rows: SectionRow[] = [
|
||||
{
|
||||
key: "ROSES_PLUGS_BROWSER",
|
||||
title: () => "Plugins",
|
||||
icon: getAssetIDByName("SettingsIcon"),
|
||||
page: StubPluginsScreen,
|
||||
},
|
||||
];
|
||||
|
||||
for (const plugin of discoverRosiesPlugins()) {
|
||||
if (!plugin.settingsComponent) continue;
|
||||
rows.push({
|
||||
key: `ROSES_PLUGS_${plugin.id}`,
|
||||
title: () => plugin.name,
|
||||
page: plugin.settingsComponent as any,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
try {
|
||||
unpatch = patchRosiesPlugsSection(buildRows);
|
||||
} catch (e: any) {
|
||||
logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e);
|
||||
}
|
||||
},
|
||||
onUnload: () => {
|
||||
unpatch?.();
|
||||
unpatch = undefined;
|
||||
},
|
||||
settings: Settings,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build, commit, push, deploy, purge**
|
||||
|
||||
Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: replace stub row with discovered plugin settings rows"`.
|
||||
|
||||
- [ ] **Step 3: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. Back out of Settings and reopen it.
|
||||
3. Confirm "Rosie's Plugs" now lists "Plugins" plus a row for every discovered plugin with settings — names should read like "ServerDrawer", "TypingAvatars", etc., not raw ids or URLs.
|
||||
4. Tap the "ServerDrawer" row — confirm it opens ServerDrawer's real settings screen (the "Hide the DMs tile" / "Show server names" toggles from earlier this session should be visible).
|
||||
5. Tap one more plugin's row (pick any with a real settings screen) and confirm it opens correctly too.
|
||||
6. Tap "Plugins" — still the blank stub for now, confirm no crash.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Nexus plugin metadata fetch + cache
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/rose-plugs/src/lib/nexusApi.ts`
|
||||
- Modify: `plugins/rose-plugs/src/index.ts` (temporary logging only, removed in Task 6)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing from earlier tasks.
|
||||
- Produces:
|
||||
- `export interface NexusPlugin { id: string; name: string; description: string; authors: string[]; category: string; status: string; accent: string; tagline: string; note: string; howItWorks: string; features: string[]; commands: string[]; limitations: string; pageUrl: string; installUrl: string; sourceUrl: string; issueUrl: string; }`
|
||||
- `export async function fetchNexusPlugins(force?: boolean): Promise<NexusPlugin[]>` — fetches `https://rp.jarviscli.dev/plugins-data.json`, caches the result in memory for the session, dedupes concurrent calls into a single in-flight request. `force: true` bypasses the cache.
|
||||
- `export function clearNexusCache(): void` — drops the in-memory cache so the next `fetchNexusPlugins()` call re-fetches.
|
||||
|
||||
- [ ] **Step 1: Write the fetch/cache module**
|
||||
|
||||
`plugins/rose-plugs/src/lib/nexusApi.ts`:
|
||||
```ts
|
||||
export interface NexusPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
authors: string[];
|
||||
category: string;
|
||||
status: string;
|
||||
accent: string;
|
||||
tagline: string;
|
||||
note: string;
|
||||
howItWorks: string;
|
||||
features: string[];
|
||||
commands: string[];
|
||||
limitations: string;
|
||||
pageUrl: string;
|
||||
installUrl: string;
|
||||
sourceUrl: string;
|
||||
issueUrl: string;
|
||||
}
|
||||
|
||||
const NEXUS_URL = "https://rp.jarviscli.dev/plugins-data.json";
|
||||
|
||||
let cache: NexusPlugin[] | null = null;
|
||||
let inflight: Promise<NexusPlugin[]> | null = null;
|
||||
|
||||
export async function fetchNexusPlugins(force = false): Promise<NexusPlugin[]> {
|
||||
if (!force && cache) return cache;
|
||||
if (!force && inflight) return inflight;
|
||||
|
||||
inflight = fetch(NEXUS_URL)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Nexus fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data: NexusPlugin[]) => {
|
||||
cache = data;
|
||||
inflight = null;
|
||||
return data;
|
||||
})
|
||||
.catch((e) => {
|
||||
inflight = null;
|
||||
throw e;
|
||||
});
|
||||
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function clearNexusCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Temporarily log a live fetch result**
|
||||
|
||||
In `plugins/rose-plugs/src/index.ts`, add this import:
|
||||
```ts
|
||||
import { fetchNexusPlugins } from "./lib/nexusApi";
|
||||
```
|
||||
And this line inside `onLoad`, after the existing patch call:
|
||||
```ts
|
||||
fetchNexusPlugins()
|
||||
.then((list) => console.log("[RosePlugs] nexus fetch ok:", list.length, JSON.stringify(list[0])))
|
||||
.catch((e) => console.log("[RosePlugs] nexus fetch failed:", String(e)));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build, commit, push, deploy, purge**
|
||||
|
||||
Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: fetch and cache Nexus plugin metadata"`.
|
||||
|
||||
- [ ] **Step 4: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. Fully reload the Discord app.
|
||||
3. `get_logs` — find `[RosePlugs] nexus fetch ok:` with a real count (should roughly match the number of plugins in this repo, currently around 20) and a real first plugin's JSON (recognizable `name`/`category`/`installUrl` fields, not placeholder or malformed data). If it logs `nexus fetch failed` instead, that's a real bug to fix before moving on — this confirms `fetch` actually works from inside the RN app against this specific host, not just in theory.
|
||||
|
||||
- [ ] **Step 5: Remove the temporary log lines**
|
||||
|
||||
Delete the `fetchNexusPlugins().then(...).catch(...)` block added in Step 2. Leave the import — Task 6 uses it.
|
||||
|
||||
```bash
|
||||
git add plugins/rose-plugs/src/index.ts
|
||||
git commit -m "RosePlugs: remove temporary nexus fetch log"
|
||||
```
|
||||
(No redeploy needed yet — Task 6 deploys this together with the real browser UI.)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Quick-install browser UI
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/rose-plugs/src/ui/PluginDetailSheet.tsx`
|
||||
- Create: `plugins/rose-plugs/src/ui/PluginsBrowser.tsx`
|
||||
- Modify: `plugins/rose-plugs/src/index.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `NexusPlugin`/`fetchNexusPlugins` from Task 5's `lib/nexusApi.ts`; `plugins`/`installPlugin` from `@vendetta/plugins`; `showCustomAlert` from `@vendetta/ui/alerts`; `showToast` from `@vendetta/ui/toasts`; `PrimaryButton`/`TableRow`/`TableRowGroup`/`SettingsScaffold`/`NoteBox` from `@shared/ui/*`.
|
||||
- Produces: `PluginsBrowser` (default export, no props) and `PluginDetailSheet` (default export, props `{ plugin: NexusPlugin }`) — replaces `StubPluginsScreen` as the "Plugins" row's page.
|
||||
|
||||
- [ ] **Step 1: Write the detail/install sheet**
|
||||
|
||||
`plugins/rose-plugs/src/ui/PluginDetailSheet.tsx`:
|
||||
```tsx
|
||||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { plugins as installedPlugins, installPlugin } from "@vendetta/plugins";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||
import { NexusPlugin } from "../lib/nexusApi";
|
||||
|
||||
const { ScrollView, Text } = ReactNative;
|
||||
|
||||
export default function PluginDetailSheet({ plugin }: { plugin: NexusPlugin }) {
|
||||
const [installing, setInstalling] = React.useState(false);
|
||||
const alreadyInstalled = plugin.installUrl in installedPlugins;
|
||||
|
||||
const onInstall = React.useCallback(() => {
|
||||
setInstalling(true);
|
||||
installPlugin(plugin.installUrl)
|
||||
.then(() => {
|
||||
setInstalling(false);
|
||||
showToast(`${plugin.name} installed`);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
setInstalling(false);
|
||||
showToast(`Install failed: ${e?.message ?? e}`);
|
||||
});
|
||||
}, [plugin.installUrl]);
|
||||
|
||||
return (
|
||||
<ScrollView style={{ maxHeight: 400, padding: 16 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", marginBottom: 8 }}>{plugin.name}</Text>
|
||||
<Text style={{ fontSize: 13, opacity: 0.8, marginBottom: 4 }}>{plugin.category}</Text>
|
||||
<Text style={{ fontSize: 14, marginBottom: 16 }}>{plugin.tagline || plugin.description}</Text>
|
||||
{plugin.status && plugin.status !== "default" && (
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", marginBottom: 16 }}>
|
||||
{plugin.status.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
<PrimaryButton
|
||||
label={alreadyInstalled ? "Installed" : installing ? "Installing…" : "Install"}
|
||||
disabled={alreadyInstalled || installing}
|
||||
onPress={onInstall}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the browser screen**
|
||||
|
||||
`plugins/rose-plugs/src/ui/PluginsBrowser.tsx`:
|
||||
```tsx
|
||||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { plugins as installedPlugins } from "@vendetta/plugins";
|
||||
import { showCustomAlert } from "@vendetta/ui/alerts";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||
import { TableRow, TableRowGroup } from "@shared/ui/table";
|
||||
import { fetchNexusPlugins, NexusPlugin } from "../lib/nexusApi";
|
||||
import PluginDetailSheet from "./PluginDetailSheet";
|
||||
|
||||
const { Text } = ReactNative;
|
||||
|
||||
interface BrowserState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
plugins: NexusPlugin[];
|
||||
}
|
||||
|
||||
export default function PluginsBrowser() {
|
||||
const [state, setState] = React.useState<BrowserState>({ loading: true, error: null, plugins: [] });
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
fetchNexusPlugins()
|
||||
.then((plugins) => setState({ loading: false, error: null, plugins }))
|
||||
.catch((e) => setState({ loading: false, error: String(e?.message ?? e), plugins: [] }));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (state.loading) {
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>Loading Rosie's plugins…</NoteBox>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>Couldn't load the plugin list: {state.error}</NoteBox>
|
||||
<PrimaryButton label="Retry" onPress={load} style={{ margin: 16 }} />
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
const byCategory = new Map<string, NexusPlugin[]>();
|
||||
for (const plugin of state.plugins) {
|
||||
const list = byCategory.get(plugin.category) ?? [];
|
||||
list.push(plugin);
|
||||
byCategory.set(plugin.category, list);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
{[...byCategory.entries()].map(([category, plugins]) => (
|
||||
<TableRowGroup key={category} title={category}>
|
||||
{plugins.map((plugin) => {
|
||||
const installed = plugin.installUrl in installedPlugins;
|
||||
return (
|
||||
<TableRow
|
||||
key={plugin.id}
|
||||
label={plugin.name}
|
||||
subLabel={installed ? "Installed" : plugin.tagline || plugin.description}
|
||||
trailing={
|
||||
plugin.status && plugin.status !== "default" ? (
|
||||
<Text style={{ fontSize: 11, fontWeight: "700", opacity: 0.7 }}>
|
||||
{plugin.status.toUpperCase()}
|
||||
</Text>
|
||||
) : installed ? (
|
||||
<Text style={{ fontSize: 14, fontWeight: "700", color: "#23A55A" }}>✓</Text>
|
||||
) : undefined
|
||||
}
|
||||
onPress={() => showCustomAlert(PluginDetailSheet, { plugin })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableRowGroup>
|
||||
))}
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the real browser into the section, remove the stub**
|
||||
|
||||
In `plugins/rose-plugs/src/index.ts`: remove the `fetchNexusPlugins` import and the temporary log block if not already removed in Task 5 Step 5; remove the `StubPluginsScreen` function; import and use the real screen instead:
|
||||
```ts
|
||||
import { logger } from "@vendetta";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import Settings from "./ui/Settings";
|
||||
import PluginsBrowser from "./ui/PluginsBrowser";
|
||||
import patchRosiesPlugsSection, { SectionRow } from "./patches/settings";
|
||||
import { discoverRosiesPlugins } from "./patches/discoverPlugins";
|
||||
|
||||
let unpatch: (() => void) | undefined;
|
||||
|
||||
function buildRows(): SectionRow[] {
|
||||
const rows: SectionRow[] = [
|
||||
{
|
||||
key: "ROSES_PLUGS_BROWSER",
|
||||
title: () => "Plugins",
|
||||
icon: getAssetIDByName("SettingsIcon"),
|
||||
page: PluginsBrowser,
|
||||
},
|
||||
];
|
||||
|
||||
for (const plugin of discoverRosiesPlugins()) {
|
||||
if (!plugin.settingsComponent) continue;
|
||||
rows.push({
|
||||
key: `ROSES_PLUGS_${plugin.id}`,
|
||||
title: () => plugin.name,
|
||||
page: plugin.settingsComponent as any,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
try {
|
||||
unpatch = patchRosiesPlugsSection(buildRows);
|
||||
} catch (e: any) {
|
||||
logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e);
|
||||
}
|
||||
},
|
||||
onUnload: () => {
|
||||
unpatch?.();
|
||||
unpatch = undefined;
|
||||
},
|
||||
settings: Settings,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build, commit, push, deploy, purge**
|
||||
|
||||
Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: build the quick-install browser"`.
|
||||
|
||||
- [ ] **Step 5: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. Back out of Settings and reopen it, navigate to Rosie's Plugs → Plugins.
|
||||
3. Confirm real category headers and plugin rows render (not blank), matching the live Nexus JSON (cross-check with a fresh `eval` call doing `fetch("https://rp.jarviscli.dev/plugins-data.json").then(r => r.json())` and comparing counts/names).
|
||||
4. Confirm already-installed plugins show a green ✓ or "Installed" sublabel; confirm any non-default `status` values show as an uppercase badge.
|
||||
5. Tap a plugin row — confirm `PluginDetailSheet` opens with the right name/category/tagline.
|
||||
6. Find a plugin in the Nexus catalog that is **not** in this device's installed list (compare against the live `plugins` record from `@vendetta/plugins`) and tap Install on it. Confirm the button shows "Installing…" then success, and via `eval` confirm `plugin.installUrl in window.vendetta.plugins.plugins` is now `true`. If every catalog plugin happens to already be installed on this test device, install-testing an already-installed one is acceptable too — the goal is confirming `installPlugin` fires without error and the button states transition correctly, not necessarily a net-new install.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: RosePlugs' own settings screen
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/rose-plugs/src/ui/Settings.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `clearNexusCache` from Task 5's `lib/nexusApi.ts`.
|
||||
- Produces: nothing new consumed elsewhere — this is RosePlugs' own settings screen, reached normally via Revenge's Plugins list (not via a row inside Rosie's Plugs itself).
|
||||
|
||||
- [ ] **Step 1: Add the refresh button**
|
||||
|
||||
Replace `plugins/rose-plugs/src/ui/Settings.tsx`:
|
||||
```tsx
|
||||
import { React } from "@vendetta/metro/common";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { clearNexusCache } from "../lib/nexusApi";
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
RosePlugs gathers Rosie's other plugins under their own "Rosie's Plugs" section in
|
||||
Settings, with a quick-install browser for anything not installed yet.
|
||||
</NoteBox>
|
||||
<PrimaryButton
|
||||
label="Refresh plugin list"
|
||||
onPress={() => {
|
||||
clearNexusCache();
|
||||
showToast("Plugin list will refresh next time you open Plugins");
|
||||
}}
|
||||
style={{ margin: 16 }}
|
||||
/>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build, commit, push, deploy, purge**
|
||||
|
||||
Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: add refresh button to its own settings"`.
|
||||
|
||||
- [ ] **Step 3: Live-verify**
|
||||
|
||||
Via `revenge-devtools`:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. Open Settings → Plugins (Revenge's own list) → RosePlugs → Settings.
|
||||
3. Confirm the NoteBox text and "Refresh plugin list" button render.
|
||||
4. Open Plugins (Rosie's Plugs → Plugins) once first, to populate the cache, then go back to RosePlugs' own settings and tap "Refresh plugin list" — confirm the toast appears.
|
||||
5. Reopen Rosie's Plugs → Plugins and confirm it still loads correctly (a fresh fetch after the cache-bust, not a crash or stale/empty state).
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Nexus metadata entry and final end-to-end sweep
|
||||
|
||||
**Files:**
|
||||
- Modify: `site/meta.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: nothing new — this is polish plus a final full-system verification pass.
|
||||
|
||||
- [ ] **Step 1: Add a `site/meta.json` entry for rose-plugs**
|
||||
|
||||
Open `site/meta.json`, add an entry keyed `"rose-plugs"` alongside the existing entries, following the same shape as the other entries read in Task's earlier exploration (`category`, `status`, `accent`, `tagline`, `note`, `howItWorks`, `features`, `commands`, `limitations` — all optional, defaults exist in `scripts/generate-site.mjs` for anything omitted):
|
||||
```json
|
||||
"rose-plugs": {
|
||||
"category": "Utility",
|
||||
"status": "new",
|
||||
"accent": "#F47FFF",
|
||||
"tagline": "A home of Rosie's own in Settings, with one-tap installs for anything not installed yet.",
|
||||
"howItWorks": "Adds a \"Rosie's Plugs\" section to Discord's Settings, separate from Revenge's own Plugins list. Its first entry, \"Plugins,\" is a quick-install browser pulling live from this site's own plugin catalog. Every other Rosie-authored plugin that's currently installed gets a direct settings shortcut listed right below it.",
|
||||
"features": [
|
||||
"New top-level \"Rosie's Plugs\" settings section",
|
||||
"One-tap install browser for every plugin in this catalog, grouped by category",
|
||||
"Direct settings shortcuts for each installed Rosie-authored plugin - no digging through Revenge's own Plugins list"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Only surfaces plugins whose manifest lists \"Rosie\" as an author - a plugin credited to someone else won't be picked up automatically."
|
||||
}
|
||||
```
|
||||
Adjust `category`/`accent` if inconsistent with this repo's existing category naming once you read the full `site/meta.json` file - match whatever categories already exist rather than inventing a new one, unless none fit.
|
||||
|
||||
- [ ] **Step 2: Build, commit, push, deploy, purge**
|
||||
|
||||
Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: add Nexus site metadata"`.
|
||||
|
||||
- [ ] **Step 3: Full end-to-end live-verify**
|
||||
|
||||
Via `revenge-devtools`, working through the spec's testing checklist in full:
|
||||
1. `devtools_clients` — confirm connected.
|
||||
2. "Rosie's Plugs" section appears in Settings, correctly positioned and titled.
|
||||
3. "Plugins" row opens the browser with real, categorized, correctly-badged data.
|
||||
4. Installing a not-yet-installed plugin actually installs it (repeat Task 6 Step 5's install check if not already covered by a genuinely new plugin at that point).
|
||||
5. Every already-installed Rosie-authored plugin with settings shows a row that opens the correct screen (spot-check at least 3, not just the 1-2 checked in Task 4).
|
||||
6. Confirm no regression: open Revenge's own Plugins list, confirm it still lists and manages every plugin normally; open one non-Rosie plugin's settings (e.g. anything from a different author in the installed list) and confirm it still works unaffected.
|
||||
7. Visit `https://rp.jarviscli.dev/rose-plugs/` in a browser and confirm the Nexus site renders RosePlugs' own page correctly with the Task 8 Step 1 metadata.
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
# Typing Avatars — Design
|
||||
|
||||
## Origin
|
||||
|
||||
Inspired by the (now-defunct) community plugin at https://fierdetta.github.io/typing-avatars/,
|
||||
which replaced Discord's typing indicator with the avatars of whoever's typing. Reviving the
|
||||
concept as a new plugin in this repo, built on top of the `TypingIndicatorInner` interception
|
||||
work already done and verified in `role-color-everywhere/src/patches/typingWrapper.ts`.
|
||||
|
||||
## Scope
|
||||
|
||||
New standalone plugin, `typing-avatars` — not folded into RoleColorEverywhere, so it can be
|
||||
enabled independently.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Reuses the proven-safe pattern from `typingWrapper.ts`:
|
||||
|
||||
1. `registerTypeDetector` (from `@shared/lib/createElementIntercept`) catches
|
||||
`TypingIndicatorInner` by name.
|
||||
2. The captured inner render function is wrapped so it's still called exactly once per render,
|
||||
from within the wrapper - this is what preserves its internal hooks and avoids the
|
||||
"Rendered more/fewer hooks than during the previous render" crash class encountered earlier
|
||||
this session when a component was called out-of-band as a side effect.
|
||||
3. `findInReactTree` locates the label node that previously held the "X is typing..." text and
|
||||
per-user colored `<Text>` children (typingWrapper.ts already established this lookup works).
|
||||
4. Instead of recoloring that text (RoleColorEverywhere's approach), this plugin replaces the
|
||||
label node's rendered content entirely with a new `AvatarStack` component, built from
|
||||
`props.typingUserIds`.
|
||||
|
||||
## AvatarStack component
|
||||
|
||||
- **Input:** `typingUserIds: string[]`, `guildId: string`.
|
||||
- **Avatar source:** per-server avatar first (via `GuildMemberStore`, matching what's shown
|
||||
elsewhere in that server's member list/messages), falling back to the user's global Discord
|
||||
avatar (via `UserStore`) if they have no server-specific avatar set.
|
||||
- **URL construction:** via Discord's own avatar-URL utility module (handles animated/gif
|
||||
avatars and the default-avatar fallback correctly) rather than hand-rolled CDN URL strings.
|
||||
The exact module/function name is **not yet verified live** - `revenge-devtools` was
|
||||
disconnected during this design session. This must be confirmed via live devtools before
|
||||
shipping, not guessed.
|
||||
- **Layout:** overlapping circular avatars (each avatar slightly overlapping the previous, like
|
||||
Discord's own "who's here" clusters elsewhere in the app).
|
||||
- **No text label** - avatars only, matching the original plugin's description most literally.
|
||||
- **No tap interaction** - purely decorative for v1. A per-avatar "tap to view profile" handler
|
||||
is a plausible future addition, out of scope here.
|
||||
- **Overflow:** no cap on typer count - if the row would overflow the available width, it wraps
|
||||
to a second overlapping row rather than clipping or truncating with a "+N" badge.
|
||||
|
||||
## Error handling
|
||||
|
||||
Matches this repo's established convention:
|
||||
|
||||
- Every patch point wrapped in try/catch; a thrown error leaves the indicator without the
|
||||
avatar-row patch rather than crashing the surrounding render.
|
||||
- If avatar resolution fails for a specific user (missing member/user store data), that one
|
||||
avatar falls back to Discord's default avatar image rather than breaking the whole row.
|
||||
- If `TypingIndicatorInner` can't be found at all (Discord build changed, module not yet
|
||||
registered), the plugin logs a warning via the shared `applyPatches` logger and no-ops,
|
||||
consistent with every other plugin in this repo.
|
||||
|
||||
## Testing plan
|
||||
|
||||
- Local build/bundle check via `node build.mjs` (repo's existing build script) before every
|
||||
deploy, same as done for the CopyRoleColor gradient fix.
|
||||
- Live verification via `revenge-devtools` (eval + react tree tools) once reconnected:
|
||||
confirm the avatar-URL module API, confirm `TypingIndicatorInner`'s label-replacement still
|
||||
renders correctly with real typing users, confirm per-server avatar fallback behaves as
|
||||
expected for a user with no server-specific avatar.
|
||||
- No claim of "working" until live-verified on a real device, per this session's established
|
||||
standard (no best-effort claims).
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
# RosePlugs — design spec
|
||||
|
||||
## Purpose
|
||||
|
||||
A new Revenge/Vendetta plugin that gives Rosie's own plugins a home of their own in
|
||||
Discord's Settings, instead of leaving them buried inside Revenge's generic Plugins
|
||||
list. Adds a new top-level settings section titled **"Rosie's Plugs"**, whose first
|
||||
row is a quick-install browser for Rosie's plugins, followed by a direct settings
|
||||
shortcut for each of Rosie's plugins that's currently installed.
|
||||
|
||||
Explicitly separate from the existing private "RP Admin" plugin. RP Admin will
|
||||
eventually be migrated to live inside this section too, but that's a future,
|
||||
separate task — not part of this build.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Does not touch, hide, or replace Revenge's own "Plugins" management screen
|
||||
(enable/disable/uninstall stays there).
|
||||
- Does not manage plugins by other authors — only plugins whose manifest lists
|
||||
"Rosie" as an author.
|
||||
- No in-browser uninstall for v1 — that stays a one-tap trip to Revenge's own
|
||||
Plugins screen.
|
||||
|
||||
## Architecture
|
||||
|
||||
RosePlugs is fully self-contained. No changes to any of the other ~20 plugins in
|
||||
this repo. Two independent patch mechanisms, both variations on the
|
||||
`SETTING_RENDERER_CONFIG` / `createList` pattern already live-verified and shipping
|
||||
in Raiden's Themes (`plugins/raidens-themes/src/patches/settings.ts`):
|
||||
|
||||
1. **New top-level section.** Raiden's Themes appends a row into the *existing*
|
||||
"Revenge" section's `settings` array. RosePlugs instead inserts a whole new
|
||||
section object — `{ label: "ROSES_PLUGS", title: "Rosie's Plugs", settings: [...] }`
|
||||
— into `config.sections` itself, as a sibling of the "Revenge" section rather than
|
||||
a row inside it. Same array, same object shape; confirmed directly against the
|
||||
Revenge client source at `/root/repos/revenge-bundle` (`core/ui/settings/pages/...`),
|
||||
not inferred from behavior alone. Patches both surfaces Raiden's Themes patches
|
||||
(the mobile list via `createList`, and the tablet/desktop panel via
|
||||
`renderTitle`/`sections`, with the same defensive try/catch since that second
|
||||
surface may not exist on a given build).
|
||||
|
||||
2. **Auto-discovering Rosie's other installed plugins.** At render time, reads
|
||||
`@vendetta/plugins`' `plugins` record (every installed plugin, keyed by id),
|
||||
filters to entries whose `manifest.authors` includes "Rosie", excludes RosePlugs
|
||||
itself, and adds one settings row per remaining plugin that actually exports
|
||||
settings. Opening that row calls the plugin's own `getSettings(id)` — the same
|
||||
generic Vendetta API Revenge's own plugin browser uses. Nothing to maintain here
|
||||
when a new plugin ships under Rosie's name; it appears automatically next time
|
||||
Settings is opened.
|
||||
|
||||
Rejected alternative: having each of the 20 plugins explicitly register itself with
|
||||
RosePlugs (a shared call added to each plugin's `index.ts`). Rejected because
|
||||
`manifest.authors` + `getSettings` already provides everything needed generically,
|
||||
and touching 20 already-shipping files for no functional gain is unnecessary risk.
|
||||
|
||||
## Components
|
||||
|
||||
- `src/index.ts` — plugin entry, wires the two patches on load, cleans up on unload.
|
||||
- `src/patches/settings.ts` — the section-insertion + row-injection patch, adapted
|
||||
from Raiden's Themes' `patches/settings.ts` (new-section insertion instead of
|
||||
row-append; adds the discovery loop over `@vendetta/plugins`).
|
||||
- `src/patches/discoverPlugins.ts` — builds the list of Rosie-authored installed
|
||||
plugins (id, manifest, `getSettings` result) from the `@vendetta/plugins` API.
|
||||
- `src/ui/PluginsBrowser.tsx` — the "Plugins" screen: fetches, caches, and renders
|
||||
the categorized quick-install list.
|
||||
- `src/ui/PluginDetailSheet.tsx` — the per-plugin detail/install action sheet shown
|
||||
on row tap.
|
||||
- `src/lib/nexusApi.ts` — fetch + in-memory cache of
|
||||
`https://rp.jarviscli.dev/plugins-data.json`.
|
||||
- `src/ui/Settings.tsx` — RosePlugs' own minimal settings screen (short description
|
||||
+ a "refresh plugin list" button that busts the cache in `nexusApi.ts`).
|
||||
|
||||
## Data flow
|
||||
|
||||
1. Settings screen opens → RosePlugs' `createList`/panel patch runs → inserts the
|
||||
"Rosie's Plugs" section with a "Plugins" row plus one row per installed
|
||||
Rosie-authored plugin with settings.
|
||||
2. Tapping "Plugins" navigates to `PluginsBrowser`, which calls `nexusApi.ts` (cached
|
||||
after first fetch this session) and renders plugins grouped by `category`, each
|
||||
row showing name, tagline, and a status badge when status isn't the default.
|
||||
3. Tapping a plugin row opens `PluginDetailSheet` with the full tagline/status note
|
||||
and an Install button (or an "Installed" indicator if already present in
|
||||
`@vendetta/plugins`' `plugins` record).
|
||||
4. Install calls `installPlugin(installUrl)` from `@vendetta/plugins` directly — no
|
||||
deep link needed, confirmed against the Revenge client source. The row reflects
|
||||
loading → installed/error state.
|
||||
5. Tapping one of the per-plugin settings rows back in the main section calls that
|
||||
plugin's `getSettings(id)` and navigates to the result, same as Revenge's own
|
||||
plugin browser does internally.
|
||||
|
||||
## Error handling
|
||||
|
||||
- `nexusApi.ts` fetch failure: `PluginsBrowser` shows an inline error state with a
|
||||
retry button. Scoped to that one screen — it can't affect the main Settings list,
|
||||
since it only runs after navigating into "Plugins."
|
||||
- Missing/unavailable `SETTING_RENDERER_CONFIG` or `createList` module: patch
|
||||
function logs a warning and returns without throwing, matching Raiden's Themes'
|
||||
existing defensive style.
|
||||
- A Rosie-authored plugin with a broken `getSettings` (throws on render): not
|
||||
specially guarded beyond React's own error boundary at the settings-screen level —
|
||||
consistent with how Revenge's own plugin browser handles the same case today.
|
||||
|
||||
## Testing / live verification
|
||||
|
||||
Standard for this repo: build, deploy, purge cache, then live-verify via
|
||||
revenge-devtools before considering any part done:
|
||||
|
||||
- "Rosie's Plugs" section appears in Settings, in the right position, with the
|
||||
right title.
|
||||
- "Plugins" row opens the browser; data is real and categorized correctly; status
|
||||
badges match the live JSON.
|
||||
- Installing a not-yet-installed plugin actually installs it and the row updates.
|
||||
- An already-installed Rosie-authored plugin shows a settings row that opens the
|
||||
correct screen.
|
||||
- No regression to Revenge's own Plugins screen or to any other installed plugin's
|
||||
settings.
|
||||
|
||||
## Manifest
|
||||
|
||||
- id: `rose-plugs`
|
||||
- name: `RosePlugs`
|
||||
- author: `Rosie`
|
||||
- section label/title: `Rosie's Plugs`
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
# Custom SVG tags + icon-only mode (staff-tags, custom-user-tags)
|
||||
|
||||
Date: 2026-08-16
|
||||
Repos/plugins: `staff-tags`, `custom-user-tags` (classic, `/root/revenge-plugins`)
|
||||
|
||||
## Problem
|
||||
|
||||
Both plugins currently support only a fixed set of ~50 preset monochrome icons (`lib/icons.ts`,
|
||||
rendered as a single SVG `path` via `ui/Icon.tsx`). Users want to supply their own SVG artwork for
|
||||
a tag's icon, and want an easy way to show only the icon with no text label at all — today a tag
|
||||
always falls back to `defaultText`/its stored text and never renders truly empty.
|
||||
|
||||
## Feasibility (confirmed live against a connected 340.13 client)
|
||||
|
||||
`findByProps("Svg")` — the module both plugins already use for `Svg`/`Path` — also exports
|
||||
`SvgXml`/`SvgFromXml`/`SvgUri`/`SvgCss` and the full `react-native-svg` primitive set (`Circle`,
|
||||
`Rect`, `G`, gradients, etc.). Discord bundles the complete library, not just the two primitives
|
||||
currently used. Rendering arbitrary user-supplied SVG markup via `SvgXml` requires no new
|
||||
dependency.
|
||||
|
||||
## Decisions (from user)
|
||||
|
||||
- **Input method:** paste raw SVG markup into a text field. No native file/document picker — avoids
|
||||
depending on an RN module that isn't confirmed available in Revenge's plugin sandbox.
|
||||
- **Coloring:** a custom SVG keeps its own authored colors. The tag's color/gradient settings only
|
||||
affect the text label and background chip, not the SVG's internal fills. (Preset icons keep their
|
||||
existing behavior: force-tinted to the tag's icon color.)
|
||||
- **Icon-only toggle:** one general "Icon only (hide text)" switch that works for both preset icons
|
||||
and custom SVGs, not a custom-SVG-specific feature.
|
||||
|
||||
## Data model changes
|
||||
|
||||
`staff-tags/src/lib/getTag.ts` — `TagOverride` interface gains:
|
||||
```ts
|
||||
customSvg?: string; // raw SVG markup, mutually exclusive with `icon` (preset id)
|
||||
customSvgFallback?: string; // short text shown in chat, where icons can't render at all
|
||||
iconOnly?: boolean;
|
||||
```
|
||||
`custom-user-tags/src/lib/tags.ts` — `UserTag` interface gains the same three fields.
|
||||
|
||||
Setting `customSvg` in the editor UI clears `icon` (and vice versa) — a tag has at most one icon
|
||||
source at a time. Nothing in storage besides these two fields changes.
|
||||
|
||||
## Icon representation and rendering
|
||||
|
||||
Extend `IconDef` (`lib/icons.ts`, both plugins) to represent either a preset or a custom SVG:
|
||||
```ts
|
||||
export interface IconDef {
|
||||
id: string;
|
||||
name: string;
|
||||
fallback: string;
|
||||
path?: string; // preset: path data
|
||||
viewBox?: string; // preset: defaults to "0 0 24 24"
|
||||
svg?: string; // custom: full raw markup
|
||||
}
|
||||
```
|
||||
`getIcon(id)` is unchanged (still resolves preset ids from `ICONS`). A new path in
|
||||
`getTag.ts`/`resolveTag.ts`: if `settings.customSvg`/`tag.customSvg` is set, build the resolved
|
||||
tag's `icon` as `{ id: "custom", name: "Custom", fallback: settings.customSvgFallback ?? "",
|
||||
svg: settings.customSvg }` instead of calling `getIcon(settings.icon)`.
|
||||
|
||||
`ui/Icon.tsx` branches on which field is present:
|
||||
- `icon.svg` set → `<SvgXml xml={icon.svg} width={size} height={size} style={style} />`, no `fill`
|
||||
prop (preserves the SVG's own colors). Wrapped in try/catch — a malformed paste renders nothing
|
||||
instead of throwing, consistent with how the rest of these patches degrade on bad input.
|
||||
- `icon.path` set → existing `<Svg><Path d={icon.path} /></Svg>` behavior, unchanged, still tinted
|
||||
via the `color` prop.
|
||||
|
||||
Every existing call site (`patches/tag.tsx`'s chat-tag styling, `ui/GradientTag.tsx`) already just
|
||||
passes `icon` and `color` through to `Icon` generically — no changes needed there.
|
||||
|
||||
## Icon-only text suppression
|
||||
|
||||
`getTag.ts` currently resolves text as `settings.text?.trim() || def.defaultText` — never actually
|
||||
empty. When `settings.iconOnly`/`tag.iconOnly` is true, resolve `text: ""` instead, skipping the
|
||||
`defaultText` fallback. This requires an icon (preset or custom) to be set — the editor UI disables
|
||||
the toggle when no icon is selected, since an empty-text/no-icon tag would render nothing.
|
||||
|
||||
This already flows correctly with no further changes:
|
||||
- Name row / member list / profile (JSX surfaces): render the icon with an empty label.
|
||||
- Chat (`patches/chat.ts`, flat-data surface, can never render a real icon — pre-existing platform
|
||||
limit both plugins already document): existing ternary
|
||||
`tag.icon ? (tag.text ? icon+text : icon.fallback) : tag.text` already falls through to
|
||||
`icon.fallback` when text is empty. Preset icons already have a `fallback` glyph. Custom SVGs
|
||||
don't, so the editor gets an optional short "fallback text for chat" input next to the paste box
|
||||
(stored as `customSvgFallback?: string`) — if left blank, chat shows an empty-but-colored chip.
|
||||
Acceptable degraded case; not worth blocking on.
|
||||
|
||||
## Input validation
|
||||
|
||||
On save, before writing `customSvg` to storage: trim whitespace, reject (inline error, don't save)
|
||||
if empty or doesn't start with `<svg` (case-insensitive), cap at 20,000 characters. No deeper XML
|
||||
parsing/validation at save time — the render-time try/catch around `SvgXml` is the actual safety
|
||||
net against malformed-but-passes-the-basic-check markup.
|
||||
|
||||
## UI changes
|
||||
|
||||
`staff-tags/src/ui/pages/Settings.tsx` and `custom-user-tags/src/ui/TagEditorAlert.tsx` (each
|
||||
plugin's real per-tag editor — confirmed these are where `IconPicker`/`ColorInput` are currently
|
||||
wired in, not any shared file):
|
||||
- A multiline text input for pasting SVG markup, next to the existing `IconPicker`. Selecting a
|
||||
preset icon clears `customSvg`; entering SVG markup clears `icon`.
|
||||
- A short "fallback text for chat" input, shown only when `customSvg` is set.
|
||||
- An "Icon only (hide text)" switch, disabled unless an icon (preset or custom) is currently set.
|
||||
|
||||
## Files touched (9 files: 4 in staff-tags, 5 in custom-user-tags — custom-user-tags splits
|
||||
resolution across two files where staff-tags has one)
|
||||
|
||||
- `lib/icons.ts` — `IconDef` shape
|
||||
- `lib/getTag.ts` (staff-tags) / `lib/resolveTag.ts` + `lib/tags.ts` (custom-user-tags) — storage
|
||||
fields, resolution logic
|
||||
- `ui/Icon.tsx` — `SvgXml` render branch
|
||||
- `ui/pages/Settings.tsx` (staff-tags) / `ui/TagEditorAlert.tsx` (custom-user-tags) — paste field,
|
||||
fallback-text field, icon-only switch
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- File/document picker upload.
|
||||
- Recoloring or otherwise transforming a custom SVG's internal fills.
|
||||
- Any change to `patches/name.tsx`, `patches/details.tsx`, `patches/profile.tsx`, or
|
||||
`patches/tag.tsx` — they already consume `icon`/`text` generically and need no changes.
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
# Split Messages - Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
When a composed message exceeds Discord's character limit (2000, or 4000 with Nitro), automatically split it into multiple sequential messages instead of showing Discord's native "message too long" popup and blocking the send.
|
||||
|
||||
Built for classic (Vendetta-compat, `/root/revenge-plugins`) first, to validate the concept before porting to Next.
|
||||
|
||||
## Reference
|
||||
|
||||
Ported from `sunnniee/vendetta-plugins`'s `SplitLargeMessages` (https://github.com/sunnniee/vendetta-plugins/tree/master/plugins/SplitLargeMessages), live-verified against a connected classic client on this session's Discord build:
|
||||
- `findByProps("MAX_MESSAGE_LENGTH")` exists, with `MAX_MESSAGE_LENGTH: 2000` and `MAX_MESSAGE_LENGTH_PREMIUM: 4000`.
|
||||
- `findByProps("sendMessage", "editMessage")._sendMessage` exists as a callable function.
|
||||
- `UserStore.getCurrentUser().premiumType` reflects Nitro status (2 = full Nitro).
|
||||
|
||||
## Architecture
|
||||
|
||||
On `onLoad`:
|
||||
1. Locate `Constants = findByProps("MAX_MESSAGE_LENGTH")` and raise `MAX_MESSAGE_LENGTH`/`MAX_MESSAGE_LENGTH_PREMIUM` to a very large number (`2 ** 30`). This is what stops Discord's own composer from ever showing the native "too long" popup or blocking the send button - the check that shows that popup reads these constants directly, not any `MessageActions` function (confirmed live: neither `sendMessage` nor `validateMessage` were called at all when a real over-limit send was attempted before this fix).
|
||||
2. Patch `before` on `MessageActions.sendMessage`. Inside the hook:
|
||||
- Read the real limit for this send: `UserStore.getCurrentUser()?.premiumType === 2 ? 4000 : 2000`.
|
||||
- If `content.length` is under that limit, return immediately (no-op, real send proceeds normally).
|
||||
- Otherwise, run the splitting algorithm (below) to get an ordered list of chunks.
|
||||
- If splitting fails (an unsplittable token longer than the limit), clear `args[1].content` to `""`, show an error toast, and return - nothing sends.
|
||||
- Otherwise, mutate `args[1].content` to the FIRST chunk only, so the original `sendMessage` call proceeds as a normal send for chunk 1.
|
||||
- Fire an async loop (not awaited by the hook itself) that sends the remaining chunks in order via `MessageActions._sendMessage(channelId, { ...same invalidEmojis/validNonShortcutEmojis/tts, content: chunk }, {})`, sleeping `Math.max(channel.rateLimitPerUser, 1000)` ms (channel slowmode, 1s floor) between each.
|
||||
|
||||
On `onUnload`: restore `Constants.MAX_MESSAGE_LENGTH = 2000` and `MAX_MESSAGE_LENGTH_PREMIUM = 4000`, and unpatch the hook, so nothing is left in a broken state if disabled.
|
||||
|
||||
## Splitting algorithm
|
||||
|
||||
Input: `content: string`, `maxLength: number`. Output: `string[] | false` (false = unsplittable).
|
||||
|
||||
1. **Segment** the content into an ordered list of pieces by extracting fenced code blocks (` ```...``` `, including the fence lines and any language tag) as atomic units, with everything between/around them as plain-text pieces. A code block piece is never split internally unless it alone exceeds `maxLength` (see step 3).
|
||||
2. **Plain-text pieces**: split by paragraph first (newline-separated), greedily packing paragraphs into sub-chunks up to `maxLength` characters (matching the reference's reduce-based packing). If any resulting sub-chunk is still over `maxLength` (a single paragraph longer than the limit with no newlines to split on), fall back to packing whole words (space-separated) instead, for that piece.
|
||||
3. **Code-block pieces**: if the whole block (fences included) fits within `maxLength`, keep it as one atomic piece. If it doesn't fit even alone, split its inner lines into groups that fit (accounting for the extra ` ``` ` fence overhead added to each group), and re-wrap each group with its own opening/closing fence so every resulting message still renders as a code block.
|
||||
4. **Pack**: greedily accumulate the ordered pieces (text sub-chunks and code-block pieces) into final chunks up to `maxLength`, same greedy strategy as the reference - never split a code-block piece across this final packing step.
|
||||
5. **Failure**: if any single atomic piece from steps 2-3 is still longer than `maxLength` on its own (e.g. one word/URL/token with no break points), splitting fails - return `false`.
|
||||
|
||||
Setting: `storage.splitOnWords` (boolean, default `false`) - when true, skip the paragraph-first pass for plain text and go straight to word-based splitting, matching the reference's existing toggle.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Editing an existing message into an over-limit state (matches the reference - only sending is handled).
|
||||
- Any "split into N messages" notification - silent by design, per user's call.
|
||||
- The Next port - this spec covers classic only; Next's plugin architecture differs enough (different module system, different `MessageActions` equivalent) that it needs its own investigation once this is validated.
|
||||
|
||||
## Testing
|
||||
|
||||
No automated test suite exists for this repo's plugins (matches the rest of the codebase - verification is live, against a connected devtools client). Verification plan:
|
||||
- Live-verify the raised constants actually suppress the native popup for an over-2000/over-4000 character message.
|
||||
- Live-verify a plain-text-only over-limit message splits and sends correctly, with all chunks arriving in order.
|
||||
- Live-verify a message containing a code block that would otherwise straddle a split point instead keeps the code block intact in one message.
|
||||
- Live-verify the unsplittable-token failure case (a single 5000-character no-space token) shows the error toast and sends nothing.
|
||||
- Live-verify `onUnload` restores native behavior (native popup returns for an over-limit message after disabling the plugin).
|
||||
Loading…
Add table
Reference in a new issue